글 목록으로개발 경험

3단계 리뷰 작성 폼의 역할을 RHF, Zod, 퍼널로 다시 나눈 과정

별점 선택부터 내용 작성과 확인까지 이어지는 리뷰 폼을 그린 일러스트

글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.

리뷰 작성 화면에서 필드를 고치려면 값을 저장하는 액션부터 오류 문구, 다음 단계로 넘어가는 조건까지 따라가야 했어요. 사용자는 방문 인증용 영수증을 올리고, 시술과 결제 정보를 넣은 뒤, 별점과 사진, 리뷰 내용을 작성해요. 하지만 각 입력 컴포넌트는 자기 필드 외에 전역 스토어를 바꾸고 검증하는 방법도 알고 있었어요.

입력값을 React Hook Form으로 옮기고 검증을 Zod에 모았어요. 단계 이동은 퍼널 훅에 남겼어요. 옮기는 동안 시술 선택도 드롭다운에서 Bottom Sheet로 바뀌었는데, 폼에 값을 넣는 시점을 그대로 둘 수는 없었어요. 시트를 닫는 것이 취소가 되려면, 고르는 중인 값부터 폼과 나눠야 했어요.

작성값과 현재 단계가 앱 전역에 있었어요

기존에는 draft를 바꾸는 스토어와 step을 움직이는 스토어가 따로 있었어요. step에 따라 입력 화면을 골라 그렸어요.

tsx
type DraftStore = {
  draft: ReviewDraft;
  setField: <K extends keyof ReviewDraft>(
    key: K,
    value: ReviewDraft[K],) => void;
};
 
type StepStore = {
  step: number;
  moveForward: () => void;
  moveBack: () => void;
};
tsx
switch (step) {
  case 1:
    return <ProofStep />;
  case 2:
    return <DetailsStep />;
  default:
    return <FeedbackStep />;
}

임시 저장은 별도 API가 하고 있었어요. 이 화면에서 작성 중인 값이나 현재 단계를 다른 화면이 쓸 필요는 없었어요. 이전 단계의 값을 보관하는 일도 페이지 안의 폼 인스턴스로 처리할 수 있었어요.

단계마다 보이는 필드를 검증하게 했어요

처음에는 전부 하나의 폼으로 합치려고 했어요. 그런데 단계마다 마운트되는 필드와 검증 시점이 달랐어요. 그래서 RHF는 값과 필드를 관리하고 Zod는 검증하며, @use-funnel/core를 감싼 공용 훅은 이동할 단계만 정하게 했어요.

전체 값을 보관하는 폼과 각 단계의 폼을 뒀어요. 단계 폼이 통과하면 그 값을 전체 폼에 합쳐 다음 단계에 넘겨요.

전체 스키마에는 추가 검증이 따라가지 않았어요

단계별로 필요한 필드를 나눈 스키마는 아래와 같아요. 이름을 바꾸고 주요 필드와 추가 검증만 남겼어요.

typescript
import { z } from 'zod';
 
export const ProofStepSchema = z.object({
  proofFiles: z.array(UploadSchema).min(1).max(5),
});
 
export const DetailsStepSchema = z
  .object({
    visibilityFlag: z.boolean(),
    items: z.array(ItemSchema).min(1).max(5),
    quoteFieldEnabled: z.boolean().optional(),
    quotedAmount: z.number().optional(),
    paidAmount: z.number().min(1),
    amountFlag: z.boolean().optional(),
    receivedBenefitInfo: z.boolean(),
    missingBenefitReason: z
      .object({
        value: z.enum(MISSING_INFO_REASONS),
        description: z.string(),
      })
      .optional()
      .nullable(),
  })
  .superRefine((data, ctx) => {
    if (data.quoteFieldEnabled && (!data.quotedAmount || data.quotedAmount <= 0)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ['quotedAmount'],
        message: '금액을 입력해 주세요.',
      });
    }
 
    if (!data.receivedBenefitInfo && !data.missingBenefitReason) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ['missingBenefitReason'],
        message: '안내받지 못한 이유를 입력해 주세요.',
      });
    }
  });
 
export const FeedbackStepSchema = z.object({
  attachments: z.array(UploadSchema),
  ratings: z.array(
    z.object({
      criterion: z.enum(RATING_CRITERIA),
      score: z.number().min(0).max(2),
    }),
  ),
  body: z.string().min(50).max(500),
  wouldRecommend: z.boolean(),
});
 
export const SubmissionSchema = z
  .object({
    // 방문 인증 단계에서는 min(1)을 쓰지만 전체 스키마에서는 필수가 아니다.
    proofFiles: z.array(UploadSchema).max(5),
  })
  .extend(DetailsStepSchema._def.schema.shape)
  .extend(FeedbackStepSchema.shape);

두 번째 단계는 조건에 따라 견적 금액이나 혜택을 안내받지 못한 이유를 요구해요. 이 검사는 DetailsStepSchema의 superRefine에서 해요. 그런데 이 스키마는 ZodEffects로 감싸져 .shape를 바로 꺼낼 수 없었어요.

전체 SubmissionSchema는 내부 속성 _def.schema로 안쪽 객체의 필드를 가져와요. 필드만 복사하므로 superRefine은 따라오지 않아요. 단계에서는 조건부 검증이 되지만, 전체 스키마에는 같은 규칙이 없는 상태예요. Zod 내부 구조에 기대는 코드도 여기에 남아 있어요.

proofFiles의 최소 개수가 다른 것은 별도 이유가 있어요. 방문 인증 단계에서는 증빙을 하나 이상 올려야 하지만 그 단계를 건너뛰는 경로도 있어요. 그래서 전체 스키마에는 min(1)을 넣지 않았어요.

금액 입력은 값과 변경 함수만 연결해요

tsx
function AmountField() {
  const { control } = useFormContext<DetailsStepValues>();
 
  return (
    <Controller
      control={control}
      name="paidAmount"
      render={({ field: { value, onChange } }) => (
        <NumericInput
          value={value}
          onChange={normalizeNumericInput(onChange)}
        />
      )}
    />
  );
}

Controller가 NumericInput의 value와 onChange를 RHF에 연결해요. 금액 조건은 단계 스키마가 보므로, 입력 컴포넌트가 Zustand 액션을 부르거나 검증 함수를 따로 실행하지 않아요.

모바일에서 여러 시술을 고르기 불편했어요

items는 시술을 1개에서 5개까지 담고 중복은 허용하지 않아요. 이 배열과 검증 조건은 유지한 채 선택 UI를 바꿨어요.

시술 선택 UI는 두 번 바뀌었어요. 드롭다운 안 복수 선택은 2차 리팩터링에서, Bottom Sheet는 디자인 시스템 적용에서 구현했고, 폼 값이 바뀌는 시점을 '고르는 즉시'에서 '확인할 때'로 옮겼어요.

시술 선택 UI는 두 번 바뀌었어요. 드롭다운 안 복수 선택은 2차 리팩터링에서, Bottom Sheet는 디자인 시스템 적용에서 구현했고, 폼 값이 바뀌는 시점을 '고르는 즉시'에서 '확인할 때'로 옮겼어요.

처음에는 동료가 만든 키워드 자동완성 드롭다운을 썼어요. 입력칸을 누르면 아래로 목록이 열리고, 하나를 고르면 목록이 닫히며 검색어가 비워졌어요. 선택한 시술은 아래에 칩으로 쌓였어요. 처음 폼을 옮길 때는 이 UI를 Controller로 감싸는 데 그쳤어요.

직접 모바일에서 써 보니 시술마다 다시 누르고 검색해야 했어요. 키패드가 목록을 가리고, 이미 고른 항목도 구분되지 않았어요. 이 불편을 Jira에 적어 PO에게 공유했고, 기획과 디자인 담당자들은 이어진 리팩터링에 넣기로 했어요.

드롭다운은 남기되 그 안에서 복수 선택을 하고, 체크와 칩으로 고른 항목을 표시하며, 입력칸이 헤더 바로 아래로 오도록 스크롤하는 방향이었어요. Radix Popover와 cmdk로 구현해 운영에 반영했어요. 그때는 항목을 누르는 즉시 useFieldArray의 append와 remove가 폼을 바꿨어요.

나중에 디자인 시스템 시안에서는 선택 UI가 Bottom Sheet로 바뀌었어요. 앞선 드롭다운 개선과는 다른 작업이었고, 동료가 만든 공용 시트를 리뷰 폼에 붙이는 것은 제가 맡았어요.

시트에서 고른 값을 바로 폼에 쓰면 취소할 수 없었어요

처음에는 드롭다운처럼 시트에서도 폼을 바로 바꿨어요. 그러면 확인하지 않고 닫아도 선택이 이미 반영돼요. 닫힌 뒤 원래 값으로 돌리는 처리를 덧대다가, 다음 날 시트가 임시 선택을 갖고 확인할 때만 넘기도록 바꿨어요. 이 두 방식은 모두 배포 전에 거친 구현이에요.

ItemsField와 ItemPickerSheet는 공개용 가명이에요. 필드가 시트를 열고 시트가 확인값을 넘기는 부분을 남기면 다음과 같아요.

tsx
// FieldBox는 입력칸 모양의 상자(가명)다. 필드 이름도 가명이다.
// openBottomSheet·closeBottomSheet는 공용 시트의 전역 스토어를 여닫는 함수다.
function ItemsField() {
  const { control } = useFormContext<DetailsStepValues>();
  const { fields: items, replace, remove } = useFieldArray({ control, name: 'items' });
 
  const openPicker = () =>
    openBottomSheet({
      detent: 'content-height',
      fullContents: true,
      children: <ItemPickerSheet initialItems={items} onConfirm={replace} />,
    });
 
  return (
    <FieldBox onClick={openPicker}>
      {items.map(({ itemId, itemName }, index) => (
        <Tag key={itemId} text={itemName} onClose={() => remove(index)} />
      ))}
    </FieldBox>
  );
}
 
function ItemPickerSheet({ initialItems, onConfirm }: ItemPickerSheetProps) {
  const [draft, setDraft] = useState(initialItems);
 
  const toggle = (item: DetailsStepValues['items'][number]) => {
    if (draft.some(({ itemId }) => itemId === item.itemId)) {
      setDraft((prev) => prev.filter(({ itemId }) => itemId !== item.itemId));
      return;
    }
    if (draft.length >= 5) {
      toast.warning('시술은 최대 5개까지 선택 가능해요.');
      return;
    }
    setDraft((prev) => [...prev, item]);
  };
 
  return (
    <div>
      {/* 검색 입력, 체크 목록(행을 누르면 toggle), 고른 항목 요약과 초기화(setDraft([])) */}
      <Button
        onClick={() => {
          onConfirm(draft);
          closeBottomSheet();
        }}
      >
        확인
      </Button>
    </div>
  );
}

공용 시트는 openBottomSheet가 받은 요소를 전역 스토어에 넣고 앱 최상단 Provider에서 그려요. FormProvider의 자식으로 열리는 게 아니어서 시트 안에서는 useFormContext를 쓸 수 없어요. 필드가 useFieldArray에서 읽은 items와 replace를 props로 넘겨요.

시트는 열릴 때의 items로 draft를 만들어요. 행을 누를 때는 draft만 바뀌고, '확인'을 눌러야 replace가 폼 배열을 바꿔요. 딤을 누르거나 아래로 끌어 닫으면 넘기는 값이 없어요. 닫힘 애니메이션 뒤 내용이 언마운트돼 draft도 사라지고, 다시 열면 현재 폼 값에서 새로 시작해요.

필드의 칩을 지우는 ×는 전파를 막아 시트를 열지 않고 remove만 실행해요. 필드에서 바로 지우는 동작과 시트 안에서 선택을 취소하는 동작을 구분한 거예요.

시트는 열 때의 값으로 임시 선택을 만들고, 확인할 때만 replace로 필드에 넣어요. 닫으면 아무것도 넘기지 않아요.

시트는 열 때의 값으로 임시 선택을 만들고, 확인할 때만 replace로 필드에 넣어요. 닫으면 아무것도 넘기지 않아요.

개수 제한은 열 때의 값이 아니라 draft를 봐야 했어요

시트는 임시 선택이 5개면 다음 선택을 토스트로 막아요. 같은 시술을 다시 누르면 빼므로 중복도 생기지 않아요. 스키마는 확인해서 폼에 들어온 items를 min(1), max(5)와 중복 검사로 봐요. 중복 검사는 앞의 예제에서는 생략했어요. 이 필드에는 별도 오류 문구가 없어서 실패하면 '다음' 버튼만 꺼져요.

시트를 develop에 처음 합쳤을 때는 제한 조건이 draft가 아니라 initialItems.length를 보고 있었어요. 열 때 5개 미만이었다면 그 안에서 계속 고를 수 있고, 확인한 뒤에야 '다음'이 꺼질 수 있었어요. 다음 날 운영에 나가기 전에 draft 기준으로 고쳤어요. 폼에 들어 있던 개수가 아니라 지금 고르고 있는 개수를 검사해야 했어요.

단계 폼도 비슷하게 움직여요. 해당 단계에서 바꾼 값을 검증한 다음에 전체 작성값에 합쳐요.

검증한 값을 합치고 다음 단계로 가요

tsx
// useSchemaForm·useStepFunnel·StepForm은 공용 폼 훅, 퍼널 훅, 단계 폼 래퍼의 공개용 가명이다.
// needsProof는 방문 인증 단계부터 시작하는 경우, isVisited는 예약이 방문 완료 상태인 경우다.
const form = useSchemaForm({
  schema: SubmissionSchema,
  defaultValues: savedDraft
    ? mapResponseToDraft(savedDraft.data)
    : emptyDraft,
});
 
const flow = useStepFunnel<Record<'proof' | 'details' | 'feedback', object>>({
  initial: {
    step: needsProof ? 'proof' : 'details',
    context: {},
  },
  id: 'public-example-flow',
});
 
const mergeAndReset = useCallback(
  (callback: (data: ReviewDraft) => void) =>
    <T extends Partial<ReviewDraft>>(stepValues?: T) => {
      const mergedDraft = {
        ...form.formState.defaultValues,
        ...stepValues,
      } as ReviewDraft;
 
      form.reset(mergedDraft);
      callback(mergedDraft);
    },
  [form],
);
 
const moveToStep = useCallback(
  (step: typeof flow.step) =>
    mergeAndReset(() => flow.history.replace(step)),
  [mergeAndReset, flow],
);
 
const confirmSubmission = mergeAndReset((data) =>
  openConfirmDialog({
    primaryAction: {
      text: '제출',
      onConfirm: () => submitDraft(data),
    },
  }),
);
 
return (
  <flow.Render
    proof={() => (
      <StepForm
        schema={ProofStepSchema}
        defaultValues={form.formState.defaultValues}
        onComplete={moveToStep('details')}
      >
        <ProofStep />
      </StepForm>
    )}
    details={() => (
      <StepForm
        schema={DetailsStepSchema}
        defaultValues={form.formState.defaultValues}
        onBack={needsProof ? moveToStep('proof') : undefined}
        onComplete={moveToStep('feedback')}
      >
        <DetailsStep />
      </StepForm>
    )}
    feedback={() => (
      <StepForm
        schema={FeedbackStepSchema}
        defaultValues={form.formState.defaultValues}
        onBack={moveToStep('details')}
        onComplete={confirmSubmission}
      >
        <FeedbackStep isVisited={isVisited} />
      </StepForm>
    )}
  />
);

StepForm은 각 단계에 mode: 'all'인 폼을 만들어요. 단계 스키마를 통과한 값은 상위 RHF의 defaultValues에 합친 뒤 reset해서 다음 단계의 초기값으로 써요. 퍼널은 history.replace로 이동할 단계만 바꿔요. 폼 값은 퍼널 context에 넣지 않고 history.push로 단계별 값을 쌓지도 않아요.

마지막 단계도 값을 합쳐 확인 대화상자와 API 요청에 넘겨요. 다만 여기서 SubmissionSchema.parse를 다시 실행하지는 않아요. 앞 단계에서 통과한 값이 계속 유효하다고 전제하므로, 최종 제출에서 모든 단계의 조건을 다시 검사하는 처리는 빠져 있어요.

수정용 응답을 받은 뒤 초기값을 만들어요

수정할 리뷰가 있으면 savedDraft를 폼 값으로 바꿔 defaultValues에 넣어요. RHF는 이 값을 폼을 만들 때 캐시하므로, 빈 값으로 폼을 먼저 만든 뒤 응답만 늦게 받아서는 채워지지 않아요.

서버 페이지가 예약과 리뷰를 미리 조회하고 HydrationBoundary로 캐시를 넘기게 했어요. 클라이언트는 같은 옵션의 useQueries로 그 응답을 읽어요.

tsx
// 컴포넌트, 쿼리 옵션, 경로 파라미터 이름은 공개용 가명이다.
// 서버 페이지. 예약 id와 리뷰 id가 모두 있는 경로이고, 다른 경로는 있는 id만 미리 요청한다.
async function ReviewEditorRoute({ params }: EditorRouteProps) {
  const { sourceId, draftId } = await params;
  const queryClient = getQueryClient();
 
  await Promise.all([
    queryClient.prefetchQuery(sourceByIdQuery(Number(sourceId))),
    queryClient.prefetchQuery(draftByIdQuery(Number(draftId))),
  ]);
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ReviewEditorPage />
    </HydrationBoundary>
  );
}
 
// 클라이언트 템플릿
function ReviewEditorPage() {
  const { sourceId, draftId } = useParams<EditorRouteParams>();
  const [{ data: sourceInfo }, { data: savedDraft }] = useQueries({
    queries: [
      sourceByIdQuery(toOptionalNumber(sourceId)),
      draftByIdQuery(toOptionalNumber(draftId)),
    ],
  });
 
  const form = useSchemaForm({
    schema: SubmissionSchema,
    defaultValues: savedDraft
      ? mapResponseToDraft(savedDraft.data)
      : emptyDraft,
  });
 
  // 퍼널과 단계 폼은 앞 절의 코드와 같다.
}

서버가 요청 완료를 기다리고 렌더링하므로 클라이언트의 첫 렌더에는 수정할 데이터가 캐시에 있어요. 그 값으로 만든 수정용 입력이 처음 defaultValues가 돼요. 이 화면은 클라이언트에서 Suspense로 응답을 기다리는 방식은 아니에요.

이동 훅이 폼의 뜻까지 알려주지는 않아요

각 단계가 자기 필드와 검증을 갖게 됐지만, 단계 사이에 defaultValues를 합치고 reset하는 연결은 남았어요. 퍼널 context는 비어 있고 replace로 화면만 바꾸고 있어서, 이 구조에 꼭 퍼널 라이브러리가 필요한지도 다시 볼 만해요. 사진처럼 브라우저 객체가 들어간 값은 URL 직렬화만으로 이동 이력을 복원하기도 어려워요.

입력 타입에도 모호한 부분이 남아 있어요. 공개 여부가 불리언이라 API에 보낼 때 숨김과 공개 enum으로 바꿔요. 다른 요청 필드 변환을 제외하면 이 부분은 아래와 같아요.

typescript
function toSubmissionRequest(form: ReviewDraft): SubmissionRequest {
  const { visibilityFlag, ...rest } = form;
 
  return {
    ...rest,
    visibilityFlag: visibilityFlag
      ? VISIBILITY.Hidden
      : VISIBILITY.Public,
  };
}

true가 공개인지 숨김인지 이 변환을 읽어야 알 수 있어요. 폼에서 PUBLIC이나 HIDDEN을 직접 쓰면 의미가 드러나겠지만, 현재는 이 불리언을 유지하고 있어요. 스토어를 폼으로 옮긴 것과 별개로 남은 해석이에요.