본문으로 건너뛰기

React 숫자 입력, 검증을 어디에서 할 것인가

· 약 5분

zod, react-hook-form, <input type="number">. 숫자 입력 처리를 검색하면 늘 같은 후보들이 나온다. 그런데 페이지 점프 input 하나를 만들면서 든 생각은, 이런 도구를 꺼내기 전에 검증의 위치를 먼저 정해야 한다는 것이었다.

결론부터 말하면 입력 시점에서 막아버리면 변환 시점은 두 줄로 끝난다. 왜 그 두 줄로 충분한지가 이 글의 주제다.

출발점: 페이지 점프 input

테이블 페이지네이션에서 "특정 페이지로 이동" 기능을 만들어야 했다. 처음 작성한 코드는 이렇다.

<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={jumpInput}
onChange={(e) => {
const val = e.target.value;
if (val === '' || /^\d+$/.test(val)) setJumpInput(val);
}}
onKeyDown={(e) => e.key === 'Enter' && handleJump()}
className="h-8 w-20 text-right text-lg"
placeholder={String(state.totalPage)}
/>

그리고 handleJump는 이렇게 단순하다.

const handleJump = () => {
const value = parseInt(jumpInput, 10);
if (isNaN(value)) return;
const clamped = Math.min(Math.max(1, value), state.totalPage);
goToPage(clamped);
};

전체 로직이 6줄 남짓이고 외부 라이브러리는 없다. 검증 라이브러리 없이도 충분히 견고한 이유는 검증을 변환 시점이 아니라 입력 시점에서 끝냈기 때문이다.

핵심 아이디어: 잘못된 상태가 들어오지 못하게 한다

onChange의 한 줄이 이 글의 핵심이다.

if (val === '' || /^\d+$/.test(val)) setJumpInput(val);

/^\d+$/는 "처음부터 끝까지 숫자만"을 의미한다. 빈 문자열은 사용자가 지우는 중일 때 필요하니 별도로 허용한다. 이 외에는 setJumpInput이 호출되지 않으므로 state는 항상 다음 둘 중 하나다.

  • '' (비어 있음)
  • 숫자로만 구성된 문자열 ("42", "0", "007" 등)

소수점, 음수 부호, 공백, 알파벳, e 같은 지수 표기 — 전부 도달하지 못한다. 사용자가 키보드에서 .을 눌러도 input에 아무 변화가 없다. 이게 의도된 동작이다.

이렇게 입력 단계에서 막아두면 handleJump에서 parseInt를 부를 때 가능한 입력은 사실상 두 종류뿐이다.

parseInt('', 10); // → NaN
parseInt('42', 10); // → 42

if (isNaN(value)) return; 한 줄로 빈 문자열만 걸러내면 된다. 그래서 두 줄로 끝난다.

만약 입력 검증을 빼면? — parseInt만 믿을 때 생기는 일

여기서 흔한 오해 하나를 짚고 가야 한다. "parseInt + isNaN만 있으면 충분하다"는 말은 입력 검증이 함께일 때만 성립한다. parseInt는 생각보다 관대하다.

parseInt('3.14', 10); // → 3 (소수부 조용히 버림)
parseInt('123abc', 10); // → 123 (숫자로 시작하면 통과)
parseInt(' 42', 10); // → 42 (앞쪽 공백 허용)
parseInt('+5', 10); // → 5 (부호 허용)
parseInt('0x10', 10); // → 0 (radix=10이면 0x를 잘라버림)
parseInt('1e5', 10); // → 1 (지수 표기 무시)

만약 <Input type="number">를 쓰고 onChange 검증 없이 parseInt로만 처리한다면, 사용자가 3.99를 입력했을 때 3페이지로 이동하게 된다. 결제 금액이나 수량이라면 더 곤란하다.

Number()는 더 엄격하지만 다른 함정이 있다.

Number('3.14'); // → 3.14 (소수 통과)
Number('123abc'); // → NaN (parseInt와 다름, 더 엄격)
Number(''); // → 0 ⚠️ 빈 문자열을 0으로 변환!
Number(' '); // → 0 ⚠️ 공백도 0

빈 input에서 Number('')0이 되는 건 isNaN으로 못 잡는다. value === '' ? null : Number(value) 같은 추가 분기가 필요해진다.

즉 두 함수 다 단독으로는 안전하지 않다. 무엇이 들어올 수 있는지 보장되어야 안전해진다.

다른 접근법들과의 비교

<input type="number">

가장 직관적인 선택지지만 실제로 쓰면 자잘한 문제가 누적된다.

<input type="number" value={jumpInput} onChange={(e) => setJumpInput(e.target.value)} />
  • 사용자가 e, +, -, .을 입력할 수 있다 (type="number"는 이걸 막지 않는다).
  • 마우스 휠로 의도치 않게 값이 바뀐다.
  • 브라우저마다 스피너 UI가 다르다.
  • iOS Safari에서 숫자 키패드를 띄우려면 결국 inputMode="numeric"을 또 붙여야 한다.
  • e.target.value는 여전히 string이고 잘못된 입력일 때 ''을 반환할 때도 있다 (브라우저별로 다르다).

이래서 많은 디자인 시스템(shadcn/ui 포함)이 기본 Input을 그대로 두고 type="text" + inputMode="numeric" 패턴을 쓴다.

zod

zod는 강력하지만 이 케이스에는 무겁다.

import { z } from 'zod';

const PageSchema = z.coerce.number().int().min(1).max(state.totalPage);

const handleJump = () => {
const result = PageSchema.safeParse(jumpInput);
if (!result.success) return;
goToPage(result.data);
};

이게 빛나는 순간은 여러 필드가 서로 의존할 때, 또는 에러 메시지를 사용자에게 보여줘야 할 때다. 회원가입 폼, 결제 폼, 검색 필터처럼 검증 결과를 UI로 표현해야 하는 경우. 페이지 점프처럼 "유효하지 않으면 그냥 무시"하는 경우엔 schema 정의 줄 수가 로직 줄 수보다 길어진다.

react-hook-form + zod

const { register, handleSubmit } = useForm({
resolver: zodResolver(PageSchema),
});

<form onSubmit={handleSubmit(({ page }) => goToPage(page))}>
<input {...register('page', { valueAsNumber: true })} />
</form>;

폼이 여러 필드일 때 진가를 발휘한다. input 하나 때문에 들이기엔 초기 설정이 좀 과하다.

어디에 어떤 도구를 쓸지

세 도구는 경쟁 관계가 아니라 다른 층위에서 일한다.

상황적합한 도구
단일 숫자 input, 잘못된 값은 무시onChange 정규식 + parseInt
단일 input이지만 에러 메시지 필요onChange 정규식 + 별도 errorMessage state
여러 필드 폼, 필드 간 의존 없음react-hook-form
복잡한 검증 규칙 (필드 간 의존, 비동기 검증)react-hook-form + zod
서버 응답 / 외부 입력 검증zod (이건 대체 불가)

페이지 점프는 첫 번째 칸이다. 그래서 두 줄로 충분하다.

정리

이 글에서 하고 싶었던 말은 "라이브러리를 쓰지 말자"가 아니다. 검증의 위치를 먼저 정하라는 것이다.

  • onChange에서 정규식으로 막으면 → state가 항상 valid → 변환은 가볍게.
  • onChange에서 안 막으면 → state는 dirty할 수 있음 → 변환 시점에 강한 검증 필요.

전자를 택하면 parseInt + isNaN 두 줄로 끝낼 수 있다. 후자를 택하면 zod든 정교한 변환 함수든, 결국 그 검증 작업이 어딘가에서는 이뤄져야 한다.

페이지 점프 input 하나에서 출발해 검증 전략 전체를 다시 보게 된 이유가 여기에 있다. 도구는 마지막에 고르는 거고, 먼저 정해야 하는 건 "어느 시점에 invalid를 차단할 것인가"다.