macOS의 Chrome에서 한글을 입력하다가 공백을 누르면, 마지막 글자들이 input 필드 밖으로 밀려나 보이지 않는 현상이 있어요. 커서는 텍스트 끝에 있는데, 스크롤이 따라오지 않는 거예요. 이 글에서는 이 문제의 원인과 해결 방법을 다뤄볼게요.
문제 발견: 영어는 괜찮은데 한글만 이상해요
처음에는 CSS나 레이아웃 문제라고 생각했어요. 하지만 영어로 타이핑할 때는 문제가 없었어요. 아무리 길게 입력해도 커서가 항상 보이는 위치에 있었죠.
한글로 입력할 때만 문제가 나타났어요:


영어와 한글 입력의 차이는 IME(Input Method Editor) 사용 여부에요. 영어는 각 키가 곧 하나의 문자이지만, 한글은 ‘ㄱ’ + ‘ㅏ’ + ‘ㄴ’ = ‘간’처럼 여러 키를 조합해 하나의 글자를 만들어요. IME는 이 조합 과정을 담당하는 시스템 수준의 입력기예요.
한글 조합이 진행 중일 때 브라우저는 compositionstart, compositionupdate, compositionend 이벤트를 발생시켜요. 이 조합 이벤트가 스크롤 동작과 어떻게 맞물리는지가 이 버그의 핵심이에요.
아래 데모에서 macOS Chrome으로 한글을 입력하고 공백을 눌러보면 문제를 확인할 수 있어요:
IME 스크롤 버그 데모
Chrome에서 한글 입력 후 공백을 눌러 차이점을 확인해보세요
문제 상황 (일반 Input)
해결된 상황 (IME Safe Input)
테스트 방법
- 1. 위 두 input 필드에 긴 한글 텍스트를 입력하세요
- 2. 텍스트가 인풋 너비를 넘어갈 때까지 입력하세요
- 3. 한글 조합 중에 공백을 눌러보세요
- 4. 첫 번째는 커서가 사라지고, 두 번째는 항상 보입니다
브라우저별 테스트: macOS + Chromium 조합의 문제
macOS 환경에서 여러 브라우저를 테스트한 결과예요:
- Chrome, Edge (macOS): 버그 발생
- Firefox, Safari (macOS): 정상 동작
- Chrome, Edge (Windows): 정상 동작
macOS의 Chromium 기반 브라우저에서만 발생하는 문제였어요. Windows에서는 동일한 Chrome 버전이라도 이 문제가 나타나지 않는데, macOS와 Windows의 IME 처리 방식이 다르기 때문으로 보여요.
관련 이슈를 찾아보니 ProseMirror에서도 비슷한 문제가 보고되었고, 여러 텍스트 에디터에서 한글 IME 관련 커서 위치 문제가 지속적으로 제기되고 있었어요.
원인 분석: Chrome의 IME 처리 방식
IME 조합 과정과 스크롤 타이밍 문제
문제의 핵심은 macOS Chrome이 IME 조합 종료 시 스크롤 위치를 갱신하지 않는 데 있어요.
한글 입력 과정을 살펴볼게요:
- 조합 시작 (
compositionstart): ‘ㄱ’ 입력 — 브라우저가 조합 영역을 확보 - 조합 업데이트 (
compositionupdate): ‘ㄱ’ → ‘가’ → ‘간’ — IME가 텍스트 노드를 갱신 - 공백 입력: 조합 완료 + 공백 추가 — 텍스트 길이가 늘어남
- 조합 종료 (
compositionend): 최종 텍스트 확정
대부분의 브라우저는 3~4번 단계에서 텍스트 길이가 변하면 스크롤 위치를 자동으로 조정해 커서가 보이게 해요. 하지만 macOS Chrome은 IME 조합이 관여한 텍스트 변경에서 이 스크롤 갱신이 누락돼요.
이 문제는 편집 위치(editing location) 개념과 관련이 있어요. IME 조합이 정상 동작하려면 조합 중 편집 위치가 유지되어야 하는데, macOS Chrome은 조합이 끝나는 시점에서 스크롤 위치까지 포함한 편집 위치 갱신을 제대로 수행하지 못하는 거예요.
브라우저 렌더링 사이클의 이해
이 문제를 해결하려면 브라우저의 렌더링 사이클을 이해해야 해요:
JavaScript 실행 → 스타일 계산 → 레이아웃 → 페인트 → 컴포지트compositionend 이벤트 핸들러는 “JavaScript 실행” 단계에서 동작해요. 이 시점에는 아직 레이아웃 계산이 일어나지 않았기 때문에, 여기서 scrollLeft를 조정해도 정확한 값을 반영할 수 없어요.
requestAnimationFrame은 다음 프레임의 페인트 직전에 콜백을 실행해요. 이 시점이면 이전 프레임의 레이아웃 계산이 완료된 상태라서, 정확한 스크롤 위치를 계산할 수 있어요.
해결: 렌더링 사이클에 맞춘 스크롤 보정
해결책의 핵심은 compositionend 직후가 아니라, 다음 프레임에서 스크롤 위치를 보정하는 거예요.
1단계: IME 상태 추적하기
먼저 IME 조합 상태와 조합 중 공백 입력 여부를 추적해요:
let isComposing = false;
let hasPendingSpace = false;
// IME 조합 시작 감지
inputElement.addEventListener('compositionstart', () => {
isComposing = true;
hasPendingSpace = false;
});
// 조합 중 공백 입력 감지
inputElement.addEventListener('keydown', (e) => {
if (isComposing && e.code === 'Space') {
hasPendingSpace = true;
}
});2단계: 렌더링 사이클 완료 후 스크롤 보정
compositionend 시점에서는 레이아웃 계산이 아직 완료되지 않았어요. requestAnimationFrame으로 다음 프레임까지 지연하면, 레이아웃이 확정된 상태에서 스크롤 위치를 보정할 수 있어요.
inputElement.addEventListener('compositionend', () => {
isComposing = false;
hasPendingSpace = false;
// 브라우저 렌더링 완료 후 스크롤 위치 보정
requestAnimationFrame(() => {
ensureCaretVisible();
});
});3단계: 스크롤 위치 보정하기
커서가 보이도록 스크롤 위치를 조정하는 함수예요:
function ensureCaretVisible() {
const selectionStart = inputElement.selectionStart;
if (selectionStart === inputElement.value.length) {
// 커서가 텍스트 끝에 있으면 스크롤을 끝까지
inputElement.scrollLeft = inputElement.scrollWidth;
} else {
// 그렇지 않으면 현재 커서 위치로 스크롤
// setSelectionRange 호출 시 브라우저가 선택 영역을 보이게 스크롤함
inputElement.setSelectionRange(selectionStart, selectionStart);
}
}완성된 해결책
위 로직을 React Hook으로 합치면 다음과 같아요:
function useImeSafeScroll(inputRef: React.RefObject<HTMLInputElement>, value: string) {
const isComposingRef = useRef(false);
const hasPendingSpaceRef = useRef(false);
const ensureCaretVisible = useCallback(() => {
if (!inputRef.current) return;
const element = inputRef.current;
const selectionStart = element.selectionStart;
if (selectionStart === null) return;
if (selectionStart === element.value.length) {
element.scrollLeft = element.scrollWidth;
} else {
element.setSelectionRange(selectionStart, selectionStart);
}
}, [inputRef]);
const onCompositionStart = () => {
isComposingRef.current = true;
hasPendingSpaceRef.current = false;
};
const onCompositionEnd = () => {
isComposingRef.current = false;
hasPendingSpaceRef.current = false;
// 모든 브라우저에서 일관되게 한 번의 requestAnimationFrame 사용
requestAnimationFrame(ensureCaretVisible);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (isComposingRef.current && e.code === 'Space') {
hasPendingSpaceRef.current = true;
}
};
const onInput = () => {
if (!isComposingRef.current) {
requestAnimationFrame(ensureCaretVisible);
}
};
// 외부 값 변경 시에도 스크롤 보정
useEffect(() => {
if (!isComposingRef.current) {
ensureCaretVisible();
}
}, [value, ensureCaretVisible]);
return { onCompositionStart, onCompositionEnd, onKeyDown, onInput };
}마무리
적용 후 macOS Chrome에서도 한글 입력 시 텍스트가 정상적으로 보여요.
핵심은 두 가지예요:
- 브라우저의 렌더링 타이밍을 존중하기 —
compositionend직후가 아니라,requestAnimationFrame으로 레이아웃 계산이 완료된 시점에 스크롤을 보정해요. - IME 조합 중에는 최소 침범 — 조합 중 DOM이나 Selection을 건드리면 입력이 깨질 수 있어요. JS는 조합이 끝난 후 동기화에 집중하는 편이 안전해요.
웹 표준이 존재해도 OS와 브라우저의 조합에 따라 세부 동작이 달라질 수 있어요. 특히 IME처럼 운영체제와 깊게 연결된 기능일수록 그 차이가 두드러지더라고요.
국제화 개발에 대한 생각
영어권 개발자에게 IME 문제는 발견하기 어려운 영역이에요. 한글, 일본어, 중국어 사용자의 불편함이 간과되기 쉬운 이유예요.
초기 Arc 브라우저에서도 비슷한 사례가 있었어요. 한글을 입력하고 Enter를 누르면 마지막 문자가 사라지는 버그였는데, 이런 기본적인 입력 문제 하나가 브라우저 전체의 사용 경험을 좌우하기도 해요.
텍스트를 자연스럽게 입력할 수 있는 것 — 이 기본적인 경험을 보장하는 일이 프론트엔드 개발의 출발점이라고 생각해요.