"Parse, don't validate" — 검사한 사실을 타입에 남긴다
외부에서 들어온 값이 우리가 기대한 모양인지 확인하는 방법은 여러 가지다. 그중 단순한 검증과 파싱의 차이는 검사에서 얻은 정보를 결과에 남기느냐에 있다.
type LoginResponse = {
status: "ok";
};
function isLoginResponse(value: unknown): value is LoginResponse {
return (
typeof value === "object" &&
value !== null &&
"status" in value &&
value.status === "ok"
);
}
function validateLoginResponse(value: unknown): void {
if (!isLoginResponse(value)) {
throw new Error("잘못된 로그인 응답");
}
}
function parseLoginResponse(value: unknown): LoginResponse {
if (!isLoginResponse(value)) {
throw new Error("잘못된 로그인 응답");
}
return value;
}
// 서버에서 받은 값이라 아직 어떤 타입인지 모른다.
const raw: unknown = await res.json();
// 방법 1: 검증만 한다.
validateLoginResponse(raw);
raw.status; // ❌ raw의 타입은 여전히 unknown
// 방법 2: 파싱한 결과를 사용한다.
const data = parseLoginResponse(raw);
data.status; // ✅ data는 LoginResponsevalidateLoginResponse도 잘못된 입력을 거부한다. 하지만 성공했을 때 void만 반환하므로, 호출부의 raw에는 검사에서 얻은 정보가 남지 않는다. 반면 parseLoginResponse는 같은 검사를 통과한 값을 LoginResponse로 반환한다. 이후 코드는 반환된 data만 사용하므로 같은 조건을 다시 확인할 필요가 없다.
이것이 이 글에서 말하는 "Parse, don't validate"다. Alexis King은 Parse, don't validate에서 이를 다음과 같이 설명한다.
"validate" always returns
(), the type that contains no information, but "parse" returns a refinement of the input type that preserves the knowledge gained in the type system.
TypeScript에서는 value is LoginResponse 형태의 타입 가드나 assertion function도 검사 결과를 타입 시스템에 전달할 수 있다. 따라서 모든 검증이 정보를 버린다고 말할 수는 없다. 이 글에서 강조하는 차이는 외부 입력을 확인한 뒤, 이후 코드가 사용할 수 있는 신뢰 가능한 값을 반환하느냐에 있다. 파서는 같은 모양을 확인하는 데 그치지 않고 값을 정규화하거나 의미가 더 강한 타입으로 바꾸는 역할까지 맡을 수 있다.
그렇다고 코드 내부의 모든 값을 매번 파싱해야 하는 것은 아니다. 이미 우리 코드가 타입에 맞게 생성한 값은 반복해서 검사할 필요가 없다. API 응답, 폼 입력, URL query, localStorage, JSON.parse, 서드파티 SDK 응답처럼 내가 통제하지 못하는 값이 내부로 들어오는 경계가 핵심이다.
파싱이 빠지면 검증이 코드 전체로 퍼진다
경계에서 파싱하지 않는 코드는 꼭 as unknown as 한 줄로만 나타나지 않는다. 아래는 실제 프로젝트 코드를 그대로 옮긴 것이 아니라, 같은 문제가 코드베이스에서 흔히 드러나는 형태를 단순화한 예다.
부분 검사가 비즈니스 로직에 섞인다
const data: any = await res.json();
if (!data.user || typeof data.user.id !== "string") {
throw new Error("잘못된 사용자 응답");
}
showProfile(data.user);
if (!Array.isArray(data.permissions)) {
throw new Error("잘못된 권한 응답");
}
enableMenus(data.permissions);각 호출부가 당장 필요한 필드만 확인하면 검증과 처리가 섞인다. user는 확인했지만 permissions가 잘못됐다는 사실은 이미 showProfile을 실행한 뒤에 발견한다. 같은 응답을 사용하는 화면이 늘어날수록 비슷한 검사가 복제되고, 어디까지 확인했는지를 코드 전체에서 추적해야 한다.
계약 위반이 기본값 뒤에 숨는다
const userName = data.user?.name ?? "알 수 없음";
const accessToken = data.accessToken ?? "";
const permissions = Array.isArray(data.permissions)
? data.permissions
: [];계약상 선택적인 값이라면 optional chaining과 기본값은 올바른 처리다. 하지만 반드시 와야 하는 필드에까지 이 패턴을 반복하면 스키마 불일치가 실패로 드러나지 않고 정상처럼 보이는 빈 값으로 바뀐다. 백엔드 계약이 깨져도 화면 일부만 조용히 사라지는 식이라 원인 추적은 더 어려워진다.
이 문법들이 각각 나쁘다는 뜻은 아니다. 외부 입력을 신뢰할 런타임 근거가 없는데도 단언·부분 검사·기본값으로 계속 통과시키는 조합이 냄새다. 경계에서 한 번 파싱하면 이후 코드는 검증을 반복하거나 방어적으로 추측하지 않고, 이미 정제된 타입을 입력으로 받을 수 있다.
이 중 가장 직접적인 냄새는 외부에서 들어온 값을 타입 단언으로 통과시키는 것이다. 내가 실제 코드에서 발견한 것도 as unknown as였다.
타입 단언은 런타임 값을 바꾸지 않는다
TypeScript의 as(타입 단언, type assertion)는 타입 체커에게 "내가 너보다 이 값의 타입을 더 잘 안다"고 선언하는 문법이다. 문제는 이게 진짜인지 컴파일러가 검증하지 않는다는 점이다. unknown을 거쳐 이중으로 단언하는 as unknown as T 패턴은 특히 위험하다 — TypeScript가 원래는 "이 두 타입은 겹치는 게 없어서 단언이 말이 안 된다"고 막아주는 안전장치까지 unknown을 경유해서 우회해버리기 때문이다. string을 number로 캐스팅하는 것도 unknown만 거치면 통과된다.
const x = "hello" as unknown as number; // 컴파일 통과. x는 여전히 문자열이다.API 응답에 이걸 쓰면 무슨 일이 생기는가. 개발 초기, 백엔드가 문서화된 스펙대로 정직하게 응답을 줄 때는 아무 문제가 없다. 문제는 나중이다.
- 백엔드가 필드 이름을 바꾸거나 nullable로 바꿨는데 프론트 타입은 그대로일 때
- 제약이 느슨한 컬럼(예: enum 제약이 없는 DB 컬럼)에 예상 밖의 값이 들어와 있을 때
- 에러 응답인데 성공 응답 타입으로 캐스팅해서 받아버렸을 때
이런 순간에 as는 아무 말도 하지 않는다. 타입은 여전히 LoginResponse라고 우기고, 실제 값은 undefined거나 다른 모양이고, 그 어긋남은 캐스팅 시점이 아니라 data.status를 실제로 읽는 코드 어딘가에서 — 캐스팅한 곳과 한참 떨어진 곳에서 — undefined is not an object 같은 애매한 런타임 에러로 튀어나온다. 원인 추적이 캐스팅한 지점부터가 아니라 증상이 나타난 지점부터 거꾸로 시작돼야 한다.
실제 코드에서 만난 as unknown as
로그인 API를 실제 백엔드로 연동하는 작업을 하다가, 셀프 리뷰 중에 내 손으로 쓴 코드 한 줄에 발목을 잡혔다.
const res = await fetch(`${API_ORIGIN}/api/auth/login`, { method: "POST", body: JSON.stringify(payload) });
const data = (await res.json()) as unknown as LoginResponse;컴파일은 통과한다. 타입도 다 맞는다. data.status를 찍어보면 IDE가 친절하게 "ok" 타입이라고 알려준다. 그런데 이 코드는 백엔드가 실제로 무엇을 돌려줬는지 단 한 번도 확인하지 않는다. as unknown as LoginResponse는 "나 이거 LoginResponse인 거 알아, 믿어줘"라고 컴파일러의 귀에 대고 속삭이는 것과 같다. 컴파일러는 그 말을 믿고 넘어간다. 문제는 런타임의 서버는 컴파일러가 아니라는 것이다.
셀프 리뷰에서 이 단언을 걷어낸 뒤, 같은 문제가 반복되지 않도록 zod 파싱을 API 경계의 기본값으로 만들기 시작했다.
이 작업을 하던 PR의 셀프 리뷰(8개 관점으로 코드를 훑는 절차)에서 나온 여러 지적 중 하나가 이
as unknown as캐스팅이었다. 리뷰 절차 자체는 다른 글에서 따로 다룰 주제라 여기서는 줄인다.
캐스팅을 걷어내고 경계에서 파싱하기
같은 응답을 캐스팅이 아니라 파싱으로 받는 코드는 이렇게 바뀐다.
// ❌ before — 컴파일러만 안심시킨다
async function login(payload: LoginPayload): Promise<LoginResponse> {
const res = await fetch("/api/auth/login", { method: "POST", body: JSON.stringify(payload) });
return (await res.json()) as unknown as LoginResponse;
}// ✅ after — 런타임에 실제로 검증하고, 검증된 타입만 반환한다
export const loginResponseSchema = z
.object({ status: z.literal("ok") })
.extend(nativeSessionTokensSchema.shape);
export type LoginResponse = z.infer<typeof loginResponseSchema>;
async function login(payload: LoginPayload): Promise<LoginResponse> {
const res = await fetch("/api/auth/login", { method: "POST", body: JSON.stringify(payload) });
const rawBody = await res.json().catch(() => null);
if (!res.ok) throw new ApiRequestError(rawBody, res.status);
return loginResponseSchema.parse(rawBody); // 실패하면 ZodError, 성공하면 타입 보증
}바뀐 게 두 가지다. 첫째, LoginResponse 타입을 손으로 선언하는 대신 z.infer<typeof loginResponseSchema>로 스키마에서 뽑아낸다. 타입과 런타임 스키마를 따로 수정하다가 생기는 구조적 불일치를 막는 방식이다. 그렇다고 백엔드의 실제 응답이 언제나 스키마와 일치한다는 뜻은 아니다. 그 위반을 경계에서 발견하는 것이 바로 .parse()의 역할이다. 둘째, 에러 응답(!res.ok)과 스키마 검증 실패(schema.parse)를 서로 다른 실패 경로로 명시적으로 나눈다 — 앞서 본 것처럼 이 구분을 호출부까지 끝까지 끌고 가는 게 관건이지만, 적어도 경계에서는 구분해서 만들어 둔다.
한 번의 수정에서 코드베이스의 기본값으로
한 파일을 고치는 건 습관이 아니다. 이 원칙이 실제로 "이 코드베이스의 기본값"이 됐다고 말할 수 있으려면 몇 가지를 확인해야 했다.
공용 타입 패키지 하나로 스키마를 모은다. 백엔드와 프론트가 각자 타입을 따로 선언하면 결국 둘이 벌어진다. shared-types 패키지에 API별 zod 스키마를 전부 모아두고, 양쪽이 같은 스키마 + z.infer로 파생된 타입을 가져다 쓴다. 지금 이 패키지에는 export const ...Schema = z. 패턴으로 정의된 스키마가 347개다(초안 작성 시점에는 230개대였는데, 두 달 사이 계속 늘어난 것을 재검증했다). 회원 인증뿐 아니라 게시글, 댓글, 공고 목록 같은 새 기능이 붙을 때마다 같은 패턴이 반복됐다.
"API 경계에서는 as를 쓰지 않는다"가 리뷰 기준이 됐다. 실제로 서버 통신을 담당하는 파일들(fetch를 직접 호출하는 파일, 각 기능의 서버 액션 파일)만 따로 훑어보면 as unknown as 캐스팅이 테스트 코드를 빼고는 한 건도 없다. 정확히 세어보면 테스트 파일을 뺀 전체 레포에 as unknown as가 13건 남아 있는데, 그중 6건은 모바일 앱의 파일 업로드용 as unknown as Blob(RN의 FormData가 DOM Blob 타입과 안 맞아서 나가는 데이터를 보정하는 것 — 들어오는 응답을 캐스팅하는 것과는 반대 방향이다)이고, 나머지는 백엔드 내부 코드다. API 응답을 캐스팅하는 용도로는 정말 0건이었다. 반면 코드베이스 전체를 보면 as 캐스팅 자체는 여전히 수백 건 남아 있다 — DOM 이벤트 타입을 좁히거나, 테스트 픽스처를 만들거나, 서드파티 타입 정의의 구멍을 메우는 용도로. as가 나쁜 게 아니라, 경계를 넘는 데이터에 쓰는 as가 나쁜 것이다. 이 구분이 리뷰에서 자연스럽게 통과되는 지점이 됐다는 게 "정착"의 실질적인 의미였다.
같은 패턴이 다음 기능에서 재현됐다. 로그인 기능 이후에 붙인 다른 화면(목록 API 연동)에서도 목업 데이터를 실제 API로 바꾸는 작업을 했는데, 이번엔 처음부터 as 캐스팅을 쓸 생각 자체를 안 했다 — 새 스키마를 shared-types에 추가하고 .parse()로 받는 게 디폴트 경로가 됐다. 습관이 정착됐다는 걸 확인하는 가장 정직한 방법은 "다음번에 아무도 그 얘기를 꺼내지 않았을 때"라고 생각한다.
레포가 다르면 파일이 아니라 계약을 공유한다
프론트와 백엔드가 다른 레포에 있다고 해서 곧바로 세 번째 공용 패키지를 만들어야 하는 것은 아니다. 먼저 정해야 할 것은 API 계약의 원본을 누가 소유하는가다.
백엔드가 API 계약의 소유자라면 응답 스키마도 백엔드 레포에 두는 편이 자연스럽다고 생각한다. 백엔드 CI는 그 스키마에서 프론트가 소비할 타입이나 런타임 파서를 산출물로 발행하고, 프론트는 필요한 버전을 명시적으로 가져간다. 백엔드 애플리케이션 코드를 공유하는 것이 아니라, 외부에 공개한 계약만 전달하는 방식이다.
양쪽이 모두 TypeScript라면 이 산출물이 Zod 스키마와 z.infer 타입을 담은 패키지일 수는 있다. 하지만 그것은 여러 선택지 중 하나일 뿐이다. 백엔드는 응답 검증이나 계약 테스트로 자신이 선언한 계약을 지키는지 확인하고, 프론트는 자신이 기대하는 계약 버전으로 실제 네트워크 응답을 파싱한다. 둘이 무조건 같은 코드를 한 번씩 실행하게 만드는 것이 목적은 아니다. 생산자는 자신의 약속을 검증하고, 소비자는 자기 경계로 들어온 값을 검증한다.
프론트와 백엔드가 계약을 공동으로 소유하거나 같은 계약을 소비하는 서비스가 많을 때는 별도 계약 패키지가 의미가 있다. 이 경우에도 UI 모델이나 데이터베이스 엔티티까지 넣지 않고, 레포 경계를 넘는 요청·응답 스키마만 둔다. 그래야 계약 공유가 애플리케이션 내부 구조의 결합으로 번지지 않는다.
백엔드가 다른 언어이거나 이미 OpenAPI를 관리한다면 계약 문서를 기준으로 생성하는 방식이 더 자연스럽다. 백엔드의 OpenAPI 문서를 단일 소스로 삼고 프론트의 요청·응답 타입이나 클라이언트를 생성한다. 다만 생성된 TypeScript 타입만으로는 런타임 검증이 생기지 않는다. 이 글의 원칙을 유지하려면 OpenAPI에서 런타임 스키마까지 생성하거나, 생성된 타입과 별개로 API 경계의 파서를 둬야 한다.
당연한 말이지만 패키지나 OpenAPI가 있어도 독립 배포에서 생기는 시간차까지 사라지는 것은 아니다. 백엔드가 필드를 제거하거나 이름을 바꾼 순간 아직 이전 프론트가 서비스 중일 수 있다. 그래서 먼저 새 필드를 추가하고 기존 필드를 유지한 채 백엔드를 배포한 다음, 새 계약을 사용하는 프론트를 배포하고, 이전 소비자가 사라진 뒤 기존 필드를 제거하는 식의 하위 호환 배포가 필요하다.
마지막 안전망은 계약 테스트다. 백엔드는 실제 응답이 공개한 스키마를 만족하는지 CI에서 확인하고, 프론트는 사용하는 계약 버전으로 빌드와 파싱 테스트를 돌린다. 같은 계약 파일을 공유하는 것은 불일치를 발견할 기반을 만들 뿐이고, 그 계약을 실제 구현과 배포 순서가 지키게 만드는 일은 별도로 남는다.
파싱은 실패를 발견할 뿐, 처리까지 결정해주지는 않는다
저 as unknown as LoginResponse 코드는 실제로 내가 작성했지만, 셀프 리뷰 단계에서 커밋되기 전에 고쳤다. 따라서 여기서 다루는 것은 이 캐스팅 때문에 발생한 장애 회고가 아니다. 캐스팅을 걷어내고 zod 파싱을 도입한 뒤, 현재의 에러 처리 흐름을 다시 살펴본 기록에 가깝다. (자세한 근거는 맨 아래 "검증 노트"에 적었다.)
그 과정에서 as를 걷어냈다고 문제가 끝나는 것은 아니라는 걸 보여주는 지점을 발견했다. 파싱 실패를 정확히 만들어내는 것과, 그 실패를 호출부에서 올바르게 분류하는 것은 별개의 문제였다.
API 경계의 공용 파싱 함수는 대략 이렇게 생겼다 (핵심만 남기고 단순화했다):
// shared/api/server-fetch.ts
async function parseApiResponse<T>(res: Response, schema: ZodType<T>): Promise<T> {
const rawBody = await res.json().catch(() => null);
if (!res.ok) {
// 에러 응답은 별도 스키마로 파싱해서 ApiRequestError로 던진다
throw new ApiRequestError(rawBody, res.status);
}
// 성공(res.ok) 응답이어도 body 모양은 보장되지 않는다 — 여기서 실제로 검증한다
return schema.parse(rawBody);
}여기까지는 정확히 "parse, don't validate"다. res.ok가 true라도 그 바디가 우리가 기대한 모양이라는 보장은 없다 — 그래서 schema.parse(rawBody)가 실제로 검증하고, 안 맞으면 ZodError를 던진다. 이 파일의 주석엔 이렇게 적혀 있다: "파싱 자체가 실패하면 이 클래스를 만들지 않고 ZodError가 그대로 전파되어 '에러를 삼키지 않는다'는 원칙을 지킨다."
그런데 이 ZodError를 실제로 받는 호출부(로그인 액션)를 보면 여러 실패가 하나의 catch로 들어온다. 실제 구현의 의미를 유지하되 분기가 잘 보이도록 펼쳐 쓰면 다음과 같다.
// features/auth/login/actions.ts (2026-09-21 기준)
try {
await apiServerFetch("/api/auth/login", payload, loginResponseSchema);
} catch (error) {
if (error instanceof ApiRequestError) {
if (error.code === "TOTP_REQUIRED") {
return { status: "totp_required" };
}
if (error.code === "INVALID_TOTP_CODE") {
return {
status: "totp_required",
message: error.message,
};
}
return {
status: "error",
message: error.message,
};
}
return {
status: "error",
message: "로그인에 실패했습니다.",
};
}error instanceof ApiRequestError가 아니면 전부 "로그인에 실패했습니다."라는 같은 한 문장으로 뭉개진다. 그런데 이 catch 블록에 걸리는 게 뭔지 세어 보면 최소 세 가지가 섞여 있다.
flowchart TD
A["apiServerFetch 호출"] --> B{"네트워크 자체가 실패<br/>fetch가 reject"}
A --> C{"res.ok = false<br/>백엔드가 에러 응답"}
A --> D{"res.ok = true인데<br/>응답을 기대한 타입으로<br/>파싱하지 못함"}
B --> E["TypeError"]
C --> F["ApiRequestError"]
D --> G["ZodError"]
E --> H["catch: 로그인에 실패했습니다"]
F --> I["catch: error.message 그대로 노출"]
G --> H
style H fill:#fff,stroke:#a33
style I fill:#fff,stroke:#3a3
진짜 네트워크 장애(TypeError)와 응답 파싱 실패(ZodError)가 호출부에서는 똑같이 "로그인에 실패했습니다"로 합쳐진다. 사용자에게는 원인을 구분할 수 없는 동일한 실패 메시지만 보이고, 운영자 역시 별도의 로깅이 없다면 네트워크 장애와 스키마 불일치를 즉시 구분하기 어렵다. as를 걷어내고 zod를 붙였다고 이 문제가 자동으로 없어지는 것은 아니다. 파싱은 실패를 정확한 지점에서 만들어내는 도구이지, 그 실패를 호출부가 알아서 분류하고 관측 가능하게 만들어준다는 보장까지 하지는 않는다. 파싱 경계를 만드는 것과, 그 경계가 낸 에러를 끝까지 구분해서 다루는 것은 별개의 규율이다. (이 지점은 지금도 다듬을 여지로 남아 있다 — 아래 마무리에서 다시 언급한다.)
실패의 치명도에 따라 중단·안내·폴백을 선택한다
파싱을 시작하면 이전에는 뒤늦은 런타임 오류로 나타났던 계약 위반을 경계에서 명시적으로 발견할 수 있다. 그다음 선택은 실패의 성격에 따라 달라진다.
const result = loginResponseSchema.safeParse(rawBody);
if (!result.success) {
reportError(result.error);
return { status: "error", message: "로그인 응답을 처리하지 못했습니다." };
}
const data = result.data; // LoginResponse- 인증·결제·주문처럼 잘못된 값으로 계속 진행하면 위험한 흐름은
.parse()로 실패를 던지거나,safeParse()의 실패 분기에서 즉시 중단한다. - 폼 입력처럼 실패가 예상 가능한 사용자 상태인 경우에는
safeParse()로 성공과 실패를 값으로 받아 필드 오류를 보여주는 편이 자연스럽다. - 프로필 문구처럼 비핵심 표시 데이터인 경우에는 기본값으로 폴백할 수 있다. 다만 계약 위반을 조용히 숨기지 않도록 반드시 로깅이나 모니터링을 함께 둔다.
중요한 것은 항상 예외를 던지는 것이 아니라, 파싱 실패를 식별 가능한 상태로 만든 뒤 의도적으로 중단·안내·폴백 중 하나를 선택하는 것이다.
마무리 — 파싱은 신뢰 경계를 만든다
as는 컴파일러에게 하는 약속이지 런타임에 대한 약속이 아니다. API 경계처럼 내 코드가 통제할 수 없는 값이 들어오는 지점에서는 그 약속이 지켜지는지 아무도 확인해주지 않는다. zod.parse()로 바꾸면 그 확인을 런타임이 대신 해주고, 통과한 값만 타입으로 승격시킨다 — Alexis King이 말한 "정보를 보존하는 파싱"이다.
다만 이번에 다시 확인한 건, 파싱을 경계에 세우는 것과 그 경계가 낸 실패를 끝까지 구분해서 다루는 건 다른 문제라는 점이다. ZodError와 진짜 네트워크 실패가 같은 catch 블록에서 같은 메시지로 뭉개질 수 있다는 건, "경계에서 파싱한다"는 원칙 하나만으로는 다 해결되지 않는 영역이 있다는 뜻이다. 다음 단계는 아마 에러 타입별로 사용자에게 보여줄 메시지와 로깅 레벨을 명시적으로 나누는 일이 될 것 같다 — 이건 아직 다 풀지 못한 숙제로 남겨둔다.
실제로 이 글을 쓰고 두 달 사이, 로그인 액션에는 ApiRequestError의 code를 보고 TOTP_REQUIRED/INVALID_TOTP_CODE만 따로 분기해 처리하는 코드가 이미 붙었다 — 후속 작업에서 에러를 구분하는 방향으로 한 걸음 나아간 셈이다. 다만 이건 한 케이스에 대한 예외적 분기일 뿐, 에러 타입 전반을 체계적으로 나누는 일은 여전히 남아 있다.
API 응답을 받는 곳에 as가 있다면, 한 번만 물어보자. 이게 지금 백엔드가 실제로 준 값이라는 걸 누가 확인했는가.