React Hook Form으로 로그인 폼 만들기 (입력 검증 + 접근성까지)
React로 폼을 만들다 보면 useState가 입력칸 개수만큼 늘어나고, onChange 핸들러가 덕지덕지 붙는 경험을 하게 됩니다. React Hook Form(RHF)은 이 보일러플레이트를 걷어내 주는 라이브러리입니다. 이 글에서는 로그인 폼 하나를 만들면서 RHF의 핵심 3가지 — register, handleSubmit, formState — 를 익히고, 중복 제출 방지·입력 검증·웹 접근성까지 붙여봅니다.
0. 설치
npm install react-hook-form
CodeSandbox 같은 온라인 에디터라면 Dependencies에서
react-hook-form을 검색해 추가하면 됩니다.
1. 왜 RHF가 빠를까? (먼저 알고 가면 이해가 쉽습니다)
React 폼에는 크게 두 방식이 있습니다.
- Controlled(제어) 컴포넌트:
value={state}+onChange로 입력값을 React state에 묶는 방식. 키를 누를 때마다 컴포넌트가 리렌더링됩니다. - Uncontrolled(비제어) 컴포넌트: 값을 React state가 아니라 DOM(input 자체)에 맡기고, 필요할 때
ref로 읽어오는 방식.
RHF는 기본적으로 비제어 방식입니다. 그래서 타이핑할 때마다 리렌더가 발생하지 않아 빠릅니다. 뒤에서 나오는 register가 바로 이 ref 연결을 대신 해주는 함수예요. "왜 value/onChange를 안 쓰지?"라고 헷갈릴 수 있는데, 안 쓰는 게 정상입니다.
2. 기본 마크업부터
특별할 것 없는 폼입니다. 다만 주니어가 자주 빠뜨리는 포인트 두 가지:
<label htmlFor="email">의 값과<input id="email">의 값을 똑같이 맞춰야 라벨 클릭 시 인풋에 포커스가 갑니다. (접근성 기본)- 제출 버튼은
type="submit"이어야 폼의onSubmit이 발동합니다.
export default function LoginForm() {
return (
<form>
<div>
<label htmlFor="email">이메일</label>
<input id="email" type="email" placeholder="test@test.com" />
</div>
<div>
<label htmlFor="password">비밀번호</label>
<input id="password" type="password" placeholder="********" />
</div>
<button type="submit">로그인</button>
</form>
);
}
3. useForm 연결하기 — register와 handleSubmit
useForm()을 호출하면 여러 함수/속성이 담긴 객체가 나옵니다. 그중 가장 기본은 register와 handleSubmit입니다.
import { useForm } from "react-hook-form";
export default function LoginForm() {
const { register, handleSubmit } = useForm();
const onSubmit = (data) => {
// data = { email: "...", password: "..." }
alert(JSON.stringify(data));
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">이메일</label>
<input id="email" type="email" placeholder="test@test.com"
{...register("email")} />
</div>
<div>
<label htmlFor="password">비밀번호</label>
<input id="password" type="password" placeholder="********"
{...register("password")} />
</div>
<button type="submit">로그인</button>
</form>
);
}
여기서 헷갈리기 쉬운 두 부분을 풀어보면:
① {...register("email")} 의 정체register("email")는 { name, onChange, onBlur, ref } 객체를 반환합니다. 이걸 {...}로 인풋에 펼쳐서(spread) 꽂는 겁니다. name으로 필드를 식별하고, ref로 DOM에 연결해요. 그래서 onSubmit으로 넘어오는 data의 키가 바로 이 "email", "password" 문자열입니다.
② handleSubmit(onSubmit) 이 하는 일handleSubmit은 우리 콜백(onSubmit)을 감싸는 함수입니다. 제출 시 브라우저 기본 새로고침을 막아주고(preventDefault), 값을 모아서 검증한 뒤, 검증을 통과했을 때만 onSubmit(data)을 호출합니다. onSubmit={onSubmit}이 아니라 onSubmit={handleSubmit(onSubmit)} 인 점 주의하세요.
③ noValidate 는 왜?type="email" 같은 인풋은 브라우저가 자체 검증 팝업을 띄웁니다. RHF로 검증을 직접 다룰 거라 이 브라우저 기본 검증이 방해가 됩니다. <form noValidate>로 꺼줍니다. (검증 자체를 끄는 게 아니라, 브라우저 기본 검증만 끄는 겁니다.)
4. 중복 제출 막기 — formState.isSubmitting
로그인은 보통 서버 통신이라 시간이 걸립니다. 그 사이 사용자가 버튼을 두 번 누르면 중복 제출이 됩니다. formState.isSubmitting으로 제출 중인지 알 수 있으니, 그동안 버튼을 비활성화하면 됩니다.
const {
register,
handleSubmit,
formState: { isSubmitting },
} = useForm();
// 데모용 지연 (실제로는 여기서 fetch/axios 호출)
const onSubmit = async (data) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
alert(JSON.stringify(data));
};
// ...
<button type="submit" disabled={isSubmitting}>
로그인
</button>
포인트:
onSubmit을async로 만들면 RHF가 Promise가 끝날 때까지isSubmitting을true로 유지해줍니다. 그래서 별도 로딩 state를 만들 필요가 없습니다.isSubmitting을 그대로disabled에 넣으면, 제출 중엔 버튼이 잠기고 끝나면 자동으로 풀립니다.
⚠️
new Promise(setTimeout)은 데모용 가짜 지연입니다. 실제 코드에서는 이 자리에await fetch(...)같은 실제 API 호출이 들어갑니다.
5. 입력 검증 추가하기 — register의 두 번째 인자
register의 두 번째 인자로 검증 규칙을 넘깁니다.
- 이메일: 필수 + 형식(정규식)
- 비밀번호: 필수 + 최소 8자
<input
id="email"
type="email"
placeholder="test@test.com"
{...register("email", {
required: "이메일은 필수 입력입니다.",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "이메일 형식에 맞지 않습니다.",
},
})}
/>
<input
id="password"
type="password"
placeholder="********"
{...register("password", {
required: "비밀번호는 필수 입력입니다.",
minLength: {
value: 8,
message: "8자리 이상 비밀번호를 사용하세요.",
},
})}
/>
헷갈리는 지점 — 규칙 값의 형태가 두 가지입니다.
required: "메시지"처럼 문자열만 주면: 규칙은 켜지고 그 문자열이 에러 메시지가 됩니다.minLength: { value: 8, message: "..." }처럼 객체로 주면:value가 실제 규칙,message가 에러 메시지입니다.
required도 객체 형태({ value: true, message: "..." })로 쓸 수 있지만, 필수는 그냥 문자열로 쓰는 게 간결합니다.
6. 에러 메시지 보여주기 — formState.errors
검증이 실패하면 그 정보가 formState.errors에 담깁니다. 필드명을 키로 접근합니다.
const {
register,
handleSubmit,
formState: { isSubmitting, errors },
} = useForm();
<input id="email" /* ...register... */ />
{errors.email && <small role="alert">{errors.email.message}</small>}
errors.email이 존재한다 = 이메일 검증에 실패했다는 뜻.errors.email.message가 우리가 규칙에 적어둔 메시지입니다.role="alert"을 붙이면 스크린 리더가 이 메시지를 자동으로 읽어줍니다. 접근성을 위해 꼭 넣으세요.
7. 시각적 피드백 + 접근성 — aria-invalid
입력칸 자체에도 유효/무효 상태를 표시하면 좋습니다. aria-invalid 속성이 스크린 리더에게 "이 칸이 잘못됐다"를 알려주고, 동시에 CSS 스타일링 훅으로도 쓸 수 있습니다.
<input
id="email"
aria-invalid={errors.email ? "true" : "false"}
{...register("email", { /* ... */ })}
/>
input[aria-invalid="true"] {
border-color: red;
}
input[aria-invalid="false"] {
border-color: green;
}
이렇게 하면 실패 시 빨간 테두리, 통과 시 초록 테두리가 됩니다.
8. 마지막 함정 — 처음부터 초록불이 켜지는 문제
위 상태 그대로 새로고침하면, 아무것도 입력 안 했는데 초록 테두리(통과) 로 보입니다. errors.email이 아직 없으니 aria-invalid가 "false"가 되기 때문이죠. 부자연스럽습니다.
해결책: formState.isSubmitted를 활용합니다. 이 값은 사용자가 제출 버튼을 한 번이라도 눌렀는지를 알려줍니다. 제출한 적이 없으면 피드백을 아예 주지 않게 합니다.
const {
register,
handleSubmit,
formState: { isSubmitting, isSubmitted, errors },
} = useForm();
aria-invalid={isSubmitted ? (errors.email ? "true" : "false") : undefined}
- 제출 전(
isSubmitted === false):aria-invalid를undefined로 → 속성 자체가 안 붙어 평범한 회색 인풋. - 제출 후: 그제서야 빨강/초록 피드백 시작.
isSubmittedvsisSubmitting헷갈리지 마세요.
isSubmitting: 지금 제출 처리 중인가 (버튼 비활성화용)isSubmitted: 한 번이라도 제출을 시도했는가 (피드백 시점 제어용)
완성 코드
import { useForm } from "react-hook-form";
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { isSubmitting, isSubmitted, errors },
} = useForm();
const onSubmit = async (data) => {
// 데모용 지연 — 실제로는 여기서 로그인 API 호출
await new Promise((resolve) => setTimeout(resolve, 1000));
alert(JSON.stringify(data));
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">이메일</label>
<input
id="email"
type="email"
placeholder="test@test.com"
aria-invalid={isSubmitted ? (errors.email ? "true" : "false") : undefined}
{...register("email", {
required: "이메일은 필수 입력입니다.",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "이메일 형식에 맞지 않습니다.",
},
})}
/>
{errors.email && <small role="alert">{errors.email.message}</small>}
</div>
<div>
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
placeholder="********"
aria-invalid={isSubmitted ? (errors.password ? "true" : "false") : undefined}
{...register("password", {
required: "비밀번호는 필수 입력입니다.",
minLength: {
value: 8,
message: "8자리 이상 비밀번호를 사용하세요.",
},
})}
/>
{errors.password && <small role="alert">{errors.password.message}</small>}
</div>
<button type="submit" disabled={isSubmitting}>
로그인
</button>
</form>
);
}
정리 — 핵심 3가지
| 요소 | 역할 |
|---|---|
register("name", 규칙) |
인풋을 폼에 연결(ref) + 검증 규칙 등록 |
handleSubmit(onSubmit) |
기본 새로고침 방지 + 검증 통과 시에만 onSubmit(data) 실행 |
formState |
errors(에러), isSubmitting(제출 중), isSubmitted(제출 이력) 등 폼 상태 |
한 걸음 더 나아가려면:
zod+@hookform/resolvers: 검증 규칙이 복잡해지면register인라인 규칙 대신 스키마로 분리하는 게 유지보수에 좋습니다.Controller컴포넌트: MUI, Ant Design 같은 비제어를 지원하지 않는 UI 라이브러리와 연결할 때 필요합니다.- 테스트: React Testing Library로 "빈 값 제출 → 에러 노출", "올바른 값 제출 → onSubmit 호출" 같은 시나리오를 작성해두면 안심입니다.
'공부 > frontend' 카테고리의 다른 글
| setState를 썼는데 값이 왜 이상하지? — async 완전 이해하기 (0) | 2026.07.01 |
|---|---|
| React 성능 개선2 — memo와 useMemo 완전 정리 (0) | 2026.07.01 |
| React 성능 개선1 — 개발자도구 활용 & lazy import (0) | 2026.07.01 |
| Tanstack Query(React Query) 완전 정복 — 실시간 데이터를 쉽게 다루는 법 (0) | 2026.07.01 |
| 리액트에서 자주 쓰는 if문 작성 패턴 5가지 (0) | 2026.06.17 |