React Hook Form과 Zod로 폼 쉽게 만들기

요약

React Hook Form & Zod 2026 완벽 가이드

2026년 최신 React Hook Form과 Zod를 활용하여 복잡한 웹 폼을 효율적으로 만들고 강력한 유효성 검사를 적용하는 실전 가이드입니다.

핵심 키워드: React Hook Form, Zod, 폼 유효성 검사

목차

1. 2026년 프론트엔드 폼 개발, 왜 React Hook Form과 Zod인가?

2. React Hook Form: 가볍고 빠른 폼의 비결

3. Zod: 강력한 스키마 기반 유효성 검사

4. React Hook Form과 Zod의 환상적인 조합

5. 실전 예제: 회원가입 폼 구축 및 유효성 검사

6. 복잡한 폼 다루기: 중첩 객체와 배열 필드

7. React Hook Form & Zod 사용 시 흔한 문제와 해결책

8. 결론: 2026년 프론트엔드 폼 개발의 표준

1. 2026년 프론트엔드 폼 개발, 왜 React Hook Form과 Zod인가?

안녕하세요, 권퓨터입니다! 2026년에도 웹 애플리케이션 개발에서 사용자 입력 폼은 여전히 핵심적인 요소입니다. 회원가입, 로그인, 설정 변경, 게시글 작성 등 거의 모든 서비스에서 폼은 필수적이죠. 하지만 이 폼을 만드는 과정은 생각보다 복잡하고 골치 아픈 경우가 많습니다. 특히 유효성 검사(Validation) 로직은 개발자의 섬세한 손길을 요구하며, 잘못 구현하면 사용자 경험 저하는 물론, 보안 취약점으로 이어질 수도 있습니다.

이러한 복잡성을 해결하고 개발 효율을 극대화하기 위해 프론트엔드 개발자들은 다양한 라이브러리를 활용해왔습니다. 2026년 현재, React 환경에서 폼 개발의 새로운 표준으로 떠오르고 있는 조합이 바로 React Hook FormZod입니다. 이 두 가지 라이브러리는 각각 폼 상태 관리와 스키마 기반 유효성 검사라는 강점을 통해, 복잡한 폼도 쉽고 견고하게 만들 수 있도록 돕습니다.

기존의 폼 라이브러리들이 가졌던 성능 문제나 번거로운 설정 과정을 개선하면서, 개발 편의성과 타입 안정성까지 제공하는 이 조합은 2026년 프론트엔드 개발의 필수 도구로 자리매김하고 있습니다. 이번 가이드에서는 이 두 라이브러리의 핵심 기능부터 실제 프로젝트에 적용하는 방법, 그리고 자주 발생하는 문제 해결 팁까지 권퓨터가 완벽하게 파헤쳐 보겠습니다!

핵심 포인트

2026년 프론트엔드 폼 개발에서 React Hook Form과 Zod는 복잡성 감소, 개발 효율 증대, 타입 안정성 확보를 위한 최적의 조합으로 평가받고 있습니다.

React Hook Form과 Zod 로고가 있는 노트북 화면의 깔끔한 폼 UI 이미지

2. React Hook Form: 가볍고 빠른 폼의 비결

React Hook Form (이하 RHF)은 React 애플리케이션에서 폼을 관리하기 위한 라이브러리입니다. 그 이름처럼 React Hooks를 기반으로 설계되었으며, 가장 큰 특징은 성능간결한 API입니다. RHF는 “비제어(Uncontrolled) 컴포넌트” 방식을 적극적으로 활용하여 불필요한 리렌더링을 최소화하고, 이를 통해 대규모 폼에서도 뛰어난 성능을 자랑합니다.

2.1. Uncontrolled Components의 힘

기존 React 폼 개발 방식 중 하나인 “제어(Controlled) 컴포넌트”는 입력 필드의 모든 변경 사항을 React 상태(State)로 관리합니다. 즉, 사용자가 한 글자를 입력할 때마다 상태가 업데이트되고, 이로 인해 컴포넌트가 리렌더링되는 과정이 반복됩니다. 간단한 폼에서는 문제가 없지만, 필드가 많아지거나 복잡한 로직이 추가되면 성능 저하의 원인이 될 수 있습니다.

반면 RHF는 HTML 표준 폼 요소를 직접 참조하는 “비제어 컴포넌트” 방식을 기본으로 합니다. React의 ref를 사용하여 입력 필드의 DOM에 직접 접근하고, 필요한 시점(예: 제출 버튼 클릭 시)에만 값을 읽어옵니다. 이 덕분에 사용자가 입력 필드를 조작하는 동안 컴포넌트가 불필요하게 리렌더링되지 않아 압도적인 성능 이점을 제공합니다.

2.2. 핵심 API: useForm, register, handleSubmit

RHF의 사용법은 매우 직관적입니다. 주요 API는 다음과 같습니다.

React Hook Form 핵심 API

useForm() — 폼의 모든 기능을 제공하는 메인 훅입니다. register, handleSubmit, formState 등을 반환합니다.

register() — HTML 입력 요소를 RHF에 등록합니다. ref와 이벤트 핸들러를 반환하여 입력 필드에 스프레드(spread)하면 됩니다.

handleSubmit(onSubmit) — 폼 제출 이벤트 핸들러입니다. 유효성 검사를 통과하면 onSubmit 함수를 호출하고, 검사 실패 시에는 에러 정보를 제공합니다.

control — 외부 UI 라이브러리 (MUI, Ant Design 등) 컴포넌트를 RHF에 연결할 때 사용합니다. Controller 컴포넌트와 함께 사용됩니다.

2.3. 다른 폼 라이브러리와의 비교 (2026년 기준)

RHF는 Formik, Redux Form 등 다른 인기 있는 폼 라이브러리들과 비교했을 때 여러 면에서 강점을 가집니다. 특히 2026년 현재는 번들 사이즈와 렌더링 성능이 더욱 중요해지면서 RHF의 장점이 부각되고 있습니다.

React 폼 라이브러리 비교 (2026)

(데이터는 2026년 5월 기준 최신 버전의 일반적인 사용 시나리오를 바탕으로 추정)

특징React Hook FormFormikRedux Form
번들 사이즈 (min+gzip)~8KB (가장 작음)~15KB~25KB (Redux 의존)
성능 (리렌더링)최소화 (비제어 컴포넌트)중간 (제어 컴포넌트)잦음 (Redux 상태 관리)
학습 곡선낮음 (React Hooks에 익숙하다면)중간높음 (Redux 지식 필요)
유효성 검사 통합Resolver 패턴 (Zod, Yup 등)Yup 통합 용이수동 구현 또는 외부 라이브러리
타입스크립트 지원최상 (Zod와 시너지)좋음보통

위 표에서 볼 수 있듯이, RHF는 번들 사이즈, 성능, 학습 곡선, 그리고 유효성 검사 통합 방식에서 뛰어난 모습을 보여줍니다. 특히 타입스크립트 환경에서는 Zod와의 조합으로 최고의 개발 경험을 제공합니다.

핵심 포인트

React Hook Form은 비제어 컴포넌트 방식을 통해 불필요한 리렌더링을 줄여 탁월한 성능을 제공하며, 간결한 API와 낮은 번들 사이즈로 2026년 프론트엔드 폼 개발의 핵심 도구로 자리매김했습니다.

3. Zod: 강력한 스키마 기반 유효성 검사

폼 데이터의 유효성 검사는 단순히 값이 비어있는지 확인하는 것을 넘어, 데이터의 형태, 범위, 관계 등 복잡한 규칙을 검증해야 합니다. 이때 Zod는 개발자에게 강력하고 유연한 스키마 기반 유효성 검사 기능을 제공합니다. Zod의 가장 큰 장점은 타입스크립트와의 완벽한 통합입니다.

3.1. 스키마 우선 접근 방식과 타입 추론

Zod는 “스키마 우선” 접근 방식을 채택합니다. 즉, 유효성 검사 규칙을 먼저 정의하고, 이 스키마를 통해 데이터의 유효성을 검사합니다. Zod가 다른 유효성 검사 라이브러리(예: Yup)와 차별화되는 지점은 바로 타입스크립트 타입 추론(Type Inference)입니다.

Zod로 스키마를 정의하면, Zod는 해당 스키마에 맞는 타입스크립트 타입을 자동으로 생성해줍니다. 이를 통해 폼 데이터의 유효성을 검사하는 동시에, 검증된 데이터가 해당 타입에 정확히 일치함을 보장하여 런타임 에러를 줄이고 개발 생산성을 크게 향상시킵니다. 2026년 현대적인 웹 개발에서 타입 안정성은 더 이상 선택이 아닌 필수 요소가 되었습니다.

3.2. 다양한 유효성 검사 타입과 커스텀 검증

Zod는 문자열, 숫자, 불리언, 객체, 배열 등 기본적인 데이터 타입에 대한 광범위한 유효성 검사 메서드를 제공합니다. 또한, 개발자가 직접 복잡한 검증 로직을 추가할 수 있는 강력한 기능도 갖추고 있습니다.

Zod의 주요 유효성 검사 기능

문자열 (String).min(길이), .max(길이), .email(), .url(), .regex()

숫자 (Number).min(값), .max(값), .int(), .positive()

객체 (Object)z.object({ 필드: 스키마 })로 중첩된 구조 정의

배열 (Array)z.array(아이템_스키마)로 배열 요소 검증

커스텀 검증.refine() 또는 .superRefine()을 사용하여 복잡한 조건 추가

선택적 필드.optional()을 사용하여 필드가 없거나 undefined일 수 있도록 허용

예를 들어, 비밀번호 확인 로직처럼 두 필드의 값이 일치하는지 확인해야 하는 경우 .refine()을 사용해 전체 객체 스키마에 대한 유효성 검사를 추가할 수 있습니다. 이는 복잡한 비즈니스 로직을 폼 유효성 검사에 통합하는 데 매우 유용합니다.

핵심 포인트

Zod는 스키마 기반의 강력한 유효성 검사 라이브러리로, 특히 타입스크립트와 완벽하게 통합되어 개발자가 런타임 오류를 줄이고 타입 안정성을 확보하는 데 결정적인 도움을 줍니다.

4. React Hook Form과 Zod의 환상적인 조합

React Hook Form이 폼 상태 관리와 렌더링 최적화에 강점을 가진다면, Zod는 데이터 유효성 검사와 타입 안정성에 특화되어 있습니다. 이 두 라이브러리가 만나면 시너지를 발휘하여 프론트엔드 폼 개발의 새로운 지평을 엽니다. 마치 퍼즐 조각처럼 서로의 부족한 부분을 완벽하게 채워주는 조합이라고 할 수 있습니다.

4.1. @hookform/resolvers/zod를 이용한 간편한 연동

React Hook Form은 다양한 유효성 검사 라이브러리와 쉽게 연동할 수 있도록 “Resolver” 패턴을 제공합니다. Zod와의 연동을 위해 @hookform/resolvers/zod 패키지를 사용하면 됩니다. 이 패키지는 Zod 스키마를 RHF가 이해할 수 있는 형태로 변환하여, useForm 훅의 resolver 옵션에 전달하기만 하면 됩니다.

코드 설명

React Hook Form의 useForm 훅에 Zod 스키마를 연결하는 방법입니다. zodResolver를 사용하여 스키마를 리졸버로 변환하여 전달합니다.

import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

// 1. Zod 스키마 정의
const loginSchema = z.object({
  email: z.string().email('유효하지 않은 이메일 형식입니다.'),
  password: z.string().min(8, '비밀번호는 최소 8자 이상이어야 합니다.'),
});

// 2. 스키마로부터 타입 추론
type LoginFormInputs = z.infer<typeof loginSchema>;

function LoginForm() {
  // 3. useForm 훅에 zodResolver와 스키마 전달
  const { register, handleSubmit, formState: { errors } } = useForm<LoginFormInputs>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = (data: LoginFormInputs) => {
    console.log('폼 제출 데이터:', data);
    // 서버 전송 로직
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">이메일</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <p style={{ color: '#e03131', fontSize: '14px' }}>{errors.email.message}</p>}
      </div>
      <div style={{ paddingTop: '16px' }}>
        <label htmlFor="password">비밀번호</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <p style={{ color: '#e03131', fontSize: '14px' }}>{errors.password.message}</p>}
      </div>
      <button type="submit" style={{ marginTop: '24px', padding: '10px 20px', backgroundColor: '#667eea', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>로그인</button>
    </form>
  );
}

export default LoginForm;

위 코드처럼 몇 줄의 설정만으로 Zod의 강력한 유효성 검사 로직을 RHF에 통합할 수 있습니다. errors 객체를 통해 각 필드의 에러 메시지에 쉽게 접근하여 사용자에게 피드백을 줄 수도 있습니다.

4.2. 타입 안정성과 개발자 경험 향상

RHF와 Zod의 조합은 특히 타입스크립트 프로젝트에서 빛을 발합니다. Zod 스키마를 통해 폼 데이터의 타입을 정의하고, 이 타입을 useForm 훅에 전달함으로써, 폼 필드 이름, 제출 데이터, 에러 객체 등 폼과 관련된 모든 데이터에 대해 완벽한 타입 안정성을 확보할 수 있습니다.

이는 개발 과정에서 오타나 잘못된 데이터 접근으로 인한 실수를 컴파일 타임에 잡아내어 런타임 버그를 예방하고, 자동 완성(IntelliSense) 기능을 통해 개발자 경험(DX)을 크게 향상시킵니다. 2026년 대규모 프론트엔드 프로젝트에서는 이러한 타입 안정성이 프로젝트의 견고함을 결정하는 중요한 요소가 됩니다.

핵심 포인트

React Hook Form과 Zod는 @hookform/resolvers/zod를 통해 쉽게 연동되며, 강력한 타입 안정성과 향상된 개발자 경험을 제공하여 견고한 폼 개발을 가능하게 합니다.

React Hook Form과 Zod의 데이터 흐름 및 연동 과정 다이어그램

5. 실전 예제: 회원가입 폼 구축 및 유효성 검사

이제 React Hook Form과 Zod를 활용하여 실제 회원가입 폼을 만들어 보겠습니다. 이 예제에서는 이름, 이메일, 비밀번호, 비밀번호 확인 필드를 포함하며, 각 필드에 대한 유효성 검사 규칙을 Zod로 정의하고, RHF를 통해 폼을 관리합니다.

5.1. Zod 스키마 정의

먼저 회원가입 폼의 데이터 구조와 유효성 검사 규칙을 Zod 스키마로 정의합니다. 비밀번호와 비밀번호 확인 필드는 .refine()을 사용하여 두 필드 간의 일치 여부를 검사합니다.

코드 설명

회원가입 폼을 위한 Zod 스키마입니다. 각 필드에 대한 기본 검증과 함께, 비밀번호와 비밀번호 확인 필드가 일치하는지 검사하는 .refine() 메서드를 사용했습니다.

// src/schemas/authSchema.ts
import * as z from 'zod';

export const signupSchema = z.object({
  name: z.string().min(2, '이름은 최소 2자 이상이어야 합니다.').max(50, '이름은 50자를 초과할 수 없습니다.'),
  email: z.string().email('유효하지 않은 이메일 형식입니다.'),
  password: z.string().min(8, '비밀번호는 최소 8자 이상이어야 합니다.')
    .regex(/[A-Z]/, '비밀번호는 최소 1개 이상의 대문자를 포함해야 합니다.')
    .regex(/[a-z]/, '비밀번호는 최소 1개 이상의 소문자를 포함해야 합니다.')
    .regex(/[0-9]/, '비밀번호는 최소 1개 이상의 숫자를 포함해야 합니다.')
    .regex(/[^A-Za-z0-9]/, '비밀번호는 최소 1개 이상의 특수문자를 포함해야 합니다.'),
  confirmPassword: z.string().min(1, '비밀번호 확인을 입력해주세요.'),
}).refine((data) => data.password === data.confirmPassword, {
  message: '비밀번호가 일치하지 않습니다.',
  path: ['confirmPassword'], // 에러 메시지를 confirmPassword 필드에 연결
});

export type SignupFormInputs = z.infer<typeof signupSchema>;

5.2. React 컴포넌트 구현

이제 위에서 정의한 스키마를 사용하여 React 컴포넌트를 만듭니다. useForm 훅과 register 메서드를 활용하여 각 입력 필드를 연결하고, 에러 메시지를 표시합니다.

코드 설명

회원가입 폼 React 컴포넌트입니다. useForm에 Zod 스키마와 zodResolver를 연결하고, 각 입력 필드에 {...register('필드명')}을 적용했습니다. 에러 발생 시 errors 객체를 통해 메시지를 표시합니다.

// src/components/SignupForm.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, SignupFormInputs } from '../schemas/authSchema';

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<SignupFormInputs>({
    resolver: zodResolver(signupSchema),
    mode: 'onBlur', // 필드를 벗어날 때 유효성 검사 실행
  });

  const onSubmit = (data: SignupFormInputs) => {
    console.log('회원가입 데이터:', data);
    alert('회원가입이 성공적으로 완료되었습니다! (콘솔 확인)');
    // 실제 서버 API 호출 로직
  };

  return (
    <div style={{ maxWidth: '400px', margin: '40px auto', padding: '30px', border: '1px solid #e9ecef', borderRadius: '12px', backgroundColor: '#f8f9fa' }}>
      <h3 style={{ fontSize: '22px', fontWeight: '700', color: '#212529', paddingBottom: '24px', textAlign: 'center' }}>회원가입</h3>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div style={{ paddingBottom: '20px' }}>
          <label htmlFor="name" style={{ display: 'block', fontSize: '15px', color: '#495057', paddingBottom: '8px' }}>이름</label>
          <input
            id="name"
            type="text"
            {...register('name')}
            style={{ width: '100%', padding: '10px', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '15px', boxSizing: 'border-box' }}
          />
          {errors.name && <p style={{ color: '#e03131', fontSize: '14px', paddingTop: '6px' }}>{errors.name.message}</p>}
        </div>

        <div style={{ paddingBottom: '20px' }}>
          <label htmlFor="email" style={{ display: 'block', fontSize: '15px', color: '#495057', paddingBottom: '8px' }}>이메일</label>
          <input
            id="email"
            type="email"
            {...register('email')}
            style={{ width: '100%', padding: '10px', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '15px', boxSizing: 'border-box' }}
          />
          {errors.email && <p style={{ color: '#e03131', fontSize: '14px', paddingTop: '6px' }}>{errors.email.message}</p>}
        </div>

        <div style={{ paddingBottom: '20px' }}>
          <label htmlFor="password" style={{ display: 'block', fontSize: '15px', color: '#495057', paddingBottom: '8px' }}>비밀번호</label>
          <input
            id="password"
            type="password"
            {...register('password')}
            style={{ width: '100%', padding: '10px', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '15px', boxSizing: 'border-box' }}
          />
          {errors.password && <p style={{ color: '#e03131', fontSize: '14px', paddingTop: '6px' }}>{errors.password.message}</p>}
        </div>

        <div style={{ paddingBottom: '20px' }}>
          <label htmlFor="confirmPassword" style={{ display: 'block', fontSize: '15px', color: '#495057', paddingBottom: '8px' }}>비밀번호 확인</label>
          <input
            id="confirmPassword"
            type="password"
            {...register('confirmPassword')}
            style={{ width: '100%', padding: '10px', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '15px', boxSizing: 'border-box' }}
          />
          {errors.confirmPassword && <p style={{ color: '#e03131', fontSize: '14px', paddingTop: '6px' }}>{errors.confirmPassword.message}</p>}
        </div>

        <button
          type="submit"
          style={{ width: '100%', padding: '12px 20px', backgroundColor: '#667eea', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '16px', fontWeight: '600', cursor: 'pointer', marginTop: '20px' }}
        >
          회원가입
        </button>
      </form>
    </div>
  );
}

export default SignupForm;

핵심 포인트

Zod 스키마를 이용해 복잡한 유효성 검사 규칙(예: 비밀번호 강도, 일치 여부)을 선언적으로 정의하고, React Hook Form의 useFormregister를 통해 간결하게 폼을 구현할 수 있습니다.

React Hook Form과 Zod로 구현된 간단한 회원가입 폼 화면 예시

6. 복잡한 폼 다루기: 중첩 객체와 배열 필드

실제 애플리케이션에서는 단순히 평탄한 구조의 폼만 있는 것이 아닙니다. 사용자 주소록, 제품 옵션, 팀 구성원 목록 등 중첩된 객체나 동적으로 추가/삭제되는 배열 형태의 필드가 필요한 경우가 많습니다. React Hook Form과 Zod는 이러한 복잡한 시나리오도 효과적으로 처리할 수 있는 기능을 제공합니다.

6.1. Zod로 중첩 객체 및 배열 스키마 정의

Zod는 z.object()z.array()를 중첩하여 복잡한 데이터 구조를 쉽게 정의할 수 있습니다. 예를 들어, 여러 명의 팀원 정보를 입력받는 폼을 생각해봅시다.

코드 설명

팀원 목록을 위한 Zod 스키마입니다. 각 팀원은 이름과 역할을 가지며, 이 팀원 객체들이 배열 형태로 구성됩니다. .min()을 사용하여 최소 1명 이상의 팀원이 필요하도록 설정했습니다.

// src/schemas/teamSchema.ts
import * as z from 'zod';

const teamMemberSchema = z.object({
  name: z.string().min(1, '팀원 이름을 입력해주세요.'),
  role: z.string().min(1, '팀원 역할을 입력해주세요.'),
});

export const teamFormSchema = z.object({
  teamName: z.string().min(2, '팀 이름을 입력해주세요.'),
  members: z.array(teamMemberSchema).min(1, '최소 한 명의 팀원을 추가해야 합니다.'),
});

export type TeamFormInputs = z.infer<typeof teamFormSchema>;

6.2. useFieldArray를 이용한 동적 필드 관리

React Hook Form은 배열 형태의 필드를 효율적으로 관리하기 위해 useFieldArray 훅을 제공합니다. 이 훅을 사용하면 필드를 동적으로 추가, 삭제, 교체하는 기능을 쉽게 구현할 수 있습니다.

코드 설명

팀원 목록을 동적으로 추가/삭제할 수 있는 React 컴포넌트입니다. useFieldArrayfields를 매핑하여 각 팀원 입력 필드를 렌더링하고, appendremove 함수로 동적 조작을 처리합니다.

// src/components/TeamForm.tsx
import React from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { teamFormSchema, TeamFormInputs } from '../schemas/teamSchema';

function TeamForm() {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<TeamFormInputs>({
    resolver: zodResolver(teamFormSchema),
    defaultValues: {
      teamName: '',
      members: [{ name: '', role: '' }], // 초기 팀원 1명 설정
    },
    mode: 'onBlur',
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'members',
  });

  const onSubmit = (data: TeamFormInputs) => {
    console.log('팀 정보 제출 데이터:', data);
    alert('팀 정보가 성공적으로 제출되었습니다! (콘솔 확인)');
  };

  return (
    <div style={{ maxWidth: '600px', margin: '40px auto', padding: '30px', border: '1px solid #e9ecef', borderRadius: '12px', backgroundColor: '#f8f9fa' }}>
      <h3 style={{ fontSize: '22px', fontWeight: '700', color: '#212529', paddingBottom: '24px', textAlign: 'center' }}>팀 정보 입력</h3>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div style={{ paddingBottom: '20px' }}>
          <label htmlFor="teamName" style={{ display: 'block', fontSize: '15px', color: '#495057', paddingBottom: '8px' }}>팀 이름</label>
          <input
            id="teamName"
            type="text"
            {...register('teamName')}
            style={{ width: '100%', padding: '10px', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '15px', boxSizing: 'border-box' }}
          />
          {errors.teamName && <p style={{ color: '#e03131', fontSize: '14px', paddingTop: '6px' }}>{errors.teamName.message}</p>}
        </div>

        <div style={{ paddingBottom: '20px' }}>
          <h4 style={{ fontSize: '18px', fontWeight: '600', color: '#212529', paddingBottom: '12px' }}>팀원 목록</h4>
          {fields.map((field, index) => (
            <div key={field.id} style={{ border: '1px dashed #ced4da', padding: '15px', borderRadius: '8px', marginBottom: '15px', position: 'relative' }}>
              <label style={{ display: 'block', fontSize: '14px', color: '#868e96', paddingBottom: '8px' }}>팀원 {index + 1}</label>
              <div style={{ paddingBottom: '10px' }}>
                <label htmlFor={`members.${index}.name`} style={{ display: 'block', fontSize: '14px', color: '#495057', paddingBottom: '6px' }}>이름</label>
                <input
                  id={`members.${index}.name`}
                  type="text"
                  {...register(`members.${index}.name`)}
                  style={{ width: '100%', padding: '8px', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '14px', boxSizing: 'border-box' }}
                />
                {errors.members?.[index]?.name && <p style={{ color: '#e03131', fontSize: '13px', paddingTop: '4px' }}>{errors.members[index]?.name?.message}</p>}
              </div>
              <div style={{ paddingBottom: '10px' }}>
                <label htmlFor={`members.${index}.role`} style={{ display: 'block', fontSize: '14px', color: '#495057', paddingBottom: '6px' }}>역할</label>
                <input
                  id={`members.${index}.role`}
                  type="text"
                  {...register(`members.${index}.role`)}
                  style={{ width: '100%', padding: '8px', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '14px', boxSizing: 'border-box' }}
                />
                {errors.members?.[index]?.role && <p style={{ color: '#e03131', fontSize: '13px', paddingTop: '4px' }}>{errors.members[index]?.role?.message}</p>}
              </div>
              {fields.length > 1 && (
                <button
                  type="button"
                  onClick={() => remove(index)}
                  style={{ position: 'absolute', top: '10px', right: '10px', backgroundColor: '#e03131', color: '#fff', border: 'none', borderRadius: '50%', width: '24px', height: '24px', fontSize: '14px', cursor: 'pointer', display: 'flex', justifyContent: 'center', alignItems: 'center' }}
                >
                  &times;
                </button>
              )}
            </div>
          ))}
          <button
            type="button"
            onClick={() => append({ name: '', role: '' })}
            style={{ display: 'block', width: '100%', padding: '10px', backgroundColor: '#20c997', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '15px', fontWeight: '600', cursor: 'pointer', marginTop: '10px' }}
          >
            + 팀원 추가
          </button>
          {errors.members && <p style={{ color: '#e03131', fontSize: '14px', paddingTop: '6px' }}>{errors.members.message}</p>}
        </div>

        <button
          type="submit"
          style={{ width: '100%', padding: '12px 20px', backgroundColor: '#667eea', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '16px', fontWeight: '600', cursor: 'pointer', marginTop: '20px' }}
        >
          팀 정보 저장
        </button>
      </form>
    </div>
  );
}

export default TeamForm;

핵심 포인트

Zod의 중첩 z.object()z.array()를 통해 복잡한 폼 스키마를 정의하고, RHF의 useFieldArray 훅을 활용하여 동적으로 필드를 추가/삭제하는 기능을 효율적으로 구현할 수 있습니다.

동적으로 팀원 추가가 가능한 팀 관리 폼 UI 목업

7. React Hook Form & Zod 사용 시 흔한 문제와 해결책

React Hook Form과 Zod는 매우 강력하고 유연하지만, 때로는 특정 상황에서 개발자를 당황하게 만드는 문제에 직면할 수 있습니다. 여기서는 2026년 현재 개발자들이 흔히 겪는 문제들과 그 해결책을 제시합니다.

7.1. 비동기 유효성 검사 (Async Validation)

사용자 이름 중복 확인이나 이메일 인증처럼 서버와의 통신이 필요한 비동기 유효성 검사는 폼 개발에서 자주 등장하는 시나리오입니다. Zod는 .refine() 메서드를 통해 비동기 검증을 지원합니다.

문제 01

서버에서 사용자 이름 중복을 확인해야 하는 경우, Zod 스키마에서 어떻게 비동기 검증을 구현할까요?

폼 제출 전에 서버에 요청하여 특정 필드(예: 사용자 이름)의 유니크함을 검사해야 할 때, 일반적인 동기 검증 방식으로는 처리할 수 없습니다.

해결

Zod의 .refine() 또는 .superRefine() 메서드는 비동기 함수를 인자로 받을 수 있습니다. 비동기 로직을 refine 내부에 구현하고, 실패 시 ctx.addIssue()를 호출하여 에러를 추가합니다.