React Hook Form + Yup

Sei·2025년 9월 12일
1
post-thumbnail

React 입력 폼 관리, 왜 이렇게 복잡한걸까

프로젝트를 하다 보면 생각보다 입력 폼을 만들어야 하는 경우가 많다.
입력 받아야 하는 항목이 적은 경우에는 간단하게 만들 수 있지만
입력 항목이 많은 경우에는 코드가 꽤나 복잡해진다.

실제로 한 스타트업에서 임시보호 지원 신청서를 만든 경험이 있는데
그때는 진짜 입력 받아야 하는 항목이 정말 많아서 힘들었다.
그래서 오늘은 입력 폼을 효율적으로 구현할 수 있는 방법을 소개하고자 한다!

입력을 받을 때 고충

일단 입력을 받을 때 고려해야 할 사항들이 많다.
예를 들어,

  1. 입력 값이 올바른지 바로 알려줘라 (유효성 검사)
  2. 전화번호, 카드번호의 경우 보기 좋게 마스킹 해서 줘라 (포맷팅)
  3. 특정 값을 채웠을 때 다른 값이 필수가 되도록 만들어라
  4. 항목을 추가·삭제할 수 있는 배열 필드를 넣어 달라
  5. 제출 버튼은 오류가 없을 때만 활성화 하도록 만들어라

이 외에도 다양한 요구사항들이 존재할 수 있다.
이러한 경우 검증 코드가 불필요하게 중복될 수 있고 성능이 저하될 가능성이 있다.

라이브러리 없이 이것을 구현하면 뭐가 불편한가

우선 이러한 요구사항을 라이브러리 없이 구현을 하면 다양한 문제를 겪는다.

➡️ 코드가 빠르게 비대해진다.

  • 입력칸마다 useState와 그에 대한 Set 함수를 만들고, 제출·포커스 이탈 등 여러 지점에서 검증 코드가 반복될 가능성이 높다.

➡️ 잦은 리렌더링

  • 모든 입력을 제어 방식 입력으로 다루면 글자를 하나 입력할 때마다 화면이 다시 그려져 반응 속도가 급격히 떨어진다.
💡제어 방식 입력: 입력 값이 항상 React 상태와 동기화 되는 방식!

➡️ 화면용 포맷과 실제 값의 충돌이 발생할 수 있다.

  • 예를 들어 010-1234-5678을 화면에 보이게 하고, 내부 값은 숫자만 저장한다고 가정했을 때, 포맷팅·치환 코드가 이곳저곳에 흩어져
    버그가 자주 발생할 수 있다.

➡️ 동적 필드의 취약성

  • 항목 추가·삭제(배열)에서 인덱스와 키 관리가 꼬이기 쉽다.

➡️ 일관성 유지의 어려움

  • 오류 메시지와 규칙이 파일·컴포넌트마다 달라져
    시간이 갈수록 정합성 관리가 힘들 수 있다.

이러한 불편함을 풀기 위한 기술 두 가지

이러한 불편함을 효과적으로 해소할 수 있는 두 가지 기술이 있다.

➡️ 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. 실패 혹은 통과

  • 실패: formState.errors에 기록되어 화면에 즉시 표시
  • 통과: 정상화된 값이 제출 처리로 이동

그럼 어떻게 사용하는가

➡️ 설치
우선 '라이브러리'라서 설치를 해야 한다!

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. 숫자만, 최소·최대, 빈 값 안전 처리

  • 입력 단계 전처리 (빈 문자열을 undefined로, 숫자만 남기기)

<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" }); 
  • 즉시 피드백이 중요한 가입·결제: onChange
  • 입력칸이 많은 설정 화면: onBlur 또는 onSubmit

장단점 정리

➡️ 장점

  • 큰 폼에서도 빠른 반응성 (불필요한 리렌더링 최소화)
  • 규칙과 메시지 중앙화로 가독성·유지보수성 향상
  • 입력 전처리 → 스키마 검증 → 오류 표시의 명확한 구조
  • 동적 필드·조건부 검증·마스킹 같은 실전 요구에 강함

➡️ 단점

  • 비제어 입력과 Controller 개념의 초기 학습 필요
  • 외부 UI 컴포넌트 연동 시 접착 코드가 약간 필요
  • 의존성(React Hook Form, Yup, 리졸버) 추가

➡️ 선택 기준
필드 수가 많거나, 검증 규칙이 존재하거나, 향후 확장이 예상된다면
이 조합이 개발 속도와 품질 모두 이득이라고 생각한다!

작은 화면 하나에 먼저 적용해서 효과를 확인해보고,
점차 범위를 넓히는 것을 추천한다.

용어 정리

💡제어 방식 입력(Controlled)
입력 값이 항상 React 상태와 동기화되는 방식
단순하지만 리렌더링이 잦음

💡비제어 방식 입력(Uncontrolled)
브라우저가 값 변화를 관리하고 우리는 참조만 등록함
리렌더링이 적어 속도가 빠름

💡스키마(schema)
데이터 형태와 검증 규칙의 설계도

💡리졸버(resolver)
React Hook Form과 검증 엔진(Yup)을 이어 주는 어댑터

💡마스킹(masking)
화면에서 보기 좋게 포맷만 입히는 것
(내부 값은 가공되지 않은 상태로 유지)

profile
front-end developer

0개의 댓글