회원가입 폼 만들기
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 · React Hook Form · Yup resolver
한 필드 계약이 등록·초기값·스키마를 묶는다
register가 수집한 현재 값은 handleSubmit에서 yupResolver(schema)로 전달됩니다. 같은 필드 이름을 defaultValues와 schema가 공유해야 오류가 올바른 입력에 연결되고, 검증 결과에 따라 errors 또는 onSubmit으로 갈립니다.
username · email · password · confirmPassword
필드를 이름으로 연결
register(name)가 현재 입력 값을 수집하며 같은 이름을 defaultValues와 schema에서 사용합니다.
handleSubmit이 제출 처리
브라우저 submit 이벤트를 받아 현재 폼 값을 resolver에 전달합니다.
yupResolver(schema)
공통 키의 값을 Yup 규칙으로 검사하고 성공 값 또는 필드 오류를 반환합니다.
errors로 복귀
필드별 메시지를 해당 입력의 접근성 설명과 연결하고 API 요청은 보내지 않습니다.
onSubmit(data) 실행
resolver가 통과시킨 데이터만 제출 함수에 전달됩니다. 서버는 같은 업무 규칙을 다시 검증합니다.
defaultValues, register, schema의 필드 이름이 어긋나면 초기값·오류·제출 데이터가 서로 다른 키를 가리킵니다. 한 계약으로 유지하되 클라이언트 통과를 서버 승인으로 간주하지 않습니다.
프로젝트 설정
Vite React 프로젝트에서 다음 패키지를 설치합니다.
npm install react-hook-form yup @hookform/resolvers파일은 다음처럼 구성합니다.
하나의 필드 계약으로 schema 만들기
폼의 네 필드는 username, email, password, confirmPassword입니다. 같은 이름을 schema, defaultValues, register에서 사용합니다.
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·업무 규칙·고유 제약을 다시 검증해야 합니다. 클라이언트 검증 통과는 서버 승인이 아닙니다.
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에 두어 사용자가 특정 입력을 잘못 고쳤다고 오해하지 않게 합니다.
상태 머신으로 제출과 복구 확인하기
아래 다이어그램은 invalid 입력 교정, Yup 검증, 제출 잠금, 성공 reset, 서버 field/global 오류와 retry를 하나의 상태 머신으로 정리합니다.
React · form state machine · server boundary
검증 통과 뒤에도 서버 실패와 복구 상태가 남는다
invalid 입력은 errors로 돌아가고, valid 입력만 비동기 제출과 잠금 상태에 들어갑니다. 서버 성공은 reset()으로 끝나지만 field/global 오류는 setError()로 표시해 사용자가 교정한 뒤 다시 제출할 수 있어야 합니다.
입력·오류 교정
필드 값을 고치고 연결된 오류 메시지를 확인한 뒤 다시 제출합니다.
Yup 검증 중
invalid면 errors를 갱신하고 API를 호출하지 않습니다. valid만 제출 함수로 갑니다.
비동기 제출 잠금
isSubmitting 동안 버튼을 비활성화해 중복 제출을 막고 서버 응답을 기다립니다.
성공 뒤 reset()
서버가 승인한 뒤에만 폼 값과 관련 상태를 초기화하고 흐름을 끝냅니다.
Field 또는 global 오류
setError()로 고칠 위치를 표시합니다. 사용자는 field를 교정하거나 global 오류가 해소된 뒤 재시도합니다.
서버 오류 매핑은 이 실습에 추가한 production extension입니다. 실제 백엔드의 오류 응답 계약에 맞춰 field 이름을 허용 목록으로 제한하고, 매핑할 수 없는 실패는 global 오류로 보여 줍니다.
isValidating은 resolver 검증 상태를, isSubmitting은 비동기 제출 상태를 나타냅니다. 이 예시는 두 상태 중 하나라도 활성화되면 버튼을 잠가 같은 폼의 중복 제출을 막습니다.
서버 성공에서만 reset()을 호출합니다. 서버 실패에서는 현재 값을 유지하고 고칠 위치를 보여 줘야 사용자가 입력을 잃지 않고 재시도할 수 있습니다.
최소 스타일
.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에 연결하기
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을 한 흐름으로 테스트해야 사용자가 오류를 이해하고 안전하게 복구할 수 있습니다.