AI 에이전트(프롬프트·모델·파라미터를 설정해 만드는 응답기)를 생성하고 편집하는 설정 폼이 있었어요. 어느 날 QA에서 버그 리포트가 하나 올라왔어요.
“에이전트 생성에서는 temperature가 필수인데, 편집에서는 선택이에요. 뭐가 맞는 건가요?”
질문은 한 줄이었어요. 그런데 개발자 두세 명이 코드를 같이 봐도 뭐가 진짜인지 바로 답을 못 했죠. 같은 필드에 규칙이 두 개였거든요. 어느 쪽이 의도된 건지 코드만 봐서는 알 수가 없었어요.
optional이 잔뜩 붙은 폼이 어쩌다 무너졌고, z.discriminatedUnion으로 도메인을 다시 모델링하기까지 제가 어떤 판단을 했는지 적어 둡니다.
같은 필드인데 왜 규칙이 둘이었을까
코드를 파보니 원인은 단순했어요. 생성 페이지와 편집 페이지가 각자 스키마를 들고 있었거든요.
생성 페이지(CreateAIAgent.tsx)에서는 temperature가 필수였어요.
// 생성 페이지의 스키마
const formSchema = z.object({
description: z.string().min(1, '설명을 입력해주세요'),
maxTokens: z.string().min(1, '최대 토큰 수를 입력해주세요'),
temperature: z.string().min(1, '온도를 입력해주세요'), // 필수
// ... 총 12개 필드
});편집 페이지(AIAgentEdit.tsx)에서는 같은 필드가 optional이었고요.
// 편집 페이지의 스키마 (미묘하지만 치명적인 차이)
const formSchema = z.object({
description: z.string().min(1, '설명을 입력해주세요'),
maxTokens: z.string().optional(), // 어? 여긴 선택?
temperature: z.string().optional(), // 여기서도 선택
// ... 똑같은 12개 필드인데 규칙이 다름
});같은 12개 필드를 두 파일이 따로 정의하고 있었어요. 그러니 한쪽만 고치고 다른 쪽을 놓치는 일이 으레 생겼죠. temperature의 타임라인을 되짚어 보니 이랬어요.
- 초기엔 두 페이지 모두
temperature를 필수로 뒀어요 - “편집할 때는 기존 값을 유지하니 선택으로 바꿔주세요”라는 요청이 들어왔고요
- 급하게 편집 페이지만
optional()로 바꿨어요 - 생성 페이지는 손대지 못한 채 그대로 남았죠
- 그러다 QA에서 “왜 두 페이지가 다르게 동작하나요?”로 발견된 거예요
문제는 temperature 하나가 아니었어요. maxTokens는 한쪽에서만 숫자 검증을 강화했고, vectorDBIds(에이전트가 참조할 벡터 DB 목록)는 한쪽에서만 빈 배열 허용 로직을 넣었거든요. 결국 바뀐 내용이 한쪽에만 반영되고 다른 쪽엔 빠지는 게 진짜 문제였어요. 빠르게 고쳐야 하는 상황에서 두 파일을 매번 동기화하는 일은 자주 놓치게 되더라고요.
처음 이 코드를 봤을 때 제 머릿속 진단은 “타입 분리가 안 돼 있어서 그렇다”였어요. 그런데 한 발 떨어져서 보니 아니더라고요. 타입이 안 갈라진 게 아니라 같은 정의가 두 군데 복제돼 있는 게 먼저였어요.
bag of optionals라는 안티패턴
두 스키마가 공통으로 가진 습관이 하나 있었어요. 애매한 필드는 “있을 수도, 없을 수도 있으니까” 하며 일단 optional()로 열어두는 거였죠.
Matt Pocock은 이걸 bag of optionals라는 안티패턴으로 불러요. optional은 “이 값은 있어도 되고 없어도 된다”를 표현하는 도구인데, 실제로 우리가 표현하려던 건 “이 필드는 특정 타입에서만 존재한다”였거든요. 의미가 다른 걸 같은 문법으로 적은 셈이었어요.
temperature(LLM 응답의 무작위성을 조절하는 0~1 값)가 편집에서 선택이었던 진짜 이유도 “값이 있어도 되고 없어도 되어서”가 아니었어요. “편집 시나리오에서는 기존 값이 깔려 있어서”였죠. 상황(생성/편집)에 따라 규칙이 갈리는 걸 optional 하나로 뭉뚱그리니, 타입 시스템은 “이 필드는 없을 수도 있구나” 정도밖에 알지 못했어요.
이걸 정확히 표현하는 도구가 discriminated union이에요.
🤔 discriminated union이란?
공통된 리터럴 필드 하나(discriminant)로 여러 타입을 구분하는 패턴입니다.{ status: 'success'; data }/{ status: 'error'; message }처럼,status값만 확인하면 TypeScript가 나머지 필드까지 정확히 좁혀줘요. “이 필드는 이 타입일 때만 존재한다”를 타입으로 못 박는 도구입니다.
discriminated union을 narrowing부터 제대로 보려면 if문 하나로 TypeScript가 똑똑해지는 이유에 정리해 뒀어요.
그러니까 temperature나 tables 같은 필드는 optional이 아니었어요. “어떤 타입의 에이전트냐”에 따라 존재 여부가 갈리는 필드였죠. 다만 이걸 깨닫는다고 바로 discriminatedUnion으로 갈 수 있는 건 아니었어요. 그 전에 걸리는 게 있었거든요.
중복부터 걷어내기
타입을 나누려면 “나눌 대상 스키마”가 한 군데 있어야 해요. 그런데 우리는 스키마가 두 군데 복제돼 있었죠. 이 상태에서 타입 분리부터 시도하면 어떻게 될까요. 생성과 편집 양쪽에서 각각 유니온을 짜는 꼴이 돼요. 복제를 둔 채로 추상화를 얹는 거죠.
그래서 구조 개선보다 중복 제거를 먼저 하기로 했어요. 스키마를 한 곳에서만 정의하고, 폼 훅으로 감싸는 거였어요.
// AgentForm.tsx: 단일 진실 공급원(Single Source of Truth)
const formSchema = z.object({
description: z.string().min(1, '설명을 입력해주세요'),
llm_type: z.nativeEnum(LlmType),
name: z.string().min(1, '에이전트 이름을 입력해주세요'),
maxTokens: z
.string()
.min(1, '최대 토큰 수를 입력해주세요')
.refine(val => !isNaN(Number(val)) && Number(val) > 0, {
message: '최대 토큰 수는 0보다 커야 합니다',
}),
temperature: z
.string()
.min(1, '온도를 입력해주세요')
.refine(val => {
const num = Number(val);
return !isNaN(num) && num >= 0 && num <= 1;
}, {
message: '온도는 0에서 1 사이여야 합니다',
}),
// ... 모든 필드가 한 곳에, 규칙도 한 번만
});
export function useAgentForm(initialValues?: FormValues) {
return useForm<FormValues>({
defaultValues: initialValues ?? {
llm_type: LlmType.LLM,
},
mode: 'onChange',
resolver: zodResolver(formSchema),
});
}🤔 zodResolver란?
@hookform/resolvers가 제공하는 어댑터입니다. react-hook-form에 Zod 스키마를 연결해, 폼 제출/변경 시점에 스키마로 검증하고 에러를formState에 채워줘요.
호출하는 쪽은 단순해졌어요. 생성 페이지는 useAgentForm() 한 줄이 됐고, 편집 페이지는 초기값만 넘기면 됐죠.
// 생성 페이지
const form = useAgentForm();
// 편집 페이지
const form = useAgentForm({
description: agentDetail.description ?? '',
maxTokens: agentDetail.parameters?.max_tokens?.toString() ?? '',
// 나머지는 기본값으로
});효과는 곧바로 느껴졌어요. 필드를 추가할 때 두 파일을 오가며 동기화하던 작업이 한 곳 수정으로 끝났고, 생성/편집이 다르게 동작하던 불일치 버그가 거의 사라졌어요. 리뷰어가 두 스키마를 나란히 놓고 비교하던 일도 없어졌고요.
이때는 몰랐는데, 스키마를 한 곳으로 모으고 나니 다음 단계가 가능해졌어요. 흩어져 있을 땐 “타입을 어떻게 나눌까”를 고민할 출발점 자체가 없었거든요.
에이전트 타입이 둘이 되자
useAgentForm으로 중복을 정리하고 얼마 안 돼서 새 요구사항이 들어왔어요.
“이제 NL2SQL 타입 에이전트도 지원해야 해요. 이름이나 설명 같은 기본은 똑같은데, 그 아래 필요한 필드가 LLM이랑 많이 달라요.”
🤔 NL2SQL이란?
자연어 질문을 SQL로 변환해 DB를 조회하는 방식입니다. LLM 에이전트가 프롬프트로 답한다면, NL2SQL은 프롬프트 대신 대상 테이블·컬럼을 입력받아요.
이번엔 같은 필드의 규칙이 다른 정도가 아니었어요. 필드 종류 자체가 갈렸죠.
- LLM 에이전트: prompt, temperature, maxTokens
- NL2SQL 에이전트: tables, columns, vectorDBIds
중복 관리는 이미 useAgentForm이 잡아준 상태라 걱정 없었어요. 단순하게 보면 단일 스키마에 NL2SQL 필드를 optional로 더 얹고, llm_type으로 구분하면 될 것 같았죠.
const formSchema = z.object({
// 기존 LLM 필드
prompt: z.string(),
temperature: z.string(),
// NL2SQL 필드를 optional로 추가
tables: z.array(/* ... */).optional(),
llm_type: z.enum(['LLM', 'NL2SQL']),
});그런데 막상 제출 핸들러를 짜려니 곳곳에서 걸렸어요. 문제는 여기서 시작됐죠. llm_type을 분기해도 TypeScript는 어떤 필드가 유효한지 몰랐거든요.
const handleSubmit = (data: FormValues) => {
if (data.llm_type === 'LLM') {
// data.prompt가 정말 존재할까? TS는 확신하지 못함 (optional이니까)
const prompt = data.prompt;
// LLM인데 data.tables에 접근하는 것도 막아주지 않음
if (data.tables) {
// 왜 LLM에 tables가 있지?
}
}
};optional로 열어둔 필드는 어느 분기에서든 T | undefined였어요. 그래서 LLM 분기 안에서도 tables에 손이 닿았고, NL2SQL 분기에서도 prompt가 보였죠.
이게 런타임 위험으로 이어졌어요. API로 보낼 payload를 만들 때 undefined를 기본값으로 채우다 보면, 엉뚱한 필드가 섞여 나갔거든요.
const payload = {
...formData,
prompt: formData.prompt ?? '', // NL2SQL인데 빈 prompt가 실려 나감
tables: formData.tables ?? [], // LLM인데 빈 tables가 실려 나감
};
// 서버는 400으로 거절UI도 마찬가지였어요. watch('llm_type') 값으로 조건부 렌더링을 하긴 했지만, 그 안에서 접근하는 필드는 타입 체크를 못 받았죠. 결국 조건문이 늘어날수록 “이 분기에선 이 필드가 있다”를 사람이 머릿속으로 보증해야 했어요. 컴파일러가 도와줄 수 없는 영역이 점점 넓어진 거죠.
여기서 분명해졌어요. LLM과 NL2SQL은 공통 필드를 좀 가졌다 뿐이지, 근본적으로 다른 엔티티였어요. 단일 스키마에 optional을 더 얹는 방식으로는, 갈라진 두 모양을 하나의 타입에 우겨넣는 셈이라 끝까지 깔끔해질 수 없었죠.
discriminatedUnion으로 도메인 모델링
선택지를 세 가지 놓고 봤어요.
| 방법 | 판단 |
|---|---|
| 단일 스키마에 optional 필드 추가 | 거부. 분기마다 T | undefined라 조건문 지옥 + 런타임 위험 |
| 클래스 상속/합성으로 공통 필드 공유 | 거부. 두 에이전트는 같은 엔티티의 변형이 아니라 다른 엔티티 |
z.discriminatedUnion으로 타입별 스키마 분리 | 채택. “이 필드는 이 타입에서만 존재”를 타입으로 못 박음 |
상속을 거른 이유는 이래요. 상속은 “같은 종류인데 일부가 특수화된” 관계에 어울리는데, LLM과 NL2SQL은 그런 관계가 아니었거든요. 둘은 공통 필드를 우연히 나눠 가질 뿐, 한쪽이 다른 쪽의 특수형이 아니었어요. 이건 union으로 표현할 문제였죠.
먼저 공통 필드를 base로 뽑고, 타입별 고유 필드를 extend로 붙인 다음, llm_type을 판별자로 묶었어요.
// 1. 모든 에이전트가 공유하는 최소 필드
const baseFormSchema = z.object({
description: z.string().min(1, '설명을 입력해주세요'),
llm_type: z.nativeEnum(LlmType), // 판별자(discriminant)
name: z.string().min(1, '에이전트 이름을 입력해주세요'),
});
// 2. LLM 에이전트 고유 필드
const llmFormSchema = baseFormSchema.extend({
maxTokens: z.string().min(1, '최대 토큰 수를 입력해주세요'),
prompt: z.string().min(1, '프롬프트를 입력해주세요'),
temperature: z.string().min(1, '온도를 입력해주세요'),
});
// 3. NL2SQL 에이전트 고유 필드
const nl2sqlFormSchema = baseFormSchema.extend({
tables: z
.array(/* 테이블/컬럼 구조 */)
.min(1, '최소 하나의 테이블을 선택해주세요'),
vectorDBIds: z.array(z.object({ vectorDBId: z.string() })),
});
// 4. 판별자로 묶기
const agentFormSchema = z.discriminatedUnion('llm_type', [
llmFormSchema,
nl2sqlFormSchema,
]);useAgentForm은 resolver만 이 스키마로 바꾸면 됐어요. 중복을 미리 걷어내 둔 덕분에, 타입을 나누는 변경이 폼 훅 한 곳에서 끝났죠. 흩어진 채였다면 두 페이지에서 각각 손봐야 했을 작업이었어요.
제출 핸들러는 타입 가드로 분기했어요.
const isLLMFormValues = (
values: AgentFormValues
): values is LLMFormValues => values.llm_type === LlmType.LLM;
const handleSubmit = (data: AgentFormValues) => {
if (isLLMFormValues(data)) {
createLLMAgent({
prompt: data.prompt, // 존재가 보장됨
temperature: Number(data.temperature),
// data.tables; // 컴파일 에러 (존재하지 않는 필드)
});
} else {
createNL2SQLAgent({
tables: data.tables, // 존재가 보장됨
// data.prompt; // 컴파일 에러
});
}
};before/after로 놓고 보면 차이가 분명해요.
// Before: optional이라 분기 안에서도 T | undefined
if (data.llm_type === 'LLM') {
data.prompt; // string | undefined
data.tables; // 접근됨 (있으면 안 되는데)
}
// After: 판별자로 좁혀져서 분기마다 정확한 모양
if (isLLMFormValues(data)) {
data.prompt; // string (보장됨)
data.tables; // ❌ 컴파일 에러
}“LLM인데 tables를 보냈다” 같은 실수가 코드 리뷰가 아니라 컴파일 단계에서 걸리기 시작했어요. IDE 자동완성도 분기에 맞춰 좁혀진 필드만 제안했고요. 사람이 머릿속으로 보증하던 일을 타입 시스템에 넘긴 거죠.
이 과정 전체를 한 장으로 보면 이래요.
flowchart LR
A["복제된 스키마<br/>생성/편집 각자 정의"] -->|중복 제거| B["단일 스키마<br/>useAgentForm (SSoT)"]
B -->|타입 분리| C["discriminatedUnion<br/>llm_type 판별"]
A -.-> A1["같은 필드 다른 규칙<br/>히스토리 누락 버그"]
B -.-> B1["타입 갈라지자<br/>optional 조건문 지옥"]
C -.-> C1["분기별 타입 보장<br/>컴파일 타임 차단"]
곧장 유니온부터 짰다면 두 페이지에 각각 유니온이 복제돼, 같은 중복 문제를 더 복잡한 형태로 다시 만났을 거예요.
남은 트레이드오프
이 방식이 항상 정답은 아니라고 생각해요. 만약 에이전트 타입이 끝까지 하나였다면, temperature를 상황에 따라 optional로 두는 정도로 충분했을 거예요. 타입이 갈라지지 않는데 미리 discriminatedUnion을 도입했다면, 그건 문제가 명확해지기 전에 추상화를 얹은 오버엔지니어링이 됐겠죠.
결국 순서 문제였어요.
- 처음 진단은 “타입 분리가 없다”였지만, 진짜 먼저 손볼 문제는 중복이었어요
- 중복을 걷어내야 “무엇을 나눌지”라는 질문이 비로소 성립했고요
- 추상화는 문제가 충분히 또렷해진 뒤에 와야 제값을 했어요
순서를 거꾸로 짚었다면 더 복잡한 코드를 더 오래 안고 갔을 거예요. 추상화가 늦는 것보다 이른 게 더 위험하다는 걸, 이번에 몸으로 배웠어요.
discriminatedUnion으로 타입은 깔끔하게 갈렸지만, 막상 react-hook-form과 Zod에 얹어 폼으로 굴려보니 문서에 안 나오는 마찰이 몇 가지 있었어요. z.union과 z.discriminatedUnion의 에러 메시지가 어떻게 다른지, 종류마다 구조가 다른데 defaultValues를 어떻게 줘야 하는지 같은 것들이요. 그 마찰들은 다음 글에서 하나씩 풀어볼게요.