프론트에서 interval=1을 보냈는데 서버는 계속 10분 간격 데이터를 반환했다. 스웨거(Swagger)에서 같은 값을 넣으면 1분 데이터가 정상적으로 내려왔고, 네트워크 탭을 보면 요청 URL에도 interval=1이 들어 있었다. 프론트는 값을 보냈고 백엔드는 그 값을 지원했지만, 둘을 연결하면 동작하지 않았다. 원인은 프론트와 백엔드 사이에 있던 Zod 스키마 한 줄이었다.
이 동작을 이해하려면 Zod가 무엇이고 z.object가 내부적으로 어떻게 동작하는지 알아야 한다. 개념을 먼저 정리하고, 그다음 버그를 설명한다.
> 버전 안내
> 이 글의 예제는 Zod 4 기준이다. Zod 3에서 넘어온 독자를 위해 차이가 있는 지점마다 구버전 API를 함께 표기했다. 이 글의 핵심 주제인 "z.object의 기본 동작은 strip"은 Zod 3과 4에서 동일하다. import 문도 Zod 4에서는 import * as z from 'zod'가 권장 형태이며, 기존 import {z} from 'zod'도 그대로 동작한다.
Part 1. Zod란 — 런타임 타입 검증
Zod는 런타임에 데이터를 검증하고, 그 스키마로부터 타입을 생성하는 라이브러리다. 핵심은 "런타임"이다.
TypeScript의 타입은 컴파일이 끝나면 사라진다. interface User나 age: number 같은 정보는 .ts → .js 변환 과정에서 제거되고, 실제로 실행되는 것은 타입이 없는 자바스크립트다. 즉 타입은 작성 시점의 도구일 뿐, 실행 시점에 값을 보장하지 않는다.
이 때문에 API 응답, URL 쿼리스트링, 폼 입력, 환경변수처럼 코드 바깥에서 들어오는 데이터는 문제가 된다. 서버가 age: number를 주다가 age: "30"(문자열)을 줘도 TypeScript는 컴파일 타임에 알 수 없다. 런타임에 값을 검사하지 않으면 잘못된 값이 그대로 코드 내부로 들어간다.
Zod는 런타임에 값을 검사하는 역할을 한다. 스키마를 정의하면 실제 값이 그 형태인지 실행 중에 확인하고, 스키마에서 TypeScript 타입도 생성한다.
import * as z from 'zod';
const User = z.object({
id: z.number(),
name: z.string(),
email: z.email(), // Zod 3: z.string().email()
age: z.number().min(0).optional(),
role: z.enum(['admin', 'user']),
tags: z.array(z.string()),
});
// 스키마에서 타입 생성
type User = z.infer<typeof User>;
// {
// id: number;
// name: string;
// email: string;
// age?: number | undefined;
// role: 'admin' | 'user';
// tags: string[];
// }
z.infer를 쓰면 스키마만 고쳐도 타입이 따라오므로, 타입 선언과 검증 로직이 어긋나는 문제가 없다.
> Zod 4에서 z.string().email() 같은 문자열 포맷 메서드는 z.email(), z.url(), z.uuid() 같은 최상위 함수로 옮겨졌다. 기존 메서드도 동작하지만 deprecated 상태다.
검증 방법은 두 가지다. parse()는 값이 틀리면 예외를 던지고, safeParse()는 성공/실패를 담은 객체를 반환한다.
// parse: 틀리면 throw
try {
const user = User.parse(data);
} catch (e) {
if (e instanceof z.ZodError) {
console.log(e.issues);
// [{
// origin: 'string',
// code: 'invalid_format',
// format: 'email',
// pattern: '/^(?!\\.)...$/', // 실제로는 전체 정규식이 담긴다
// path: ['email'],
// message: 'Invalid email address'
// }, ...]
}
}
// safeParse: throw 대신 결과 객체
const result = User.safeParse(data);
if (result.success) {
console.log(result.data); // 타입: User
} else {
console.log(result.error.issues);
}
ZodError의 issues 배열에는 어떤 경로(path)의 값이 왜(message, code) 틀렸는지가 구조적으로 담긴다. 폼 검증에서 필드별 에러를 뿌릴 때 이 구조를 그대로 쓸 수 있다.
> Zod 3에서 이메일 검증 실패의 code는 invalid_string이었지만, Zod 4에서는 포맷 검증이 invalid_format으로 통합되고 format 필드가 추가됐다. code 값으로 분기하는 코드가 있다면 마이그레이션 시 확인이 필요하다.
Part 2. 왜 쓰는가 — Parse, don't validate
Zod의 목적은 단순 검증이 아니라, 경계에서 데이터를 한 번 걸러내면 이후 코드가 값을 신뢰할 수 있게 만드는 것이다.
다음 코드는 흔한 패턴이다.
const data = await res.json();
const user = data as User;
as User는 컴파일러에게 타입을 단언할 뿐, 실제 값이 다른지 검사하지 않는다. 서버가 name을 누락하면 이 지점은 통과하고, 멀리 떨어진 user.name.toUpperCase()에서 Cannot read properties of undefined가 발생한다. 에러가 원인(잘못된 응답)에서 먼 곳에서 나기 때문에 디버깅이 어려워진다.
parse()는 데이터가 들어오는 경계에서 검증한다.
const user = User.parse(await res.json());
// 통과하면 user는 User이며, 이후 코드는 user.name이 string임을 신뢰할 수 있다.
이 방식을 "Parse, don't validate"라고 부른다. 값이 유효한지 여러 곳에서 반복해서 검사(validate)하는 대신, 경계에서 한 번 파싱해 신뢰 가능한 타입의 값으로 변환하고 이후에는 그대로 쓴다. 검증 책임이 한 곳에 모이므로 나머지 코드가 단순해진다.
> 이 표현은 Alexis King이 2019년에 쓴 Parse, don't validate에서 나왔다. 원문은 Haskell 맥락에서 "검사 결과를 boolean으로 버리지 말고, 더 좁은 타입으로 변환해 정보를 보존하라"는 이야기다. Zod의 parse()는 이 아이디어를 TypeScript에 옮겨온 형태로 볼 수 있다.
실무에서 Zod가 쓰이는 지점은 대부분 이 경계다.
- API 응답 검증:
fetch결과를Schema.parse()로 통과시켜 신뢰 가능한 값으로 변환. - 폼 입력:
react-hook-form+@hookform/resolvers/zod로 스키마 = 검증 = 타입. - 환경변수:
z.object({ DATABASE_URL: z.url() }).parse(process.env)로 부팅 시점에 검증. - URL 쿼리스트링: 이번 버그가 발생한 지점이다.
Part 3. z.object의 기본 동작 — strip
핵심 개념이다. z.object로 만든 스키마는 기본적으로 스키마에 정의되지 않은 키를 결과에서 제거한다.
const S = z.object({a: z.string()});
const out = S.parse({a: 'x', b: 'y'});
// out === { a: 'x' } // b 제거됨
에러나 경고 없이 b를 제거한다. 이 동작을 Zod에서는 strip이라 하며, z.object의 기본값이다. Zod 3과 Zod 4 모두 동일하다.
여기서 중요한 디테일이 하나 있다. parse()는 입력 객체를 수정하지 않는다. 원본 {a:'x', b:'y'}는 그대로 두고, 알려진 키만 담은 새 객체 {a:'x'}를 반환한다. 따라서 "넘긴 객체에서 b가 지워졌다"가 아니라 "parse가 반환한 결과에 b가 없다"가 정확하다. 이후 로직에서 사용하는 것은 원본이 아니라 이 반환값이다. 이 점이 버그에서 그대로 작용한다.
strip이 기본값인 이유는 있다. 검증 스키마를 화이트리스트로 보면, 명시적으로 허용한 필드만 통과시키는 것은 안전한 기본값이다. 예를 들어 폼 데이터를 DB에 저장할 때 사용자가 끼워넣은 isAdmin: true 같은 필드를 자동으로 제거하는 것은 바람직하다. 문제는 이 제거가 통과시켜야 할 값에도 적용될 때 발생한다.
이 동작은 세 가지로 설정할 수 있다.
// 기본 = strip: 모르는 키 제거
z.object({a: z.string()});
// loose: 모르는 키 유지
z.looseObject({a: z.string()});
// parse({a:'x', b:'y'}) → { a:'x', b:'y' }
// strict: 모르는 키가 있으면 에러
z.strictObject({a: z.string()});
// parse({a:'x', b:'y'}) → ZodError
// [{ code: 'unrecognized_keys', keys: ['b'], path: [], message: 'Unrecognized key: "b"' }]
// 모르는 키가 여러 개면 keys 배열에 모두 담기고 message는 복수형이 된다.
// 예: Unrecognized keys: "b", "c"
> Zod 3에서는 같은 동작을 .passthrough()와 .strict() 메서드로 지정했다. Zod 4에서 이 메서드들은 legacy로 분류되어 계속 동작하지만, 새로 작성하는 스키마는 z.looseObject() / z.strictObject() 최상위 생성자를 쓰는 것이 권장된다. 키별로 더 세밀하게 제어하려면 .catchall(z.unknown())(loose와 동일), .catchall(z.never())(strict와 동일)을 쓸 수 있다.
strip은 관대하지만 조용하고, strict는 엄격하고 명시적이다. 어느 쪽이 맞는지는 그 경계가 데이터를 소비하는지, 전달하는지에 달려 있다.
- 소비 경계 — 값을 받아 DB에 저장하거나 도메인 로직에 넣는 지점. 여기서는 화이트리스트가 의미를 가지므로
strip또는strictObject가 맞다. 모르는 필드가 들어오는 것 자체가 계약 위반이라면strictObject가 실수를 조기에 드러낸다. - 전달(릴레이) 경계 — 프록시나 BFF처럼 받은 값을 다른 서버로 넘기는 지점. 여기서
strip을 쓰면 스키마에 없는 파라미터가 조용히 사라진다. 통과시킬 필드를 모두 열거할 자신이 없다면looseObject가 안전하다.
이번 버그는 정확히 전달 경계에 소비 경계용 기본값을 쓴 사례다.
Part 4. 실전 버그 — 제거된 interval
예를 들어, 하루치 데이터를 분 단위 간격으로 조회하는 차트 API가 있다. 기본값은 10분이고, 요청에 interval=1을 넣으면 1분 간격 데이터가 내려오는 스펙이다. 프론트에서 값만 바꿔 보내면 되는 작업으로 보였다.
프론트를 수정해도 계속 10분 데이터가 왔다. 가설을 하나씩 확인했다.
가설 1: API가 interval을 지원하지 않는다.
스웨거에서 interval=1로 직접 실행했다. 1분 데이터가 정상적으로 내려왔다. 백엔드는 이 파라미터를 지원한다. 백엔드 문제가 아니다.
가설 2: 프론트가 값을 보내지 않는다.
개발자도구 Network 탭에서 요청 URL을 확인했다. interval=1이 들어 있었다. 프론트도 정상이다.
가설 3: react-query가 캐시된 데이터를 반환한다.
데이터 훅은 useQuery를 쓰는데 queryKey에 interval이 빠져 있었다. 캐시 키가 같으면 값이 바뀌어도 리페치를 하지 않으므로, 이것만으로도 같은 증상이 나올 수 있다. 실제로 존재하는 버그였고, 가장 유력한 후보였다.
이 가설은 하드 리로드로 배제했다. 페이지를 새로 띄우면 react-query 캐시는 메모리에 있으므로 완전히 비어 있고, 첫 마운트에서는 queryKey가 무엇이든 캐시 히트가 발생할 수 없다. 즉 반드시 새 요청이 나간다. 그럼에도 응답은 10분이었으므로, 캐시는 이 증상의 원인이 아니다. (queryKey 누락은 "값을 바꿔도 갱신되지 않는다"는 별개의 버그이므로 따로 수정했다. 원인이 두 개일 때 하나를 고치고 증상이 남으면 첫 번째 수정이 틀렸다고 판단하기 쉬운데, 이 경우가 그랬다.)
여기서 정리된 사실은 이렇다. 요청 URL에는 interval=1이 있는데 응답은 10분이다. 프론트는 값을 보냈고 백엔드는 값을 지원한다. 따라서 값은 프론트와 백엔드 사이에서 제거되고 있다. 중간 레이어를 확인할 차례다.
이 구조에는 중간 레이어가 있다. 프론트가 백엔드를 직접 호출하지 않고 BFF(Backend for Frontend) 성격의 프록시 라우트를 거친다. (인증 처리, 응답 가공, 내부 API 은닉 등의 이유로 두는 구조다.) 프록시 핸들러는 다음과 같은 형태였다.
export async function getChart(req, reply) {
const query = ChartQuery.parse(req.query ?? {}); // 원인 지점
const data = await fetchChart(query); // 검증된 query를 백엔드로 전달
reply.send(data);
}
핸들러는 들어온 쿼리를 ChartQuery.parse()로 검증한 뒤 그 결과(query)를 백엔드로 넘긴다. 스키마는 다음과 같았다.
const ChartQuery = z.object({
type: z.string().min(1),
category: z.string().min(1),
date: z.string().min(1),
startDate: z.string().min(1),
endDate: z.string().min(1),
period: z.string().min(1),
// interval 없음
});
Part 3의 동작이 그대로 적용된다. 프론트가 interval=1을 프록시로 보내면 parse(req.query)가 실행되고, 스키마에 interval이 없으므로 Zod가 strip으로 제거한 새 객체를 반환한다. 핸들러는 원본이 아니라 이 반환값을 백엔드로 넘기므로, interval이 빠진 채 전달된다. 백엔드는 값을 받지 못해 기본값 10분을 반환한다.
스웨거가 동작한 이유도 여기서 설명된다. 스웨거는 이 프록시를 거치지 않고 백엔드를 직접 호출한다. Zod 검증이 없으므로 interval=1이 백엔드까지 도달한다. "스웨거는 되고 프론트는 안 된다"는 것은 두 경로가 다르고, 프론트 경로에만 이 검증 레이어가 있다는 의미였다.
[프론트] interval=1 → [프록시: Zod.parse → interval 제거] → [백엔드: interval 없음 → 10분]
[스웨거] interval=1 ──────────────── 직통 ────────────────→ [백엔드: interval=1 → 1분]
Part 5. 해결과 정리
수정은 한 줄이다. 스키마에 interval을 추가한다.
const ChartQuery = z.object({
type: z.string().min(1),
category: z.string().min(1),
date: z.string().min(1),
startDate: z.string().min(1),
endDate: z.string().min(1),
period: z.string().min(1),
interval: z.coerce.number().int().positive().optional(), // 추가
});
z.coerce.number()를 쓴 이유는 URL 쿼리스트링의 값이 항상 문자열이기 때문이다. ?interval=1은 1이 아니라 "1"로 들어온다. z.number()로 받으면 문자열이라 검증에 실패한다. z.coerce.number()는 내부적으로 Number(value)를 실행해 "1"을 1로 변환한 뒤 검증한다.
z.coerce에는 주의점이 있다. Number()는 빈 문자열을 0으로, 잘못된 문자열을 NaN으로 변환한다. 즉 아무 제약 없이 z.coerce.number().optional()만 쓰면 ?interval=(빈 값)이 0으로 조용히 통과한다. 위 스키마에 붙인 .int().positive()가 이 경우를 막는다. 빈 문자열은 0으로 변환되지만 .positive()가 0을 걸러내 too_small 에러가 나므로, 의미 없는 0이 백엔드로 전달되지 않는다.
.optional()의 위치도 짚어둘 만하다. z.coerce.number().optional()은 Number(undefined)가 NaN인데도 값이 없는 요청을 정상 통과시킨다. .optional()이 감싼 바깥쪽에서 undefined를 먼저 걸러내고 내부 스키마를 아예 호출하지 않기 때문이다. 반면 .default(10)을 붙이면 기본값이 프록시에서 채워져 백엔드로 전달되므로, "값이 없으면 백엔드 기본값을 쓴다"는 의도와 달라진다. 프록시에서는 값을 만들어내지 않고 그대로 흘려보내는 쪽이 안전하다.
정리하면 세 가지다.
첫째, 검증 스키마는 화이트리스트다. z.object는 스키마에 정의되지 않은 값을 그 지점에서 제거한다. 프론트와 백엔드 사이에 프록시/BFF가 있고 거기서 요청을 검증한다면, "요청은 정상인데 백엔드에는 값이 도달하지 않는" 문제가 생긴다. 파라미터를 end-to-end로 추가할 때는 그 값이 지나는 모든 레이어(프론트 → 프록시 스키마 → 백엔드)가 그 필드를 알고 있어야 한다.
둘째, 경계의 성격에 따라 모드를 고른다. 이번 사례에서 스키마가 z.strictObject였다면 모르는 키가 들어온 순간 에러가 발생해 문제를 즉시 알 수 있었다. 다만 프록시가 릴레이하는 경계임을 감안하면, 애초에 z.looseObject를 썼다면 버그 자체가 발생하지 않았다. 정리하면 값을 소비하는 경계에서는 strict가 실수를 조기에 드러내고, 값을 전달하는 경계에서는 loose가 누락을 막는다. 이번 코드는 전달 경계에 소비 경계용 기본값(strip)을 쓴 것이 문제였다. 물론 프록시가 전달할 파라미터를 명시적으로 통제하겠다는 판단이라면 strip을 유지하고 필드를 추가하는 이번 수정이 맞다. 중요한 것은 그 선택이 의식적이어야 한다는 점이다.
셋째, 요청에는 있는데 응답에 반영되지 않으면 중간 레이어를 확인한다. Network 탭에서 요청 파라미터가 정상으로 보이면 문제는 프론트가 아니다. 프록시, 검증 스키마, 어댑터, 캐시 등 요청이 최종 목적지에 도달하기 전에 거치는 지점을 순서대로 확인한다. 스웨거(직통)와 프론트(프록시 경유)의 경로 차이가 중간 레이어를 지목하는 단서였다.
Zod는 런타임의 빈틈을 메우고 타입과 검증을 하나로 묶는 유용한 도구다. 다만 z.object의 기본 동작이 strip이라는 점을 모르면 이번과 같은 문제를 겪는다. 도구가 기본값으로 무엇을 하는지 아는 것이 몇 시간의 디버깅과 한 줄의 수정을 가른다.
