본문 바로가기

폼 상태 커스텀 훅과 라이브러리

폼 상태와 검증을 커스텀 훅으로 추상화하고 폼 라이브러리가 필요한 기준을 판단합니다.

앞 글의 ReducerForm에서는 값의 변경과 오류의 저장, 초기화를 액션으로 나누어 보았습니다. 이 글에서는 이 로직을 커스텀 훅으로 묶어, 훅의 입력을 initialValues와 validate로, 출력을 values와 errors, 처리 함수들로 정하고 제출의 성공과 실패를 화면에서 확인합니다. 그다음 직접 만든 훅으로 부족해지는 때와, React Hook Form이나 Formik 같은 폼 라이브러리를 고르는 기준을 알아봅니다. 같은 훅을 부른 서로 다른 폼이 저절로 한 state를 함께 쓰는 것은 아니며, 폼마다 자기 state를 가집니다.


커스텀 훅을 이용한 폼 상태 추상화

useReducer를 써도 폼마다 리듀서와 handleChange 로직을 작성하는 일은 여전히 되풀이될 수 있습니다. 이때 커스텀 훅(Custom Hook)을 쓰면 폼 상태를 관리하는 로직을 떼어 내 여러 폼에서 재사용할 수 있습니다. useForm 같은 커스텀 훅은 useState나 useReducer에 기반한 폼 데이터의 상태 관리, 모든 input 필드에 쓸 수 있는 범용 handleChange 함수, 제출(handleSubmit)할 때 데이터를 처리하는 로직, 그리고 유효성 검사와 오류 상태의 관리를 제공할 수 있습니다.

useForm 값·검증·제출 상태 계약 — 상태 추적

현재 values 검증 뒤 오류면 callback을 부르지 않고, 통과하면 기다린 뒤 성공 정책 또는 errors.submit 및 finally로 종료한다. 제출 guard는 실제 구현에 없어 엄격한 중복 차단으로 주장하지 않는다.

현재 useForm 예제에서 values로 오류를 계산하고 제출 성공과 실패를 처리하는 흐름, 그리고 touched, 중복 제출, 서버 필드 오류처럼 별도 계약이 필요한 확장 축

React · Custom hook submit contract

useForm 예제의 제출 경로와 확장할 상태

현재 useForm 예제의 기준선은 values → errors → submit result 흐름입니다. 방문 여부, 동시 제출 방지, 서버 필드 오류는 자동으로 생기지 않으므로 별도 상태와 정책으로 추가합니다.

현재 값 검증 뒤 제출 허용과 차단values→검증 결과에서 오류가 있으면 callback없이 종료하고, 오류가 없으면 callback을 기다린다. callback 성공의 resetForm은 호출자가 선택하는 정책이고 예외는 errors.submit으로 간다. 모든 제출 경로에서 finally가 isSubmitting을 false로 돌린다. 중복 요청 guard는 구현하지 않은 별도 계약이다.현재 values입력 snapshotvalidate(values)오류 결과 계산오류 있음callback 호출 없음오류 없음await callback성공 callback필요 시 resetFormcallback 예외errors.submit 표시finallyisSubmitting=false오류의 유무로 갈림모든 제출 경로는 종료 복구
현재 값 검증 뒤 제출 허용과 차단values→검증 결과에서 오류가 있으면 callback없이 종료하고, 오류가 없으면 callback을 기다린다. callback 성공의 resetForm은 호출자가 선택하는 정책이고 예외는 errors.submit으로 간다. 모든 제출 경로에서 finally가 isSubmitting을 false로 돌린다. 중복 요청 guard는 구현하지 않은 별도 계약이다.현재 values입력 snapshotvalidate(values)오류 결과 계산오류 있음callback 호출없음오류 없음await callback성공 callback필요 시 resetFormcallback 예외errors.submit표시finallyisSubmitting= false오류의 유무로 갈림오류·성공 모두종료 복구
Current hook

입력 변경에서 제출 종료까지

  1. handleChange가 한 필드의 values를 갱신

    name을 key로 사용하고 checkbox만 checked boolean을 읽습니다.

  2. 제출 시 현재 values로 새 validationErrors를 계산

    isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다.

  3. 오류가 있으면 callback을 실행하지 않음

    오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다.

  4. 예외는 일반 제출 오류로 기록하고 finally에서 종료

    성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다.

폼 상태 축별 현재 예제와 확장 경계
상태 축현재 예제복잡도가 커질 때 추가할 계약
values입력 이벤트가 만드는 현재 값의 원천동적 필드의 기본값·등록 해제·배열 key 정책
errorsvalidate(values)가 만든 클라이언트 오류와 일반 submit 오류스키마 오류와 서버 오류를 필드·폼 수준으로 명시적으로 매핑
touched · dirty현재 훅은 추적하지 않음touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 구분
isSubmitting버튼 비활성화와 진행 문구를 위한 화면 상태동시 요청 guard와 idempotency는 별도 설계
Values

현재 값의 원천

입력 변경은 해당 필드를 갱신하고 검증은 이 snapshot을 읽습니다. 동적 필드는 기본값·등록 해제·배열 key 정책을 추가합니다.

Errors

계산 결과와 제출 오류

현재 예제는 클라이언트 검증 오류와 일반 제출 오류를 저장합니다. 스키마·서버 오류의 필드·폼 매핑은 별도 계약입니다.

Touched · dirty

현재 예제 밖의 축

현재 훅은 둘 다 추적하지 않습니다. touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 따로 정의합니다.

Submitting

진행 표시와 복구

finally로 상태를 복구하되 동시 요청 차단은 별도 guard가 필요합니다.

Server errors

명시적인 매핑

서버 업무 규칙 실패를 필드 또는 폼 오류로 옮기는 정책을 따로 둡니다.

화면의 disabled는 사용자 피드백일 뿐 요청 중복 방지를 보장하지 않습니다. 제출 guard와 서버 idempotency가 필요하면 훅 계약에 명시합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명values→검증 결과에서 오류가 있으면 callback 없이 종료하고, 오류가 없으면 callback을 기다린다. callback 성공의 resetForm은 호출자가 선택하는 정책이고 예외는 errors.submit으로 간다. 모든 제출 경로에서 finally가 isSubmitting을 false로 돌린다. 중복 요청 guard는 구현하지 않은 별도 계약이다.
원도표 설명handleChange가 한 필드의 values를 갱신
원도표 설명name을 key로 사용하고 checkbox만 checked boolean을 읽습니다.
원도표 조건·주석handleChange가 한 필드의 values를 갱신: name을 key로 사용하고 checkbox만 checked boolean을 읽습니다.
원도표 설명제출 시 현재 values로 새 validationErrors를 계산
원도표 설명isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다.
원도표 조건·주석제출 시 현재 values로 새 validationErrors를 계산: isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다.
원도표 설명오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다.
원도표 조건·주석오류가 있으면 callback을 실행하지 않음: 오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다.
원도표 설명예외는 일반 제출 오류로 기록하고 finally에서 종료
원도표 설명성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다.
원도표 조건·주석예외는 일반 제출 오류로 기록하고 finally에서 종료: 성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다.
원도표 표 행상태 축 | 현재 예제 | 복잡도가 커질 때 추가할 계약
원도표 표 행values | 입력 이벤트가 만드는 현재 값의 원천 | 동적 필드의 기본값·등록 해제·배열 key 정책
원도표 표 행errors | validate(values)가 만든 클라이언트 오류와 일반 submit 오류 | 스키마 오류와 서버 오류를 필드·폼 수준으로 명시적으로 매핑
원도표 표 행touched · dirty | 현재 훅은 추적하지 않음 | touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 구분
원도표 표 행isSubmitting | 버튼 비활성화와 진행 문구를 위한 화면 상태 | 동시 요청 guard와 idempotency는 별도 설계
원도표 설명finally로 상태를 복구하되 동시 요청 차단은 별도 guard가 필요합니다.
원도표 설명화면의 disabled는 사용자 피드백일 뿐 요청 중복 방지를 보장하지 않습니다. 제출 guard와 서버 idempotency가 필요하면 훅 계약에 명시합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
현재 values 검사검사: validate(values) / invalid: callback 호출 없음submit에서 현재 값의 오류를 계산한다표시된 errors만 읽어 제출을 결정하지 않는다
valid callback 대기isSubmitting: 진행 표시 / callback: pending오류가 없으면 callback의 완료를 기다린다실제 중복 guard는 구현되지 않은 확장이다
성공·실패 종료성공: 선택 reset / 실패: submit 오류성공 reset은 호출 정책, 예외는 errors.submit이며 finally가 진행 표시를 푼다실패에서 값을 무조건 초기화하지 않는다

useForm 값·검증·제출 상태 계약 — 상태 추적

현재 values 검증 뒤 오류면 callback을 부르지 않고, 통과하면 기다린 뒤 성공 정책 또는 errors.submit 및 finally로 종료한다. 제출 guard는 실제 구현에 없어 엄격한 중복 차단으로 주장하지 않는다.

현재 useForm 예제에서 values로 오류를 계산하고 제출 성공과 실패를 처리하는 흐름, 그리고 touched, 중복 제출, 서버 필드 오류처럼 별도 계약이 필요한 확장 축

React · Custom hook submit contract

useForm 예제의 제출 경로와 확장할 상태

현재 useForm 예제의 기준선은 values → errors → submit result 흐름입니다. 방문 여부, 동시 제출 방지, 서버 필드 오류는 자동으로 생기지 않으므로 별도 상태와 정책으로 추가합니다.

현재 값 검증 뒤 제출 허용과 차단values→검증 결과에서 오류가 있으면 callback없이 종료하고, 오류가 없으면 callback을 기다린다. callback 성공의 resetForm은 호출자가 선택하는 정책이고 예외는 errors.submit으로 간다. 모든 제출 경로에서 finally가 isSubmitting을 false로 돌린다. 중복 요청 guard는 구현하지 않은 별도 계약이다.현재 values입력 snapshotvalidate(values)오류 결과 계산오류 있음callback 호출 없음오류 없음await callback성공 callback필요 시 resetFormcallback 예외errors.submit 표시finallyisSubmitting=false오류의 유무로 갈림모든 제출 경로는 종료 복구
현재 값 검증 뒤 제출 허용과 차단values→검증 결과에서 오류가 있으면 callback없이 종료하고, 오류가 없으면 callback을 기다린다. callback 성공의 resetForm은 호출자가 선택하는 정책이고 예외는 errors.submit으로 간다. 모든 제출 경로에서 finally가 isSubmitting을 false로 돌린다. 중복 요청 guard는 구현하지 않은 별도 계약이다.현재 values입력 snapshotvalidate(values)오류 결과 계산오류 있음callback 호출없음오류 없음await callback성공 callback필요 시 resetFormcallback 예외errors.submit표시finallyisSubmitting= false오류의 유무로 갈림오류·성공 모두종료 복구
Current hook

입력 변경에서 제출 종료까지

  1. handleChange가 한 필드의 values를 갱신

    name을 key로 사용하고 checkbox만 checked boolean을 읽습니다.

  2. 제출 시 현재 values로 새 validationErrors를 계산

    isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다.

  3. 오류가 있으면 callback을 실행하지 않음

    오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다.

  4. 예외는 일반 제출 오류로 기록하고 finally에서 종료

    성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다.

폼 상태 축별 현재 예제와 확장 경계
상태 축현재 예제복잡도가 커질 때 추가할 계약
values입력 이벤트가 만드는 현재 값의 원천동적 필드의 기본값·등록 해제·배열 key 정책
errorsvalidate(values)가 만든 클라이언트 오류와 일반 submit 오류스키마 오류와 서버 오류를 필드·폼 수준으로 명시적으로 매핑
touched · dirty현재 훅은 추적하지 않음touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 구분
isSubmitting버튼 비활성화와 진행 문구를 위한 화면 상태동시 요청 guard와 idempotency는 별도 설계
Values

현재 값의 원천

입력 변경은 해당 필드를 갱신하고 검증은 이 snapshot을 읽습니다. 동적 필드는 기본값·등록 해제·배열 key 정책을 추가합니다.

Errors

계산 결과와 제출 오류

현재 예제는 클라이언트 검증 오류와 일반 제출 오류를 저장합니다. 스키마·서버 오류의 필드·폼 매핑은 별도 계약입니다.

Touched · dirty

현재 예제 밖의 축

현재 훅은 둘 다 추적하지 않습니다. touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 따로 정의합니다.

Submitting

진행 표시와 복구

finally로 상태를 복구하되 동시 요청 차단은 별도 guard가 필요합니다.

Server errors

명시적인 매핑

서버 업무 규칙 실패를 필드 또는 폼 오류로 옮기는 정책을 따로 둡니다.

화면의 disabled는 사용자 피드백일 뿐 요청 중복 방지를 보장하지 않습니다. 제출 guard와 서버 idempotency가 필요하면 훅 계약에 명시합니다.

이 예의 조건

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

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명values→검증 결과에서 오류가 있으면 callback 없이 종료하고, 오류가 없으면 callback을 기다린다. callback 성공의 resetForm은 호출자가 선택하는 정책이고 예외는 errors.submit으로 간다. 모든 제출 경로에서 finally가 isSubmitting을 false로 돌린다. 중복 요청 guard는 구현하지 않은 별도 계약이다.
원도표 설명handleChange가 한 필드의 values를 갱신
원도표 설명name을 key로 사용하고 checkbox만 checked boolean을 읽습니다.
원도표 조건·주석handleChange가 한 필드의 values를 갱신: name을 key로 사용하고 checkbox만 checked boolean을 읽습니다.
원도표 설명제출 시 현재 values로 새 validationErrors를 계산
원도표 설명isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다.
원도표 조건·주석제출 시 현재 values로 새 validationErrors를 계산: isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다.
원도표 설명오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다.
원도표 조건·주석오류가 있으면 callback을 실행하지 않음: 오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다.
원도표 설명예외는 일반 제출 오류로 기록하고 finally에서 종료
원도표 설명성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다.
원도표 조건·주석예외는 일반 제출 오류로 기록하고 finally에서 종료: 성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다.
원도표 표 행상태 축 | 현재 예제 | 복잡도가 커질 때 추가할 계약
원도표 표 행values | 입력 이벤트가 만드는 현재 값의 원천 | 동적 필드의 기본값·등록 해제·배열 key 정책
원도표 표 행errors | validate(values)가 만든 클라이언트 오류와 일반 submit 오류 | 스키마 오류와 서버 오류를 필드·폼 수준으로 명시적으로 매핑
원도표 표 행touched · dirty | 현재 훅은 추적하지 않음 | touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 구분
원도표 표 행isSubmitting | 버튼 비활성화와 진행 문구를 위한 화면 상태 | 동시 요청 guard와 idempotency는 별도 설계
원도표 설명finally로 상태를 복구하되 동시 요청 차단은 별도 guard가 필요합니다.
원도표 설명화면의 disabled는 사용자 피드백일 뿐 요청 중복 방지를 보장하지 않습니다. 제출 guard와 서버 idempotency가 필요하면 훅 계약에 명시합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
현재 values 검사검사: validate(values) / invalid: callback 호출 없음submit에서 현재 값의 오류를 계산한다표시된 errors만 읽어 제출을 결정하지 않는다
valid callback 대기isSubmitting: 진행 표시 / callback: pending오류가 없으면 callback의 완료를 기다린다실제 중복 guard는 구현되지 않은 확장이다
성공·실패 종료성공: 선택 reset / 실패: submit 오류성공 reset은 호출 정책, 예외는 errors.submit이며 finally가 진행 표시를 푼다실패에서 값을 무조건 초기화하지 않는다

위 그림은 이 글의 useForm 예제가 values에서 errors를 계산하고 제출의 성공과 실패를 처리하는 흐름, 그리고 이 훅이 다루지 않아 따로 정해야 하는 상태를 보여 줍니다. 그림의 요약처럼 이 예제의 기본 흐름은 values → errors → 제출 결과이며, 방문 여부나 동시 제출 방지, 서버의 필드 오류는 저절로 생기지 않으므로 별도의 상태와 정책으로 더해야 합니다. 위쪽의 흐름에서 현재 values를 validate(values)로 검사해 오류가 있으면 콜백을 부르지 않고 끝내고, 오류가 없으면 콜백을 기다리며, 콜백이 성공하면 필요할 때 resetForm을 부르고 예외를 던지면 errors.submit에 표시합니다. 어느 경로로 끝나든 finally가 isSubmitting을 false로 되돌립니다.

그림 가운데의 단계 설명은 이 흐름을 코드와 이어 줍니다. handleChange는 name을 키로 써서 한 필드의 values를 갱신하며 checkbox만 checked boolean을 읽고, 제출할 때는 isSubmitting을 켠 뒤 현재 values로 새 validationErrors를 계산해 errors에 저장하며, 오류가 없을 때만 호출하는 쪽이 넘긴 비동기 제출 콜백을 await합니다. 아래의 표는 상태의 축마다 이 예제가 하는 일과 복잡해질 때 더할 규칙을 나눕니다. values에는 동적 필드의 기본값과 등록 해제, 배열 key의 정책을, errors에는 스키마 오류와 서버 오류를 필드와 폼 수준에 연결하는 방식을 더하고, 이 훅이 추적하지 않는 touched는 blur 같은 방문 전이로, dirty는 초기값과 현재 값의 비교로 따로 정의합니다. 그림의 마지막 설명처럼 화면의 disabled는 사용자에게 주는 피드백일 뿐 요청의 중복을 막아 주지는 않으므로, 제출 가드나 서버의 멱등성이 필요하다면 훅의 동작으로 분명히 정해야 합니다.

다음은 useForm 커스텀 훅입니다. JSX가 없으므로 .js로 저장합니다.

src/hooks/useForm.js
import { useState, useCallback } from 'react';

/**
 * 폼 상태와 유효성 검사를 관리하는 커스텀 훅
 * @param {object} initialValues - 폼 필드의 초기 값
 * @param {function} validate - 폼 데이터 객체를 받아 에러 객체를 반환하는 유효성 검사 함수
 * @returns {{ values, errors, handleChange, handleSubmit, resetForm }}
 */
const useForm = (initialValues, validate) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false); // 제출 중 상태 (선택 사항)

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.currentTarget;
    setValues(prevValues => ({
      ...prevValues,
      [name]: type === 'checkbox' ? checked : value,
    }));
    // 입력 시 실시간 유효성 검사를 하고 싶다면 여기에 validate 로직을 추가할 수 있습니다.
    // 하지만 보통은 제출 시점에 모든 유효성을 검사하는 것이 일반적입니다.
  }, []);

  const handleSubmit = useCallback((callback) => async (e) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      const validationErrors = validate(values); // 유효성 검사 실행
      setErrors(validationErrors);

      if (Object.keys(validationErrors).length === 0) {
        // 에러가 없으면 콜백 함수 실행
        await callback(values); // 비동기 콜백을 지원하기 위해 await
      }
    } catch (submitError) {
      // 검증 또는 제출 콜백에서 발생한 오류 처리 (예: API 제출 실패)
      console.error("폼 제출 오류:", submitError);
      setErrors(prevErrors => ({
        ...prevErrors,
        submit: submitError.message || '폼 제출 중 오류가 발생했습니다.'
      }));
    } finally {
      setIsSubmitting(false);
    }
  }, [values, validate]);

  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setIsSubmitting(false);
  }, [initialValues]);

  return {
    values,
    errors,
    isSubmitting,
    handleChange,
    handleSubmit,
    resetForm,
  };
};

export default useForm;

이 훅을 쓰는 회원가입 폼은 다음과 같습니다. JSX가 들어 있으므로 .jsx로 저장합니다.

src/components/UserRegistrationForm.jsx
import React from 'react';
import useForm from '../hooks/useForm'; // 커스텀 훅 임포트

// 유효성 검사 함수 (useForm에 전달될 콜백)
const validateUserInfo = (values) => {
  const errors = {};
  if (!values.username.trim()) {
    errors.username = '사용자 이름은 필수입니다.';
  } else if (values.username.length < 3) {
    errors.username = '사용자 이름은 3자 이상이어야 합니다.';
  }
  if (!values.email.trim()) {
    errors.email = '이메일은 필수입니다.';
  } else if (!/\S+@\S+\.\S+/.test(values.email)) {
    errors.email = '유효한 이메일 주소를 입력해주세요.';
  }
  if (!values.password) {
    errors.password = '비밀번호는 필수입니다.';
  } else if (values.password.length < 6) {
    errors.password = '비밀번호는 6자 이상이어야 합니다.';
  }
  if (values.password !== values.confirmPassword) {
    errors.confirmPassword = '비밀번호가 일치하지 않습니다.';
  }
  return errors;
};

function UserRegistrationForm() {
  const {
    values,
    errors,
    isSubmitting,
    handleChange,
    handleSubmit,
    resetForm,
  } = useForm(
    { username: '', email: '', password: '', confirmPassword: '' }, // 초기값
    validateUserInfo // 유효성 검사 함수
  );

  // 폼 제출 로직 (useForm의 handleSubmit에 전달될 콜백)
  const onSubmit = async (formData) => {
    // 실제 API 호출 로직을 여기에 작성
    console.log('폼 데이터 제출 시작:', formData);
    try {
      // 가상 API 호출 지연
      await new Promise(resolve => setTimeout(resolve, 1000));
      console.log('폼 데이터 제출 완료:', formData);
      alert('회원가입이 완료되었습니다!');
      resetForm(); // 제출 성공 후 폼 초기화
    } catch (error) {
      console.error('회원가입 실패:', error);
      alert('회원가입에 실패했습니다.');
      throw error; // useForm 훅에서 에러를 잡을 수 있도록 다시 던짐
    }
  };

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>회원가입 폼 (커스텀 훅)</h2>
      <form onSubmit={handleSubmit(onSubmit)} aria-busy={isSubmitting}> {/* handleSubmit에 실제 제출 함수를 전달 */}
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="registration-username" style={{ display: 'block', marginBottom: '5px' }}>사용자 이름:</label>
          <input
            id="registration-username"
            type="text"
            name="username"
            value={values.username}
            onChange={handleChange}
            aria-invalid={Boolean(errors.username)}
            aria-describedby={errors.username ? 'registration-username-error' : undefined}
            style={{ width: '100%', padding: '8px', border: errors.username ? '1px solid red' : '1px solid #ccc' }}
          />
          {errors.username && <p id="registration-username-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.username}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="registration-email" style={{ display: 'block', marginBottom: '5px' }}>이메일:</label>
          <input
            id="registration-email"
            type="email"
            name="email"
            value={values.email}
            onChange={handleChange}
            aria-invalid={Boolean(errors.email)}
            aria-describedby={errors.email ? 'registration-email-error' : undefined}
            style={{ width: '100%', padding: '8px', border: errors.email ? '1px solid red' : '1px solid #ccc' }}
          />
          {errors.email && <p id="registration-email-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.email}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="registration-password" style={{ display: 'block', marginBottom: '5px' }}>비밀번호:</label>
          <input
            id="registration-password"
            type="password"
            name="password"
            value={values.password}
            onChange={handleChange}
            aria-invalid={Boolean(errors.password)}
            aria-describedby={errors.password ? 'registration-password-error' : undefined}
            style={{ width: '100%', padding: '8px', border: errors.password ? '1px solid red' : '1px solid #ccc' }}
          />
          {errors.password && <p id="registration-password-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.password}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="registration-confirm-password" style={{ display: 'block', marginBottom: '5px' }}>비밀번호 확인:</label>
          <input
            id="registration-confirm-password"
            type="password"
            name="confirmPassword"
            value={values.confirmPassword}
            onChange={handleChange}
            aria-invalid={Boolean(errors.confirmPassword)}
            aria-describedby={errors.confirmPassword ? 'registration-confirm-password-error' : undefined}
            style={{ width: '100%', padding: '8px', border: errors.confirmPassword ? '1px solid red' : '1px solid #ccc' }}
          />
          {errors.confirmPassword && <p id="registration-confirm-password-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.confirmPassword}</p>}
        </div>
        {errors.submit && <p role="alert" style={{ color: 'red', fontSize: '0.9em', marginTop: '10px' }}>{errors.submit}</p>} {/* 제출 오류 */}
        <button type="submit" className="button" disabled={isSubmitting} style={{ width: '100%', padding: '10px' }}>
          {isSubmitting ? '등록 중...' : '회원가입'}
        </button>
      </form>
    </div>
  );
}

export default UserRegistrationForm;

이 useForm 커스텀 훅 덕분에 폼 컴포넌트의 로직이 훨씬 간결해지고 재사용할 수 있게 됩니다. 이제 각 폼 컴포넌트는 자신의 폼 필드와 유효성 검사 규칙만 정의하면 됩니다. handleSubmit(onSubmit)은 제출 콜백을 받아 실제 onSubmit 처리 함수를 돌려주는 함수이므로, <form onSubmit={handleSubmit(onSubmit)}>처럼 바로 넘길 수 있습니다.

두 파일을 이어 성공과 실패를 확인하기

useForm은 JSX가 없는 훅 파일로 src/hooks/useForm.js에, UserRegistrationForm은 JSX가 있는 화면 파일로 src/components/UserRegistrationForm.jsx에 저장하고, App.jsx에서 UserRegistrationForm을 렌더합니다. onSubmit의 1초 대기는 setTimeout을 기다리는 모형일 뿐 실제 API로 회원을 만들지는 않으며, 성공 alert를 닫으면 resetForm이 네 필드를 빈 값으로, errors를 빈 객체로 되돌립니다. onSubmit의 console.log는 비밀번호까지 그대로 출력하므로, 실제 서비스의 코드에서는 지우고 성공과 실패의 종류만 기록하세요.

사용자 이름에 Mina, 이메일에 [email protected], 비밀번호와 비밀번호 확인에 abcdef를 넣으면 네 규칙을 모두 통과합니다. 제출 콜백을 기다리는 1초 동안 버튼에 “등록 중...”이 보이고, 끝나면 “회원가입이 완료되었습니다!” alert 뒤에 폼이 초기화됩니다. 비밀번호 확인만 abcdeg로 바꾸면 그 필드에 “비밀번호가 일치하지 않습니다.”가 생기고 콜백은 호출되지 않습니다. 다만 disabled가 화면에 반영되기 전에 같은 처리 함수를 다시 직접 부르는 경우까지, 이 코드가 별도의 ref 가드로 막지는 않습니다.

사용자 이름의 검사는 trim()으로 빈 값인지 확인한 뒤, 다듬지 않은 원래 문자열의 length를 봅니다. 그래서 ' a '는 길이 3으로 통과합니다. 앞뒤 공백을 뺀 세 글자 이상이라는 정책을 원한다면, 검증 함수에서 다듬은 값을 길이 검사에도 쓰도록 사용자 이름의 두 조건만 다음처럼 바꿉니다.

validateUserInfo의 사용자 이름 검사 부분
const normalizedUsername = values.username.trim();
// 원 validateUserInfo의 username 검사 두 조건만 이 값으로 비교합니다.
if (!normalizedUsername) {
  errors.username = '사용자 이름은 필수입니다.';
} else if (normalizedUsername.length < 3) {
  errors.username = '사용자 이름은 3자 이상이어야 합니다.';
}

이 코드는 errors와 values가 있는 validateUserInfo 안의 비교 부분일 뿐 완성된 컴포넌트가 아니며, 다른 필드의 규칙은 그대로 둡니다. 검증을 통과하는지와 실제로 저장할 문자열을 다듬을지는 따로 정할 일입니다.


폼 라이브러리 활용

실제 프로덕션에서 아주 크고 복잡한 폼을 다룰 때는 useReducer나 직접 만든 useForm만으로는 한계에 부딪힐 수 있습니다. 이때는 상태를 직접 들고 있을 수 있는지보다, 필드의 등록과 검증, 오류의 표시, 제출의 복구를 일관되게 유지할 수 있는지를 봐야 합니다.

예를 들어 필드가 늘어 값과 오류, 방문 여부, dirty 상태를 따로 추적해야 할 때, 조건부 렌더링으로 필드를 숨길 때 값을 유지할지 해제할지 정해야 할 때, 동적으로 추가되고 제거되는 필드 배열에서 key와 기본값, 항목 단위의 오류와 배열 단위의 오류를 나눠야 할 때가 그렇습니다. 클라이언트의 스키마 검증과 서버의 중복 확인 같은 비동기 검증이 함께 필요할 때, 입력 하나가 큰 폼 전체를 다시 렌더링해 타이핑이 늦어 보일 때도 마찬가지입니다. 이런 복잡성을 다루려고 React Hook Form이나 Formik 같은 전문 폼 관리 라이브러리가 있으며, 이 라이브러리들은 폼 상태의 관리와 유효성 검사, 제출 처리, 성능 최적화를 위한 여러 기능을 제공합니다.

직접 만든 useForm, React Hook Form, Formik의 필드 연결, 검증, 동적 배열, 렌더 범위를 비교하고 화면, 폼 로직, 스키마, 서버의 소유권 경계를 구분하는 선택표

React · Form tool boundary

직접 훅·React Hook Form·Formik의 책임 비교

폼 도구는 필드 개수보다 값·오류·방문·동적 배열·비동기 검증·렌더 범위가 함께 움직이는지로 고릅니다. 어떤 도구를 써도 클라이언트 스키마와 서버의 최종 판단 경계는 사라지지 않습니다.

직접 훅, React Hook Form, Formik 선택 기준
기준직접 useFormReact Hook FormFormik
필드 연결name, value, onChange 계약을 직접 작성register와 defaultValues, 제어형 위젯은 ControllerinitialValues, values, Field 중심의 명시적 상태
검증 위치validate(values)와 오류 매핑을 직접 소유resolver로 Zod·Yup 같은 스키마 연결validate 또는 validationSchema
동적 배열key, 기본값, item·array 오류 정책을 직접 설계useFieldArray로 추가·삭제·재정렬FieldArray로 배열 전이를 표현
렌더 범위컴포넌트 분리와 memoization을 직접 판단필요한 필드·formState 구독 범위를 좁힘명시적 상태 흐름을 유지하며 독립 필드의 FastField·분할 검토
우선 선택필드와 검증·제출 계약이 작고 안정적일 때등록형 입력, 동적 구조, 세밀한 구독이 중요할 때React 상태가 드러나는 모델과 디버깅 흐름을 선호할 때
Direct hook

작고 안정적인 계약

연결: name·value·onChange를 직접 작성.

검증: validate(values)와 오류 매핑을 직접 소유.

배열: key·기본값·item/array 오류 정책을 설계.

렌더: 컴포넌트 분리와 memoization을 판단.

선택: 필드와 검증·제출 계약이 작고 안정적일 때.

React Hook Form

등록·동적 구조·구독

연결: register·defaultValues, 제어형 위젯은 Controller.

검증: resolver로 Zod·Yup 스키마 연결.

배열: useFieldArray로 추가·삭제·재정렬.

렌더: 필요한 필드·formState 구독만 사용.

선택: 등록형 입력·동적 구조·세밀한 구독이 중요할 때.

Formik

명시적인 React 상태

연결: initialValues·values·Field 중심.

검증: validate 또는 validationSchema.

배열: FieldArray로 전이 표현.

렌더: 독립 필드에 FastField와 분할 검토.

선택: React 상태 모델과 디버깅 흐름을 선호할 때.

Ownership invariant

도구가 바뀌어도 책임 경계는 유지한다

  1. 화면 컴포넌트

    열림, 접힘, 포커스처럼 표시 상태와 필드 UI를 소유합니다.

  2. 폼 로직 또는 라이브러리

    values, errors, touched, dirty, 제출 전이를 일관된 모델로 관리합니다.

  3. 클라이언트 스키마와 서버

    스키마는 즉시 피드백 규칙을 공유하고, 권한·중복·최종 유효성은 서버가 판단합니다.

라이브러리는 필드 등록과 상태 전이를 조직하므로 내부의 controlled/uncontrolled 연결 계약을 확인합니다. 서버 오류를 필드나 폼 수준에 연결하는 방식은 앱이 정합니다.

위 표는 직접 만든 useForm과 React Hook Form, Formik을 필드 연결과 검증 위치, 동적 배열, 렌더 범위의 네 기준으로 비교합니다. 그림의 요약처럼 폼 도구는 필드의 개수보다 값과 오류, 방문, 동적 배열, 비동기 검증, 렌더 범위가 함께 움직이는지로 고르며, 어떤 도구를 써도 클라이언트 스키마와 서버의 최종 판단은 따로 남습니다. 직접 만든 훅은 name, value, onChange의 연결과 validate(values), 배열의 key와 오류 정책, 컴포넌트 분리와 메모이제이션을 모두 직접 정하므로 필드와 검증, 제출 규칙이 작고 안정적일 때 알맞습니다.

React Hook Form은 register와 defaultValues로 입력을 연결하고 제어형 위젯은 Controller로 감싸며, resolver로 Zod나 Yup 같은 스키마를 잇고, useFieldArray로 배열의 추가와 삭제, 재정렬을 다루며, 필요한 필드와 formState만 구독해 렌더 범위를 좁힙니다. 그래서 등록형 입력과 동적 구조, 세밀한 구독이 중요할 때 고릅니다. Formik은 initialValues, values, Field를 중심으로 상태를 드러내고, validate나 validationSchema로 검증하며, FieldArray로 배열의 전이를 표현하고, 독립된 필드에는 FastField와 컴포넌트 분할을 검토합니다. React 상태가 드러나는 모델과 디버깅 흐름을 좋아한다면 Formik이 맞습니다.

그림 아래의 세 칸은 도구가 바뀌어도 유지되는 역할 분담입니다. 화면 컴포넌트는 열림과 접힘, 포커스 같은 표시 상태와 필드의 UI를 맡고, 폼 로직이나 라이브러리는 values, errors, touched, dirty, 제출의 전이를 한 모델로 관리하며, 클라이언트 스키마는 즉시 피드백할 규칙을 나누고 권한과 중복, 최종 유효성은 서버가 판단합니다. 그림의 마지막 설명처럼 라이브러리가 안에서 제어와 비제어 가운데 어떤 방식으로 필드를 연결하는지 확인하고, 서버 오류를 필드나 폼 수준에 연결하는 방식은 앱이 정합니다.

두 라이브러리를 조금 더 자세히 보면 다음과 같습니다. React Hook Form은 필드 등록이 중심이어서, register와 defaultValues로 네이티브 입력을 연결하고 필요한 필드 상태만 구독해 다시 렌더링되는 범위를 줄입니다. UI 라이브러리의 날짜 선택기나 자동완성처럼 제어형 흐름이 필요한 입력은 Controller로 감싸며, 이때는 그 컴포넌트의 렌더 비용도 함께 봐야 합니다. formState의 errors, dirtyFields, isSubmitting 같은 값은 필요한 곳에서만 읽도록 설계합니다(React Hook Form 문서).

Formik은 initialValues, values, errors, touched가 React의 상태 흐름 안에 드러나므로 폼 상태를 읽고 디버깅하기 쉽습니다. validate나 validationSchema로 검증하는 위치를 정하고 제출 오류와 필드 오류를 나눠 관리할 수 있으며, Field, FieldArray, FastField 같은 컴포넌트 API를 쓸 수 있지만 큰 폼에서는 갱신 범위를 의식해 컴포넌트를 나누어야 합니다(Formik 문서). 폼 라이브러리를 이용한 유효성 검사는 다음 글들에서 더 자세히 다루며, 여기서는 직접 만든 훅과 React Hook Form, Formik 가운데 무엇을 고를지 정하기 위해 필드 등록 방식과 검증 위치, 동적 배열, 다시 렌더링되는 범위를 먼저 비교했습니다.


정리

  • 커스텀 훅 useForm은 initialValues와 validate를 받아 values, errors, isSubmitting과 처리 함수들을 돌려주며, 폼 컴포넌트는 필드와 검증 규칙만 정의하면 됩니다.
  • 같은 훅을 부른 폼들이 state를 함께 쓰지는 않으며, 폼마다 자기 state를 가집니다.
  • 제출할 때는 현재 값으로 검증해 오류가 없을 때만 콜백을 기다리고, 콜백의 예외는 errors.submit에, 진행 상태의 복구는 finally에 둡니다.
  • touched와 dirty, 중복 제출 방지, 서버 필드 오류는 이 훅이 다루지 않으므로 필요하면 따로 정합니다.
  • 동적 필드와 비동기 검증, 렌더 범위가 함께 커지면 React Hook Form이나 Formik을 검토하되, 서버의 최종 판단은 어느 도구에서나 따로 남습니다.

연습 문제

1. 제출 콜백이 실패할 때

네 필드에 유효한 값을 둔 채, onSubmit이 new Error('연습용 제출 실패')를 던지도록 바꿉니다. 오류는 어디에 생기고, isSubmitting과 values는 어떻게 끝날까요? 검증이 실패한 경우 콜백은 몇 번 호출될까요? 콜백이 성공하지만 resetForm을 부르지 않는다면 값은 누가 초기화해야 할까요?

해설 보기

유효한 입력에서는 훅이 콜백을 기다리다 예외를 받아 errors.submit에 “연습용 제출 실패”를 저장하고, 화면에는 role="alert"인 제출 오류 문단이 보입니다. finally가 isSubmitting을 false로 되돌리고 입력값은 그대로 남습니다. 이 오류는 서버의 이메일 오류처럼 특정 필드에 저절로 연결되지 않으며, 이 예는 Error 객체를 던지는 경우입니다. null 같은 아무 값이나 던질 수 있는 코드를 받는다면 catch에서 message를 읽기 전에 타입을 확인해야 합니다.

검증 오류가 하나라도 있으면 콜백은 한 번도 호출되지 않습니다. 콜백이 성공해도 훅 자체는 values를 비우지 않으므로, resetForm을 부를지는 호출하는 쪽이 정합니다. 이 예제는 성공 직후 resetForm을 부르지만, 기다리는 동안 사용자가 새로 입력한 내용까지 지울 수 있으므로 실제 서비스에서는 제출 중 입력 잠금이나 제출 시점의 값만 초기화하는 정책을 함께 정합니다.

2. 공백이 섞인 사용자 이름

원래의 validateUserInfo에서 사용자 이름이 ' a ', ' ', 'ab'일 때 각각 어떤 오류가 생길까요? 이 글의 바꾼 검사 부분을 적용하면 어떻게 달라질까요?

해설 보기

원래 검사에서 ' a '는 trim()한 값이 빈 문자열이 아니고 원래 문자열의 길이가 3이므로 통과합니다. ' '는 trim()하면 빈 문자열이라 “사용자 이름은 필수입니다.”가, 'ab'는 길이가 2라 “사용자 이름은 3자 이상이어야 합니다.”가 생깁니다.

바꾼 검사에서는 다듬은 값 'a'의 길이가 1이므로 ' a '도 “사용자 이름은 3자 이상이어야 합니다.”가 되고, 나머지 두 경우는 그대로입니다. 어느 쪽이든 저장하는 값 자체는 다듬지 않으므로, 공백을 빼고 저장하려면 제출 콜백에서 따로 정리해야 합니다.

다음 글 기본적인 폼 유효성 검사 구현에서는 필드를 언제 검사하고 오류를 언제 보여 줄지 정해 기본적인 폼 유효성 검사를 직접 구현해 봅니다.