정의
한글로 "안녕하세요"를 치다가 다른 곳을 클릭했더니 "안녕하세"까지만 저장된 적이 있다면 이 개념이 필요하다.
커밋 경계(commit boundary) 는 편집 중인 값을 "이제 확정"으로 보고 저장하는 시점이다. 한글·일본어·중국어는 여러 키 입력이 모여 한 글자가 되는 조합(composition) 구간이 있는데, 이 구간이 끝나기 전에 blur나 Enter가 커밋을 실행하면 화면에 보이는 값과 저장된 값이 달라진다. 이 문제는 값을 다듬어서 고치는 문제가 아니라, 확정 시점을 미뤄서 고치는 문제다.
왜 필요한가
영문 입력만 가정한 편집 UI는 관습적으로 onBlur에서 저장하고 Enter에서 확정한다. 한글에서는 마지막 글자가 아직 조합 중인 상태로 그 이벤트가 먼저 도착할 수 있다. 값이 조용히 잘못 저장되므로 사용자는 한참 뒤에야 알아차린다.
더 나쁜 점은 compositionend·keydown·blur의 도착 순서가 브라우저·IME 조합마다 다르다는 것이다. 한 환경에서 순서를 가정해 고치면 다른 환경에서 그대로 재발한다. 그래서 "순서"가 아니라 "지금 조합 중인가"라는 상태로 판단해야 한다.
동작 원리
- 사용자가 한글 입력을 시작하면 브라우저가
compositionstart를 발생시키고 미확정 문자를 화면에 보여 준다. - 조합 중 발생하는 키 이벤트는
event.isComposing === true를 갖는다. - 이 구간에 blur가 발생하면, 핸들러는 즉시 저장하지 않고 "저장 예약" 플래그만 세운다.
- 조합이 끝나면
compositionend가 오고 값이 확정된다. compositionend핸들러가 예약 플래그를 보고 그때 실제 저장을 실행한다.
| 이벤트 | 조합 중일 때 처리 | 이유 |
|---|---|---|
keydown (Enter) | 무시 | 확정 키가 아니라 조합 확정 키일 수 있다 |
blur | 커밋 보류 + 예약 | 값이 아직 미완성 |
compositionend | 예약돼 있으면 커밋 | 여기가 진짜 경계 |
change/input | 로컬 상태만 갱신 | 서버 전송은 커밋 경계에서만 |
실무 적용
function EditableField({ initial, onCommit }: Props) {
const composingRef = useRef(false); // 훅은 early return보다 앞에서 호출
const pendingBlurRef = useRef(false);
const [value, setValue] = useState(initial);
const commit = () => onCommit(value);
return (
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
if (pendingBlurRef.current) {
pendingBlurRef.current = false;
commit();
}
}}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return; // 조합 중 Enter는 확정이 아니다
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
commit();
}
}}
onBlur={() => {
if (composingRef.current) {
pendingBlurRef.current = true; // 조합 끝난 뒤로 미룬다
return;
}
commit();
}}
/>
);
}
회귀 테스트는 "조합 중 blur 후 일정 시간이 지나도 저장 요청이 나가지 않는다"를 검증한다. 값 비교가 아니라 요청 발생 여부를 보는 것이 핵심이다.
트레이드오프
- native
textarea+ 커밋 경계: 코드가 적고 IME 동작이 브라우저 기본값 그대로다. 대신 서식·인라인 위젯은 못 넣는다. - 리치 텍스트 에디터 도입: 서식을 얻지만 IME 문제가 사라지지 않는다. 조합 세션과 에디터의 재렌더가 충돌해 오히려 어려워지고, 번들도 커진다.
- 커밋 보류: 저장이 수십~수백 ms 늦어진다. 사용자 체감상 무해하지만, 보류 중 언마운트되면 저장이 유실되므로 언마운트 경로에서도 예약 플래그를 확인해야 한다.
사용하면 안 되는 경우
- 값이 숫자·날짜·enum처럼 IME를 타지 않는 입력. 조합 상태 추적은 불필요한 복잡도다.
- 조합 중에도 실시간 협업으로 미확정 문자를 그대로 전송해야 하는 경우 — 이때는 커밋을 미루는 대신 미확정 상태임을 명시해 전송하는 별도 설계가 필요하다.
흔한 실수
- 문자열 후처리로 덮는다. "글자가 이상하니 중복을 제거하자"는 접근은 사용자가 실제로 같은 글자를 두 번 친 입력까지 훼손한다. 경계 문제를 값 문제로 오진한 것이다.
keyCode === 229판별에 의존한다. 레거시 우회이고 브라우저마다 다르다.isComposing과 composition 이벤트를 쓴다.- 이벤트 순서를 가정한다. "blur 다음에 compositionend가 온다"는 환경 의존이다. 상태로 판단한다.
- 훅을 조건부 early return 뒤에서 호출한다. 렌더마다 훅 순서가 달라져 상태가 엉킨다.
- jsdom 유닛 테스트만으로 검증한다. composition 라이프사이클을 충실히 흉내 내지 않으므로 실제 브라우저 E2E가 필요하다.
관련 개념
- ime-composition-contenteditable — 리치텍스트 에디터에서의 조합 세션 파괴
- contenteditable-keyboard-history-delegation — 키 이벤트와 편집 히스토리 경계
- race-safe-async-ui-requests — 지연 커밋과 요청 경쟁 처리