이 글에서 다룰 이야기
지난 글에서 비결정론적인 AI 생산자를 결정론적인 검문소로 거른다는 얘기를 했어요. 온도계·저울·타이머 비유를 썼었죠.
이 글은 그중 타입 체크라는 검문소 하나를 확대해서 봐요. 그런데 여기엔 함정이 하나 있어요.
타입이 복잡하다고 안전한 코드가 아니에요.
AI한테 "타입을 안전하게 짜줘"라고 하면 정교해 보이는 결과가 나와요. conditional type이 중첩되고, mapped type이 돌아가고, infer가 들어가 있어요. 그런데 열어보면 이런 것들이 섞여 있어요.
- generic의 추론 권위가 여러 인자로 흩어져서 union이 조용히
string으로 넓어짐 - conditional type이 의도치 않게 union에 분배되거나
never를 잘못 처리함 - mapped type이
readonly나 optional 표시를 잃어버림 - 헬퍼 수준 테스트(
Equal<A, B>)는 통과하는데 실제 공개 호출은 잘못된 값을 허용함 any, 단언,@ts-ignore,skipLibCheck로 타입 오류를 숨김- discriminated union을 만들어 놓고 비동기 순서 문제까지 해결됐다고 보고함
전부 컴파일은 통과해요. 그래서 "타입 체크 통과"라는 신호만으로는 아무것도 알 수 없어요.
이 글은 제가 쓰는 frontend-oracle-design 워크플로우가 이 문제를 어떻게 다루는지, 그리고 그 방법을 다른 곳에도 어떻게 응용할 수 있는지 정리한 거예요.
먼저: 검문소는 통과 도장이 아니라 필터예요
용어 하나를 먼저 정리할게요. 건전성(soundness) 은 "타입 검사를 통과했으면 그 종류의 런타임 오류가 절대 안 난다"는 성질이에요.
TypeScript는 의도적으로 건전하지 않아요. 버그가 아니라 설계 결정이에요.
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // 통과
animals.push(new Cat()); // 통과
dogs[1].bark(); // 💥
배열을 공변으로 처리하고, 메서드 축약 문법에 이변성 예외를 두고, 초과 속성 검사를 객체 리터럴에만 적용하는 것 — 전부 실용성을 위해 받아들인 구멍이에요. 엄격하게 만들면 실제 코드 대부분이 막히거든요.
그래서 이렇게 정리해요.
컴파일 통과는 안전 증명이 아니라, 값싸고 결정적인 필터예요.
값싸다는 건 사람이 리뷰하는 것보다 훨씬 빠르다는 뜻이고, 결정적이라는 건 같은 코드·같은 컴파일러·같은 설정이면 항상 같은 판정이 나온다는 뜻이에요. 이 두 성질이 AI 결과물을 거르는 데 정확히 필요한 것들이에요.
목표를 다시 쓰면 이래요. AI 생성 자체를 결정론적으로 만드는 건 불가능해요. 대신 AI가 만든 여러 후보를 같은 기준으로 통과·거절시킬 수는 있어요. 생성의 결정성이 아니라 수용 판정의 결정성이에요.
철학 1 — 무엇이 소유자인지 못 박아요
타입이 모든 걸 책임진다고 생각하는 순간 거짓 보고가 시작돼요. 그래서 먼저 소유권을 나눠요.
| 대상 | 소유자 |
|---|---|
| 값·Props·상태 조합·입출력 관계 | 타입 |
| API·저장소·URL·메시지 같은 외부 입력 | unknown에서 시작하는 런타임 파서 |
| 관찰 가능한 제품 동작 | 테스트 |
| 순서 역전·중복 제출·retry·언마운트 후 도착 | abort signal·중복 방지 가드·멱등키·서버 검증 |
| 같은 프롬프트의 생성 재현성 | 모델과 제공자 — 이 워크플로우가 보장하지 않아요 |
여기서 제일 중요한 줄이 네 번째예요.
시간축은 타입으로 증명되지 않아요.
union을 만들었다고 "순서 문제 해결됨"이라고 쓰면 그건 거짓이에요. 요청 A가 먼저 나갔는데 응답 B가 먼저 도착하는 상황은 타입 시스템이 볼 수 없는 영역이에요. 그건 abort signal이나 요청 ID 비교 같은 런타임 장치가 막아요.
한 줄로 요약하면 이래요.
type-valid는behavior-correct의 동의어가 아니에요.
타입이 통과했다는 걸 동작이 맞다는 뜻으로 보고하는 건 그 자체로 결함이에요. 그리고 이건 AI가 정말 자주 하는 보고예요. 타입을 고쳐 놓고 "안전하게 처리했습니다"라고 말하거든요.
타입으로 못 잡아서 런타임으로 막은 항목은 반드시 결정 기록에 남겨요. 남기지 않으면 다음 사람이 "여긴 타입이 지켜주는구나" 하고 그 방어를 지워요.
철학 2 — 사다리로 과설계를 탈락시켜요
AI는 요구하면 요구할수록 정교한 타입을 만들어요. 문제는 정교함이 목표가 아니라는 것이에요.
그래서 사다리를 씁니다. 앞 단에서 실제 오용이 닫히면 뒷 단을 쓰지 않아요.
1. API 부재·API 분리로 잘못된 조합을 만들 수 없게 해요
2. schema·config·as const 값에서 typeof·keyof·indexed access·satisfies로 파생해요
3. 내장 유틸리티를 써요 (Pick·Omit·Extract·Exclude·Parameters·ReturnType·Awaited·NoInfer·Record)
4. 이미 설치된 라이브러리 타입을 재사용해요
5. 대표 호출부에서 자동 추론되는 최소 generic을 써요
6. 그래도 관계가 안 닫히면 mapped·conditional·template-literal·recursive를 types/internal에 격리해요
1단이 제일 강력한데 제일 자주 잊혀요. 가장 안전한 API는 잘못 쓸 방법이 아예 없는 API예요.
같은 문제를 6단·3단·1단으로 풀어보기
말로만 하면 감이 안 오니까 실제로 자주 나오는 상황 하나로 볼게요.
달력 컴포넌트가 있어요.
mode가single이면 날짜 하나,range면 기간,multiple이면 날짜 배열을 다뤄요. mode에 따라value와onChange의 타입이 달라져야 해요.
AI가 들고 오는 답 (6단 — conditional type)
type CalendarMode = "single" | "range" | "multiple";
type CalendarValue<M extends CalendarMode> = M extends "single"
? Date | null
: M extends "range"
? DateRange | null
: M extends "multiple"
? readonly Date[]
: never;
type CalendarProps<M extends CalendarMode> = {
mode: M;
value: CalendarValue<M>;
onChange: (value: CalendarValue<M>) => void;
};
동작은 해요. 그런데 비용이 붙어요. M이 벌거벗은 타입 파라미터라 union이 들어오면 분배돼서 의도치 않은 결과가 나오고, 마지막 : never 가지가 언제 걸리는지 아무도 몰라요. 그리고 타입 오류가 나면 소비자가 보는 메시지가 이래요.
Type 'Date' is not assignable to type 'CalendarValue<M>'.
Type 'Date' is not assignable to type 'M extends "single" ? Date | null : ...'
한 단 내려온 답 (3단 — lookup map + indexed access)
conditional type이 하는 일이 결국 "mode 이름으로 타입을 하나 고르는 것"뿐이에요. 그럼 그냥 맵을 만들면 돼요.
type CalendarValueByMode = {
single: Date | null;
range: DateRange | null;
multiple: readonly Date[];
};
type CalendarMode = keyof CalendarValueByMode; // 'single' | 'range' | 'multiple'
type CalendarProps<M extends CalendarMode> = {
mode: M;
value: CalendarValueByMode[M];
onChange: (value: CalendarValueByMode[M]) => void;
};
같은 관계를 keyof와 indexed access 두 개로 표현했어요. 분배 함정이 없고, mode 목록이 맵에서 파생되니 두 곳이 어긋날 수가 없고, 새 mode를 추가하려면 맵에 한 줄만 넣으면 돼요. 오류 메시지도 Type 'Date' is not assignable to type 'DateRange | null' 로 읽혀요.
여기서 오용이 실제로 닫혔어요. 그러면 6단은 안 써요.
<Calendar mode="range" value={new Date()} onChange={...} />
// ~~~~~ Type 'Date' is not assignable to 'DateRange | null'
더 내려간 답 (1단 — API 부재·분리)
그런데 한 발 더 물어봐요. 제품이 세 mode를 다 쓰나요?
multiple을 아무도 안 쓴다면, 그건 "타입으로 막을 대상"이 아니라 애초에 없어야 할 것이에요. 그리고 single과 range가 키보드 조작이나 내부 상태 관리까지 다르다면, 하나의 컴포넌트로 묶은 것 자체가 무리예요.
// mode prop 자체가 사라져요
export function CalendarSingle(props: {
value: Date | null;
onChange: (value: Date | null) => void;
}) { /* ... */ }
export function CalendarRange(props: {
value: DateRange | null;
onChange: (value: DateRange | null) => void;
}) { /* ... */ }
제네릭이 없어졌어요. 잘못된 조합을 표현할 문법 자체가 없어요. CalendarSingle에 DateRange를 넘기는 코드는 애초에 쓸 수가 없어요.
그래서 어느 걸 고르나요
| 상황 | 답 |
|---|---|
| mode가 값 타입만 기계적으로 결정함 | 3단 — lookup map |
| mode마다 동작·키보드 조작·상태 수명이 다름 | 1단 — 컴포넌트 분리 |
| 제품이 그 mode를 안 씀 | 1단 — 구현하지 않음 (API 부재) |
판정 질문은 이거예요. "mode를 지웠을 때 남는 게 같은 컴포넌트인가요?" 같으면 값 타입만 다른 거니까 3단이고, 다르면 원래 다른 컴포넌트였던 거예요.
한 가지 주의할 게 있어요. 사다리는 여러 개고 서로 독립이에요.
A. 소유권·경계 기존 owner 재사용 → 파생 → API 부재로 불가능하게 → 외부 값은 unknown에서 파싱
B. 상태 공간 프레임워크 union 소비 → capability 분리 → union + never → discriminated union → 상태 기계
C. API 관계 typeof·as const·satisfies → keyof·indexed access → 내장 유틸리티 → 관계형 generic → ...
"keyof가 discriminated union보다 항상 뒤"같은 전역 순서는 없어요. 축이 다른 문제를 한 줄로 세우면 엉뚱한 판정이 나와요. 각 사다리 안에서만 앞 단부터 확인해요.
그리고 사다리의 목적이 재밌어요. 생성 결과를 똑같이 만드는 게 아니라, 불필요하게 복잡한 뒷 단 메커니즘을 일관되게 탈락시키는 것이에요. AI가 6단짜리 conditional type을 들고 왔을 때 "이건 3단으로 닫혀요"라고 매번 같은 근거로 말할 수 있게요.
철학 3 — 판정 질문 하나로 수렴시켜요
이 워크플로우 전체를 관통하는 질문이 딱 하나예요.
AI가 생성할 수 있었던 잘못된 코드 중, 무엇이 이제 컴파일되지 않나요?
이 질문의 좋은 점은 답이 구체적이거나 없거나 둘 중 하나라는 거예요.
"타입 안전성이 향상됩니다" 같은 답은 답이 아니에요. "컬럼을 지우면 그 id로 정렬하던 세 군데 호출부가 컴파일 에러가 납니다"는 답이에요. 그리고 후자는 그대로 테스트 케이스가 돼요.
구체적으로 답할 수 없는 타입 복잡성은 추가하지 않아요. 이 규칙 하나로 걸러지는 게 놀랄 만큼 많아요.
설계 전에 찾는 여섯 지점
무엇을 닫을지 찾을 때는 이 여섯 개를 봐요. 그리고 컴파일되면 안 되는 잘못된 사용을 먼저 적어요.
| 지점 | 증상 | 후보 |
|---|---|---|
| 값 | 넓은 string·number·Date | 브랜드 타입, 의미 타입 |
| 조합 | boolean 여러 개, 배타적 optional Props | discriminated union, union + never |
| 관계 | mode가 반환 타입을 정하는데 타입엔 없음 | generic lookup map, 컴포넌트 분리 |
| 경로·키 | route·query key·field path가 자유 문자열 | factory, keyof, 파생 union |
| 결과 | 성공·실패·부재·유지·삭제가 undefined 하나에 | Result, 연산 union |
| 확장 | 소비자가 확장할 키가 string으로 열림 | typed registry, module augmentation |
공개 API라면 그 목록이 그대로 .test-d.ts의 @ts-expect-error 케이스가 돼요. 설계 단계의 메모가 검증 코드로 바로 이어지는 거예요. 몇 개를 적을지는 아래 응용 1의 경계 축이 정해요 — 개수를 먼저 정하지 않아요.
응용 1 — 증거 패킷을 요구해요
고급 타입을 채택하려면 여섯 가지 증거를 함께 남겨요. 이게 "그럴듯해 보임"과 "실제로 막힘"을 가르는 장치예요.
| 증거 | 기준 |
|---|---|
| positive | 대표 제품 호출 1개가 명시적 타입 인자 없이 컴파일돼요 |
| negative | 이 API가 닫는 경계 축마다 1개씩, 각각 한 줄 @ts-expect-error |
| edge | 관련 있는 any·unknown·never·union·readonly 튜플·optional만 |
| mutation | 계약을 약화하면 스위트가 실제로 RED가 돼요 |
| runtime complement | URL·저장소·API·시간축은 파서·가드·런타임 테스트가 증명해요 |
| soundness gap | overload 마지막 시그니처, 메서드 이변성처럼 남은 구멍을 적어요 |
positive 증거가 왜 필요한지가 재밌어요. 정상 호출이 타입 인자 없이 추론되지 않으면, 부정 테스트를 다 통과해도 좋은 API가 아니에요. 아무도 안 쓸 테니까요. 안전한데 못 쓰는 API는 안전하지 않은 API랑 결과가 같아요.
mutation 증거는 더 중요해요. 계약을 일부러 넓혀 보고 테스트가 빨개지는지 확인하는 거예요. NoInfer<T>를 T로 바꾸거나, union을 string으로 넓히거나, 필수 필드를 optional로 바꿔요. 빨개지지 않으면 그 테스트는 아무것도 지키지 않고 있었던 거예요.
@ts-expect-error가 @ts-ignore와 다른 점이 여기서 핵심이 돼요. 오류가 사라지면 그 줄이 "사용되지 않음"(TS2578)으로 빨개져요. 보호가 없어지는 것 자체가 실패가 되는 구조예요.
부정 케이스는 경계에서 골라요
무슨 오용을 막을지는 개수가 아니라 경계로 정해요. "최소 3개"처럼 개수를 목표로 두면 사람도 도구도 숫자를 채워요 — 오타 세 개를 쓰고 3개 채웠다고 하는 식이죠. 셋 다 같은 경계예요.
이건 런타임 테스트에서 오래 쓰던 경계값 분석(BVA) 을 타입에 그대로 옮긴 거예요. "최소 2글자" 규칙이면 1·2·3을 보듯이, 타입에도 넘나드는 경계가 있어요. 다만 값의 크기가 아니라 타입 격자의 극단이에요.
// 경계: 리터럴 / 넓어진 string
const ok: SortKey = "total";
// @ts-expect-error 이미 string으로 넓어진 값은 못 들어와요
const bad: SortKey = "total" as string;
그리고 여기서 중요한 게 하나 더 있어요. BVA 4축 중 타입이 못 덮는 축이 있어요. 시간·순서 경계와 부작용 횟수 경계는 union을 아무리 정교하게 짜도 안 덮여요. 타입 케이스를 다 채운 뒤에 그 두 축을 런타임 테스트로 명시적으로 넘겨야 해요. 안 넘기면 "타입 테스트 다 짰다"가 "다 검증됐다"로 읽혀요.
→ 타입 경계값 분석 — BVA로 타입 테스트 케이스 고르기
응용 2 — 전제 환경을 저장소당 한 번 고정해요
"컴파일되지 않는다"가 tsconfig의 함수라면, 환경을 모르는 판정은 판정이 아니에요.
pnpm exec tsc --showConfig # extends 체인을 따라간 실효 값
pnpm exec tsc --version # lockfile이 실제로 resolve한 컴파일러
파일에 적힌 값이 아니라 실효 값이 기준이에요. Playground나 최신 버전에서만 통과한 결과는 증거가 아니고요.
strict나 버전이 미충족이면 여기서 멈춰요. tsconfig를 조용히 바꾸지 않아요 — 저장소 전체에 파급되는 정책 변경이라 사람이 결정할 일이에요. 권장 플래그가 꺼져 있으면 켜자고 제안하되, 거절되면 약해지는 계약 목록을 적고 진행해요. "이건 컴파일이 아니라 리뷰와 테스트가 잡아야 한다"는 뜻이니까요.
응용 3 — 자주 나오는 거짓 계약을 목록으로 갖고 있어요
리뷰에서 반복적으로 잡히는 패턴이 있어요. 목록으로 갖고 있으면 판정이 빨라져요.
Record<K, V>는 totality 계약이에요. 모든 K가 존재한다는 뜻이에요. 관찰된 키만 채우는 sparse 결과이고 키가 유한 union이면Partial<Record<K, V>>, ID처럼 열린 도메인이면Map이에요.- type predicate는 검사 의무가 있어요.
value is T는 본문이 필수 invariant를 실제로 검사할 때만 써요.as를 감싼 predicate는 컴파일러를 속이는 것뿐이에요. - wrapper의 반환 계약은 실행 시점을 따라요.
Parameters는 보존해도 되지만ReturnType은 같은 호출에서 값이 나올 때만이에요. debounce는void고, 캐시는| undefined예요. - 초과 속성 검사는 sanitizer가 아니에요. 객체 리터럴 대입에만 걸리고, 걸려도 런타임에서 필드를 지우지 않아요.
- 스키마는 경계에만 둬요. 앱 안에서 생기는 유한 값은
as const로 충분하고, 파서는 그 값이 저장소·URL·응답에서 돌아오는 읽기 지점에 붙여요. 반대로 잡으면 비용만 늘고 정작 위험한 곳은 비어 있어요.
응용 4 — 상태는 union을 만들기 전에 두 번 막아요
타입 얘기를 하다 보면 상태를 전부 discriminated union으로 만들고 싶어져요. 그런데 실제 버그는 그 앞에서 나요.
- 파생 가능하면 저장하지 않아요.
itemCount = items.length. 중복 저장된 상태는 한쪽만 갱신돼요. - 라이브러리가 이미 union을 소유하면 그대로 소비해요. 쿼리 라이브러리의
status는 이미 discriminated contract이고, 최신 호출 기준 시간축 처리까지 포함해요.useState로 복사하면 한 렌더 늦게 따라오는 두 번째 진실이 생겨요. - 그래도 남는 진짜 client 상태만 union + 의도 함수로 만들어요.
그리고 하나 더. 상태는 데이터, action은 형제예요.
// 금지 — stale closure와 가짜 retry가 동시에 생겨요
type DetailState = { status: "loading" } | { status: "failure"; retry: () => void };
// 허용 — 상태는 데이터, action은 형제로
type DetailState = { status: "loading" } | { status: "failure"; reason: LoadFailure };
function useDetail(id: DetailId): { state: DetailState; retry: () => void };
state에 저장한 함수는 그것을 만든 렌더의 클로저에 고정돼요. 그리고 쓸 수 없는 상태에 retry: () => undefined 같은 no-op을 채우면 UI에 거짓 정보를 주고요.
응용 5 — 계약 파일 완화를 정책 변경으로 취급해요
이게 실무에서 제일 중요한 규칙일 수도 있어요.
AI에게 "타입 오류를 고쳐줘"라고 하면, 오류를 없애는 가장 쉬운 길을 찾아요. 그리고 그 길은 대개 계약을 넓히는 것이에요.
.test-d.ts의@ts-expect-error케이스를 삭제해요- 필수 필드를 optional로 바꿔요
- union을
string으로 넓혀요 @ts-ignore나 double assertion을 넣어요
전부 초록불을 만들어요. 그리고 전부 보호를 없애요.
계약 파일은 검수의 신뢰 뿌리예요. 완화는 구현 결정이 아니라 정책 변경이에요.
그래서 구현 diff가 계약을 넓혔으면 그건 "통과"가 아니라 사람이 결정해야 하는 항목으로 올라가요. 요구사항이 실제로 바뀐 거라면 근거를 인용하고 넓히면 되고, 그냥 오류를 없애려던 거라면 되돌려요.
리뷰에서 볼 목록도 함께 정리해 두면 좋아요.
- 파생 가능한 값을 상태로 저장 / 쿼리 상태를 로컬 기계로 복사 / raw setter 노출
- state에 action 저장, 쓸 수 없는 상태의 no-op action
- 경계 값을 파싱 없이 단언,
any가 애플리케이션 계층으로 누출 - 앞 단으로 닫히는 문제에 뒷 단 타입, feature 코드의 자작 고급 유틸리티
satisfies·as const를 런타임 검증으로 보고- 시간축 비결정성을 타입만으로 "해결됨" 처리
반대로 판정 대상이 아닌 것도 정해 둬요. 상태 이름 취향, reducer 대 개별 핸들러 문법 선호, 패턴 매칭 라이브러리 선호 — 이런 건 의견이지 결함이 아니에요. 이 구분이 없으면 리뷰가 취향 싸움이 돼요.
다른 곳에 응용하려면
이 방식을 그대로 쓰지 않더라도, 옮겨갈 만한 조각이 몇 개 있어요.
하나. 판정 질문을 하나 정해요. "무엇이 이제 컴파일되지 않는가"가 강력한 이유는 답이 구체적이거나 없거나 둘 중 하나라서예요. 여러분의 도메인에도 이런 질문을 하나 만들 수 있어요. "이 검증을 지우면 어떤 테스트가 빨개지나요?" 같은 것들이요.
둘. 소유권 표를 만들어요. 무엇을 타입이, 무엇을 파서가, 무엇을 테스트가 책임지는지 적어 두면 거짓 보고가 줄어요. 특히 "AI가 보장하지 않는 것"을 명시하는 줄이 중요해요.
셋. 사다리를 만들어요. 도구 목록이 아니라 순서를 정하는 거예요. 앞 단으로 닫히면 뒷 단을 안 쓴다는 규칙 하나가 과설계를 계속 걸러줘요.
넷. mutation 검증을 습관으로 만들어요. 만든 방어 장치를 일부러 부숴 보고 빨간불이 켜지는지 확인해요. 안 켜지면 그건 장식이었던 거예요.
다섯. 완화를 별도 트랙으로 빼요. 검증 장치를 약하게 만드는 변경은 기능 구현과 다른 승인 경로를 타게 해요.
마무리
오늘 정리한 걸 한 줄로 줄이면 이래요.
타입으로 막았다는 주장은 "무엇이 이제 컴파일되지 않는가"에 구체적으로 답할 수 있을 때만 참이고, 그 답은 대개 문법 한 조각이 빠지면 아무 경고 없이 거짓이 돼요.
그리고 이 워크플로우가 실제로 하는 일은 안전을 증명하는 게 아니에요. 매번 같은 기준으로 통과와 거절을 반복 가능하게 만드는 것이에요. AI는 계속 다른 코드를 내놓을 거고, 검문소는 계속 같은 판정을 내리면 돼요.
관련해서 같이 읽으면 좋은 글이에요.
- 구현보다 정답 기준을 먼저 잠가요 — 이 워크플로우의 철학 전체
- 비결정론적인 AI 코드, 결정론적인 검문소로 걸러내요 — 단계별 절차