채팅 프로젝트
한글 이벤트 중복 입력 - IME composition
문제 상황
채팅 입력창에서 한글을 입력하고 Enter 키를 눌렀을 때, 메시지가 중복으로 전송되는 문제가 발생했습니다. 영어나 숫자 입력 시에는 정상적으로 한 번만 전송되지만, 한글 입력 시에만 두 번 이상 전송되는 현상이 나타났습니다.
원인 분석
이 문제는 **IME(Input Method Editor)**의 동작 방식 때문에 발생합니다.
IME란?
- IME(Input Method Editor): 한글, 중국어, 일본어 등 복합 문자를 입력하기 위한 시스템
- 한글의 경우 자음과 모음을 조합하여 하나의 완성된 글자를 만드는 과정이 필요함.
문제 발생 과정
- 사용자가 ‘한글’ 입력 후 Enter 키를 누름
- IME가 조합 중인 상태에서 첫 번째
keydown이벤트 발생 (isComposing: true) - IME 조합이 완료되면서 두 번째
keydown이벤트 발생 (isComposing: false) - 결과적으로 Enter 키 이벤트가 두 번 처리되어 메시지가 중복 전송됨
해결 방법
isComposing 속성을 사용하여 IME 입력 중인지 확인하고, 조합 중일 때는 이벤트 처리를 중단합니다.
해결 코드
const handleChatInputKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
// IME 입력 중에는 처리하지 않음
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter') {
e.preventDefault(); // 기본 Enter 키 동작 방지
handleSendMessage();
}
};코드 설명
e.nativeEvent.isComposing: IME가 현재 조합 중인지 확인하는 속성true: 한글 조합 중 (자음, 모음 입력 과정)false: 조합 완료 또는 영어/숫자 등 단일 문자 입력
추가 고려사항
1. 다른 이벤트에서도 적용
const handleCompositionCheck = (e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing) return true;
return false;
};
const handleSubmit = (e: React.FormEvent) => {
if (handleCompositionCheck(e as any)) return;
// 실제 처리 로직
};2. compositionstart/compositionend 이벤트 활용
const [isComposing, setIsComposing] = useState(false);
const handleCompositionStart = () => {
setIsComposing(true);
};
const handleCompositionEnd = () => {
setIsComposing(false);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (isComposing) return;
if (e.key === 'Enter') {
handleSendMessage();
}
};
// JSX
<input
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyPress}
/>React 상태 업데이트의 비동기성과 스테일 클로저 문제
문제 상황
채팅 애플리케이션에서 WebSocket을 통해 실시간 메시지를 받을 때, useState를 사용한 상태 업데이트에서 예상과 다른 동작이 발생했습니다.
// 문제가 발생한 코드
const [messages, setMessages] = useState<ChatLogResponseDto[]>([]);
useEffect(() => {
subscribe(`/topic/room/${room}`, (message: ChatLogResponseDto) => {
// ❌ 문제: messages가 항상 초기값([])으로 보임
const updated = [...messages, message];
setMessages(updated);
// ❌ 이후 messages를 참조하려고 하면 null 또는 빈 배열만 나옴
console.log(messages); // 항상 []
});
}, [selectedChatRoom, isConnected]);React 상태 업데이트의 비동기성
문제의 핵심
React의 setState(또는 setMessages)는 비동기적으로 동작합니다:
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // ❌ 여전히 0! 즉시 업데이트되지 않음
setCount(count + 1); // ❌ 여전히 0 + 1 = 1
setCount(count + 1); // ❌ 여전히 0 + 1 = 1
// 결과: 3이 아닌 1만 증가
};왜 비동기인가?
// React가 내부적으로 하는 일
function setState(newValue) {
// 1. 상태 변경을 예약(queue)에 추가
stateQueue.push(newValue);
// 2. 리렌더링을 예약 (즉시 실행하지 않음)
scheduleRerender();
// 3. 함수는 즉시 반환 (상태는 아직 변경되지 않음)
}이유:
- 성능 최적화: 여러 상태 변경을 일괄 처리(batching)
- 일관성 보장: 모든 상태 변경이 동시에 반영
- 예측 가능한 렌더링: 렌더링 중간에 상태가 변경되지 않음
Stale Closure문제 발생
실제 채팅 시나리오에서의 문제
문제 상황
// 시나리오: 3개 메시지가 빠르게 도착
// Message 1 도착
subscribe callback: messages = [] (stale)
setMessages([message1])
// Message 2 도착 (Message 1 반영 전)
subscribe callback: messages = [] (여전히 stale)
setMessages([message2]) // ❌ message1 손실!
// Message 3 도착
subscribe callback: messages = [] (여전히 stale)
setMessages([message3]) // ❌ message1, message2 손실!해결 후
// useRef 사용 시
// Message 1 도착
messagesRef.current = [message1]
setMessages([message1])
// Message 2 도착
messagesRef.current = [message1, message2] // ✅ 최신 상태 참조
setMessages([message1, message2])
// Message 3 도착
messagesRef.current = [message1, message2, message3] // ✅ 모든 메시지 보존
setMessages([message1, message2, message3])언제 어떤 방법을 사용할까?
useRef 사용이 적합한 경우
- 실시간 데이터 스트림 (채팅, 주식 데이터 등)
- 빠른 상태 변경이 필요한 경우
- 이벤트 핸들러에서 최신 상태가 필요한 경우
함수형 업데이트가 적합한 경우
- 단순한 상태 업데이트
- 이전 값 기반 계산이 필요한 경우
- 일반적인 UI 상태 관리
의존성 배열 방식이 적합한 경우
- 상태 변경이 드문 경우
- effect 재실행이 문제되지 않는 경우
6. 핵심 포인트
- React 상태는 비동기:
setState호출 후 즉시 반영되지 않음 - 스테일 클로저: 콜백이 생성 시점의 값을 계속 참조
- useRef는 동기적: 항상 최신 값을 즉시 반영
- 함수형 업데이트: 스테일 클로저 문제의 일반적 해결책
- 적절한 방법 선택: 상황에 맞는 패턴 사용