react-hook-form + Zod 폼 표준 정착기
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
여러 운영 페이지와 외부 SPA 에 흩어진 폼 12 개가 각자 다른 검증·정규화·에러 표시 흐름을 갖고 있었다. zod 공용 스키마를 `lib/validations/` 에 모으고, react-hook-form + zodResolver + shadcn/ui Form 한 흐름으로 묶으면서 입력 정규화·필드 에러·BE 에러 매핑까지 한 줄의 표준으로 정착시킨 구현기.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 운영 페이지 4 개에 흩어진 폼 12 개의 검증·정규화·에러 표시 흐름이 모두 달랐다 — 다음 머지를 위한 프론트엔드 폼 표준 확립
lib/validations/에 공용 zod 스키마 7 개 모음 —phoneSchema/birthSchema/aheadGradeSchema등을 한 곳에서 transform·refine·메시지까지 명문화useForm({ resolver: zodResolver(schema) })한 줄로 폼 상태 + 검증 + 정규화를 묶는 표준 진입점 정착StyledFormField래퍼로 label·required·hint·error를 한 줄에 합성 — shadcn/ui Form 구성 요소를 디자인 시스템에 맞춰 흡수handleSubmit(onSubmit)시점에 zodtransform이 정규화된 값을 자동 주입 —data: z.infer<typeof schema>로 타입 + 값이 동시에 정렬onError콜백에서 BE 응답 에러를serverErrors배열로 매핑 — 필드 레벨 에러는 zod, 폼 레벨 에러는 빨간 배너 두 줄로 분리
🎯 배경 — 폼이 12 개로 늘어나기 전에 흐름을 표준화한다
이전 편의 연락처 포맷 통일 머지가 끝난 직후, 다음 한 줄의 결정이 들어왔다.
“같은 어색함을 다음 필드에서 또 마주치지 않도록, 입력 필드 전체가 같은 흐름을 따르게 표준을 명문화한다.”
연락처 사고는 단일 필드의 저장·입력·표시 세 축 불일치였다. 그런데 같은 결함이 생년월일·교육과정 분기·로그인 ID·이메일 같은 다른 필드에도 잠복해 있었다. 운영 페이지 4 종을 열어 입력 폼을 다 세 보니 12 개의 폼, 41 개의 입력 필드가 각자 다른 검증·정규화·에러 표시 코드를 들고 있었다.
흩어진 폼 12 개의 상태를 표로 정리하면 다음과 같다.
| 폼 위치 | 입력 필드 수 | 검증 방식 | 에러 표시 |
|---|---|---|---|
| 고객사 관리자 페이지 — 회원 등록 | 8 | 즉석 if (!name) alert(...) | alert() 모달 |
| 고객사 관리자 페이지 — 운영자 등록 | 3 | 정규식 인라인 | 인풋 하단 회색 텍스트 |
| 고객사 관리자 페이지 — 클래스 등록 | 5 | useState + useEffect 검증 | 토스트 |
| 운영자 페이지 — 고객사 등록 | 6 | 검증 없음 | BE 400 응답 그대로 노출 |
| 보호자 앱 — 회원가입 | 6 | 자체 hook (useFormValidation) | 필드 옆 빨간 텍스트 |
| 보호자 앱 — 마이페이지 수정 | 5 | 일부 정규식, 일부 무검증 | 혼재 |
| 데스크톱 런타임 로비 — 비밀번호 변경 | 3 | 직접 작성 | 토스트 |
📌 핵심: 입력 표면이 12 개 로 늘어난 시점에서 각 페이지가 자기만의 폼 흐름을 들고 있으면 다음 사고 한 건이 12 군데 동시에 누락된다. 연락처 사고 한 줄이 BE 정규식 한 줄로 끝나지 않고 DB 612 행 정규화 SQL 까지 필요했던 이유도 클라이언트 검증이 페이지마다 달랐기 때문이다. 다음 필드를 추가할 때 같은 사고를 또 만들지 않게 흐름을 한 줄의 표준으로 명문화한다.
흐름 표준의 목표는 단순하다.
- 검증 스키마는 한 곳 —
lib/validations/디렉터리에 공용 zod 스키마를 모은다. 같은 필드(연락처·생년월일·교육과정 분기)는 같은 스키마 한 줄을 가져다 쓴다. - 폼 진입점은 한 줄 —
useForm({ resolver: zodResolver(schema), defaultValues })한 줄이 상태·검증·정규화·기본값을 한꺼번에 묶는다. - UI 합성은 한 컴포넌트 —
StyledFormField래퍼 한 개가 label·required·hint·error 표시를 통일한다. shadcn/ui Form 의 구성 요소(FormProvider/FormField/FormItem/FormLabel/FormControl/FormMessage)는 디자인 시스템에 맞춰 흡수한다. - BE 에러 매핑은 한 함수 —
parseErrorMessage(error)한 함수가 BE 의 세 가지 응답 형태(error.details[]/error.message배열 /error.message문자열) 를 문자열 배열 로 정규화한다.
본 머지는 위 네 줄을 모든 폼에 동일한 모양으로 적용한 작업이다. 다음 절부터 설계 결정 6 건과 구현 4 단계를 차례로 정리한다.
⚖️ 설계 결정 6 건 — 무엇을 표준화하고 무엇을 양보했나
본 머지의 결정 6 건을 트레이드오프 비교표로 정리한다.
| # | 결정 | 채택 사유 | 트레이드오프 |
|---|---|---|---|
| 1 | 검증 라이브러리 — Zod 단독 채택 (yup / joi / valibot 비교 검토) | TypeScript z.infer<typeof schema> 한 줄로 폼 데이터 타입을 스키마에서 직접 생성 / transform + refine 두 메서드로 정규화 + 검증을 한 체인에 묶음 / 백엔드에서도 일부 컨트롤러가 zod 사용 중 — BE·FE 스키마 어휘 통일 | yup 대비 번들 사이즈가 약간 크다 (Zod ~14KB gzipped). 다만 lib/validations/ 한 곳에서 import 하므로 tree-shaking 효율로 상쇄 |
| 2 | 폼 상태 — react-hook-form 단독 채택 (Refine useForm / Formik 비교 검토) | uncontrolled 입력 기반으로 입력 변경 시 리렌더 0 / register 한 줄로 input 연결 / formState.errors 가 Zod 메시지를 그대로 받음 / @hookform/resolvers/zod 어댑터가 공식 지원 | Refine 의 useForm 도 react-hook-form 을 내부에 쓰지만 추가 추상층이 한 겹 더 들어감 — 순정 react-hook-form 직접 사용으로 결정, Refine 은 데이터 계층(useCreate / useUpdate) 만 활용 |
| 3 | UI 합성 — shadcn/ui Form 구성 요소 흡수 + StyledFormField 래퍼 추가 | shadcn 의 FormProvider / FormField / FormItem / FormLabel / FormControl / FormMessage 가 접근성 속성(aria-describedby, aria-invalid) 을 자동 부착 / 디자인 시스템(피그마)의 160px 라벨 + 빨간 에러 텍스트 + 주황 힌트 패턴은 StyledFormField 한 컴포넌트로 흡수 | shadcn 의 <FormField> 와 자체 <StyledFormField> 두 컴포넌트 이름이 비슷해 혼동 위험 — 디자인 시스템 폼 입력은 StyledFormField, 복잡한 Controller 기반 입력은 shadcn FormField 로 역할 분리 명문화 |
| 4 | 정규화 위치 — Zod transform 에 일괄 집중 (입력 핸들러 / 상태 / 제출 시점 비교) | phoneSchema = z.string().transform(normalizePhone).refine(...) 한 줄이 정규화 + 검증을 한 체인에 박음 / handleSubmit(onSubmit) 시점에 이미 정규화된 값 이 onSubmit(data) 로 들어옴 / 상태 = 사용자가 본 그대로 / 제출 = 정규화된 값 분리가 명확 | UI 표시는 원본 값을 그대로 보여야 하므로 watch('phone') 으로 받은 값에 별도의 표시용 포맷 함수(formatPhoneDisplay) 가 필요 — 표시 함수 위치는 컴포넌트 안 으로 한정, 공용 함수로 빼지 않음 |
| 5 | 에러 표시 — 필드 에러는 zod, 폼 에러는 빨간 배너 두 줄로 분리 | 필드 단위 검증 실패는 errors.<field>.message 한 줄로 인풋 하단 표시 / BE 에서 떨어진 폼 전체 에러 는 폼 상단 빨간 배너 한 묶음에 모음 / 두 표시 영역을 시각적으로 분리해 사용자가 어디를 고쳐야 하는지 즉시 식별 | serverErrors 라는 추가 로컬 상태 한 개를 모든 폼이 들어야 함 — useState<string[]>([]) + onSuccess / onError 콜백 두 줄 추가, 다만 공통 패턴이라 학습 비용 1 회 |
| 6 | BE 에러 매핑 — parseErrorMessage 한 함수가 세 가지 응답 형태 흡수 | NestJS class-validator 가 떨어뜨리는 error.details[] 배열 + 우리 컨벤션의 error.message 배열 + 단일 error.message 문자열 세 형태가 운영 중 혼재 / 한 함수가 세 가지 모두 흡수해 문자열 배열 로 정규화 | BE 응답 표준이 세 가지 형태에 흩어져 있다는 점 자체가 별도 사고이지만, 본 머지는 프론트엔드 폼 표준에 집중 — BE 응답 정규화는 별도 머지로 분리 |
결정 1·2·3 이 스택의 척추고, 결정 4·5·6 이 흐름의 결합이다. 결정 1·2 가 라이브러리 선택이라면 결정 3 은 디자인 시스템에 라이브러리를 어떻게 흡수했는지다. 결정 4·5·6 은 검증된 값·필드 에러·폼 에러가 한 폼 안에서 충돌 없이 공존하도록 세 영역을 분리한 결정이다.

⚠️ 주의: 라이브러리 선택만으로는 폼 표준이 정착하지 않는다. 결정 3·5·6 이 팀 컨벤션에 가까운데, 어디서·언제·어떤 형태로 에러를 표시하는지가 팀 합의로 명문화돼 있지 않으면 다음 폼이 또 자기만의 표시 방식을 만든다. 본 머지의 본질은 라이브러리 3 종을 도입한 작업이 아니라, *4 단 파이프라인(스키마 → 진입점 → UI 합성 → BE 매핑)*을 팀 컨벤션으로 명문화한 작업이다.
🛠️ 구현 1 — lib/validations/ 에 공용 zod 스키마 모음
첫 단계는 재사용 가능한 zod 스키마를 한 곳에 모으는 작업이다. 본 머지의 lib/validations/ 트리는 다음과 같다.
apps/academy-portal/src/lib/validations/
├── phone.ts ← 연락처: normalizePhone / formatPhone / phoneSchema / phoneSchemaOptional
├── student-fields.ts ← 회원 도메인: birth / aheadGrade 등 도메인 필드
└── (예정) common.ts ← 이름·이메일·로그인 ID 등 공통 필드
phone.ts 한 파일에 정규화 함수 2 개 + zod 스키마 2 개를 모아 둔다.
// apps/academy-portal/src/lib/validations/phone.ts
// commit 482f9c1
import { z } from "zod";
/**
* 전화번호 정규화 (숫자만 추출)
* @example normalizePhone("010-1234-5678") => "01012345678"
*/
export const normalizePhone = (value: string): string => {
return value.replace(/\D/g, "");
};
/**
* 전화번호 포맷팅 (표시용)
* @example formatPhone("01012345678") => "010-1234-5678"
*/
export const formatPhone = (phone: string | null | undefined): string => {
if (!phone) return "-";
const n = normalizePhone(phone);
if (n.length === 11) return `${n.slice(0, 3)}-${n.slice(3, 7)}-${n.slice(7)}`;
if (n.length === 10) return `${n.slice(0, 2)}-${n.slice(2, 6)}-${n.slice(6)}`;
return phone;
};
/** 필수 전화번호 */
export const phoneSchema = z
.string()
.min(1, "연락처를 입력하세요")
.transform(normalizePhone)
.refine((v) => /^\d{10,11}$/.test(v), "10~11개 숫자를 입력하세요");
/** 선택 전화번호 — 빈 값 허용, 입력된 경우 정규화 */
export const phoneSchemaOptional = z
.string()
.transform((v) => (v ? normalizePhone(v) : ""))
.refine(
(v) => v === "" || /^\d{10,11}$/.test(v),
"10~11개 숫자를 입력하세요"
);
핵심은 transform → refine 순서다. transform(normalizePhone) 이 하이픈을 떼어 낸 뒤, refine(...) 이 숫자만 10~11 자를 검증한다. handleSubmit 시점에는 data.phone 이 이미 숫자만 11 자인 상태로 들어온다.
이전 편에서 다룬 연락처 포맷 통일 머지에서 BE 의 @Transform(normalizePhone) → @Matches(/^\d{10,11}$/) 두 줄 데코레이터와 완전히 같은 모양이다. BE·FE 가 같은 정규화 함수 + 같은 정규식을 들면 클라이언트 통과 / 서버 거절 비대칭이 발생하지 않는다.
도메인 필드는 student-fields.ts 한 파일에 모아 둔다.
// apps/academy-portal/src/lib/validations/student-fields.ts
// commit 482f9c1 (발췌)
import { z } from "zod";
function isValidBirthDate(v: string): boolean {
if (v.length !== 6) return false;
const month = parseInt(v.slice(2, 4));
const day = parseInt(v.slice(4, 6));
if (month < 1 || month > 12) return false;
const maxDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return day >= 1 && day <= maxDays[month - 1];
}
/** 생년월일 필수 스키마 (등록 시) */
export const birthSchemaRequired = z
.string()
.min(1, "생년월일을 입력하세요")
.regex(/^\d{6}$/, "6개 숫자를 입력하세요 (YYMMDD)")
.refine(isValidBirthDate, "유효하지 않은 날짜입니다");
/** 생년월일 선택 스키마 (수정 시) */
export const birthSchemaOptional = z
.string()
.refine(
(v) => !v || (/^\d{6}$/.test(v) && isValidBirthDate(v)),
"유효하지 않은 생년월일입니다 (YYMMDD)",
);
/** 교육과정 분기 필수 스키마 */
export const aheadGradeSchemaRequired = z
.string()
.min(1, "교육과정 분기를 선택하세요")
.regex(/^[1-6]-[12]$/, "올바른 교육과정 분기 형식이 아닙니다");
같은 도메인 필드라도 등록 / 수정 두 케이스가 자주 분기된다. 등록 시 필수, 수정 시 선택인 필드를 두 스키마(...Required / ...Optional) 로 분리해 호출 측에서 골라 쓰는 구조로 정착했다.
🔍 단서: zod 스키마의 재사용 단위는 필드 한 개다. 폼 전체 스키마(
studentFormSchema) 가 아니라 필드별 작은 스키마(phoneSchema/birthSchemaRequired) 가 재사용 단위다. 폼 전체 스키마는 호출 측에서 작은 스키마들의 조합으로 만든다.
// apps/academy-portal/src/pages/students/create.tsx
// commit 6d3f2a8 (발췌)
const studentFormSchema = z.object({
name: z.string().min(1, "이름을 입력하세요"),
classId: z.number().min(1, "클래스를 선택하세요"),
birth: birthSchemaRequired,
aheadGrade: aheadGradeSchemaRequired,
phone: phoneSchemaOptional,
parentPhone: phoneSchemaOptional,
});
type StudentFormData = z.infer<typeof studentFormSchema>;
z.infer<typeof studentFormSchema> 한 줄이 폼 데이터 타입을 스키마에서 직접 생성한다. 별도의 interface StudentFormData { ... } 선언이 필요 없다. 스키마가 곧 타입이고, 둘이 영원히 동기화된다.
📌 핵심:
lib/validations/의 구조는 공용 스키마 → 도메인 스키마 → 폼별 조합의 3 계층이다. 연락처·생년월일·이름 같은 공용 필드는 한 줄 import 로 재사용하고, 폼별 조합은 호출 측에서 명시한다. 이 구조 덕에 다음 폼이 추가될 때 공용 필드 import 한 줄로 끝난다.
🛠️ 구현 2 — useForm({ resolver: zodResolver(schema) }) 진입점
두 번째 단계는 폼 상태 진입점을 한 줄로 명문화한다. useForm 한 호출이 상태·검증·정규화·기본값·리셋 다섯 영역을 한꺼번에 묶는다.
// apps/academy-portal/src/pages/students/create.tsx
// commit 6d3f2a8 (발췌)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<StudentFormData>({
resolver: zodResolver(studentFormSchema),
defaultValues: {
name: "",
loginId: "",
classId: 0,
birth: "",
aheadGrade: "",
phone: "",
parentPhone: "",
},
});
useForm 의 반환 값 다섯 개가 모든 폼이 공통으로 들고 있는 API다.
| 반환 값 | 역할 | 예시 |
|---|---|---|
register | input 한 줄 연결 | <Input {...register("name")} /> |
handleSubmit | 제출 시점 검증·정규화 일괄 실행 | <form onSubmit={handleSubmit(onSubmit)}> |
setValue | 외부 이벤트로 값 주입 | URL 쿼리 파라미터 → setValue("classId", ...) |
watch | 특정 필드 실시간 구독 | 표시용 포맷에 watch("phone") 활용 |
formState.errors | 필드별 zod 메시지 | errors.phone?.message |
// 표시용 포맷 적용 — watch + setValue 한 쌍
const phoneValue = watch("phone");
const formatPhoneDisplay = (value: string) => {
if (!value) return "";
if (value.length <= 3) return value;
if (value.length <= 7) return `${value.slice(0, 3)}-${value.slice(3)}`;
return `${value.slice(0, 3)}-${value.slice(3, 7)}-${value.slice(7, 11)}`;
};
const handlePhoneChange =
(field: "phone" | "parentPhone") =>
(e: React.ChangeEvent<HTMLInputElement>) => {
const numericOnly = e.target.value.replace(/\D/g, "").slice(0, 11);
setValue(field, numericOnly, { shouldValidate: true });
};
watch + setValue 한 쌍이 입력 즉시 정규화를 담당한다. 사용자가 본 표시는 formatPhoneDisplay 가 그리지만, 내부 상태는 항상 숫자만 11 자다. shouldValidate: true 옵션으로 입력 변경 시점에 필드 에러가 실시간 갱신된다.
useEffect + reset 한 쌍은 수정 폼의 초기값 주입에 사용한다.
// apps/academy-portal/src/pages/students/edit.tsx
// commit 6d3f2a8 (발췌)
const { reset } = useForm<StudentEditFormData>({
resolver: zodResolver(studentEditSchema),
defaultValues: { name: "", classId: 0, birth: "", aheadGrade: "" },
});
useEffect(() => {
if (member) {
reset({
name: member.name,
classId: member.classId,
birth: member.birth || "",
aheadGrade: member.aheadGrade || "",
phone: member.phone || "",
parentPhone: member.parentPhone || "",
});
}
}, [member, reset]);
reset 은 defaultValues 를 갱신하면서 dirty 상태도 초기화한다. 수정 후 저장 → 다시 같은 폼 진입 시점에 서버 응답을 새 기본값으로 반영하는 용도다. useEffect 의 의존성 배열에 reset 을 포함해도 react-hook-form 의 reset 은 안정 참조라 무한 루프가 발생하지 않는다.
⚠️ 주의:
defaultValues와setValue를 동시에 사용할 때, defaultValues 의 빈 문자열과 setValue 의 후속 값 사이에 짧은 깜빡임이 발생할 수 있다. 운영 페이지에서는isLoading동안 로딩 표시를 띄우고, 데이터 도착 후reset으로 한 번에 채우는 패턴으로 통일했다.
zodResolver 가 Zod 메시지를 react-hook-form 의 formState.errors 로 그대로 연결한다.
{/* 이름 필드 에러 — zod 메시지가 그대로 들어옴 */}
{errors.name?.message && (
<p className="text-sm text-red-500">{errors.name.message}</p>
)}
errors.name?.message 한 줄이 zod 의 .min(1, "이름을 입력하세요") 두 번째 인자를 그대로 출력한다. FE 코드 어디에도 검증 메시지 문자열이 안 들어옴 — 스키마가 곧 메시지의 단일 출처다.
📌 핵심:
useForm진입점 한 줄에 상태·검증·정규화·기본값·리셋·메시지가 모두 묶인다. 모든 폼이 같은 모양의 진입점을 들면 다음 폼을 만들 때도 같은 5 줄 패턴을 복사·필드만 갈아끼우면 된다. 학습 비용 1 회로 폼 12 개의 흐름을 통일하는 핵심.
🛠️ 구현 3 — shadcn/ui Form + StyledFormField 합성
세 번째 단계는 UI 합성이다. shadcn/ui 의 <Form> 구성 요소를 디자인 시스템에 맞게 흡수한다.
shadcn/ui 의 Form 모듈(apps/academy-portal/src/components/ui/form.tsx)은 접근성 속성 자동 부착이 핵심이다.
// apps/academy-portal/src/components/ui/form.tsx
// 발췌 — shadcn 표준 구조
import { Controller, FormProvider, useFormContext } from "react-hook-form";
const Form = FormProvider; // react-hook-form 의 FormProvider 그대로 export
const FormFieldContext = React.createContext<{ name: string }>({} as any);
const FormField = <T extends FieldValues, N extends FieldPath<T>>({
...props
}: ControllerProps<T, N>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl 한 줄이 입력 컨트롤에 aria-describedby 와 aria-invalid 를 자동 부착한다. 스크린 리더가 에러 메시지 위치를 바로 안내받게 된다. 본문 작성자가 접근성 속성을 직접 적을 필요가 없다.
그러나 운영 페이지의 폼은 피그마 디자인 시스템에 맞춰 160px 라벨 + 빨간 에러 텍스트 + 주황 힌트 패턴이 박혀 있다. shadcn 의 기본 레이아웃(Label 위, Input 아래) 과 디자인 패턴(Label 좌측 고정폭, Input 우측) 이 다르다.
이 차이를 별도 래퍼(StyledFormField) 로 흡수했다.
// apps/academy-portal/src/pages/students/create.tsx
// commit 6d3f2a8 — StyledFormField 정의
function StyledFormField({
label,
required,
hint,
error,
children,
}: {
label: string;
required?: boolean;
hint?: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start gap-4">
<label className="w-[160px] text-2xl font-semibold text-right text-[#334155] shrink-0 pt-4">
{required && <span className="text-[#00b7ff]">*</span>} {label}
</label>
<div className="flex-1">
{children}
{hint && <span className="text-base text-[#ff7f00] ml-4">{hint}</span>}
{error && <p className="mt-1 text-base text-red-500">{error}</p>}
</div>
</div>
);
}
호출 측은 한 줄 합성으로 끝난다.
<StyledFormField label="이름" required error={errors.name?.message}>
<Input
placeholder="회원 이름"
{...register("name")}
className="h-[56px] w-[320px] rounded-[10px] border-2 border-[#d1d5db] text-2xl px-5"
/>
</StyledFormField>
label · required 표시 · 힌트 · 에러가 한 줄에 모이고, 입력 컨트롤은 children 으로 끼워 넣는다. shadcn 의 FormProvider / FormField / Controller 가 제어 컴포넌트(select 등) 가 필요한 경우에만 복잡한 합성에 사용한다.
두 래퍼의 역할 분리는 다음과 같다.
| 래퍼 | 역할 | 사용 시점 |
|---|---|---|
StyledFormField | 디자인 시스템(피그마) 패턴 합성 — 좌측 고정폭 라벨 + 우측 입력 + 하단 에러 | 평범한 <Input> / <select> 단건 입력 |
shadcn FormField (Controller 래핑) | 접근성 속성 + 제어 컴포넌트 합성 | <Combobox> / <DatePicker> 같은 복합 컨트롤 — 추가 도입 시점 에 적용 예정 |
🔍 단서: 두 래퍼의 역할 분리가 모호하면 다음 폼이 어느 쪽을 골라야 하는지 헷갈린다. 본 머지에서는 기본은
StyledFormField로 통일하고, 복합 컨트롤이 필요한 폼에 한해 shadcnFormField를 도입하기로 컨벤션 한 줄을 명문화했다.
{/* 폼 전체 합성 — 모든 폼이 같은 모양 */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-[60px]">
<StyledFormField label="이름" required error={errors.name?.message}>
<Input placeholder="회원 이름" {...register("name")} className="..." />
</StyledFormField>
<StyledFormField label="이메일" required hint="※ 로그인 ID로 사용" error={errors.email?.message}>
<Input type="email" placeholder="[email protected]" {...register("email")} className="..." />
</StyledFormField>
<StyledFormField label="연락처" error={errors.phone?.message}>
<Input
placeholder="010-1234-5678"
inputMode="numeric"
maxLength={13}
value={formatPhoneDisplay(phoneValue || "")}
onChange={handlePhoneChange}
className="..."
/>
</StyledFormField>
<div className="flex justify-center gap-10">
<button type="button" onClick={...}>취소</button>
<button type="submit" disabled={isPending}>
{isPending ? "등록 중..." : "등록하기"}
</button>
</div>
</form>
폼 12 개가 같은 외형을 가지면 디자인 일관성이 코드 레벨에서 보장된다. 피그마 컴포넌트와 React 컴포넌트가 1:1 매핑된다.
📌 핵심: UI 합성의 표준은 *한 컴포넌트(StyledFormField)*가 라벨·필수 표시·힌트·에러를 한 줄에 모은다. shadcn 의 Form 구성 요소는 접근성 속성을 자동 부착하는 도구로 활용하되, 디자인 시스템 패턴은 별도 래퍼로 흡수한다. 기본 입력은
StyledFormField, 복합 컨트롤은 shadcnFormField두 줄의 컨벤션이 본 머지의 핵심.
🛠️ 구현 4 — parseErrorMessage + serverErrors 빨간 배너
네 번째 단계는 BE 에러 매핑이다. 필드 단위 검증은 zod 가 다 잡지만, BE 에서 떨어지는 에러(중복 키 · 권한 부족 · 비즈니스 규칙 위반) 는 폼 상단 빨간 배너로 분리해 표시한다.
NestJS 응답 표면이 세 가지 형태로 운영 중에 혼재한다.
| 응답 형태 | 출처 | 예시 |
|---|---|---|
error.details: { message: string }[] | 우리 컨벤션 — class-validator 다중 에러를 details 배열로 정리 | [{ message: "이메일 형식 오류" }, { message: "비밀번호 8자 이상" }] |
error.message: string[] | NestJS ValidationPipe 기본 | ["email must be an email", "password must be at least 8 chars"] |
error.message: string | 일반 도메인 에러 | "이미 등록된 이메일입니다" |
한 함수가 세 형태를 모두 흡수해 문자열 배열 로 정규화한다.
// apps/academy-portal/src/pages/students/create.tsx
// commit 6d3f2a8 — parseErrorMessage 정규화 함수
const parseErrorMessage = (error: any): string[] => {
const data = error?.response?.data;
const errorObj = data?.error || data;
if (errorObj?.details && Array.isArray(errorObj.details)) {
return errorObj.details.map((d: { message: string }) => d.message);
}
if (Array.isArray(errorObj?.message)) {
return errorObj.message;
}
if (errorObj?.message) {
return [errorObj.message];
}
return [error?.message || "등록에 실패했습니다"];
};
세 가지 응답 형태에 대해 세 가지 분기가 우선순위 순으로 일치한다. 마지막 폴백은 axios 자체 에러 메시지를 그대로 노출한다.
이 함수의 반환값을 로컬 상태에 담아 폼 상단 빨간 배너에 출력한다.
const [serverErrors, setServerErrors] = useState<string[]>([]);
const onSubmit = (data: StudentFormData) => {
setServerErrors([]);
create(
{
resource: "students",
values: data,
},
{
onSuccess: () => {
toast.success("회원이 등록되었습니다");
list("students");
},
onError: (error: any) => {
const errors = parseErrorMessage(error);
setServerErrors(errors);
},
},
);
};
빨간 배너 합성은 폼 상단 단일 블록이다.
{serverErrors.length > 0 && (
<div className="p-4 text-base text-red-600 bg-red-50 rounded-[10px]">
<ul className="list-disc list-inside space-y-1">
{serverErrors.map((err, idx) => (
<li key={idx}>{err}</li>
))}
</ul>
</div>
)}
필드 단위 에러와 폼 단위 에러가 시각적으로 분리되면 사용자가 어디를 고쳐야 하는지 즉시 식별한다.
| 영역 | 위치 | 출처 |
|---|---|---|
| 필드 에러 | 인풋 하단 빨간 텍스트 | zod formState.errors.<field>.message |
| 폼 에러 | 폼 상단 빨간 배너 (불릿 리스트) | BE 응답 parseErrorMessage(error) |
| 성공 | 우측 하단 토스트 | sonner toast.success(...) |
🔍 단서: 세 영역을 세 위치로 분리하면 사용자 시야가 한 곳에 머무르지 않는다. 입력 도중 = 인풋 하단 / 제출 직후 BE 거절 = 폼 상단 / 제출 성공 = 우측 하단 토스트 세 위치를 습관으로 만들면 모든 폼이 같은 표시 패턴을 갖는다.
useUpdate 도 같은 구조다.
// apps/academy-portal/src/pages/students/edit.tsx
// commit 6d3f2a8 (발췌)
const onSubmit = (data: StudentEditFormData) => {
setServerErrors([]);
update(
{
resource: "students",
id: id!,
values: data,
},
{
onSuccess: () => {
toast.success("회원 정보가 수정되었습니다");
show("students", id!);
},
onError: (error: any) => {
const errors = parseErrorMessage(error);
setServerErrors(errors);
},
},
);
};
useCreate / useUpdate 가 같은 모양의 onSuccess / onError 콜백을 들면 수정 폼·등록 폼이 같은 BE 에러 흐름을 공유한다. 다음 폼이 추가될 때 복사·필드만 갈아끼우면 된다.
📌 핵심: BE 에러 매핑은 한 함수(
parseErrorMessage) 와 한 상태(serverErrors) 와 한 위치(폼 상단 빨간 배너) 의 세 줄로 끝난다. 세 가지 응답 형태가 운영 중 혼재해 있어도 한 함수의 우선순위 분기가 다 흡수한다. 세 줄의 표준이 모든 폼에 그대로 들어가면서, 다음 BE 에러 응답 형태가 추가돼도 한 함수 갱신으로 끝난다.
📊 결과 — 측정 가능한 지표 5 건
본 머지 적용 전후의 변화를 측정 가능한 지표로 정리한다.
| 지표 | 적용 전 | 적용 후 | 변화 |
|---|---|---|---|
| 검증 코드 흩어진 위치 | 폼 12 개 × 평균 5 곳 = 약 60 위치 | lib/validations/ 1 곳 (스키마 7 개) | -88% 위치 집중 |
alert() 검증 호출 | 폼 8 개 × 평균 2 회 = 16 회 | 0 회 (zod + 필드 에러) | -100% |
| BE 응답 그대로 노출 | 폼 4 개 (운영자 페이지 등록 등) | 0 폼 (parseErrorMessage 흡수) | -100% |
| 신규 폼 평균 작성 시간 | 약 2.5 시간 (UI + 검증 + 에러 흐름 각자 작성) | 약 35 분 (StyledFormField + 공용 스키마 import) | -77% |
z.infer<> 타입 자동 생성 폼 | 0 | 12 (모든 폼) | +12 |
신규 폼 평균 작성 시간 단축이 가장 큰 체감 변화다. 이전에는 입력 컨트롤 디자인 + 정규식 작성 + 에러 표시 위치 결정 + BE 에러 처리 네 영역을 각자 새로 작성해야 했다. 본 머지 이후에는 공용 스키마 import + 폼 스키마 조합 + StyledFormField 합성 + onSubmit 표준 패턴 네 줄로 끝난다.
z.infer<> 한 줄로 타입 정의가 사라진 것도 작은 변화가 아니다. 스키마와 타입이 영원히 동기화되면 필드 추가 시 타입 누락이 발생하지 않는다. 스키마 한 줄 추가가 타입 + 검증 + 에러 메시지를 한꺼번에 늘린다.
// 본 머지 적용 전 — 타입과 검증을 따로 관리
interface StudentFormData {
name: string;
phone: string;
birth: string;
aheadGrade: string;
}
function validateStudent(data: StudentFormData) {
if (!data.name) return "이름을 입력하세요";
if (!/^\d{10,11}$/.test(data.phone.replace(/\D/g, ""))) {
return "연락처 형식이 올바르지 않습니다";
}
// ... 생년월일·교육과정 분기 검증 반복
}
// 본 머지 적용 후 — 한 스키마가 타입·검증·메시지를 모두 들고 있음
const studentFormSchema = z.object({
name: z.string().min(1, "이름을 입력하세요"),
phone: phoneSchemaOptional,
birth: birthSchemaRequired,
aheadGrade: aheadGradeSchemaRequired,
});
type StudentFormData = z.infer<typeof studentFormSchema>;
코드 줄 수만 보면 비슷하지만, 유지 비용은 비교가 안 된다. 적용 후 코드는 스키마 한 줄 수정이 타입·검증·메시지·정규화를 한꺼번에 반영한다.
| 영역 | 적용 전 | 적용 후 |
|---|---|---|
| 폼 데이터 타입 | interface StudentFormData 수동 선언 | z.infer<typeof studentFormSchema> 자동 생성 |
| 검증 함수 | validateStudent(data) 직접 작성 | zodResolver(studentFormSchema) 자동 |
| 에러 메시지 | 검증 함수 안에 문자열 하드코딩 | 스키마 두 번째 인자 (.min(1, "이름을 입력하세요")) |
| 정규화 | onSubmit 핸들러에서 직접 replace(/\D/g, "") | phoneSchema.transform(normalizePhone) 자동 |
| 필드 추가 | interface + 검증 함수 + 에러 메시지 3 곳 수정 | 스키마 한 줄 수정 |
💡 인사이트: 스키마 단일 출처가 명문화된 시점부터 팀 합의는 zod 스키마 한 줄을 갱신하는 형태로 한다. PR 리뷰가 검증 로직을 보지 않고 스키마 변경만 본다. 코드 리뷰 비용도 함께 줄어든다.
🔄 회고 — 같은 결정을 다시 한다면
본 머지를 회고하면 세 가지를 다시 결정하고 두 가지를 그대로 유지한다.
다시 결정한다면
하나 — lib/validations/ 디렉터리를 처음부터 모노레포 공용 패키지로 뺀다. 현재는 운영 페이지 두 종(academy-portal, admin-portal) 이 각자의 lib/validations/ 를 들고 있다. 같은 phoneSchema 가 두 파일에 복사돼 있다. 모노레포 공용 패키지(packages/validations) 한 곳에 모으면 한 줄 갱신이 두 운영 페이지에 동시 반영된다. 본 머지에서는 공용 패키지 추출까지 가지 않고 운영 페이지 안에 머물렀는데, 세 번째 폼이 추가되는 시점에 공용 패키지 추출을 다음 머지로 미뤘다. 다음에 같은 결정을 한다면 처음부터 공용 패키지로 시작한다.
둘 — StyledFormField 와 shadcn FormField 의 역할 분리를 문서화까지 끌고 간다. 현재는 컨벤션 한 줄(기본은 StyledFormField, 복합 컨트롤은 shadcn FormField) 이 PR 설명 본문에만 적혀 있다. 컴포넌트 주석과 팀 위키에 역할 분리 기준을 명문화해야 팀원이 새로 합류해도 같은 결정을 따라간다. 본 머지 이후 두 번째 운영자가 합류해 복합 컨트롤이 아닌데도 shadcn FormField 를 쓴 사례가 한 번 있었다. 문서화 부재가 원인이었다.
셋 — parseErrorMessage 의 세 가지 응답 형태 흡수는 BE 응답 표준 통일을 동시 머지로 끝냈어야 한다. 본 머지에서는 FE 가 BE 의 세 응답 형태를 흡수하는 방어 코드를 들고 있다. 정작 BE 측 응답 표준 통일은 별도 머지로 분리했는데, 별도 머지가 미뤄지는 동안 FE 의 방어 코드가 기술 부채로 잔존한다. 다음에 같은 결정을 한다면 BE·FE 동시 머지로 세 가지 응답 형태를 한 가지로 줄인다.
그대로 유지한다면
하나 — Refine 의 useForm 대신 순정 react-hook-form 직접 사용 결정은 유지한다. Refine useForm 도 react-hook-form 을 내부에 쓰지만 추가 추상층이 한 겹 더 들어가고, Refine 의 dataProvider 추상과 react-hook-form 의 resolver 추상이 맞물려 디버깅이 어려워진다. 데이터 계층은 useCreate / useUpdate 만 활용하고, 폼 상태는 react-hook-form 직접 사용하는 분리가 디버깅 비용을 가장 낮춘다. 공식 문서가 가장 두꺼운 곳이 순정 react-hook-form이라는 점도 무시 못 할 이유다.
둘 — Zod 의 transform → refine 순서도 유지한다. 정규화 후 검증 흐름이 handleSubmit 시점에 정규화된 값을 받아 BE 와 동일한 검증 어휘를 가지게 한다. BE 의 @Transform → @Matches 두 줄 데코레이터와 완전히 같은 모양을 FE 가 거울처럼 들고 있어야 클라이언트 통과 / 서버 거절 비대칭이 사라진다. zod 의 두 메서드 순서가 팀 어휘의 일부가 됐다.
⚠️ 주의: 재시작 시점에 Refine 의
useForm으로 갈아탈 유혹이 종종 있다. 대시보드 list / show 페이지가 Refine 의 dataProvider를 적극 활용하기 때문에 폼 페이지도 Refine 으로 통일하면 일관성 있어 보인다. 그러나 폼 진입점의 디버깅 비용은 순정 react-hook-form 쪽이 훨씬 낮다. 데이터 계층 일관성과 디버깅 비용 사이의 트레이드오프에서, 디버깅 비용을 항상 더 무겁게 보는 결정을 유지한다.
📋 정리 — 핵심 요약
본 머지의 4 단 파이프라인을 한 표로 정리한다.
| 단계 | 위치 | 핵심 표준 |
|---|---|---|
| 1. 검증 스키마 | lib/validations/ | 공용 zod 스키마 7 개 — phoneSchema / birthSchema / aheadGradeSchema 등 |
| 2. 폼 진입점 | 페이지 컴포넌트 | useForm({ resolver: zodResolver(schema), defaultValues }) 한 줄 |
| 3. UI 합성 | 페이지 컴포넌트 + shadcn ui | StyledFormField 기본 + shadcn FormField 복합 컨트롤 |
| 4. BE 에러 매핑 | 페이지 컴포넌트 | parseErrorMessage 함수 + serverErrors 상태 + 폼 상단 빨간 배너 |
다음 폼을 추가할 때 따라가는 4 단 체크리스트다.
| 적용 안 한 패턴 (안티) | 적용한 표준 패턴 |
|---|---|
❌ 폼마다 interface FormData { ... } 수동 선언 | ✅ z.infer<typeof formSchema> 자동 생성 |
❌ validateForm(data) 함수 직접 작성 | ✅ zodResolver(formSchema) 한 줄 |
| ❌ 검증 메시지 문자열을 컴포넌트 곳곳에 하드코딩 | ✅ 스키마 .min(1, "...") / .refine(..., "...") 두 번째 인자 |
❌ 입력 핸들러에서 replace(/\D/g, "") 즉시 정규화 | ✅ transform(normalizePhone) 으로 제출 직전 정규화 |
| ❌ BE 응답을 그대로 컴포넌트에 노출 | ✅ parseErrorMessage 함수로 세 형태 흡수 |
| ❌ 에러를 인풋 / 토스트 / 모달 세 곳에 산발 | ✅ 필드 = 인풋 하단 / 폼 = 상단 빨간 배너 / 성공 = 우측 하단 토스트 3 위치 표준 |
핵심을 세 줄로 다시 정리한다.
- 폼 표준의 본질은 4 단 파이프라인을 컨벤션으로 명문화하는 작업이다. 라이브러리 도입만으로는 다음 폼이 또 자기만의 흐름을 만든다. 검증 스키마 위치 + 진입점 한 줄 + UI 합성 컴포넌트 + BE 에러 매핑 함수 네 줄을 팀 어휘로 명문화해야 폼 12 개의 흐름이 통일된다.
- Zod 의
transform → refine순서는 BE·FE 어휘 통일의 핵심이다. 정규화 후 검증 흐름이 handleSubmit 시점에 정규화된 값을 받으면, BE 의@Transform → @Matches두 줄과 거울 같은 모양을 갖는다. 클라이언트 통과 / 서버 거절 비대칭을 어휘 단일화로 잡는 게 검증 한 줄 추가보다 우선이다. - 에러 표시 세 위치(필드 / 폼 / 토스트) 가 시각적으로 분리돼야 사용자 시야가 어디를 고쳐야 하는지 즉시 식별한다. 필드 단위 검증과 폼 단위 BE 에러가 같은 위치에 섞이면 입력 흐름이 끊긴다. 본 머지의 세 영역 × 세 위치 분리가 작지만 가장 체감 큰 결정 중 하나.
다음 편 (devlog-65) 에서는 Soft Delete 의 삭제하지 않는 삭제 설계 구현기를 다룬다. 본 머지가 프론트엔드 폼 표준이었다면, 다음 편은 백엔드 도메인 모델 표준 — deletedAt 컬럼 한 줄이 Prisma 미들웨어·필터 범위·복원 정책 세 영역으로 어떻게 퍼지는지를 구현기 관점에서 정리한다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
- 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
- 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
- 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
- 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
- 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
- 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
- 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
- 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로
- 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
- 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
- 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
- 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
- 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
- 60. react-hook-form + Zod 폼 표준 정착기
- 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
- 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
- 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음