본문 바로가기

회원가입 폼 만들기

React Hook Form과 Yup으로 필드 계약을 공유하고, 접근 가능한 오류 표시와 제출 잠금, 서버 field/global 오류 복구를 갖춘 회원가입 폼을 만듭니다.

9장에서는 제어와 비제어 컴포넌트, 객체 상태와 useReducer, 커스텀 훅으로 폼을 관리하고, 기본적인 유효성 검사를 직접 구현했습니다. 이 실습에서는 React Hook Form이 폼의 값과 제출 상태를 관리하고, Yup 스키마(schema)가 검증 규칙을 맡도록 역할을 나누어 회원가입 폼을 만듭니다. register로 필드를 이름에 연결하고, handleSubmit과 yupResolver(schema)로 현재 값을 검증하며, formState.errors를 입력과 접근성 설명에 연결합니다. isValidating과 isSubmitting으로 버튼을 비활성화해 제출 중의 재클릭을 줄이고, 서버 오류를 필드 오류와 전체 오류로 나누어 고치고 다시 시도하는 흐름을 만듭니다.

React Hook Form과 Yup의 단일 폼 처리 파이프라인 — 상태 추적

동일 필드 계약의 현재 값이 handleSubmit/resolver를 지나 errors 또는 onSubmit data로 갈린다. 입력/검증 결과/요청 허용과 서버 경계를 추적할 가치가 있다.

같은 필드 이름을 defaultValues, register, Yup schema가 공유하고 제출 이벤트가 handleSubmit과 yupResolver를 거쳐 invalid이면 errors로, valid이면 onSubmit으로 전달되는 단일 폼 데이터 흐름

React · React Hook Form · Yup resolver

한 필드 계약이 등록·초기값·스키마를 묶는다

register가 수집한 현재 값은 handleSubmit에서 yupResolver(schema)로 전달됩니다. 같은 필드 이름을 defaultValues와 schema가 공유해야 오류가 올바른 입력에 연결되고, 검증 결과에 따라 errors 또는 onSubmit으로 갈립니다.

동일 필드 계약과 Yup 검증의 실패·성공 분기공통 필드 이름을 defaultValues, register, schema가 공유합니다. register로 연결된 현재 값을 handleSubmit이 yupResolver로 보내고 오류면 formState.errors, 통과면 onSubmit으로 전달합니다.공통 필드 계약username · email · password · confirmPassworddefaultValues ↔ register(name) ↔ Yup schema동일한 필드 이름으로 초기값·입력·오류를 연결INVALIDVALIDregister(name)현재 필드 값 수집handleSubmit제출 이벤트 처리Yup 규칙yupResolver(schema)값 검사 · 결과 변환formState.errors필드 오류 · 요청 보내지 않음onSubmit(data)검증 통과 값으로 API 호출클라이언트 검증 통과 뒤에도 서버의 독립적인 검증이 필요합니다.
공유 필드 이름과 실패·성공 두 제출 결과공통 필드이름 네개username/email/password/confirmPassword를 defaultValues/register/schema가 공유한다. 공통계약은 실행노드가아니므로 간선을추가하지 않는다. register→handleSubmit→resolver에서 invalid errors 또는 valid onSubmit으로 갈린다. 같은4edges 유지하며 두결과사이간선없다.공통 필드 계약defaultValuesregister ·schemaregister(name)현재 값 수집handleSubmit제출 이벤트Yup 규칙yupResolver검사 결과 변환INVALID → errors오류 위치 안내요청 보내지 않음VALID → onSubmit통과한 값서버 독립 검증상단은 공유 이름 계약검증 결과에서 분기INVALID와 VALID는 별개
공통 키 username · email · password · confirmPassword
register

필드를 이름으로 연결

register(name)가 현재 입력 값을 수집하며 같은 이름을 defaultValues와 schema에서 사용합니다.

submit

handleSubmit이 제출 처리

브라우저 submit 이벤트를 받아 현재 폼 값을 resolver에 전달합니다.

resolver

yupResolver(schema)

공통 키의 값을 Yup 규칙으로 검사하고 성공 값 또는 필드 오류를 반환합니다.

invalid

errors로 복귀

필드별 메시지를 해당 입력의 접근성 설명과 연결하고 API 요청은 보내지 않습니다.

valid

onSubmit(data) 실행

resolver가 통과시킨 데이터만 제출 함수에 전달됩니다. 서버는 같은 업무 규칙을 다시 검증합니다.

defaultValues, register, schema의 필드 이름이 어긋나면 초기값·오류·제출 데이터가 서로 다른 키를 가리킵니다. 한 계약으로 유지하되 클라이언트 통과를 서버 승인으로 간주하지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명register가 수집한 현재 값은 handleSubmit에서 yupResolver(schema)로 전달됩니다. 같은 필드 이름을 defaultValues와 schema가 공유해야 오류가 올바른 입력에 연결되고, 검증 결과에 따라 errors 또는 onSubmit으로 갈립니다.
전체 관계·조건 설명공통 필드 이름 네 개 username/email/password/confirmPassword를 defaultValues/register/schema가 공유한다. 공통 계약은 실행 노드가 아니므로 간선을 추가하지 않는다. register→handleSubmit→resolver에서 invalid errors 또는 valid onSubmit으로 갈린다. 같은 간선 4개를 유지하며 두 결과 사이에는 간선이 없다.
원도표 설명register(name)가 현재 입력 값을 수집하며 같은 이름을 defaultValues와 schema에서 사용합니다.
원도표 설명handleSubmit이 제출 처리
원도표 설명errors로 복귀
원도표 설명defaultValues, register, schema의 필드 이름이 어긋나면 초기값·오류·제출 데이터가 서로 다른 키를 가리킵니다. 한 계약으로 유지하되 클라이언트 통과를 서버 승인으로 간주하지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
동일 field 이름연결: 같은 field 이름 / 입력: 현재 valuesdefaultValues·register·schema가 같은 username·email·password·confirmPassword를 연결한다필드 이름이 오류와 기본값의 연결 계약이다
resolver 검사invalid: formState.errors / valid: onSubmitdatahandleSubmit이 yupResolver로 현재 값을 검사한다검증 실패는 API 요청으로 가지 않는다
서버 경계client: valid data / server: 독립 검증통과한 data로 API를 호출하되 서버의 판정을 기다린다클라이언트 schema가 권한·중복·무결성을 최종 보장하지는 않는다

React Hook Form과 Yup의 단일 폼 처리 파이프라인 — 상태 추적

동일 필드 계약의 현재 값이 handleSubmit/resolver를 지나 errors 또는 onSubmit data로 갈린다. 입력/검증 결과/요청 허용과 서버 경계를 추적할 가치가 있다.

같은 필드 이름을 defaultValues, register, Yup schema가 공유하고 제출 이벤트가 handleSubmit과 yupResolver를 거쳐 invalid이면 errors로, valid이면 onSubmit으로 전달되는 단일 폼 데이터 흐름

React · React Hook Form · Yup resolver

한 필드 계약이 등록·초기값·스키마를 묶는다

register가 수집한 현재 값은 handleSubmit에서 yupResolver(schema)로 전달됩니다. 같은 필드 이름을 defaultValues와 schema가 공유해야 오류가 올바른 입력에 연결되고, 검증 결과에 따라 errors 또는 onSubmit으로 갈립니다.

동일 필드 계약과 Yup 검증의 실패·성공 분기공통 필드 이름을 defaultValues, register, schema가 공유합니다. register로 연결된 현재 값을 handleSubmit이 yupResolver로 보내고 오류면 formState.errors, 통과면 onSubmit으로 전달합니다.공통 필드 계약username · email · password · confirmPassworddefaultValues ↔ register(name) ↔ Yup schema동일한 필드 이름으로 초기값·입력·오류를 연결INVALIDVALIDregister(name)현재 필드 값 수집handleSubmit제출 이벤트 처리Yup 규칙yupResolver(schema)값 검사 · 결과 변환formState.errors필드 오류 · 요청 보내지 않음onSubmit(data)검증 통과 값으로 API 호출클라이언트 검증 통과 뒤에도 서버의 독립적인 검증이 필요합니다.
공유 필드 이름과 실패·성공 두 제출 결과공통 필드이름 네개username/email/password/confirmPassword를 defaultValues/register/schema가 공유한다. 공통계약은 실행노드가아니므로 간선을추가하지 않는다. register→handleSubmit→resolver에서 invalid errors 또는 valid onSubmit으로 갈린다. 같은4edges 유지하며 두결과사이간선없다.공통 필드 계약defaultValuesregister ·schemaregister(name)현재 값 수집handleSubmit제출 이벤트Yup 규칙yupResolver검사 결과 변환INVALID → errors오류 위치 안내요청 보내지 않음VALID → onSubmit통과한 값서버 독립 검증상단은 공유 이름 계약검증 결과에서 분기INVALID와 VALID는 별개
공통 키 username · email · password · confirmPassword
register

필드를 이름으로 연결

register(name)가 현재 입력 값을 수집하며 같은 이름을 defaultValues와 schema에서 사용합니다.

submit

handleSubmit이 제출 처리

브라우저 submit 이벤트를 받아 현재 폼 값을 resolver에 전달합니다.

resolver

yupResolver(schema)

공통 키의 값을 Yup 규칙으로 검사하고 성공 값 또는 필드 오류를 반환합니다.

invalid

errors로 복귀

필드별 메시지를 해당 입력의 접근성 설명과 연결하고 API 요청은 보내지 않습니다.

valid

onSubmit(data) 실행

resolver가 통과시킨 데이터만 제출 함수에 전달됩니다. 서버는 같은 업무 규칙을 다시 검증합니다.

defaultValues, register, schema의 필드 이름이 어긋나면 초기값·오류·제출 데이터가 서로 다른 키를 가리킵니다. 한 계약으로 유지하되 클라이언트 통과를 서버 승인으로 간주하지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명register가 수집한 현재 값은 handleSubmit에서 yupResolver(schema)로 전달됩니다. 같은 필드 이름을 defaultValues와 schema가 공유해야 오류가 올바른 입력에 연결되고, 검증 결과에 따라 errors 또는 onSubmit으로 갈립니다.
전체 관계·조건 설명공통 필드 이름 네 개 username/email/password/confirmPassword를 defaultValues/register/schema가 공유한다. 공통 계약은 실행 노드가 아니므로 간선을 추가하지 않는다. register→handleSubmit→resolver에서 invalid errors 또는 valid onSubmit으로 갈린다. 같은 간선 4개를 유지하며 두 결과 사이에는 간선이 없다.
원도표 설명register(name)가 현재 입력 값을 수집하며 같은 이름을 defaultValues와 schema에서 사용합니다.
원도표 설명handleSubmit이 제출 처리
원도표 설명errors로 복귀
원도표 설명defaultValues, register, schema의 필드 이름이 어긋나면 초기값·오류·제출 데이터가 서로 다른 키를 가리킵니다. 한 계약으로 유지하되 클라이언트 통과를 서버 승인으로 간주하지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
동일 field 이름연결: 같은 field 이름 / 입력: 현재 valuesdefaultValues·register·schema가 같은 username·email·password·confirmPassword를 연결한다필드 이름이 오류와 기본값의 연결 계약이다
resolver 검사invalid: formState.errors / valid: onSubmitdatahandleSubmit이 yupResolver로 현재 값을 검사한다검증 실패는 API 요청으로 가지 않는다
서버 경계client: valid data / server: 독립 검증통과한 data로 API를 호출하되 서버의 판정을 기다린다클라이언트 schema가 권한·중복·무결성을 최종 보장하지는 않는다

위 그림은 defaultValues와 register, Yup 스키마가 같은 필드 이름을 함께 쓰고, 검증 결과에 따라 errors나 onSubmit으로 갈리는 흐름을 보여 줍니다. 그림의 요약처럼 register가 모은 현재 값은 handleSubmit에서 yupResolver(schema)로 넘어가며, 같은 필드 이름을 defaultValues와 스키마가 함께 써야 오류가 올바른 입력에 이어집니다. 위쪽의 공통 필드 칸은 username, email, password, confirmPassword의 네 이름이 초기값과 입력, 오류를 묶는다는 뜻이며, 실행되는 단계가 아니라서 화살표로 이어지지 않습니다.

아래쪽의 흐름에서 register(name)이 현재 필드 값을 모으고, handleSubmit이 브라우저의 submit 이벤트를 받아 현재 폼 값을 resolver에 넘기며, resolver가 Yup 규칙으로 검사해 성공한 값이나 필드 오류를 돌려줍니다. 검사에 실패하면 formState.errors의 필드별 메시지를 해당 입력의 접근성 설명과 잇고 API 요청은 보내지 않으며, 통과하면 resolver가 통과시킨 데이터만 onSubmit(data)에 넘어갑니다. 그림의 마지막 설명처럼 세 곳의 필드 이름이 어긋나면 초기값과 오류, 제출 데이터가 서로 다른 키를 가리키게 되고, 클라이언트 검증을 통과했다고 서버가 승인한 것은 아니므로 서버는 같은 업무 규칙을 다시 검증합니다.


프로젝트 설정

Vite React 프로젝트에서 다음 패키지를 설치합니다. react-hook-form은 폼의 값과 오류, 제출 상태를 관리하고, yup은 검증 규칙을 스키마로 정의하며, @hookform/resolvers는 그 스키마를 React Hook Form의 검증 단계에 연결합니다. 세 패키지 모두 앱이 실행될 때 쓰이므로 일반 의존성으로 설치합니다.

npm install react-hook-form yup @hookform/resolvers

파일은 다음처럼 구성합니다.

App.jsx
index.css
UserRegistrationForm.jsx

App.jsx와 UserRegistrationForm.jsx는 서로 다른 파일입니다. 뒤에서 스키마를 담은 앞부분과 컴포넌트를 담은 뒷부분으로 나누어 보여 주는 코드는 같은 UserRegistrationForm.jsx에 차례로 이어 붙이며, 두 부분을 각각 default export 파일로 만들지 않습니다. 이 설치 명령은 독자가 자신의 실습 프로젝트에서 실행하는 단계이며, 뒤의 POST /api/users를 실제로 제공하는 서버까지 설치하지는 않습니다.

이 폼은 POST /api/users의 응답 형식에 기대므로, 개발 서버에 그 경로가 없다면 성공하는 실습을 할 수 없습니다. 그래서 먼저 뒤의 모형으로 실패와 성공을 나누어 확인한 뒤 실제 API와 연결합니다. 원래 파일은 그대로 두고, 실습용 복사본에서만 요청 함수 한 줄을 바꿉니다.


하나의 필드 이름으로 스키마 만들기

폼의 네 필드는 username, email, password, confirmPassword이며, 같은 이름을 스키마와 defaultValues, register에서 씁니다. 아래 앞부분은 컴포넌트 바깥에 두는 두 상수입니다. defaultValues는 네 필드의 초기값이고, schema는 필드마다 검사할 규칙을 차례로 이어 붙인 것이며, 각 규칙에 넘긴 문자열이 그 규칙에 실패했을 때 errors에 들어갈 문구입니다.

src/components/UserRegistrationForm.jsx · 앞부분 (스키마)
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

const defaultValues = {
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
};

const schema = yup.object({
  username: yup.string()
    .required('사용자 이름은 필수입니다.')
    .min(3, '사용자 이름은 최소 3자 이상이어야 합니다.')
    .max(20, '사용자 이름은 최대 20자 이하여야 합니다.'),

  email: yup.string()
    .required('이메일은 필수입니다.')
    .email('유효한 이메일 주소를 입력해주세요.'),

  password: yup.string()
    .required('비밀번호는 필수입니다.')
    .min(8, '비밀번호는 최소 8자 이상이어야 합니다.')
    .matches(/[A-Z]/, '비밀번호는 최소 하나의 대문자를 포함해야 합니다.')
    .matches(/[a-z]/, '비밀번호는 최소 하나의 소문자를 포함해야 합니다.')
    .matches(/[0-9]/, '비밀번호는 최소 하나의 숫자를 포함해야 합니다.')
    .matches(
      /[!@#$%^&*]/,
      '비밀번호는 최소 하나의 특수 문자(!@#$%^&*)를 포함해야 합니다.',
    ),

  confirmPassword: yup.string()
    .required('비밀번호 확인은 필수입니다.')
    .oneOf([yup.ref('password')], '비밀번호가 일치하지 않습니다.'),
}).required();

정규식이 허용하는 특수 문자와 오류 메시지에 적은 목록을 같게 맞추었습니다. confirmPassword는 required()이므로 oneOf의 허용 목록에 null을 더하지 않으며, oneOf([yup.ref('password')])는 같은 객체의 password 값과 같을 때만 통과합니다.

useForm({ defaultValues })를 폼 초기값의 유일한 기준으로 씁니다. 같은 필드에 입력마다의 defaultValue와 폼 수준의 defaultValues를 섞으면, 초기화와 dirty 비교의 기준을 이해하기 어려워질 수 있습니다.


검증·제출·서버 오류 연결 구현

다음 컴포넌트는 실제 서비스를 생각해 더한 부분으로, POST /api/users를 부릅니다. 백엔드는 성공하면 2xx를 돌려주고, 실패하면 다음 모양의 JSON을 돌려줄 수 있다고 가정합니다.

POST /api/users 실패 응답의 예
{
  "fieldErrors": {
    "email": "이미 사용 중인 이메일입니다."
  },
  "message": "회원가입 요청을 처리하지 못했습니다."
}

실제 프로젝트에서는 백엔드의 응답 형식에 맞게 연결 방식을 조정하고, 서버에서도 스키마와 업무 규칙, 고유 제약을 다시 검증해야 합니다. 클라이언트 검증을 통과했다고 서버가 승인한 것은 아닙니다.

src/components/UserRegistrationForm.jsx · 뒷부분 (컴포넌트)
const allowedServerFields = new Set([
  'username',
  'email',
  'password',
  'confirmPassword',
]);

export default function UserRegistrationForm() {
  const {
    register,
    handleSubmit,
    setError,
    clearErrors,
    reset,
    formState: {
      errors,
      isSubmitting,
      isValidating,
    },
  } = useForm({
    resolver: yupResolver(schema),
    mode: 'onBlur',
    defaultValues,
  });

  const registerField = name => register(name, {
    onChange: () => {
      if (errors[name]?.type === 'server') {
        clearErrors(name);
      }
    },
  });

  const onSubmit = async data => {
    clearErrors('root.server');

    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      });

      const result = await response.json().catch(() => ({}));

      if (!response.ok) {
        let mappedFieldError = false;

        for (const [name, message] of Object.entries(
          result.fieldErrors ?? {},
        )) {
          if (
            allowedServerFields.has(name) &&
            typeof message === 'string'
          ) {
            setError(name, {
              type: 'server',
              message,
            });
            mappedFieldError = true;
          }
        }

        if (!mappedFieldError) {
          setError('root.server', {
            type: 'server',
            message: typeof result.message === 'string'
              ? result.message
              : '회원가입 요청을 처리하지 못했습니다.',
          });
        }

        return;
      }

      reset();
      alert('회원가입이 완료되었습니다.');
    } catch {
      setError('root.server', {
        type: 'network',
        message: '네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
      });
    }
  };

  const busy = isValidating || isSubmitting;

  return (
    <div className="form-container">
      <h2>사용자 등록</h2>

      <form
        onSubmit={handleSubmit(onSubmit)}
        aria-describedby={errors.root?.server ? 'form-error' : undefined}
      >
        <div className="form-group">
          <label htmlFor="username">사용자 이름</label>
          <input
            id="username"
            type="text"
            autoComplete="username"
            aria-invalid={Boolean(errors.username)}
            aria-describedby={
              errors.username ? 'username-error' : undefined
            }
            {...registerField('username')}
          />
          {errors.username && (
            <p id="username-error" className="error-message" role="alert">
              {errors.username.message}
            </p>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="email">이메일</label>
          <input
            id="email"
            type="email"
            autoComplete="email"
            aria-invalid={Boolean(errors.email)}
            aria-describedby={errors.email ? 'email-error' : undefined}
            {...registerField('email')}
          />
          {errors.email && (
            <p id="email-error" className="error-message" role="alert">
              {errors.email.message}
            </p>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="password">비밀번호</label>
          <input
            id="password"
            type="password"
            autoComplete="new-password"
            aria-invalid={Boolean(errors.password)}
            aria-describedby={
              errors.password ? 'password-error' : undefined
            }
            {...registerField('password')}
          />
          {errors.password && (
            <p id="password-error" className="error-message" role="alert">
              {errors.password.message}
            </p>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="confirmPassword">비밀번호 확인</label>
          <input
            id="confirmPassword"
            type="password"
            autoComplete="new-password"
            aria-invalid={Boolean(errors.confirmPassword)}
            aria-describedby={
              errors.confirmPassword
                ? 'confirm-password-error'
                : undefined
            }
            {...registerField('confirmPassword')}
          />
          {errors.confirmPassword && (
            <p
              id="confirm-password-error"
              className="error-message"
              role="alert"
            >
              {errors.confirmPassword.message}
            </p>
          )}
        </div>

        {errors.root?.server && (
          <p id="form-error" className="error-message" role="alert">
            {errors.root.server.message}
          </p>
        )}

        <button type="submit" disabled={busy}>
          {isSubmitting
            ? '등록 중...'
            : isValidating
              ? '검증 중...'
              : '회원가입'}
        </button>
      </form>
    </div>
  );
}

mode: 'onBlur'이므로 처음 제출하기 전에는 필드를 벗어날 때 그 필드를 검사하고, 한 번 제출한 뒤에는 React Hook Form의 기본 reValidateMode인 'onChange'에 따라 입력이 바뀔 때마다 다시 검사합니다(React Hook Form의 useForm). busy가 true인 동안 버튼은 비활성화되고, 글자는 제출 중이면 “등록 중...”, 검증 중이면 “검증 중...”으로 바뀝니다.

비밀번호를 console.log로 출력하지 않습니다. 운영 로그나 오류 수집 도구, 분석용 데이터에도 평문 비밀번호가 들어가지 않는지 따로 확인합니다.

setError에는 백엔드가 보낸 아무 키나 그대로 넘기지 않고, allowedServerFields에 있는 필드 이름만 넘깁니다. 연결할 수 없는 오류는 root.server에 두어, 사용자가 특정 입력을 잘못 고쳤다고 오해하지 않게 합니다. registerField는 register의 onChange 옵션으로, 서버가 준 오류(type: 'server')가 있는 필드를 사용자가 고치기 시작하면 그 오류를 지웁니다.

서버 없이 응답의 종류를 따라가는 실습

다음 파일은 외부로 요청을 보내지 않는 개발용 모형입니다. 입력 스키마를 대신하는 서버 검증의 구현이 아니며, 유효한 폼에서 onSubmit이 호출된 뒤 화면이 어떻게 복구되는지만 확인합니다.

src/registrationMock.js
export function makeRegistrationFetch(mode) {
  return async (url, options) => {
    if (url !== '/api/users' || options?.method !== 'POST') {
      throw new Error('모형의 요청 계약 밖입니다.');
    }
    JSON.parse(options.body); // 실제 예제의 JSON 요청 모양만 확인
    await new Promise(resolve => setTimeout(resolve, 150));
    if (mode === 'network') {
      throw new TypeError('연습용 연결 실패');
    }
    if (mode === 'field') {
      return new Response(JSON.stringify({
        fieldErrors: { email: '연습용 이메일 중복' },
      }), { status: 409 });
    }
    if (mode === 'global') {
      return new Response(JSON.stringify({
        fieldErrors: { unknownField: '허용하지 않는 key' },
        message: '연습용 일반 서버 실패',
      }), { status: 503 });
    }
    if (mode !== 'success') {
      throw new Error('정의하지 않은 모형 모드');
    }
    return new Response('{}', { status: 201 });
  };
}

실습용 복사본의 UserRegistrationForm.jsx 맨 위에 다음 두 줄을 더하고, onSubmit의 await fetch 호출 하나만 await registrationFetch로 바꿉니다. URL과 options, 그 뒤의 오류 연결 코드는 그대로 둡니다. 모드는 하나씩 바꾸고 화면을 새로 시작해 다른 시나리오의 오류가 섞이지 않게 하며, 실제 서버와 연결할 때는 모형의 import와 선언을 지우고 원래의 fetch를 씁니다.

UserRegistrationForm.jsx 맨 위에 더할 모형 연결 (실습용 복사본)
import { makeRegistrationFetch } from '../registrationMock';
const registrationFetch = makeRegistrationFetch('field');

사용자 이름에 Mina, 이메일에 [email protected], 비밀번호와 확인에 Abcdef1!를 넣고 제출하면 모드마다 다음처럼 보입니다.

모드응답화면
field409, fieldErrors.email이메일 아래에 “연습용 이메일 중복”, 네 값은 유지
global503, 알 수 없는 unknownField와 message필드 오류 없이 root.server에 “연습용 일반 서버 실패”, 값은 유지
network요청 함수가 TypeError를 던짐catch가 root.server에 네트워크 오류 메시지, 값은 유지
success201reset()으로 네 값이 비고 “회원가입이 완료되었습니다.” alert

field 모드에서 이메일을 고치면 registerField의 onChange가 그 서버 오류를 지우지만, 새로 제출할 때는 현재 값을 다시 검사합니다. global 모드에서는 허용하지 않은 unknownField를 실제 필드 오류로 만들지 않고, 응답의 message를 일반 메시지로 보여 줍니다.

이 실습의 비밀번호 조합은 스키마의 조건을 재현하려는 예일 뿐, 인증 서비스의 비밀번호 정책이나 안전한 저장을 보장하는 조건이 아닙니다. 또한 스키마의 username은 trim()을 쓰지 않으므로 공백 세 칸도 required()와 min(3)을 통과할 수 있습니다. 공백을 뺀 이름 정책이 필요하다면 실습용 복사본에서 username 문자열 스키마에 .trim()을 더하고 서버의 정규화 규칙과 맞춥니다.

Yup의 required와 oneOf는 Yup 저장소의 API 설명에서 확인할 수 있으며, required와 두 필드의 일치 검사는 서로 다른 조건입니다. React Hook Form의 등록과 서버 오류, 제출 상태는 사용하는 패키지 버전의 문서를 함께 확인하세요. 위 모형의 응답은 JSON 객체로 한정했으므로, 실제 서버가 null이나 배열 같은 다른 모양을 돌려줄 수 있다면 그 값이 객체인지 확인한 뒤 fieldErrors를 읽도록 보완해야 합니다.


상태 머신으로 제출과 복구 확인하기

회원가입 검증·제출·복구 상태 머신 — 상태 추적

제출 검증 때부터 isSubmitting과 isValidating은 겹칠 수 있고 server success만 reset한다. 실패 field/root 오류·값 유지·교정/재시도를 실제 busy/값/오류 상태로 보강할 가치가 크다.

입력과 오류 교정에서 Yup 검증, 제출 잠금으로 진행하고 성공하면 reset, 서버 실패면 field 또는 global error를 표시한 뒤 교정과 재시도로 돌아가는 회원가입 폼 상태 머신

React · form state machine · server boundary

검증 통과 뒤에도 서버 실패와 복구 상태가 남는다

제출 시 검증부터 버튼이 비활성화되고, invalid 입력은 errors로 돌아가며 valid 입력만 API 요청으로 진행합니다. 서버 성공은 reset()으로 끝나지만 field/global 오류는 setError()로 표시해 사용자가 교정한 뒤 다시 제출할 수 있어야 합니다.

회원가입의 검증·요청·서버 오류 복구 단계제출 시 검증부터 busy로 버튼을 비활성화합니다. invalid는 오류 교정으로, valid는 API 요청으로 진행합니다. 성공은 reset하고 실패는 field 또는 root.server 오류를 표시하며 교정 또는 재시도에서 다시 검증합니다. isSubmitting은 제출 검증 전부터 켜지므로 단계별 boolean을 배타적인 상태로 해석하지 않습니다.submitinvalid · errorsvalid2xxHTTP 실패또는 networkretrychange · field 교정입력 · 오류 교정values · errorsYup 검증 중isValidating제출 시 busy 유지API 요청 중isSubmitting서버 응답 기다림성공 · 초기화reset()서버 승인 뒤 초기화서버 오류setError()field 또는 root.serverbusy = isValidating || isSubmitting 으로 버튼 비활성화검증과 요청은 업무 단계이며 boolean 상태는 겹칠 수 있습니다.
검증·API·성공과 서버 오류 복구입력→Yup검증에서 invalid는입력교정,valid는API요청으로간다.API에서2xx는reset,HTTP/network실패는서버오류로간다.서버오류에서교정또는retry가검증으로돌아간다.첫시작화살표의circle만생략하고5nodes/7업무관계가동일하며busy의두boolean은배타적상태가아니다.버튼disabled는엄격중복요청차단증거아님.입력 · 오류 교정values · errorsYup 검증 중isValidating제출 시 busyAPI 요청 중isSubmitting서버 응답 기다림성공 · 초기화reset()서버 오류setError()field /root.serverinvalid는입력 교정valid만API 호출2xx면 초기화HTTP / network실패서버 오류 뒤 현재 값 재검증busy의 두 boolean은 겹침
edit

입력·오류 교정

필드 값을 고치고 연결된 오류 메시지를 확인한 뒤 다시 제출합니다.

validate

Yup 검증 중

제출 때 isSubmitting은 검증 전부터 켜집니다. busy가 버튼을 비활성화하고, invalid면 errors로 돌아가며 valid만 API 요청으로 갑니다.

locked

API 요청과 버튼 비활성화

busy = isValidating || isSubmitting이면 버튼을 비활성화합니다. 통과한 값으로 요청하고 서버 응답을 기다립니다. 엄격한 중복 요청 차단은 별도 계약입니다.

success

성공 뒤 reset()

서버가 승인한 뒤에만 폼 값과 관련 상태를 초기화하고 흐름을 끝냅니다.

server error

Field 또는 global 오류

HTTP 실패·network 오류는 값을 유지합니다. 허용한 field만 setError로 연결하고 나머지는 root.server에 표시합니다. 필드 교정 또는 global 오류 해소 뒤 retry도 현재 값을 다시 검증합니다.

업무 단계를 표시한 그림이며 isSubmitting은 제출 검증부터 켜집니다. 버튼 disabled는 재클릭을 줄이며 엄격한 중복 요청 차단은 요청 guard·서버 idempotency를 별도로 설계합니다. 서버 오류 매핑은 이 실습에 추가한 production extension입니다. 실제 백엔드의 오류 응답 계약에 맞춰 field 이름을 허용 목록으로 제한하고, 매핑할 수 없는 실패는 global 오류로 보여 줍니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명제출 시 검증부터 버튼이 비활성화되고, invalid 입력은 errors로 돌아가며 valid 입력만 API 요청으로 진행합니다. 서버 성공은 reset()으로 끝나지만 field/global 오류는 setError()로 표시해 사용자가 교정한 뒤 다시 제출할 수 있어야 합니다.
전체 관계·조건 설명입력→Yup 검증에서 invalid는 입력 교정, valid는 API 요청으로 간다. API에서 2xx는 reset, HTTP/network 실패는 서버 오류로 간다. 서버 오류에서 교정 또는 retry가 검증으로 돌아간다. 첫 시작 화살표의 circle만 생략하고 노드 5개/업무 관계 7개가 동일하며 busy의 두 boolean은 배타적 상태가 아니다. 버튼 disabled는 엄격한 중복 요청 차단의 증거가 아니다.
원도표 설명busy = isValidating || isSubmitting이면 버튼을 비활성화합니다. 통과한 값으로 요청하고 서버 응답을 기다립니다. 엄격한 중복 요청 차단은 별도 계약입니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
submit 검증 busybusy: validating || submitting / button: disabledisSubmitting은 검증 전부터 켜지고 isValidating과 겹칠 수 있다두 Boolean을 배타적인 업무 단계로 표시하지 않는다
valid API 요청values: 유지 / isSubmitting: true통과한 현재 값으로 서버의 응답을 기다린다클라이언트 검증과 서버 승인은 다르다
결과와 복구2xx: reset / 실패: 값 유지+교정/retry성공만 reset하고 실패는 field·root 오류와 값을 유지한다버튼 disabled가 서버 idempotency나 엄격한 중복 guard를 대신하지 않는다

회원가입 검증·제출·복구 상태 머신 — 상태 추적

제출 검증 때부터 isSubmitting과 isValidating은 겹칠 수 있고 server success만 reset한다. 실패 field/root 오류·값 유지·교정/재시도를 실제 busy/값/오류 상태로 보강할 가치가 크다.

입력과 오류 교정에서 Yup 검증, 제출 잠금으로 진행하고 성공하면 reset, 서버 실패면 field 또는 global error를 표시한 뒤 교정과 재시도로 돌아가는 회원가입 폼 상태 머신

React · form state machine · server boundary

검증 통과 뒤에도 서버 실패와 복구 상태가 남는다

제출 시 검증부터 버튼이 비활성화되고, invalid 입력은 errors로 돌아가며 valid 입력만 API 요청으로 진행합니다. 서버 성공은 reset()으로 끝나지만 field/global 오류는 setError()로 표시해 사용자가 교정한 뒤 다시 제출할 수 있어야 합니다.

회원가입의 검증·요청·서버 오류 복구 단계제출 시 검증부터 busy로 버튼을 비활성화합니다. invalid는 오류 교정으로, valid는 API 요청으로 진행합니다. 성공은 reset하고 실패는 field 또는 root.server 오류를 표시하며 교정 또는 재시도에서 다시 검증합니다. isSubmitting은 제출 검증 전부터 켜지므로 단계별 boolean을 배타적인 상태로 해석하지 않습니다.submitinvalid · errorsvalid2xxHTTP 실패또는 networkretrychange · field 교정입력 · 오류 교정values · errorsYup 검증 중isValidating제출 시 busy 유지API 요청 중isSubmitting서버 응답 기다림성공 · 초기화reset()서버 승인 뒤 초기화서버 오류setError()field 또는 root.serverbusy = isValidating || isSubmitting 으로 버튼 비활성화검증과 요청은 업무 단계이며 boolean 상태는 겹칠 수 있습니다.
검증·API·성공과 서버 오류 복구입력→Yup검증에서 invalid는입력교정,valid는API요청으로간다.API에서2xx는reset,HTTP/network실패는서버오류로간다.서버오류에서교정또는retry가검증으로돌아간다.첫시작화살표의circle만생략하고5nodes/7업무관계가동일하며busy의두boolean은배타적상태가아니다.버튼disabled는엄격중복요청차단증거아님.입력 · 오류 교정values · errorsYup 검증 중isValidating제출 시 busyAPI 요청 중isSubmitting서버 응답 기다림성공 · 초기화reset()서버 오류setError()field /root.serverinvalid는입력 교정valid만API 호출2xx면 초기화HTTP / network실패서버 오류 뒤 현재 값 재검증busy의 두 boolean은 겹침
edit

입력·오류 교정

필드 값을 고치고 연결된 오류 메시지를 확인한 뒤 다시 제출합니다.

validate

Yup 검증 중

제출 때 isSubmitting은 검증 전부터 켜집니다. busy가 버튼을 비활성화하고, invalid면 errors로 돌아가며 valid만 API 요청으로 갑니다.

locked

API 요청과 버튼 비활성화

busy = isValidating || isSubmitting이면 버튼을 비활성화합니다. 통과한 값으로 요청하고 서버 응답을 기다립니다. 엄격한 중복 요청 차단은 별도 계약입니다.

success

성공 뒤 reset()

서버가 승인한 뒤에만 폼 값과 관련 상태를 초기화하고 흐름을 끝냅니다.

server error

Field 또는 global 오류

HTTP 실패·network 오류는 값을 유지합니다. 허용한 field만 setError로 연결하고 나머지는 root.server에 표시합니다. 필드 교정 또는 global 오류 해소 뒤 retry도 현재 값을 다시 검증합니다.

업무 단계를 표시한 그림이며 isSubmitting은 제출 검증부터 켜집니다. 버튼 disabled는 재클릭을 줄이며 엄격한 중복 요청 차단은 요청 guard·서버 idempotency를 별도로 설계합니다. 서버 오류 매핑은 이 실습에 추가한 production extension입니다. 실제 백엔드의 오류 응답 계약에 맞춰 field 이름을 허용 목록으로 제한하고, 매핑할 수 없는 실패는 global 오류로 보여 줍니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명제출 시 검증부터 버튼이 비활성화되고, invalid 입력은 errors로 돌아가며 valid 입력만 API 요청으로 진행합니다. 서버 성공은 reset()으로 끝나지만 field/global 오류는 setError()로 표시해 사용자가 교정한 뒤 다시 제출할 수 있어야 합니다.
전체 관계·조건 설명입력→Yup 검증에서 invalid는 입력 교정, valid는 API 요청으로 간다. API에서 2xx는 reset, HTTP/network 실패는 서버 오류로 간다. 서버 오류에서 교정 또는 retry가 검증으로 돌아간다. 첫 시작 화살표의 circle만 생략하고 노드 5개/업무 관계 7개가 동일하며 busy의 두 boolean은 배타적 상태가 아니다. 버튼 disabled는 엄격한 중복 요청 차단의 증거가 아니다.
원도표 설명busy = isValidating || isSubmitting이면 버튼을 비활성화합니다. 통과한 값으로 요청하고 서버 응답을 기다립니다. 엄격한 중복 요청 차단은 별도 계약입니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
submit 검증 busybusy: validating || submitting / button: disabledisSubmitting은 검증 전부터 켜지고 isValidating과 겹칠 수 있다두 Boolean을 배타적인 업무 단계로 표시하지 않는다
valid API 요청values: 유지 / isSubmitting: true통과한 현재 값으로 서버의 응답을 기다린다클라이언트 검증과 서버 승인은 다르다
결과와 복구2xx: reset / 실패: 값 유지+교정/retry성공만 reset하고 실패는 field·root 오류와 값을 유지한다버튼 disabled가 서버 idempotency나 엄격한 중복 guard를 대신하지 않는다

위 그림은 잘못된 입력의 교정과 Yup 검증, 제출 잠금, 성공 뒤의 reset, 서버의 필드·전체 오류와 다시 시도를 하나의 상태 머신으로 정리합니다. 그림의 요약처럼 제출하면 검증부터 버튼이 비활성화되고, 잘못된 입력은 errors로 돌아가며 유효한 입력만 API 요청으로 나아갑니다. 서버가 성공하면 reset()으로 끝나지만, 필드나 전체 오류는 setError()로 보여 주어 사용자가 고친 뒤 다시 제출할 수 있어야 합니다.

흐름을 따라가면 입력과 오류 교정 단계에서 제출하면 isValidating인 Yup 검증 단계로 가고, 실패하면 입력 교정으로, 통과하면 isSubmitting인 API 요청 단계로 갑니다. 2xx 응답이면 서버가 승인한 뒤에만 값과 관련 상태를 초기화하는 성공 단계로 끝나고, HTTP 실패나 네트워크 오류는 값을 유지한 채 허용한 필드는 setError로, 나머지는 root.server로 보여 주는 서버 오류 단계로 갑니다. 서버 오류에서 필드를 고치거나 다시 시도하면 현재 값을 다시 검증합니다.

그림의 아래쪽 설명이 덧붙이듯 busy = isValidating || isSubmitting이면 버튼을 비활성화하며, 이 두 boolean은 서로 겹칠 수 있으므로 하나만 참인 배타적 상태로 해석하지 않습니다. isValidating은 resolver가 검증하는 동안을 나타내고, isSubmitting은 제출할 때 검증보다 먼저 켜져 제출 처리가 끝나면 꺼집니다. 이 예제는 둘 가운데 하나라도 켜지면 버튼을 비활성화해 재클릭을 줄이지만, 버튼의 disabled가 중복 요청을 엄격하게 막아 주지는 않으므로 그런 보장이 필요하면 요청 가드와 서버의 멱등성을 따로 설계합니다.

reset()은 서버가 성공했을 때만 부릅니다. 서버가 실패하면 현재 값을 유지하고 고칠 곳을 보여 줘야, 사용자가 입력을 잃지 않고 다시 시도할 수 있습니다.


최소 스타일

src/index.css
.form-container {
  max-width: 38rem;
  margin: 2rem auto;
  padding: 1.5rem;
  border: 1px solid #d4d4d4;
  border-radius: 0.5rem;
}

.form-group {
  margin-block: 1rem;
}

.form-group label {
  display: block;
  margin-block-end: 0.375rem;
  font-weight: 700;
}

.form-group input {
  width: 100%;
  box-sizing: border-box;
  padding: 0.75rem;
  border: 1px solid #a3a3a3;
  border-radius: 0.375rem;
}

.form-group input[aria-invalid="true"] {
  border-color: #b91c1c;
}

.error-message {
  color: #b91c1c;
  margin-block: 0.375rem 0;
}

button {
  width: 100%;
  padding: 0.75rem;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

오류가 있는 입력은 aria-invalid="true" 속성 선택자로 테두리를 빨갛게 바꾸므로, 화면 낭독기에 알리는 속성과 시각적 표시가 같은 상태를 따릅니다. 비활성화된 버튼은 cursor: wait와 낮은 불투명도로 진행 중임을 보여 줍니다.


App에 연결하기

src/App.jsx
import './index.css';
import UserRegistrationForm from './components/UserRegistrationForm';

export default function App() {
  return (
    <main>
      <UserRegistrationForm />
    </main>
  );
}

App은 전역 CSS를 불러오고 폼 하나를 <main> 안에 놓기만 하므로, 폼의 동작은 모두 UserRegistrationForm 안에서 확인합니다.


확인 순서

구현을 마치면 다음 순서로 확인합니다.

단계조작확인할 것
필수값빈 폼을 제출네 입력의 오류 메시지가 각각 aria-describedby로 연결됨
형식과 비밀번호 규칙잘못된 이메일, 짧은 비밀번호, 대문자·소문자·숫자·특수 문자 누락을 하나씩 재현규칙마다 해당 메시지가 보임
필드 관계password와 confirmPassword를 다르게 입력한 뒤 고침일치 오류가 생겼다가 사라짐
제출 잠금유효한 데이터를 제출제출 중 버튼이 비활성화되고 중복 요청이 생기지 않음
서버 필드 오류중복 이메일 같은 응답setError('email', ...)로 표시되고 고친 뒤 다시 시도할 수 있음
서버 전체 오류네트워크나 일반 서버 오류root.server에 표시되고 입력값이 유지됨
성공서버가 승인그때만 reset()이 실행됨

React Hook Form과 Yup을 함께 쓸 때 중요한 것은 라이브러리의 수가 아니라 일관성입니다. 필드 이름과 초기값, 스키마, 접근성 연결, 제출 상태, 서버 오류의 연결을 한 흐름으로 시험해야 사용자가 오류를 이해하고 안전하게 복구할 수 있습니다.


더 깊이: React Hook Form이 다시 렌더링을 줄이는 방법

React Hook Form은 기본으로 비제어 방식을 씁니다. React Hook Form의 FAQ에 따르면 이 라이브러리는 기본 입력 요소의 값을 DOM에 두고 ref로 읽으므로, 글자를 입력할 때마다 폼 전체를 다시 렌더링할 필요가 없습니다. register가 돌려주는 ref, name, onChange, onBlur를 입력에 펼쳐 넣는 것도 이 연결을 만들기 위해서입니다. 반대로 value와 onChange로만 제어되는 외부 컴포넌트는 Controller나 useController로 연결하며, register로 등록한 입력에 value까지 넘기면 그 입력은 React가 제어하게 되어 첫 입력이 무시되는 문제가 생길 수 있습니다.

formState에도 같은 목적의 장치가 있습니다. 문서는 formState를 프록시(Proxy)로 감싸 렌더 성능을 높이며, 렌더하기 전에 읽은 속성만 갱신을 구독한다고 설명합니다. 그래서 이 글의 errors, isSubmitting, isValidating처럼 쓸 값은 렌더 중에 구조 분해로 미리 읽어 두고, 조건식 안에서만 formState.isValid를 읽으면 그 값의 변화를 구독하지 못할 수 있습니다. 또 isValid를 구독하면 마운트할 때와 값이 바뀔 때마다 모든 필드나 스키마 전체를 검사하므로, 큰 폼에서는 그 비용도 함께 따집니다.


정리

  • 필드 이름 하나를 defaultValues와 register, Yup 스키마가 함께 써야 초기값과 오류, 제출 데이터가 같은 키를 가리킵니다.
  • handleSubmit과 yupResolver(schema)는 검사에 실패하면 errors에 메시지를 두고 요청을 보내지 않으며, 통과한 값만 onSubmit에 넘깁니다.
  • 서버 오류는 허용한 필드 이름만 setError로 필드에 연결하고, 나머지는 root.server에 둡니다.
  • isValidating과 isSubmitting은 겹칠 수 있으며, 버튼의 비활성화는 재클릭을 줄일 뿐 중복 요청을 엄격하게 막지는 않습니다.
  • reset()은 서버가 승인했을 때만 부르고, 실패하면 입력을 유지해 고쳐서 다시 시도하게 합니다.

연습 문제

1. 서버 오류를 어디에 보여 줄까

새 폼에 유효한 네 값을 넣고 제출했습니다. HTTP 409 응답의 fieldErrors가 { email: '중복', admin: '거부' }라면 어떤 오류를 보여 주고 값을 초기화할까요? unknownField만 있는 실패와 success 모드에서 각각 root.server와 reset()은 어떻게 될까요? 비밀번호 확인이 틀린 경우에는 네 가지 모형 응답 가운데 어느 것을 볼 수 있을까요?

해설 보기

email은 허용한 필드이므로 이메일에 서버 오류 “중복”을 넣고, admin은 허용 목록 밖이라 연결하지 않습니다. 이미 필드 오류 하나를 연결했으므로 이 코드는 root.server에 일반 오류를 따로 더하지 않으며 값도 유지합니다. 여러 서버 메시지를 모두 보여 줘야 한다면 이 조건을 따로 설계합니다.

unknownField만 있는 실패는 mappedFieldError가 false이므로 root.server에 응답의 message나 기본 메시지를 넣고 reset()하지 않습니다. success는 response.ok인 201을 받아 reset()한 뒤 안내합니다. 확인 값이 틀리면 resolver 단계에서 실패하므로 onSubmit도 모형 함수도 호출되지 않아, 어떤 모형 응답도 볼 수 없습니다. 클라이언트의 검증 오류와 서버 오류의 위치를 나누어야, 이미 올바른 입력을 계속 고치라고 잘못 안내하는 일을 줄일 수 있습니다.

2. 검사 시점

이 폼에서 처음 제출하기 전에 이메일 칸에 mina만 입력하고 아직 칸을 벗어나지 않았다면 오류가 보일까요? 칸을 벗어난 뒤와, 한 번 제출한 다음 이메일을 다시 고칠 때는 어떻게 될까요?

해설 보기

mode: 'onBlur'이므로 칸을 벗어나기 전에는 검사하지 않아 오류가 보이지 않습니다. 칸을 벗어나면 그 필드를 검사해 “유효한 이메일 주소를 입력해주세요.”가 나타납니다.

한 번 제출한 뒤에는 기본 reValidateMode인 'onChange'에 따라 입력이 바뀔 때마다 다시 검사하므로, [email protected]처럼 올바른 주소가 되는 순간 오류가 사라집니다.

3. 공백뿐인 사용자 이름

사용자 이름에 공백 세 칸을 넣고 나머지를 유효하게 채워 success 모드로 제출하면 어떻게 될까요? 스키마에 .trim()을 더하면 무엇이 달라질까요?

해설 보기

원래 스키마는 trim()을 쓰지 않으므로 공백 세 칸은 빈 문자열이 아니어서 required()를 통과하고, 길이도 3이라 min(3)을 통과합니다. 그래서 검증을 통과해 모형 요청이 성공하고 폼이 초기화됩니다.

.trim()을 더하면 Yup이 앞뒤 공백을 뺀 값으로 검사하므로 빈 문자열이 되어 “사용자 이름은 필수입니다.”가 보이고 요청은 보내지 않습니다. 클라이언트에서 다듬더라도 서버의 정규화 규칙과 맞춰야 같은 이름을 다르게 판단하지 않습니다.

이것으로 9장을 마칩니다. 9장에서는 제어와 비제어 컴포넌트의 차이에서 시작해, 객체 상태와 useReducer, 커스텀 훅으로 복잡한 폼을 관리하는 방법, 입력과 제출 시점에 맞춘 기본적인 유효성 검사를 익히고, React Hook Form과 Yup으로 서버 오류까지 복구하는 회원가입 폼을 만들었습니다. 다음 장의 React Developer Tools 사용법부터는 성능 최적화를 다루며, Components와 Profiler 탭으로 상태의 전달과 렌더링 비용을 측정하는 것으로 시작합니다.