react-hook-form + Zod 폼 표준 정착기
여러 운영 페이지와 외부 SPA 에 흩어진 폼 12 개가 각자 다른 검증·정규화·에러 표시 흐름을 갖고 있었다. zod 공용 스키마를 `lib/validations/` 에 모으고, react-hook-form + zodResolver + shadcn/ui Form 한 흐름으로 묶으면서 입력 정규화·필드 에러·BE 에러 매핑까지 한 줄의 표준으로 정착시킨 구현기.
여러 운영 페이지와 외부 SPA 에 흩어진 폼 12 개가 각자 다른 검증·정규화·에러 표시 흐름을 갖고 있었다. zod 공용 스키마를 `lib/validations/` 에 모으고, react-hook-form + zodResolver + shadcn/ui Form 한 흐름으로 묶으면서 입력 정규화·필드 에러·BE 에러 매핑까지 한 줄의 표준으로 정착시킨 구현기.
관리자 페이지 회원 레포트 페이지를 5탭(개요·학습 분석·지표 분석·레벨 이력·상세 기록)으로 설계한 머지. 신규 4 API + 기존 1 API 재사용, 공통 기간 필터, 인사이트 3파트(긍정/격려/개선) 구조, 학습 진도 집계 단위를 묶음에서 콘텐츠로 바꾼 결정, 상세 기록의 드릴다운 3계층(과제→묶음→콘텐츠) 응답 표준을 정리한다. NestJS + Prisma 환경에서 명세 우선·FE Mock 선행 워크플로우로 BE 구현 전 사용자 검토를 확보한 도입 단계 마일스톤이다.
PATCH body에 끼워 넣은 다른 tenantId가 통과해 옆 고객사 회원이 옮겨 쓰였다. NestJS Guard + DTO에서 tenantId 제거 + whitelist:true 3중 차단으로 쓰기 가드를 만들고, count/sum/groupBy는 where 변수 추출 + $transaction으로 묶었다. 시간 슬라이스는 KST 자정 고정, 멱등성은 Redis EX 60 + 충돌 검사 + audit, 정책 위반은 bypassPolicy + reason + audit. ts-morph 정적 스캔까지 1일 트러블슈팅.
NestJS 멀티테넌트 도입 다음 날 아침, 다른 고객사 토큰으로 회원 상세를 열었더니 200 OK가 떨어졌다. JWT payload·@CurrentUser·Repository 시그니처 3계층에 tenantId를 강제하고, ts-morph 정적 스캔과 cross-tenant e2e로 회귀까지 차단한 6시간 트러블슈팅.
SystemPolicy 8필드를 id @default(1) 싱글톤으로 묶고, 상호 의존 임계값은 zod superRefine으로 BE/FE 두 곳에서 검증했다. 동사 액션은 별도 라우트로 분리하고, 정책 변경은 방향성만 검증하는 e2e로 회귀를 차단한 1.5일 트러블슈팅.
1차 점검(SC-A01~A17)이 *FE 단독·BE 단독*의 단위 검수였다면, 2차 점검은 *FE-BE를 처음 같이 돌려 보는 자리*였어요. 그날 자정부터 새벽까지 한 사이클에 11건이 터졌습니다. CORS PATCH·DTO interface→class·운영자 상태 변경 400·콘텐츠 FK 제약·dbVersion 동적 조회 다섯 자리(BE)에 모니터링 useCustom·진단평가 문제 추가 버튼 결정·이메일 인라인 에러·SUPER_ADMIN 활성화 가드·배치고사 상태 엔드포인트 분리·콘텐츠 목록 null 크래시 여섯 자리(FE)까지. 코드 변경은 한 줄짜리 자리가 많았는데, *어떤 자리가 깨질 수 있는지*를 미리 보지 못한 게 11건이 한꺼번에 몰린 이유였어요. 합류 지점이라는 디버깅 환경의 본질을 11개의 자리로 짚은 글입니다.
VITE_USE_MOCK_API=false로 토글한 순간 useTable이 멈췄다. BE는 TransformInterceptor로 모든 응답을 `{ success, data, meta }`로 표준화했고, Refine은 `{ data, total }`을 기대했다. dataProvider 어댑터 한 줄로 메우면 끝나는 줄 알았는데 — 경로가 `/api/v1/api/v1/...`로 중복되고, FE가 `order`로 보낸 정렬 키를 BE는 `sortOrder`로 받았으며, 중첩 리소스 `/contents/:id/problems`는 dataProvider 기본 구현이 못 그렸다. 결국 어댑터 한 줄이 아니라 경로 정규화·필드 매핑·중첩 리소스 라우터·401 인터셉터까지 네 자리를 박아야 토글이 진짜 한 줄이 됐다.
SC-A01부터 SC-A20까지 20개 시나리오를 5일 만에 75%에서 90%까지 끌어올린 Admin Portal MVP 사이클. 혼자 PM·BE·FE 세 역할을 돌리면서 만든 병렬화의 환상은 사실 코드 한 줄 — `VITE_USE_MOCK_API=true` 환경변수 토글과 그 뒤에 붙은 Mock/REST DataProvider 두 구현체에서 시작됐다. Refine + Vite 위에서 14개 page 모듈을 어떤 순서로 박았는지, [FE → PM] 태그로 셀프 보고하던 협업 프로토콜이 왜 효과가 있었는지, Mock에서 REST로 갈아끼울 때 실제로 바뀐 건 한 줄이었는지 — 다섯 H2로 정리한다.
Refine의 useCustom hook에서 config.query 객체에 number 타입 값을 전달해도, URL 쿼리 파라미터로 직렬화되면서 문자열이 된다. NestJS @Type(() => Number) 검증과 조합하면 targetClassId must be an integer number 400 에러가 터진다. URL 직접 삽입 패턴으로 해결한 실전 사례를 정리한다.
Refine 기반 React 어드민에서 DataProvider 커스터마이징 시 흔히 겪는 두 가지 함정을 정리합니다. BE가 객체를 반환하는데 useList를 쓴 경우, 그리고 total 파싱 경로가 어긋난 경우의 증상·원인·해결법입니다.