Zod v4, `.errors`는 사라지고 coerce 타입은 `unknown`이 됐다 — 프로덕션 코드베이스를 단계별로 안전하게 옮기는 법
2025년 7월부터 npm install zod를 실행하면 v4가 설치됩니다. 메이저 버전 변경이라 뭔가 크게 다르겠다 싶었는데, 막상 설치하고 나면 기존 TypeScript 코드 대부분이 컴파일 오류 없이 그대로 동작합니다. 그런데 얼마 지나지 않아 API 에러 응답이 빈 객체로 내려가는 걸 발견했습니다. 로그에도 조용하고, TypeScript도 불평 하나 없이 말입니다.
이 글에서 다룰 핵심 변경 사항은 세 가지입니다. ZodError.errors 프로퍼티가 제거되어 .issues로 교체되었고 — 특정 패턴에서는 TypeScript 오류 없이 런타임에서 undefined를 반환합니다. z.coerce.*의 input 타입이 string에서 unknown으로 바뀌어 react-hook-form 같은 폼 라이브러리와 연동 시 타입 충돌이 생깁니다. 그리고 z.string().trim().email() 체이닝 패턴이 더 이상 동작하지 않아 .pipe(z.email())로 재구성해야 합니다.
v3를 계속 쓰고 싶다면 zod@3으로 명시해서 설치하면 됩니다. 다만 v4의 성능 향상(객체 safeParse 6.5배, 문자열 파싱 14배)과 내장 JSON Schema 변환, TypeScript 빌드 속도 개선은 꽤 매력적입니다. 무엇부터 어떤 순서로 처리하면 좋을지 살펴보겠습니다.
핵심 개념
.coerce — input 타입이 string에서 unknown으로
v3에서 z.coerce.number()의 input 타입은 string으로 추론되었습니다. 폼 데이터가 항상 문자열로 들어온다는 가정이 반영된 건데, 실제로 coerce는 어떤 값이든 받아서 변환을 시도하기 때문에 string은 사실 부정확한 표현이었습니다. v4에서 이 부분이 수정되어 z.input<typeof schema>가 unknown을 반환합니다.
// v3: input 타입이 string으로 추론됨
const schemaV3 = z.coerce.number();
type V3Input = z.input<typeof schemaV3>; // string
// v4: input 타입이 unknown으로 변경
const schemaV4 = z.coerce.number();
type V4Input = z.input<typeof schemaV4>; // unknown타입이 더 정확해진 건 맞는데, 기존 코드에서 z.input<typeof schema>를 string으로 가정하고 사용하던 부분이 있다면 TypeScript 오류가 새로 발생합니다. react-hook-form의 zodResolver와 연동할 때 이 변경이 특히 문제가 됩니다.
ZodError 구조 개편 — .errors가 어디서 조용히 사라지나
이 변경이 가장 위험합니다.
ZodError.errors는 v4에서 제거되었습니다. 강타입 코드(safeParse()반환값)에서는 TypeScript 컴파일 오류로 즉시 잡히지만,catch (e)나err: any패턴에서는 런타임에서야undefined가 반환되어 빈 에러 응답을 만듭니다.
핵심은 이렇습니다. safeParse()를 쓰면 result.error가 강타입 ZodError로 추론되어 .errors 접근 시 컴파일 오류가 납니다 — 이쪽은 TypeScript가 잡아줍니다. 문제는 .parse()를 try-catch로 감싸거나 Express 에러 미들웨어처럼 any 타입이 끼어드는 경우입니다.
// ✅ 강타입 코드 — 컴파일 타임에 잡힘
const result = userSchema.safeParse(req.body);
if (!result.success) {
result.error.errors; // v4에서 TypeScript 컴파일 오류 발생 → 즉시 발견 가능
}
// ❌ 위험한 패턴 — TypeScript가 조용히 넘어감
try {
userSchema.parse(req.body);
} catch (e) {
// e는 unknown → TypeScript가 .errors를 검사하지 못함
const zodErr = e as ZodError;
console.log(zodErr.errors); // v4에서 undefined 반환 — 런타임 버그
}
// ❌ Express 에러 미들웨어 패턴
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err instanceof ZodError) {
return res.status(400).json({ errors: err.errors }); // undefined 반환
}
});에러 핸들링 관련 변경 사항을 정리하면 이렇습니다.
| v3 | v4 | 비고 |
|---|---|---|
error.errors |
error.issues |
프로퍼티 제거 |
error.formErrors |
z.flattenError(error).formErrors |
직접 사용 시 교체 필요 |
error.format() |
z.treeifyError(error) |
deprecated → 독립 함수 |
error.flatten() |
z.flattenError(error) |
deprecated → 독립 함수 |
v4에서 올바른 방식은 이렇습니다.
if (result.error) {
console.log(result.error.issues); // ✅ 드롭인 대체 — ZodIssue[] 반환
console.log(z.flattenError(result.error)); // ✅ { fieldErrors, formErrors }
console.log(z.treeifyError(result.error)); // ✅ 중첩 트리 구조
}treeifyError()의 출력 구조는 v3의 format()과 다릅니다. _errors 키가 errors로 바뀌고, 중첩 필드가 properties 하위로 들어갑니다.
const schema = z.object({ email: z.email() });
const result = schema.safeParse({ email: "not-valid" });
// v3: error.format()
// {
// "_errors": [],
// "email": { "_errors": ["Invalid email"] }
// }
// v4: z.treeifyError(result.error)
// {
// "errors": [],
// "properties": {
// "email": { "errors": ["Invalid email"] }
// }
// }클라이언트에서 에러 응답을 파싱하는 코드가 있다면 _errors → errors, fieldName → properties.fieldName 구조 변경을 함께 업데이트해야 합니다.
z.string().trim().email() — 체인이 끊어진 이유
v4에서 .trim(), .toLowerCase(), .toUpperCase()는 내부적으로 .overwrite()로 재구현되었습니다. 동시에 z.string().email()이 deprecated되고 z.email()이라는 독립 함수로 분리되었습니다.
이 두 가지가 맞물리면서 기존의 z.string().trim().email() 패턴은 더 이상 동작하지 않습니다. .trim()이 내부적으로 값을 변환한 뒤 그 결과를 별도의 z.email() 스키마로 검증하는 구조, 즉 .pipe()로 연결해야 합니다.
// v3
const emailField = z.string().trim().toLowerCase().email("이메일 형식 오류");
// v4 — .pipe()로 변환 후 검증을 명시적으로 분리
const emailField = z
.string()
.trim()
.toLowerCase()
.pipe(z.email("이메일 형식 오류"));.pipe()를 쓰면 변환(transform)과 검증(validate)의 경계가 명시적으로 드러나서 오히려 의도가 더 명확해집니다. z.preprocess()도 대안이지만, 입력 타입이 string이 아닐 수 있는 경우처럼 변환 로직을 더 세밀하게 제어해야 할 때 씁니다. 대부분의 경우 .pipe()로 충분합니다.
실전 적용
시나리오 1 — API 엔드포인트의 에러 응답이 비어있다
가장 먼저 처리해야 할, 그리고 가장 찾기 어려운 문제입니다. v3에서 흔히 볼 수 있는 Express 에러 미들웨어 패턴입니다.
// v3 패턴 — any 타입 에러 핸들러에서 조용히 망가짐
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err instanceof ZodError) {
return res.status(400).json({
errors: err.errors, // ❌ v4에서 undefined 반환 — TypeScript가 잡아주지 않음
});
}
next(err);
});.errors의 진짜 드롭인 대체는 .issues입니다. 둘 다 ZodIssue[] 배열을 반환하기 때문에 클라이언트 응답 구조가 바뀌지 않습니다.
// v4 마이그레이션 — 드롭인 대체
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err instanceof ZodError) {
return res.status(400).json({
errors: err.issues, // ✅ ZodIssue[] — 클라이언트 구조 유지
});
}
next(err);
});필드별로 그룹화된 에러 객체가 필요하다면 flattenError()를 씁니다. 단, 이쪽은 응답 구조가 배열에서 객체로 바뀌기 때문에 클라이언트 코드도 함께 수정해야 합니다.
// 필드별 에러 객체가 필요한 경우 — 클라이언트 스펙 변경 동반
app.post("/users", async (req, res) => {
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: z.flattenError(result.error).fieldErrors, // { email: ["..."], age: ["..."] }
});
}
});어느 쪽을 선택할지는 클라이언트가 기대하는 에러 구조에 달려 있습니다.
시나리오 2 — react-hook-form과 coerce 타입 충돌
// v3: FormInput의 age가 string으로 추론됨
const formSchema = z.object({
age: z.coerce.number().min(0).max(120),
});
type V3FormInput = z.input<typeof formSchema>;
// { age: string }
// v4: age가 unknown으로 변경
type V4FormInput = z.input<typeof formSchema>;
// { age: unknown } ← zodResolver 구버전에서 타입 충돌 발생@hookform/resolvers v5 미만이라면 이 타입 변경으로 인해 폼 타입 충돌이 발생합니다. @hookform/resolvers@5 이상으로 올리면 v4의 unknown input 타입을 올바르게 처리합니다.
시나리오 3 — 에러 메시지 커스터마이징 API 통합
v3에서는 required_error와 invalid_type_error를 따로 지정했는데, v4에서는 error 파라미터로 통합되었습니다.
// v3
const schema = z.string({
required_error: "필수 항목입니다",
invalid_type_error: "문자열이어야 합니다",
});
// v4 — 단순 통합
const schema = z.string({
error: "문자열이어야 합니다",
});
// v4 — 함수형으로 세밀하게 제어
const schema = z.string({
error: (issue) =>
issue.input === undefined ? "필수 항목입니다" : "문자열이어야 합니다",
});이 변환은 반복적이라 codemod로 자동화할 수 있습니다.
시나리오 4 — z.record() 두 번째 인자 누락
v4에서 z.record()는 키 스키마와 값 스키마를 분리해서 명시해야 합니다. v3는 단일 인자(z.record(z.string()))를 허용했는데, v4는 키 타입을 z.string() 외에도 z.enum(), z.union() 등으로 지정할 수 있게 되면서 두 인자를 명시적으로 요구합니다.
// v3: 키 스키마 생략 가능 — 암묵적으로 string
const v3record = z.record(z.string());
// v4: 키와 값 스키마 모두 명시 필요
const v4record = z.record(z.string(), z.string()); // ✅v4에서 z.record(z.string())처럼 단일 인자로 호출하면 TypeScript 오류가 발생합니다. 컴파일 타임에 바로 눈에 보이기 때문에 상대적으로 찾기 쉬운 케이스입니다.
codemod로 반복 작업 줄이기
required_error, invalid_type_error, .format() 같은 반복 패턴은 codemod를 활용하면 좋습니다.
# 드라이런으로 먼저 확인
npx codemod@latest zod/v3-to-v4 --dry-run ./src
# 실제 적용
npx codemod@latest zod/v3-to-v4 ./srccodemod는 모호한 케이스에 TODO(zod-4-migration) 주석을 달아둡니다. 자동 변환 후 이 주석을 검색해서 수동으로 처리하면 빠뜨리는 부분을 최소화할 수 있습니다.
위험도가 높은 항목부터 처리하는 게 핵심입니다.
v4에서 추가로 얻는 것 — JSON Schema 내장 지원
JSON Schema 자동 생성은 마이그레이션 필수 항목이 아니라 선택적 개선입니다. 기존에 zod-to-json-schema 같은 외부 패키지를 쓰고 있었다면, v4에서는 z.toJSONSchema()로 대체할 수 있습니다.
const userSchema = z.object({
id: z.uuid(),
email: z.email(),
age: z.number().int().min(0),
});
const jsonSchema = z.toJSONSchema(userSchema);
// OpenAPI spec 생성에 직접 활용 가능외부 의존성을 하나 줄이는 것 외에 별다른 이유가 없다면, 이 마이그레이션은 나중에 해도 됩니다.
장단점 분석
마이그레이션 후 얻는 것
| 항목 | 내용 |
|---|---|
| 파싱 성능 | 문자열 14배, 배열 7배, 객체 safeParse 6.5배 향상 |
| TypeScript 빌드 속도 | 타입 인스턴스화 20배 감소 — 대형 모노레포에서 tsc 47초 → 5초 사례 |
| 번들 크기 | 코어 번들 2배 축소, @zod/mini 활용 시 추가 절감 |
| 에러 API 일관성 | error 파라미터로 통합되어 코드 예측 가능성 향상 |
| JSON Schema 내장 | zod-to-json-schema 외부 의존성 제거 가능 |
| Standard Schema 호환 | valibot, arktype 등과 상호 운용 기반 마련 |
마이그레이션 시 감수해야 하는 것
| 항목 | 내용 |
|---|---|
.errors 침묵 버그 |
any/unknown 타입 에러 핸들러에서 undefined 반환 — 가장 위험 |
treeifyError 구조 변경 |
_errors → errors, 중첩 필드 → properties 하위로 이동 — 클라이언트 파싱 코드 수정 필요 |
z.record() 전수 수정 |
단일 인자 패턴 모두 교체 필요 |
.optional().default() 순서 |
v4에서는 체이닝 순서에 따라 타입 추론이 달라집니다. 기본값이 실제로 적용되는지 관련 스키마를 테스트로 확인하세요. |
| 연동 라이브러리 호환성 | drizzle-zod, @hookform/resolvers, tRPC의 v4 지원 버전 확인 필수 |
실무에서 흔한 실수
실수 1 — .errors 그대로 두기
v4 설치 후 TypeScript가 조용하다고 안심하면 위험합니다. safeParse() 반환값은 컴파일 타임에 잡히지만, 에러가 any로 흘러드는 try-catch나 Express 미들웨어 패턴은 잡히지 않습니다. grep으로 전수 검색해두세요.
grep -rn "\.errors\b" ./src --include="*.ts"실수 2 — codemod만 믿고 수동 검토 생략하기
codemod는 기계적으로 반복되는 패턴을 잘 처리하지만, 런타임에서만 드러나는 타입 흐름 변경은 잡아내지 못합니다. z.coerce.*의 input 타입 변경이나 treeifyError() 출력 구조 변화는 직접 확인이 필요합니다.
실수 3 — 연동 라이브러리 버전 확인 생략
zod 자체는 v4로 올렸는데 @hookform/resolvers가 구버전이라 폼이 이상하게 동작하는 경우가 있습니다. 특히 coerce 필드가 포함된 폼에서 타입 추론이 꼬이는 양상으로 나타납니다. zod v4 업그레이드 시 @hookform/resolvers, drizzle-zod, tRPC의 릴리즈 노트를 함께 확인하세요.
마치며
v4의 핵심 변경 사항을 요약하면 이렇습니다. error.errors는 error.issues로 교체해야 하며, any 타입 에러 핸들러에서 런타임 버그를 만들기 때문에 가장 먼저 처리하는 것이 중요합니다. z.coerce.*의 input 타입이 unknown으로 바뀌어 폼 라이브러리 연동 시 버전 확인이 필요합니다. z.string().trim().email() 체인은 .pipe(z.email())로 재구성해야 하고, 이 패턴은 타입 오류로 즉시 발견됩니다.
지금 시작할 수 있는 세 단계입니다.
grep -rn "\.errors\b" ./src로.errors사용처를 모두 찾아error.issues와z.flattenError()/z.treeifyError()로 교체하세요.npx codemod@latest zod/v3-to-v4 --dry-run ./src로 드라이런을 먼저 실행해서 codemod가 어떤 패턴을 자동 처리하는지 확인하세요.@hookform/resolvers,drizzle-zod등 연동 라이브러리의 v4 호환 버전을 함께 올려 타입 충돌을 미리 방지하세요.
참고 자료
- Zod v4 공식 릴리즈 노트
- Zod v4 마이그레이션 가이드
- Zod v4 에러 포맷팅 공식 문서
- Zod JSON Schema 공식 문서
- Migrating to Zod 4: The Complete Guide (DEV Community)
- Lessons from Upgrading a Large TypeScript App to Zod 4 (Viget)
- Lessons Learned Migrating a Large Production Codebase from Zod v3 to v4 (Hashnode)
- Zod v4 Migration Guide for TypeScript Validation (BuildMVPFast)
- ZodError.errors removed in v4 — GitHub Issue #5063
- z.string().trim().email() deprecated issue — GitHub Issue #4850
- How to trim an email address with Zod v4? — GitHub Issue #4642
- Zod v4 Available with Major Performance Improvements — InfoQ
- Here's why everyone's going crazy over Zod 4 (LogRocket Blog)
- nicoespeon/zod-v3-to-v4 codemod (GitHub)
- Zod 3 to 4 Migration — Codemod.com
- Zod v3 Compatibility and Migration — DeepWiki