본문 바로가기

회원가입 폼 만들기

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

이 실습에서는 React Hook Form이 폼 값과 제출 상태를 관리하고, Yup schema가 검증 규칙을 담당하도록 역할을 나눕니다.

  • register로 필드를 이름에 연결합니다.
  • handleSubmit과 yupResolver(schema)로 현재 값을 검증합니다.
  • formState.errors를 입력과 접근성 설명에 연결합니다.
  • isValidating과 isSubmitting으로 버튼을 비활성화해 제출 중 재클릭을 줄입니다.
  • 서버 오류를 field 오류와 global 오류로 나눠 교정·재시도 흐름을 만듭니다.

아래 다이어그램은 defaultValues, register, schema가 같은 필드 이름을 공유하고, 검증 결과가 errors 또는 onSubmit으로 갈리는 데이터 흐름을 보여 줍니다.

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가 권한·중복·무결성을 최종 보장하지는 않는다

프로젝트 설정

Vite React 프로젝트에서 다음 패키지를 설치합니다.

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

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

App.jsx
index.css
UserRegistrationForm.jsx

App.jsx와 UserRegistrationForm.jsx는 별도 파일입니다. 같은 제목으로 나뉜 schema 블록과 구현 블록은 같은 UserRegistrationForm.jsx에 앞뒤로 연결합니다. 두 블록을 각각 default export 파일로 만들지 않습니다. 이 설치 명령은 독자가 자기 실습 프로젝트에서 수행할 단계이며, 아래 endpoint를 실제 제공하는 서버까지 설치하는 명령은 아닙니다.

이 폼은 POST /api/users의 응답 계약이 필요합니다. 개발 서버에 그 route가 없으면 성공 실습을 할 수 없으므로 먼저 다음 모형으로 실패·성공을 분리해 관찰한 뒤 실제 endpoint와 연결합니다. 원 파일은 보존하고 실습 복사본에서만 요청 함수 한 줄을 바꿉니다.

하나의 필드 계약으로 schema 만들기

폼의 네 필드는 username, email, password, confirmPassword입니다. 같은 이름을 schema, defaultValues, register에서 사용합니다.

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을 추가하지 않습니다.

useForm({ defaultValues })를 폼 초기값의 단일 기준으로 사용합니다. 같은 필드에 개별 defaultValue와 폼 수준 defaultValues를 섞으면 초기화와 dirty 비교 기준을 이해하기 어려워질 수 있습니다.

검증·제출·서버 오류 매핑 구현

다음 예시는 production extension으로 POST /api/users를 호출합니다. 백엔드는 성공 시 2xx를 반환하고, 실패 시 선택적으로 다음 형태의 JSON을 반환한다고 가정합니다.

{
  "fieldErrors": {
    "email": "이미 사용 중인 이메일입니다."
  },
  "message": "회원가입 요청을 처리하지 못했습니다."
}

실제 프로젝트에서는 백엔드 계약에 맞게 mapping을 조정하고, 서버에서도 schema·업무 규칙·고유 제약을 다시 검증해야 합니다. 클라이언트 검증 통과는 서버 승인이 아닙니다.

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>
  );
}

비밀번호를 console.log로 출력하지 않습니다. 운영 로그, 오류 수집 도구, analytics payload에도 plaintext password가 들어가지 않도록 별도로 확인합니다.

setError에는 백엔드가 보낸 임의의 key를 그대로 사용하지 않고 허용한 필드 이름만 전달합니다. 매핑할 수 없는 오류는 root.server에 두어 사용자가 특정 입력을 잘못 고쳤다고 오해하지 않게 합니다.

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

다음 파일은 외부 요청을 보내지 않는 개발용 모형입니다. 입력 schema를 대신하는 서버 검증 구현은 아니며, 유효한 폼에서 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·아래 오류 매핑은 그대로 둡니다. mode를 하나씩 바꾸고 화면을 새로 시작해 다른 시나리오의 errors를 섞지 마세요. 실제 서버 연결 때에는 모형 import·선언을 제거하고 원 fetch를 사용합니다.

import { makeRegistrationFetch } from '../registrationMock';
const registrationFetch = makeRegistrationFetch('field');

예시

username Mina, email [email protected], password와 확인을 Abcdef1!로 넣습니다. field에서는 email 오류가 나타나고 네 값이 유지됩니다. 이메일을 수정하면 registerField의 onChange가 해당 server 오류를 지우지만 새 제출 때 현재 값을 다시 검사합니다. global에서는 알 수 없는 unknownField를 실제 필드 오류로 만들지 않고 root.server에 일반 메시지를 둡니다. network는 catch로 연결 오류를 표시합니다. success는 201 뒤 reset해 네 값이 비고 성공 안내가 나타납니다.

이 실습의 비밀번호 조합은 schema 조건을 재현하기 위한 예시입니다. 인증 서비스의 비밀번호 정책이나 안전한 저장 구현을 보장하는 조건으로 쓰지 않습니다. 원 schema의 username도 trim을 적용하지 않으므로 공백 세 개가 min(3)을 통과할 수 있습니다. 공백 제외 이름 정책이 필요하면 실행 복사본에서 username 문자열 schema에 trim을 추가하고 서버의 정규화 규칙과 맞춥니다.

Yup의 required·oneOf 계약은 공식 API 원문에서 확인할 수 있습니다. required와 두 필드 일치 검사는 서로 다른 조건입니다. React Hook Form의 등록·서버 오류·제출 상태는 사용하는 패키지 버전의 문서를 함께 확인하세요. 위 모형의 반환값은 JSON 객체로 한정했으며, 실제 서버가 null이나 배열 같은 다른 형태도 반환한다면 그 값을 객체로 검증한 뒤 fieldErrors를 읽는 복사본 보완이 필요합니다.

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

아래 다이어그램은 invalid 입력 교정, Yup 검증, 제출 잠금, 성공 reset, 서버 field/global 오류와 retry를 하나의 상태 머신으로 정리합니다.

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

제출 검증 때부터 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를 대신하지 않는다

isValidating은 resolver 검증 상태를 나타내고, isSubmitting은 제출 시 검증 전부터 켜져 제출 처리가 끝나면 꺼집니다. 두 상태는 겹칠 수 있습니다. 이 예시는 두 상태 중 하나라도 활성화되면 버튼을 비활성화해 재클릭을 줄입니다. 엄격한 중복 요청 차단이 필요하면 요청 guard와 서버 idempotency를 별도로 설계합니다.

서버 성공에서만 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;
}

App에 연결하기

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

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

확인 순서

필수값: 빈 폼을 제출하고 네 입력의 오류 메시지가 각각 aria-describedby로 연결되는지 확인합니다.

형식과 비밀번호 규칙: 잘못된 email, 짧은 비밀번호, 대문자·소문자·숫자·특수 문자 누락을 각각 재현합니다.

필드 관계: password와 confirmPassword를 다르게 입력해 일치 오류를 확인한 뒤 교정합니다.

제출 잠금: valid 데이터 제출 중 버튼이 비활성화되고 중복 요청이 생기지 않는지 확인합니다.

서버 field 오류: 중복 email 같은 응답을 setError('email', ...)로 표시하고 수정 뒤 재시도합니다.

서버 global 오류: network·일반 서버 오류가 root.server에 표시되고 입력 값은 유지되는지 확인합니다.

성공: 서버가 승인했을 때만 reset()이 실행되는지 확인합니다.

React Hook Form과 Yup을 함께 쓰는 핵심은 라이브러리 수가 아니라 계약의 일관성입니다. 필드 이름, 초기값, schema, 접근성 연결, 제출 상태, 서버 오류 mapping을 한 흐름으로 테스트해야 사용자가 오류를 이해하고 안전하게 복구할 수 있습니다.

서버 오류의 위치를 독립적으로 판단하기

새 폼의 유효한 네 값으로 제출했다고 가정합니다. HTTP 409 응답의 fieldErrors가 email: '중복', admin: '거부'라면 어떤 오류를 표시하며 값을 초기화하나요? unknownField만 있는 실패와 success 모드에서 각각 root.server·reset 여부를 비교하세요. 비밀번호 확인이 틀린 경우에는 위 네 모형 중 어느 응답을 관찰할 수 있나요?

검증·매핑·성공 경계를 나눈 풀이

email은 허용 필드이므로 email에 server 오류를 넣고, admin은 허용 목록 밖이라 매핑하지 않습니다. 이미 한 필드 오류를 매핑했으므로 이 코드에서는 별도의 root.server 일반 오류를 추가하지 않고 값도 유지합니다. 여러 서버 메시지를 모두 표시하는 정책이 필요하면 이 조건을 따로 설계합니다.

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