react-hook-form + Zod 폼 표준 정착기

여러 운영 페이지와 외부 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) 시점에 zod transform정규화된 값을 자동 주입data: z.infer<typeof schema> 로 타입 + 값이 동시에 정렬
  • onError 콜백에서 BE 응답 에러serverErrors 배열로 매핑 — 필드 레벨 에러는 zod, 폼 레벨 에러는 빨간 배너 두 줄로 분리

🎯 배경 — 폼이 12 개로 늘어나기 전에 흐름을 표준화한다

이전 편의 연락처 포맷 통일 머지가 끝난 직후, 다음 한 줄의 결정이 들어왔다.

“같은 어색함을 다음 필드에서 또 마주치지 않도록, 입력 필드 전체가 같은 흐름을 따르게 표준을 명문화한다.”

연락처 사고는 단일 필드의 저장·입력·표시 세 축 불일치였다. 그런데 같은 결함이 생년월일·교육과정 분기·로그인 ID·이메일 같은 다른 필드에도 잠복해 있었다. 운영 페이지 4 종을 열어 입력 폼을 다 세 보니 12 개의 폼, 41 개의 입력 필드각자 다른 검증·정규화·에러 표시 코드를 들고 있었다.

흩어진 폼 12 개의 상태를 표로 정리하면 다음과 같다.

폼 위치입력 필드 수검증 방식에러 표시
고객사 관리자 페이지 — 회원 등록8즉석 if (!name) alert(...)alert() 모달
고객사 관리자 페이지 — 운영자 등록3정규식 인라인인풋 하단 회색 텍스트
고객사 관리자 페이지 — 클래스 등록5useState + useEffect 검증토스트
운영자 페이지 — 고객사 등록6검증 없음BE 400 응답 그대로 노출
보호자 앱 — 회원가입6자체 hook (useFormValidation)필드 옆 빨간 텍스트
보호자 앱 — 마이페이지 수정5일부 정규식, 일부 무검증혼재
데스크톱 런타임 로비 — 비밀번호 변경3직접 작성토스트

📌 핵심: 입력 표면이 12 개 로 늘어난 시점에서 각 페이지가 자기만의 폼 흐름을 들고 있으면 다음 사고 한 건12 군데 동시에 누락된다. 연락처 사고 한 줄이 BE 정규식 한 줄로 끝나지 않고 DB 612 행 정규화 SQL 까지 필요했던 이유도 클라이언트 검증이 페이지마다 달랐기 때문이다. 다음 필드를 추가할 때 같은 사고를 또 만들지 않게 흐름을 한 줄의 표준으로 명문화한다.

흐름 표준의 목표는 단순하다.

  1. 검증 스키마는 한 곳lib/validations/ 디렉터리에 공용 zod 스키마를 모은다. 같은 필드(연락처·생년월일·교육과정 분기)는 같은 스키마 한 줄을 가져다 쓴다.
  2. 폼 진입점은 한 줄useForm({ resolver: zodResolver(schema), defaultValues }) 한 줄이 상태·검증·정규화·기본값을 한꺼번에 묶는다.
  3. UI 합성은 한 컴포넌트StyledFormField 래퍼 한 개가 label·required·hint·error 표시를 통일한다. shadcn/ui Form 의 구성 요소(FormProvider / FormField / FormItem / FormLabel / FormControl / FormMessage)는 디자인 시스템에 맞춰 흡수한다.
  4. 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.errorsZod 메시지를 그대로 받음 / @hookform/resolvers/zod 어댑터가 공식 지원Refine 의 useForm 도 react-hook-form 을 내부에 쓰지만 추가 추상층이 한 겹 더 들어감 — 순정 react-hook-form 직접 사용으로 결정, Refine 은 데이터 계층(useCreate / useUpdate) 만 활용
3UI 합성 — 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 회
6BE 에러 매핑 — parseErrorMessage 한 함수가 세 가지 응답 형태 흡수NestJS class-validator 가 떨어뜨리는 error.details[] 배열 + 우리 컨벤션의 error.message 배열 + 단일 error.message 문자열 세 형태가 운영 중 혼재 / 한 함수가 세 가지 모두 흡수문자열 배열 로 정규화BE 응답 표준이 세 가지 형태에 흩어져 있다는 점 자체가 별도 사고이지만, 본 머지는 프론트엔드 폼 표준에 집중 — BE 응답 정규화는 별도 머지로 분리

결정 1·2·3 이 스택의 척추고, 결정 4·5·6 이 흐름의 결합이다. 결정 1·2 가 라이브러리 선택이라면 결정 3 은 디자인 시스템에 라이브러리를 어떻게 흡수했는지다. 결정 4·5·6 은 검증된 값·필드 에러·폼 에러한 폼 안에서 충돌 없이 공존하도록 세 영역을 분리한 결정이다.

직접 정리한 react-hook-form + Zod + shadcn Form 폼 표준 데이터 흐름도 — lib/validations/ 공용 스키마와 useForm + zodResolver 진입점, StyledFormField 합성, handleSubmit 정규화, Refine useCreate BE 에러 매핑까지의 5 단 파이프라인 구조도
직접 정리한 react-hook-form + Zod + shadcn Form 폼 표준 데이터 흐름도 — lib/validations/ 공용 스키마와 useForm + zodResolver 진입점, StyledFormField 합성, handleSubmit 정규화, Refine useCreate BE 에러 매핑까지의 5 단 파이프라인 구조도

⚠️ 주의: 라이브러리 선택만으로는 폼 표준이 정착하지 않는다. 결정 3·5·6 이 팀 컨벤션에 가까운데, 어디서·언제·어떤 형태로 에러를 표시하는지팀 합의로 명문화돼 있지 않으면 다음 폼또 자기만의 표시 방식을 만든다. 본 머지의 본질은 라이브러리 3 종을 도입한 작업이 아니라, *4 단 파이프라인(스키마 → 진입점 → UI 합성 → BE 매핑)*을 팀 컨벤션으로 명문화한 작업이다.

react-hook-form.com
ui.shadcn.com

🛠️ 구현 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다.

반환 값역할예시
registerinput 한 줄 연결<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]);

resetdefaultValues 를 갱신하면서 dirty 상태도 초기화한다. 수정 후 저장 → 다시 같은 폼 진입 시점에 서버 응답을 새 기본값으로 반영하는 용도다. useEffect 의 의존성 배열에 reset 을 포함해도 react-hook-form 의 reset 은 안정 참조라 무한 루프가 발생하지 않는다.

⚠️ 주의: defaultValuessetValue동시에 사용할 때, 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-describedbyaria-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 로 통일하고, 복합 컨트롤이 필요한 폼에 한해 shadcn FormField 를 도입하기로 컨벤션 한 줄을 명문화했다.

{/* 폼 전체 합성 — 모든 폼이 같은 모양 */}
<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="member@tenant.com" {...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, 복합 컨트롤은 shadcn FormField 두 줄의 컨벤션이 본 머지의 핵심.


🛠️ 구현 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<> 타입 자동 생성 폼012 (모든 폼)+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 uiStyledFormField 기본 + 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 위치 표준

핵심을 세 줄로 다시 정리한다.

  1. 폼 표준의 본질은 4 단 파이프라인을 컨벤션으로 명문화하는 작업이다. 라이브러리 도입만으로는 다음 폼또 자기만의 흐름을 만든다. 검증 스키마 위치 + 진입점 한 줄 + UI 합성 컴포넌트 + BE 에러 매핑 함수 네 줄을 팀 어휘로 명문화해야 폼 12 개의 흐름이 통일된다.
  2. Zod 의 transform → refine 순서는 BE·FE 어휘 통일의 핵심이다. 정규화 후 검증 흐름이 handleSubmit 시점정규화된 값을 받으면, BE 의 @Transform → @Matches 두 줄과 거울 같은 모양을 갖는다. 클라이언트 통과 / 서버 거절 비대칭어휘 단일화로 잡는 게 검증 한 줄 추가보다 우선이다.
  3. 에러 표시 세 위치(필드 / 폼 / 토스트)시각적으로 분리돼야 사용자 시야가 어디를 고쳐야 하는지 즉시 식별한다. 필드 단위 검증폼 단위 BE 에러같은 위치에 섞이면 입력 흐름이 끊긴다. 본 머지의 세 영역 × 세 위치 분리가 작지만 가장 체감 큰 결정 중 하나.

다음 편 (devlog-65) 에서는 Soft Delete삭제하지 않는 삭제 설계 구현기를 다룬다. 본 머지가 프론트엔드 폼 표준이었다면, 다음 편은 백엔드 도메인 모델 표준deletedAt 컬럼 한 줄Prisma 미들웨어·필터 범위·복원 정책 세 영역으로 어떻게 퍼지는지를 구현기 관점에서 정리한다.


📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
  47. 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
  48. 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
  49. 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
  50. 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
  51. 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
  52. 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
  53. 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
  54. 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로
  55. 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
  56. 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
  57. 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
  58. 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
  59. 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
  60. 60. react-hook-form + Zod 폼 표준 정착기
  61. 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
  62. 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
  63. 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음