Zod로 폼 검증할 때 아무도 안 알려주는 것들

Zod로 폼 검증할 때 아무도 안 알려주는 것들

June 16, 2026

AI 에이전트 설정 폼을 만들고 있었어요. LLM이냐 NL2SQL이냐에 따라 입력 필드가 달라지는 폼이라, 타입을 z.discriminatedUnion으로 종류별로 갈라놨죠. (어쩌다 그렇게 짰는지는 지난 글에 적어 뒀어요.)

타입은 깔끔했어요. 그런데 이걸 react-hook-form에 실제로 붙여 보니, 공식 문서엔 없는 문제가 줄줄이 나오더라고요. 에러가 엉뚱하게 뜨거나, 아예 안 뜨거나, 멀쩡해 보이던 검증이 조용히 통과하거나.

그때 겪은 Zod 함정 세 가지와, 각각 어떻게 풀었는지 적어 둡니다.

  1. union이 모든 에러를 한꺼번에 쏟아낸다 — 보고 있지도 않은 필드의 에러까지
  2. 조건부 필드 하나에 중첩 DU는 과하다 — “A일 때만 B 필수”는 어떻게 검증할까
  3. defaultValues에서 에러가 조용히 사라진다 — 판별자 초기값을 빠뜨리면

discriminatedUnion이 뭔지부터 궁금하면 따로 정리한 글이 있어요.


union이 모든 브랜치 에러를 한꺼번에 뱉을 때

폼에는 두 종류의 스키마가 있었어요. llm_type 값에 따라 갈리고, 종류마다 필수 필드가 달랐죠.

const llmFormSchema = z.object({
  llm_type: z.literal('llm'),
  temperature: z.string().min(1, '온도를 입력해주세요'),
  // ...
});

const nl2sqlFormSchema = z.object({
  llm_type: z.literal('nl2sql'),
  connectionId: z.string().min(1, '연결을 선택해주세요'),
  // ...
});

처음엔 이 둘을 z.union으로 묶었어요. discriminatedUnion이 따로 있는 줄도 모르고, 그냥 “두 스키마 중 하나만 맞으면 통과”라는 뜻으로요.

const formSchema = z.union([llmFormSchema, nl2sqlFormSchema]);

타입만 보면 둘은 거의 똑같이 동작해요. llm_type으로 좁히면 그 종류의 필드에 안전하게 접근할 수 있고요. 그래서 한동안은 별문제를 못 느꼈어요.

문제는 검증이 실패하고 나서였어요.

증상: 한 필드를 비웠는데 에러가 세 개

LLM 쪽엔 temperature가, NL2SQL 쪽엔 connectionId가 필수였어요. LLM 폼에서 필수 필드 하나를 비우고 제출했더니, formState.errors에 NL2SQL 쪽 필드 에러까지 같이 들어와 있더라고요. 사용자는 LLM 폼을 보고 있는데, 화면엔 존재하지도 않는 필드의 에러가 섞여 든 거죠.

원인은 z.union의 검증 방식이었어요. union은 멤버를 순서대로 전부 시도해 보고, 전부 실패하면 모든 브랜치의 에러를 한꺼번에 모아서 돌려줘요.

union 입장에선 어느 쪽이 “맞는” 브랜치인지 알 길이 없거든요. 그래서 LLM 스키마에서 난 에러와 NL2SQL 스키마에서 난 에러가 구분 없이 쌓여요.

ZodError:
  - llm_type 분기 실패: temperature is required
  - nl2sql_type 분기 실패: connectionId is required
  - nl2sql_type 분기 실패: llm_type expected "nl2sql"
  ...

폼 입장에선 이걸 어떻게 보여줄지 막막해요. 사용자가 채운 적도 없는 필드를 빨갛게 칠할 수는 없으니까요.

진짜 위험한 건 조용히 통과할 때

에러가 너무 많이 뜨는 건 그래도 눈에 띄어요. 제가 직접 겪지는 않았지만, 더 위험한 쪽은 엉뚱한 브랜치로 검증이 통과해 버리는 경우예요.

union은 “통과하는 멤버가 하나라도 있으면 통과”거든요. 두 스키마에 공통 필드가 많으면, LLM 데이터가 NL2SQL 스키마 검증을 우연히 통과할 수 있어요. NL2SQL 전용 필드가 optional이거나 기본값이 있으면 더 그렇고요. 그러면 사용자는 LLM 폼을 채웠는데 데이터는 NL2SQL로 파싱되어 넘어가고, 정작 그 사실은 아무도 모르죠.

micahjon같은 함정을 정리한 글에서 이걸 “silent validation against the wrong schema”라고 부르더라고요. 타입은 멀쩡한데 런타임 데이터가 잘못 흘러가는, 디버깅하기 제일 까다로운 종류예요.

discriminatedUnion으로 바꾸니 에러가 한 종류만

z.discriminatedUnion은 검증 방식 자체가 달라요. 먼저 판별자 필드(llm_type)를 보고 어느 스키마인지 하나만 고른 다음, 그 스키마로만 검증하거든요.

const formSchema = z.discriminatedUnion('llm_type', [
  llmFormSchema,
  nl2sqlFormSchema,
]);

llm_type'llm'이면 LLM 스키마만 돌려요. 덕분에 LLM 폼에서 나는 에러는 LLM 필드 에러뿐이에요. NL2SQL 필드 에러가 섞여 들 일이 없고, 엉뚱한 브랜치로 조용히 통과할 일도 없죠. 판별자에 맞는 스키마가 아예 없을 때만 “판별자 값이 잘못됐다”는 한 줄짜리 에러가 나오고요.

검증 실패 시z.unionz.discriminatedUnion
에러 범위모든 브랜치 에러 합침고른 스키마 에러만
엉뚱한 브랜치 통과 위험있음 (silent)없음
검증 방식전부 시도 후 OR판별자로 하나만 골라 검증

두 방식의 차이를 그림으로 보면 이래요.

flowchart TD
    subgraph U["z.union — 다 시도하고 에러를 합침"]
      UI["입력"] --> UA["LLM 스키마 검증"]
      UI --> UB["NL2SQL 스키마 검증"]
      UA --> UE["양쪽 에러 전부 모아서 반환"]
      UB --> UE
    end
    subgraph D["z.discriminatedUnion — 하나만 골라 검증"]
      DI["입력"] --> DK{"llm_type 확인"}
      DK -->|llm| DA["LLM 스키마만 검증"]
      DK -->|nl2sql| DB["NL2SQL 스키마만 검증"]
    end

폼에서 제일 크게 와닿은 차이가 이거였어요. 타입만 놓고 보면 union이든 discriminatedUnion이든 비슷하게 좁혀지는데, 검증 에러를 화면에 그려야 하는 폼에선 “에러가 한 종류만 나온다”가 결정적이었죠. 종류가 갈리는 폼이라면 union보다 discriminatedUnion을 기본으로 두는 게 맞겠더라고요.

단, discriminatedUnion을 쓰려면 모든 멤버가 같은 이름의 리터럴 판별자 필드를 가져야 해요. llm_type: z.literal('llm')처럼요. 이 제약이 곧 두 번째 함정으로 이어집니다.


의존 필드 하나에 DU까지 꺼낼 필요가 있었을까

discriminatedUnion이 종류 갈리는 폼에 잘 맞는다는 걸 알고 나니, 이번엔 반대로 과하게 쓰는 게 문제였어요. “필드 하나가 다른 필드에 의존하는” 상황만 나오면 자꾸 DU부터 떠올리게 되더라고요.

마침 다른 프로젝트에서 비슷한 요구사항을 만났어요. 폼 자체는 다르지만 “한 필드가 다른 필드에 의존한다”는 구조가 똑같아서 같은 함정으로 묶어 적어 둡니다. 배치 스케줄러에서 실행 주기를 고르는 화면이었는데, 주기가 WEEKLY일 때만 요일을 선택해야 하고 그땐 최소 하나가 필수였거든요.

repeatPeriod: DAILY | WEEKLY | MONTHLY | YEARLY
→ WEEKLY일 때만 repeatDays 필요, 최소 1개 필수

“WEEKLY일 때만 필수”는 전형적인 조건부 필드예요. 풀 방법은 크게 두 갈래였어요.

방법 A: 중첩 discriminatedUnion

repeatPeriod를 판별자로 삼아 주기마다 별도 스키마를 만드는 방법이에요.

const dailySchema = z.object({ repeatPeriod: z.literal('DAILY'), ... });
const weeklySchema = z.object({
  repeatPeriod: z.literal('WEEKLY'),
  repeatDays: z.array(dayOfWeekSchema).min(1), // 여기서만 필수
  ...
});
// MONTHLY, YEARLY도 각각
const scheduleSchema = z.discriminatedUnion('repeatPeriod', [
  dailySchema, weeklySchema, monthlySchema, yearlySchema,
]);

타입은 정확해요. WEEKLY일 때만 repeatDays가 존재하고 그것도 필수라는 게 컴파일 타임에 보장되거든요. 첫 번째 함정에서 본 대로 에러도 깔끔하게 나오고요.

문제는 비용이었어요. 이 시작 노드는 이미 수동 실행이냐 스케줄 실행이냐를 가르는 start_type 판별자로 한 번 갈라져 있어서, start_type으로 discriminatedUnion을 한 겹 쓰고 있었거든요. 여기에 repeatPeriod로 또 한 겹을 중첩하면 서브타입이 네 개로 불어나요. DAILY, MONTHLY, YEARLY는 repeatDays만 없을 뿐 구조가 거의 같은데, 한 필드의 조건부 필수를 표현하겠다고 거의 똑같은 스키마를 네 벌이나 복제하는 꼴이었죠.

방법 B: superRefine

다른 방법은 base 스키마 하나에 검증 규칙만 얹는 거예요.

🤔 superRefine이란?
Zod에서 여러 필드를 한꺼번에 보고 커스텀 검증을 거는 메서드입니다. ctx.addIssue로 원하는 경로(path)에 에러를 직접 추가할 수 있어서, “A 필드 값에 따라 B 필드를 검사한다” 같은 의존 검증에 씁니다. (공식 문서)

const scheduleSchema = z
  .object({
    repeatPeriod: z.enum(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']),
    repeatDays: z.array(dayOfWeekSchema).optional(),
    ...
  })
  .superRefine((data, ctx) => {
    if (data.repeatPeriod === 'WEEKLY' && !data.repeatDays?.length) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: '최소 하나의 요일을 선택해주세요',
        path: ['repeatDays'],
      });
    }
  });

스키마가 하나뿐이라 복제할 일이 없어요. 나중에 MONTHLY에 “날짜 선택”, YEARLY에 “월+일 선택”이 붙어도 superRefine 안에 if를 하나씩 더 넣으면 되고요.

사실 둘 중 뭐가 더 낫냐를 따지는 건 큰 의미가 없었어요. 중첩 DU는 타입을 컴파일 타임에 못 박는 대신 스키마 복제를 떠안고, superRefine은 그 정밀도를 런타임으로 미루는 대신 구조를 가볍게 가져가요. 어느 쪽이 맞느냐는 결국 “지금 조건부 필드가 몇 개냐”에 달린 문제였죠.

그래서 저는 B를 골랐어요. 당장 요구사항은 “WEEKLY일 때 요일 필수” 하나뿐인데, 이걸 위해 타입을 네 갈래로 쪼개는 건 YAGNI다 싶었거든요. 폼 검증은 어차피 제출 시점의 런타임 검증이고, react-hook-form + zodResolver 조합에서 superRefine은 충분히 잘 돌아갔어요.

기준superRefine중첩 DU
스키마 복제없음스키마 4벌
타입 정밀도런타임만 보장컴파일 타임 보장
조건 추가if 한 줄스키마 한 벌
기존 구조변경 최소대폭 수정

그런데 superRefine엔 함정이 있었어요

superRefinebase object 검증을 통과한 다음에야 실행돼요.

repeatPeriod가 enum에 없는 값이거나 repeatTime이 빈 문자열이면, base 검증이 먼저 실패하면서 superRefine은 아예 돌지도 않아요. 그래서 WEEKLY에서 요일을 비웠을 때 나와야 할 누락 에러가, base 필드를 다 채운 뒤에야 뒤늦게 떴어요. 사용자 입장에선 에러가 한 번에 안 보이고 단계적으로 나타나는 것처럼 느껴지고요.

이건 superRefine의 동작 자체라 없앨 수는 없었어요. 대신 base 필드에 적절한 기본값을 두고, 의존 검증은 “다른 필드는 어차피 채워져 있겠지”라는 전제 위에서만 거는 식으로 영향을 줄였어요. 조건부 필드가 여러 개로 늘어나 검증이 서로 얽히기 시작하면, 그땐 중첩 DU로 넘어가는 게 맞겠죠. 지금은 하나뿐이라 superRefine으로 충분했어요.


defaultValues는 왜 에러를 안 보여줬을까

세 번째는 제일 오래 붙잡고 있던 함정이에요. 스키마와 검증은 정리됐는데, 정작 폼을 초기화하는 defaultValues에서 막혔거든요.

discriminatedUnion에서 추론한 타입은 두 종류의 합집합이에요. LLM 값이거나 NL2SQL 값이거나, 둘 중 하나죠. 그런데 react-hook-form의 defaultValues는 객체 하나를 요구해요. 종류마다 구조가 다른데 그 둘을 동시에 만족하는 단일 객체를 만들려니 영 애매했어요.

증상: 에러가 formState에 안 뜬다

처음엔 양쪽 필드를 다 합친 객체를 기본값으로 넣었어요.

const form = useForm({
  resolver: zodResolver(formSchema),
  defaultValues: {
    // llm_type을 안 정하고, 양쪽 필드를 다 비워둠
    temperature: '',
    connectionId: '',
  },
});

그랬더니 검증에 실패해도 formState.errors가 텅 비어 있는 일이 생겼어요. 폼은 제출되지 않는데 화면엔 아무 에러도 안 뜨니, 사용자는 왜 버튼이 안 먹는지 알 도리가 없죠.

원인은 판별자였어요. llm_type을 초기값에 안 넣어 둔 게 화근이었죠. discriminatedUnion은 판별자 값으로 스키마를 고르는데, 판별자가 undefined고를 스키마가 없어요. 이때 Zod는 “invalid discriminator”라는 한 줄짜리 에러를 내는데, 이게 어느 필드에도 매핑되지 않아서 화면 어디에도 안 붙어요. 에러는 났지만 보이지 않는 상태가 되는 거죠.

flowchart LR
    A["llm_type 초기값 누락"] --> B["판별자 = undefined"]
    B --> C["고를 스키마 없음"]
    C --> D["invalid discriminator 에러"]
    D --> E["어느 필드에도 매핑 안 됨"]
    E --> F["화면엔 에러 안 보임 😶"]

워크어라운드 세 가지

결국 세 가지를 함께 적용해서 풀었어요.

워크어라운드역할
Zod 필드에 .default()종류별 필드 누락 시 기본값으로 채움
defaultValues엔 판별자만폼에 “지금 어느 종류인지”를 못 박음
판별자는 undefined 금지스키마를 못 고르는 상태 자체를 차단

첫째, 모든 Zod 필드에 .default()를 답니다. 종류별 필드가 검증 시점에 비어 있어도 스키마가 기본값을 채워 주니까, base 통과에 실패해 엉뚱한 에러가 나는 걸 줄일 수 있었어요.

const baseSchema = z.object({
  name: z.string().default(''),
  temperature: z.string().default(''),
});

둘째, react-hook-form의 defaultValues엔 판별자 초기값만 확실히 넣습니다. 나머지는 Zod의 .default()에 맡기고, 폼에는 “지금 어느 종류인지”만 정확히 알려주면 됐죠.

const form = useForm({
  resolver: zodResolver(formSchema),
  defaultValues: {
    llm_type: 'llm', 
  },
});

셋째, 판별자는 절대 undefined로 두지 않습니다. 저는 판별자에 항상 기본값('llm')을 주고, “선택 전” 상태 자체를 폼 단에서 막는 쪽으로 처리했어요. 정 비워야 한다면 undefined 대신 null이나 'unselected' 같은 명시적인 값을 쓰는 선택지도 있었지만, 이 폼은 진입하자마자 어느 한 종류로 시작해도 무방했거든요. undefined는 “스키마를 못 고르는” 상태라 에러가 통째로 사라지지만, 명시값이면 최소한 “이 판별자에 맞는 스키마가 없다”는 에러라도 잡혀요.

이 세 가지를 묶고 나니 폼 초기 상태부터 검증 에러까지 흐름이 일관되게 맞아떨어졌어요.


정리하기

discriminatedUnion으로 폼을 짜다 겪은 세 가지를 돌아보면 이래요.

  • z.union은 실패하면 모든 브랜치 에러를 한꺼번에 뱉고 엉뚱한 브랜치로 조용히 통과할 수도 있어서, 종류가 갈리는 폼엔 z.discriminatedUnion이 맞았어요
  • 조건부 필드 하나 때문에 중첩 DU까지 가는 건 과했고, 지금 요구사항엔 superRefine이면 충분했어요. 다만 base 통과 후에야 실행돼 에러가 지연되는 동작은 미리 알아 둬야 했죠
  • defaultValues에선 판별자만큼은 명시값으로 확실히 넣는 게 핵심이었어요. 판별자가 undefined면 에러가 화면에서 통째로 사라졌거든요

세 함정 모두 타입은 멀쩡한데 런타임에서 어긋나는 종류였어요. 타입이 깔끔하다고 폼이 잘 굴러간다는 보장은 없더라고요. 컴파일러가 통과시킨 타입과 사용자가 실제로 채워 제출하는 런타임 사이엔 공식 문서에 안 적힌 틈이 있었고, 이번 세 가지는 전부 거기서 나왔어요.

지금은 종류가 둘뿐이라 superRefine과 단일 DU로 버티고 있어요. 종류가 더 늘거나 조건부 필드가 서로 얽히기 시작하면 초기화 전략부터 다시 봐야 할 텐데, 그건 그때 가서 요구사항을 보고 판단하면 되겠죠.


참고 자료