게스트 입장 페이지의 아동 이름 입력 필드에서 iOS/iPad의 10키 한글 키보드로 입력 시, 글자가 누락되는 현상.
components/pages/guest-entry.tsx — 아동 이름 입력 inputiOS 10키 한글 키보드(천지인·모아)는 복합 모음을 만들 때 중간점(·, U+00B7)을 내부 조합 문자로 사용합니다.
| 모음 | 10키 조합 | 내부 표현 예시 |
|---|---|---|
ㅐ | ㅏ + · | ㅏ· → ㅐ |
ㅔ | ㅓ + · | ㅓ· → ㅔ |
ㅒ | ㅏ + · + · | ㅏ·· → ㅒ |
"해"(ㅎ + ㅐ) 를 입력할 때 브라우저가 받는 조합 중간 상태:
ㅎ → ㅏ 입력 → 해ㅏ → 중간점(·) 적용 → 해 완성
이 중간점이 적용되는 순간에 브라우저가 compositionend 이벤트를 발화합니다.
onCompositionEnd: (e: CompositionEvent<HTMLInputElement>) => { isComposingRef.current = false; setValue(sanitize(e.currentTarget.value)); // ← 원인 },
isComposing = true
isComposing=true → raw 값 그대로 저장
isComposing=true → raw 값 그대로 저장
e.currentTarget.value = "전ㅎ" (ㅐ 완성 전 중간 상태)
→ setValue(sanitize("전ㅎ")) 호출
→ React가 DOM input value를 즉시 "전ㅎ"으로 덮어씀
DOM이 "전ㅎ"으로 리셋되어 중간점(·)을 적용해 ㅐ를 완성하려던 IME 내부 상태가 깨짐
→ "해" 전체 유실
새 composition이 시작되어 "온"은 정상 입력 → 최종값 "전온"
// apps/web/lib/utils.ts export function removeSpecialChars(value: string): string { return value.replace(/[^a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣]/g, ""); }
중간점(·)은 [^…가-힣] 범위에 포함되지 않으므로 제거됩니다. 조합 복구에 필요한 중간점이 사라지면서 IME가 이전 상태로 되돌아갈 수도 없게 됩니다.
compositionend 시점의 e.currentTarget.value는 iOS에서 조합이 완성되기 전의 중간 상태를 담고 있습니다. 여기서 setValue를 호출하면 React가 DOM을 조기에 덮어씌워 IME 조합을 파괴합니다.
// 변경 전 onCompositionEnd: (e: CompositionEvent<HTMLInputElement>) => { isComposingRef.current = false; setValue(sanitize(e.currentTarget.value)); }, // 변경 후 onCompositionEnd: () => { isComposingRef.current = false; // compositionend 시점에 setValue를 호출하면 React가 DOM을 덮어써서 // iOS 10키 한글 키보드의 모음 조합(예: ㅏ+· → ㅐ)을 깨뜨린다. // compositionend 직후 onChange가 발화하며 isComposing=false 상태로 sanitize를 적용한다. },
모든 주요 브라우저(iOS Safari 포함)에서 compositionend 직후 onChange(input 이벤트)가 발화됩니다. 이 시점에 isComposing이 false이므로 sanitize가 정상 적용됩니다.
| 환경 | compositionend 후 onChange 발화 여부 | 결과 |
|---|---|---|
| iOS Safari (10키 한글) | ✅ 발화 (최종 조합값 포함) | sanitize 정상 적용 |
| Chrome (macOS, QWERTY 한글) | ✅ 발화 | sanitize 정상 적용 |
| Firefox (Windows) | ✅ 발화 | sanitize 정상 적용 |
| Safari (macOS) | ✅ 발화 | sanitize 정상 적용 |
"전해온" 입력 시 수정 후 이벤트 시퀀스:
isComposing=true → raw 값 저장 (DOM 덮어쓰기 없음)
isComposing = false로만 설정. setValue 호출 없음 → DOM 그대로 유지
isComposing=false → sanitize("전해") = "전해" ✅
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| onCompositionEnd 파라미터 | (e: CompositionEvent<HTMLInputElement>) |
() |
| onCompositionEnd 내 setValue 호출 | setValue(sanitize(e.currentTarget.value)) |
제거 |
| sanitize 적용 시점 | compositionend + onChange(비조합시) | onChange(비조합시)만 |
| 사용하지 않는 import | CompositionEvent import 있음 |
CompositionEvent import 제거 |