React 입력 폼 관리, 왜 이렇게 복잡한걸까
프로젝트를 하다 보면 생각보다 입력 폼을 만들어야 하는 경우가 많다.
입력 받아야 하는 항목이 적은 경우에는 간단하게 만들 수 있지만
입력 항목이 많은 경우에는 코드가 꽤나 복잡해진다.
실제로 한 스타트업에서 임시보호 지원 신청서를 만든 경험이 있는데
그때는 진짜 입력 받아야 하는 항목이 정말 많아서 힘들었다.
그래서 오늘은 입력 폼을 효율적으로 구현할 수 있는 방법을 소개하고자 한다!
입력을 받을 때 고충
일단 입력을 받을 때 고려해야 할 사항들이 많다.
예를 들어,
이 외에도 다양한 요구사항들이 존재할 수 있다.
이러한 경우 검증 코드가 불필요하게 중복될 수 있고 성능이 저하될 가능성이 있다.
라이브러리 없이 이것을 구현하면 뭐가 불편한가
우선 이러한 요구사항을 라이브러리 없이 구현을 하면 다양한 문제를 겪는다.
➡️ 코드가 빠르게 비대해진다.
➡️ 잦은 리렌더링
💡제어 방식 입력: 입력 값이 항상 React 상태와 동기화 되는 방식!
➡️ 화면용 포맷과 실제 값의 충돌이 발생할 수 있다.
➡️ 동적 필드의 취약성
➡️ 일관성 유지의 어려움
이러한 불편함을 풀기 위한 기술 두 가지
이러한 불편함을 효과적으로 해소할 수 있는 두 가지 기술이 있다.
➡️ React Hook Form
💡비제어 방식 입력: 브라우저가 입력 값을 보관하고,
우리는 참조를 통해 필요한 데이터를 읽는 방식이다!
➡️ Yup
💡스키마: 데이터의 형태와 규칙을 코드로 정의한 설계도
➡️ 이 둘을 Resolver로 연결해서 사용한다!
💡Resolver: React Hook Form이 어떤 검증 엔진을 쓸지 이어주는 어댑터
(여기서는 Yup 사용)
React Hook Form과 Yup의 원리와 개념
➡️ React Hook Form의 원리
입력 요소에 register("필드 이름")을 호출하면 내부적으로
참조 등록과 이벤트 구독이 이루어진다.
값이 바뀌어도 폼 전체가 아니라 필요한 부분만 다시 그린다. (성능 이점!)
마스킹처럼 값을 직접 통제해야 하는 특수 경우에만
Controller로 그 필드만 제어 방식으로 전환해서 처리한다.
💡리렌더링: 상태 변화로 컴포넌트가 다시 그려지는 것으로,
입력마다 리렌더링이 많으면 느려진다.
💡Controller: 외부 디자인 컴포넌트나 마스킹처럼
직접 value-onChange를 다뤄야 하는 필드에 쓰는 어댑터 컴포넌트
➡️ Yup의 원리
"문자열 → 필수 → 이메일 형식" 같은 규칙을 사슬로 연결하듯 선언한다.
transform(전처리), when(조건부), test(맞춤) 이 세 가지로
복작한 규칙도 짧게 기술할 수 있다.
규칙과 메시지가 한 곳에 모여 중복·불일치가 줄어드는 효과가 있다!
➡️ 함께 쓸 때의 흐름
1. 사용자 입력
2. (선택) setValueAs로 입력 단계 전처리
3. Yup 스키마 검증
4. 실패 혹은 통과
그럼 어떻게 사용하는가
➡️ 설치
우선 '라이브러리'라서 설치를 해야 한다!
npm i react-hook-form yup @hookform/resolvers
# 또는
yarn add react-hook-form yup @hookform/resolvers
import * as yup from "yup";
const schema = yup.object({
name: yup.string().required("이름을 입력하세요"),
email: yup.string().email("이메일 형식이 아닙니다").required("이메일을 입력하세요"),
});
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
type FormValues = { name: string; email: string };
export default function SimpleForm() {
const { register, handleSubmit, formState: { errors, isValid } } = useForm<FormValues>({
resolver: yupResolver(schema), // Yup과 연결
mode: "onChange", // 입력 중 검증 (필요에 따라 onBlur, onSubmit로 변경 가능)
defaultValues: { name: "", email: "" },
});
const onSubmit = (data: FormValues) => alert(JSON.stringify(data, null, 2));
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label>
이름
<input {...register("name")} aria-invalid={!!errors.name} aria-describedby="name-err" />
</label>
{errors.name && <small id="name-err" role="alert">{errors.name.message}</small>}
<label>
이메일
<input {...register("email")} aria-invalid={!!errors.email} aria-describedby="email-err" />
</label>
{errors.email && <small id="email-err" role="alert">{errors.email.message}</small>}
<button type="submit" disabled={!isValid}>제출</button>
</form>
);
}
Ex. 숫자만, 최소·최대, 빈 값 안전 처리
<input
inputMode="numeric"
placeholder="나이(숫자만)"
{...register("age", {
setValueAs: (v) => {
const digits = String(v ?? "").replace(/\D/g, "");
return digits === "" ? undefined : Number(digits);
},
})}
/>
age: yup
.number()
.typeError("숫자만 입력하세요")
.integer("정수만 입력하세요")
.min(14, "만 14세 이상만 가능합니다")
.max(120, "값이 너무 큽니다")
.required("나이를 입력하세요"),
Ex. 문자열 다듬기(앞뒤 공백 제거, 길이 제한)
username: yup
.string()
.transform((v) => (v ?? "").trim())
.min(2, "최소 2자")
.max(20, "최대 20자")
.required("아이디를 입력하세요"),
Ex. 조건부 검증 (값 A가 있으면 값 B는 필수)
phone: yup.string().optional(),
code: yup.string().when("phone", (phone, s) =>
phone ? s.required("인증 코드를 입력하세요") : s.notRequired()
),
Ex. 마스킹이 필요한 입력 (화면 포맷과 실제 값 분리)
💡마스킹: 사용자에게는 1234-1234-...처럼 보기 좋게 보여 주고,
내부 값은 숫자만 저장하는 것
import { Controller, useForm } from "react-hook-form";
const formatCard = (s: string) =>
(s || "").replace(/\D/g, "").slice(0, 16).replace(/(\d{4})(?=\d)/g, "$1-");
export default function MaskedCardField() {
const { control } = useForm<{ cardNumber: string }>({ defaultValues: { cardNumber: "" } });
return (
<Controller
name="cardNumber"
control={control}
render={({ field }) => (
<input
inputMode="numeric"
placeholder="XXXX-XXXX-XXXX-XXXX"
value={formatCard(field.value)}
onChange={(e) => field.onChange(e.target.value.replace(/\D/g, ""))}
/>
)}
/>
);
}
cardNumber: yup
.string()
.transform((v) => (v ? v.replace(/\D/g, "") : ""))
.length(16, "카드번호는 16자리입니다")
.required("카드번호를 입력하세요"),
Ex. 동적 필드 (추가·삭제 가능한 배열)
import { useForm, useFieldArray } from "react-hook-form";
type Link = { url: string };
type Values = { links: Link[] };
export default function DynamicLinks() {
const { control, register, handleSubmit } = useForm<Values>({ defaultValues: { links: [{ url: "" }] } });
const { fields, append, remove } = useFieldArray({ control, name: "links" });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((f, i) => (
<div key={f.id}>
<input {...register(`links.${i}.url`)} placeholder={`링크 #${i + 1}`} />
<button type="button" onClick={() => remove(i)}>삭제</button>
</div>
))}
<button type="button" onClick={() => append({ url: "" })}>추가</button>
<button type="submit">저장</button>
</form>
);
}
links: yup.array().of(
yup.object({
url: yup.string().url("주소 형식이 아닙니다").required("주소를 입력하세요"),
})
),
Ex. 검증 시점(모드) 선택 가이드
// 제출 시에만 검사
useForm({ resolver: yupResolver(schema), mode: "onSubmit" });
// 포커스가 나갈 때 검사
useForm({ resolver: yupResolver(schema), mode: "onBlur" });
// 타이핑 중 즉시 검사
useForm({ resolver: yupResolver(schema), mode: "onChange" });
장단점 정리
➡️ 장점
➡️ 단점
➡️ 선택 기준
필드 수가 많거나, 검증 규칙이 존재하거나, 향후 확장이 예상된다면
이 조합이 개발 속도와 품질 모두 이득이라고 생각한다!
작은 화면 하나에 먼저 적용해서 효과를 확인해보고,
점차 범위를 넓히는 것을 추천한다.
용어 정리
💡제어 방식 입력(Controlled)
입력 값이 항상 React 상태와 동기화되는 방식
단순하지만 리렌더링이 잦음
💡비제어 방식 입력(Uncontrolled)
브라우저가 값 변화를 관리하고 우리는 참조만 등록함
리렌더링이 적어 속도가 빠름
💡스키마(schema)
데이터 형태와 검증 규칙의 설계도
💡리졸버(resolver)
React Hook Form과 검증 엔진(Yup)을 이어 주는 어댑터
💡마스킹(masking)
화면에서 보기 좋게 포맷만 입히는 것
(내부 값은 가공되지 않은 상태로 유지)