input에서 숫자만 입력받도록 하기

진행하고 있는 사이드 프로젝트에서 미션 모달 내 미션 주차 input이 한글이 입력되는 문제가 있었다.
<FormField<CreateMissionFormValues, 'weekNum'>
name="weekNum"
label="미션 주차"
direction="vertical"
>
<BaseInput
type="number"
min={0}
id="weekNum"
placeholder="커리큘럼 미션인 경우 주차를 입력해 주세요. (예: 1)"
/>
</FormField>
크리티컬한 문제는 아니었지만 미션 주차라는 성격에 맞게 숫자만 입력받게 하고 싶었다.
input type이 number 로 되어 있어서 당연히 막힐거라고 생각했는데 왜 한글이 입력되고 있는지 의문이 들었다.
HTML 스펙, 브라우저의 IME 처리 방식, React의 controlled component에 대해 공부해보면서 이 글은 "왜 막히지 않았는가"를 추적하면서 알게 된 것들을 정리한 글이다.
HTML 명세가 말하는 것: Number state, type='number'
숫자만 입력받아야 하는 필드니까 type="number"를 붙이면 브라우저가 알아서 숫자 외 입력을 막아줄 거라고 생각했다. 그런데 막상 써보니 한글도, 영어도 아무렇지 않게 입력됐다.
User agents must not allow the user to set the value to a non-empty string that is not a valid floating-point number.
여기서 핵심은 value라는 단어다. 스펙이 금지하는 건 input.value라는 내부 값에 숫자가 아닌 문자열이 저장되는 것이지, 화면에 타이핑하는 행위 자체를 막으라는 뜻이 아니다.
또한, value sanitization algorithm(값 정제 알고리즘)이라는 개념도 있다. 사용자가 뭔가를 입력하면 브라우저는 그게 유효한 부동소수점 숫자인지 확인하고, 아니면 input.value를 ""(빈 문자열)로 만들어버린다. 그래서 화면에는 "가나다"가 보여도, JavaScript로 input.value를 읽으면 ""이 나오는 것이다.
"The value sanitization algorithm is as follows: If the value of the element is not a valid floating-point number, then set it to the empty string instead.
그런데 왜 inputMode는 한글을 막지 못하는가
type="number"를 쓰지 않고 inputMode="numeric"을 선택한 이유는 모바일 환경에서 UX가 좋다는 글을 봐서였다.
inputMode="numeric"을 사용하면 모바일에서 숫자 키패드가 자동으로 표시되어 사용자가 빠르게 입력할 수 있다는 장점이 있다. url, email, tel 등 다양한 값도 있지만, 어디까지나 가상 키보드 UI에만 영향을 줄 뿐 입력값을 제한하지는 않는다.
한글에서의 동작
한글은 영어와 다르게 IME(Input Method Editor)를 거쳐서 입력된다. 'ㅎ', 'ㅏ', 'ㄴ'처럼 자음·모음을 하나씩 누르면 IME가 이걸 조합해서 완성형 글자 '한'을 만들어내는 방식이다. 이 조합 과정은 브라우저에 세 가지 이벤트로 전달된다.
compositionstart: IME 세션이 시작됐다는 신호. 예를 들어 'ㄱ'을 처음 눌렀을 때 발생한다.
compositionupdate: 조합 중인 글자가 바뀔 때마다 발생한다. 'ㄱ' → '가' → '각' 이런 식으로 변할 때마다 화면에 표시되지만, 아직 완성되지 않은 중간 상태이다.
compositionend: 글자 조합이 끝났다는 신호. 스페이스나 엔터를 눌러 글자가 확정될 때 발생한다. 이 시점에 브라우저가 input.value에 값을 반영하려 하지만, '가' 같은 글자는 숫자가 아니므로 sanitization algorithm에 의해 value는 "", 즉 빈 값이 된다.
영어에서의 동작
영어도 마찬가지로 브라우저마다 동작이 다르다. 영어는 한글처럼 IME를 거치지 않고 키를 누르는 즉시 글자가 확정되기 때문에, 브라우저가 keydown 이벤트 단계에서 바로 허용 여부를 결정한다.
크롬은 영문자 'a', 'b', 'c' 같은 키를 누를 때 아예 입력 자체를 막는다. 다만 완벽하지는 않아서 'e'(지수 표기), '+', '-' 같은 문자는 숫자 표현의 일부일 수 있어서 허용된다. 파이어폭스는 영문자도 자유롭게 입력되지만, 폼 제출 시점에 유효성 검사로 막힌다.
onChange 사용하기
const handlePreventInvalidValue = (event: FormEvent<HTMLInputElement>) => {
event.currentTarget.value = event.currentTarget.value.replace(
/[^0-9]/g,
'',
);
};
...
onChange={handlePreventInvalidValue}
onChange 핸들러에 handlePreventInvalidValue 함수를 연결시켜봤지만, 기대와 다르게 동작했다. 숫자를 입력하면 값이 즉시 사라지고, 한글을 입력하면 화면에 잠깐 보였다가 사라졌다. 필터링이 되는 게 아니라, 입력한 모든 값이 리셋되는 현상이었다.
동작하지 않은 이유
문제는 FormField였다.
// create-mission-modal.tsx
<FormField<CreateMissionFormValues, 'weekNum'>
name="weekNum"
direction="vertical"
>
<BaseInput
type="number"
min={0}
onChange={handlePreventInvalidValue} // // ← child.props.onChange 로 저장됨
id="weekNum"
/>
</FormField>
// form-field.tsx
<FieldControl<T, N, V>
name="weekNum"
controlId={fieldId}
>
{children} // = <BaseInput onChange={handlePreventInvalidValue} .../>
</FieldControl>
// field-control.tsx
<Controller
name="weekNum"
control={control}
render={({ field }) => {
const child = children;
const coreOnChange =
child.props.onChange ?? ((arg) => { field.onChange(arg) });
// coreOnChange = handlePreventInvalidValue
// field.onChange는 이 시점에서 완전히 제거됨
const nextOnChange = coreOnChange;
injected = cloneElement(child, {
name: field.name, // 'weekNum'
value: field.value, // '' ← controlled input 확정
onChange: nextOnChange, // handlePreventInvalidValue
onBlur: field.onBlur,
});
return injected; // value='' 이 박힌 controlled BaseInput
}}
/>
field.value는 "" 이고, 이것이 injected의 value로 주입되어 controlled input이 된다.
사용자가 값을 타이핑하면 handlePreventInvalidValue가 실행되지만 field.onChange를 호출하지 않아 RHF 상태가 ""로 유지된다. 그 결과 다음 렌더마다 value = ""가 다시 주입되어 타이핑한 값이 즉시 리셋되는 현상이 발생한다.
최종 해결법 : Controller 사용하기
<Controller
name="weekNum"
control={control}
render={({ field }) => (
<BaseInput
type="text"
inputMode="numeric"
{...field}
onChange={(e) => {
const numeric = e.target.value.replace(/[^0-9]/g, '');
field.onChange(numeric);
}}
id="weekNum"
>
)}
/>
프로젝트가 react-hook-form을 사용하고 있었고 다른 파일들에서도 비슷한 코드가 사용되고 있었기 때문에 해당 라이브러리에 내장되어 있는 Controller를 적용해보기로 했다.
핵심은 "React가 value를 제어하고 있는가"다. register는 value를 React가 쥐지 않아서 onChange 필터링이 DOM에 반영되지 않는다. Controller는 field.value로 렌더링을 직접 통제하기 때문에 가능하다.
마무리
지금까지 input에서 숫자를 입력하려면 무조건 type='number'만 사용했었는데, 내가 생각한 것과 전혀 달랐고, 우연히 알게된 inputMode라는 속성도 모바일에서만 제대로 작동했고, 브라우저마다 입력되는 방식도 달랐다.
결국엔 마지막 해결책으로 Controller를 사용했지만, 매번 Controller를 적용할 수는 없기에... 프로젝트에 있는 BaseInput 에 문제점이 있다면 이를 좀 더 개선시키고자 한다.
참고 자료
https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inputmode



