Zod 스키마 하나로 TypeScript 타입 추론, 런타임 검증, OpenAPI 문서를 한 번에 해결하는 Fastify 5 서버 구조
TypeScript REST API 프로젝트가 어느 정도 궤도에 오르면 세 파일이 조금씩 어긋나는 시점이 찾아옵니다. Zod 스키마에 필드를 하나 추가했는데 OpenAPI YAML 업데이트를 잊었거나, 타입 정의는 바꿨는데 검증 로직이 뒤처지는 식입니다. 이 드리프트가 실제 버그로 이어지기 전에 PR 리뷰에서 잡아내려면 팀 전체가 세 곳을 동시에 확인하는 습관을 가져야 합니다. 그 비용은 생각보다 큽니다.
Fastify 5와 @fastify/type-provider-zod의 조합은 이 문제를 구조적으로 차단합니다. Zod 스키마를 한 번만 작성하면, 컴파일 타임에 TypeScript 타입이 자동 추론되고, 런타임에 HTTP 요청 데이터가 검증되며, Swagger UI에서 열람할 수 있는 OpenAPI 3.x 문서가 자동으로 생성됩니다. 코드 생성 단계도, 별도 설정 파일도 필요 없습니다.
이 글에서는 이 세 가지가 실제로 어떤 원리로 연결되는지, 그리고 실무에서 바로 쓸 수 있는 셋업 패턴을 다뤄보겠습니다. 이미 TypeScript와 Zod에 어느 정도 익숙한 백엔드 개발자를 대상으로 합니다. Fastify를 처음 접하더라도 Express 경험이 있다면 충분히 따라올 수 있습니다.
핵심 개념
Fastify 5: 레거시를 걷어낸 고성능 프레임워크
Fastify v5는 2024년 9월 Fastify 팀이 직접 출시했습니다. OpenJS Foundation은 거버넌스 기반을 제공하는 역할이고, 릴리스를 주도한 것은 프레임워크 팀 자체입니다. v5는 기능을 잔뜩 추가한 메이저 버전이 아니라, 누적된 deprecated API를 제거하고 Node.js v20 이상의 최신 환경에 맞게 최적화한 "기반 다지기" 릴리스라는 평가가 지배적입니다.
성능 면에서는 JSON 직렬화에 fast-json-stringify를 사용하고, JSON Schema 기반의 컴파일 타임 직렬화/역직렬화 구조 덕분에 Express 대비 처리량이 크게 높습니다. 공식 벤치마크 기준으로 단일 코어에서의 req/s와 레이턴시 모두 Express를 크게 앞서며, npm 주간 다운로드는 수백만 건, 기여자는 수백 명 규모로 생태계의 안정성도 검증됐습니다.
@fastify/type-provider-zod: 공식 지원 어댑터
이 패키지는 원래 turkerdev/fastify-type-provider-zod라는 별도 저장소로 시작했습니다. 사용자가 워낙 많아지다 보니 Fastify 공식 조직(fastify/)으로 저장소가 통째로 이관(transfer)됐고, 현재는 1급 지원(first-class support) 상태입니다. fork처럼 원본이 별도로 남아있는 게 아니라, 저장소 자체가 공식 네임스페이스로 옮겨온 것입니다. 사실상 Zod + Fastify 조합의 de facto 표준입니다.
이 라이브러리가 하는 일을 한 문장으로 요약하면, Zod 스키마를 Fastify의 타입 시스템과 OpenAPI 파이프라인에 연결하는 어댑터입니다. 주요 export를 보면 역할이 선명하게 나뉩니다:
import {
ZodTypeProvider, // 타입 레벨 연결 — withTypeProvider<ZodTypeProvider>()
validatorCompiler, // 런타임 검증
serializerCompiler, // 응답 직렬화
jsonSchemaTransform, // 라우트별 Zod → JSON Schema 변환
jsonSchemaTransformObject, // 앱 전체 스펙 변환
FastifyPluginAsyncZod, // 플러그인 타입 헬퍼 (withTypeProvider 대체)
} from "@fastify/type-provider-zod";Zod 스키마 하나, 세 가지 출력
핵심 철학을 도식화하면 이렇습니다:
flowchart LR
Z["Zod 스키마"]
Z --> T["TypeScript 타입 추론"]
Z --> V["런타임 검증"]
Z --> O["OpenAPI 스펙 생성"]
T --> DX["타입 안전한 핸들러"]
V --> E["400 에러 자동 응답"]
O --> S["Swagger UI 렌더링"]정적 타입 추론은 withTypeProvider<ZodTypeProvider>()로 라우트를 감싸는 순간 작동합니다. 핸들러 안에서 req.body를 찍으면 Zod 스키마에서 추론된 TypeScript 타입이 자동으로 붙고 IDE 자동완성도 됩니다.
런타임 검증은 validatorCompiler가 담당합니다. HTTP 요청이 들어오면 Fastify가 body, querystring, params, headers를 각각 해당 Zod 스키마로 파싱합니다. 검증 실패 시 자동으로 400 응답이 반환됩니다.
OpenAPI 스펙 생성은 jsonSchemaTransform이 각 라우트의 Zod 스키마를 JSON Schema로 변환하고, @fastify/swagger가 이를 OpenAPI 3.x 문서로 조립합니다. 별도 YAML 파일이나 데코레이터 없이 라우트 정의 자체가 문서의 소스가 됩니다.
코드 생성 단계가 없다는 점이 핵심입니다. tRPC나 OpenAPI Generator 방식처럼 빌드 파이프라인에 별도 스텝을 추가하지 않아도 됩니다. 서버가 뜨는 시점에 모든 변환이 이루어집니다.
Zod v4 지원과 달라진 점
2025년 기준 @fastify/type-provider-zod v7 이상은 Zod 4.x를 지원합니다. 주목할 변화는 다음과 같습니다.
| 항목 | v6 이하 (Zod 3) | v7 이상 (Zod 4) |
|---|---|---|
| 응답 직렬화 기준 | z.input<T> |
z.output<T> |
| 파싱 성능 | 기준 | 최대 14배 향상 |
| 전역 스키마 Registry | 미지원 | z.globalRegistry 지원 |
| OpenAPI $ref 재사용 | 제한적 | .meta({ id }) 로 자동 생성 |
| 문자열 포맷 API | z.string().email() 메서드 체인 |
z.email() 최상위 함수 (메서드 체인 deprecated) |
Zod v4에서는 z.string().email() 같은 메서드 체인 형태가 deprecated되고 z.email(), z.url(), z.uuid() 같은 최상위 함수 형태가 도입됐습니다. 이 글의 코드 예시는 모두 v4 스타일로 작성합니다.
z.input 대신 z.output 기반으로 전환된 것도 중요합니다. .transform()을 쓰는 스키마에서 입출력 타입이 다를 경우 v3에서 v4로 마이그레이션할 때 타입 오류가 생길 수 있습니다:
// transform이 있는 스키마 예시
const DateSchema = z.object({
createdAt: z.string().transform((s) => new Date(s)),
});
// Zod v3: 응답 직렬화 시 z.input 기준 → string 타입 기대
// Zod v4: 응답 직렬화 시 z.output 기준 → Date 타입 기대
// → v4에서 serializerCompiler가 Date 객체를 응답에 그대로 넣으려 하면
// JSON 직렬화 과정에서 타입 불일치 오류 발생v3에서 v4로 올릴 때 .transform()이 있는 응답 스키마는 반드시 재검토하세요.
언제 쓸까?
기술 스택 선택을 먼저 정리하는 게 중요합니다. Fastify + Zod가 모든 상황에 맞는 선택은 아닙니다.
flowchart TD
Start["REST API + TypeScript 시작"]
Q1{"클라이언트도 TypeScript 전용?"}
Q2{"OpenAPI 문서가 필요한가?"}
Q3{"엣지 런타임이 필요한가?"}
Start --> Q1
Q1 -->|"예"| tRPC["tRPC\n최강 타입 안전성"]
Q1 -->|"다양한 클라이언트"| Q2
Q2 -->|"불필요"| Q3
Q3 -->|"예"| Hono["Hono + Zod\n경량 엣지 친화적"]
Q3 -->|"아니오"| Fastify["Fastify 5 + type-provider-zod\n성능 + 문서 자동화"]
Q2 -->|"필요, 코드 생성 허용"| NestJS["NestJS + nestjs-swagger\n엔터프라이즈 구조"]- tRPC: 클라이언트와 서버 모두 TypeScript일 때 타입 안전성이 가장 강력합니다. 외부 팀이나 브라우저 외 클라이언트와 연동이 필요하면 OpenAPI가 필요한 시점이 옵니다.
- Hono + Zod: Cloudflare Workers, Deno Deploy 같은 엣지 런타임 환경에 적합합니다. 번들 크기가 중요한 경우.
- NestJS: 데코레이터 기반 엔터프라이즈 구조를 선호하거나, 이미 팀이 NestJS 생태계에 익숙한 경우.
- Fastify 5 + type-provider-zod: OpenAPI 문서가 필요하고, 코드 생성 없이 단일 진실 공급원을 유지하고 싶을 때. 성능도 중요한 경우.
실전 적용
기본 셋업: 세 가지 핵심 설정
Fastify 앱을 올바르게 구성하려면 컴파일러 등록 순서가 중요합니다. 처음에 validatorCompiler만 등록하고 serializerCompiler를 빠뜨리는 실수가 흔한데, 두 가지 모두 등록해야 합니다.
import Fastify from "fastify";
import {
serializerCompiler,
validatorCompiler,
ZodTypeProvider,
jsonSchemaTransform,
jsonSchemaTransformObject,
} from "@fastify/type-provider-zod";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUI from "@fastify/swagger-ui";
const app = Fastify({ logger: true });
// 핵심 1: 두 컴파일러 모두 등록
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
// 핵심 2: Swagger 플러그인은 라우트 등록 전에 먼저
await app.register(fastifySwagger, {
openapi: {
openapi: "3.0.3",
info: { title: "My API", version: "1.0.0" },
},
transform: jsonSchemaTransform,
transformObject: jsonSchemaTransformObject,
});
await app.register(fastifySwaggerUI, { routePrefix: "/docs" });타입 안전한 라우트 정의
withTypeProvider<ZodTypeProvider>()를 체이닝하면 핸들러 내부에서 req.body의 타입이 Zod 스키마에서 자동 추론됩니다. 단일 라우트를 인라인으로 정의할 때 적합한 방식입니다.
import { z } from "zod";
app.withTypeProvider<ZodTypeProvider>().route({
method: "POST",
url: "/users",
schema: {
tags: ["users"],
summary: "사용자 생성",
body: z.object({
name: z.string().min(2, "이름은 2자 이상이어야 합니다"),
email: z.email(), // Zod v4 스타일: z.string().email() 대신 사용
role: z.enum(["admin", "viewer"]).default("viewer"),
}),
response: {
201: z.object({
id: z.number(),
name: z.string(),
email: z.email(),
role: z.enum(["admin", "viewer"]),
}),
},
},
handler: async (req, reply) => {
// req.body는 { name: string, email: string, role: "admin" | "viewer" }
// IDE 자동완성 + 타입 체크 모두 작동
const user = await createUser(req.body);
return reply.status(201).send(user);
},
});요청 처리 흐름
실제 HTTP 요청이 들어왔을 때 내부에서 어떤 순서로 처리되는지 보면 이렇습니다:
sequenceDiagram
participant C as 클라이언트
participant F as Fastify
participant V as validatorCompiler
participant H as 핸들러
participant S as serializerCompiler
C->>F: POST /users 요청
F->>V: body를 Zod 스키마로 파싱
alt 검증 실패
V-->>C: 400 Bad Request 자동 응답
else 검증 성공
V->>H: 타입 추론된 req.body 전달
H->>S: reply.send 호출
S-->>C: JSON 직렬화 응답
endquerystring과 params 검증
body 말고 query string이나 URL 파라미터도 동일한 방식으로 검증할 수 있습니다. 다만 boolean 파라미터 처리에 주의가 필요합니다.
app.withTypeProvider<ZodTypeProvider>().get(
"/users/:id",
{
schema: {
params: z.object({
id: z.coerce.number().int().positive(),
// URL 파라미터는 항상 문자열로 들어옴.
// z.coerce.number()가 Number("123") → 123 변환을 처리해준다.
}),
querystring: z.object({
// z.coerce.boolean()은 쓰지 않는다.
// Boolean("false") === true이므로 ?includeDeleted=false가 true로 파싱된다.
includeDeleted: z.preprocess(
(v) => (v === undefined ? undefined : v === "true"),
z.boolean().optional().default(false)
),
}),
response: {
200: z.object({
id: z.number(),
name: z.string(),
role: z.enum(["admin", "viewer"]),
}),
},
},
},
async (req, reply) => {
// req.params.id는 number, req.query.includeDeleted는 boolean
const user = await findUser(req.params.id, req.query.includeDeleted);
return reply.send(user);
}
);z.coerce.number()는 숫자에 잘 동작합니다. Number("123") === 123이고, 유효하지 않은 문자열은 NaN을 만들어 Zod 검증이 실패합니다. 반면 z.coerce.boolean()은 Boolean("false") === true이므로 비어있지 않은 모든 문자열이 true로 처리됩니다. ?includeDeleted=false를 보내도 true가 되는 것입니다. boolean 쿼리 파라미터는 z.preprocess로 명시적으로 처리하세요.
Zod v4 전역 Registry로 $ref 재사용
Zod 4에서 도입된 전역 Registry를 활용하면, @fastify/swagger가 OpenAPI의 $ref 컴포넌트로 재사용 가능한 스키마를 자동 생성합니다. 여러 엔드포인트에서 공통 스키마를 참조할 때 OpenAPI 문서가 훨씬 깔끔해집니다.
import { z } from "zod";
const UserSchema = z
.object({
id: z.number(),
name: z.string(),
email: z.email(),
})
.meta({ id: "User" });
// .meta({ id: "User" })를 붙이면 OpenAPI 문서에서
// $ref: '#/components/schemas/User'로 참조됨
app.withTypeProvider<ZodTypeProvider>().get(
"/users/:id",
{
schema: {
params: z.object({ id: z.coerce.number() }),
response: { 200: UserSchema },
},
},
async (req, reply) => {
const user = await findUser(req.params.id);
return reply.send(user);
}
);플러그인 패턴으로 라우트 분리
실제 프로젝트에서는 라우트를 플러그인으로 분리하는 게 일반적입니다. 두 가지 접근 방식이 있고, 권장 사용 패턴이 다릅니다.
withTypeProvider<ZodTypeProvider>(): 단일 파일 또는 앱 수준에서 인라인으로 라우트를 정의할 때 적합합니다.FastifyPluginAsyncZod: 라우트를 별도 파일(플러그인)로 분리할 때 권장합니다. 플러그인 내부에서withTypeProvider를 다시 호출하지 않아도 ZodTypeProvider가 이미 적용된 상태로 사용할 수 있습니다.
// routes/users.ts
import { FastifyPluginAsyncZod } from "@fastify/type-provider-zod";
import { z } from "zod";
const usersPlugin: FastifyPluginAsyncZod = async (app) => {
// withTypeProvider 없이도 ZodTypeProvider가 적용된 상태
app.get(
"/",
{
schema: {
response: { 200: z.array(UserSchema) },
},
},
async (req, reply) => {
return reply.send(await getAllUsers());
}
);
app.post(
"/",
{
schema: {
body: z.object({
name: z.string().min(2),
email: z.email(),
}),
response: { 201: UserSchema },
},
},
async (req, reply) => {
const user = await createUser(req.body);
return reply.status(201).send(user);
}
);
};
export default usersPlugin;// app.ts
await app.register(usersPlugin, { prefix: "/users" });두 패턴을 혼합해서 사용하면 타입 컨텍스트가 의도치 않게 중첩될 수 있습니다. 플러그인 기반 구조라면 FastifyPluginAsyncZod로 일관성을 유지하는 편이 낫습니다.
장단점 분석
솔직한 장단점
| 구분 | 내용 |
|---|---|
| ✅ 단일 진실 공급원 | Zod 스키마 하나로 타입, 검증, 문서를 동시 관리. 코드 드리프트가 구조적으로 차단됨 |
| ✅ 코드 생성 불필요 | 빌드 파이프라인에 별도 스텝 없음. 스키마 수정이 즉시 문서에 반영 |
| ✅ 고성능 | Express 대비 처리량 우위. Zod v4 파싱 성능 최대 14배 향상 |
| ✅ 개발자 경험 | 핸들러 내부에서 req.body 타입 자동 추론 + IDE 자동완성 |
| ✅ 생태계 통합 | Swagger UI, Scalar, Redoc 등 다양한 OpenAPI 렌더러와 즉시 연동 |
| ⚠️ Zod v3→v4 마이그레이션 | v7 이상은 Zod 4 필요. z.output 기반 변경으로 기존 transform 스키마에 타입 오류 발생 가능 |
| ⚠️ Zod 특화 기능 한계 | z.lazy() 재귀 타입, 복잡한 discriminated union 등은 OpenAPI 문서에서 의도와 다르게 표현될 수 있음 |
| ⚠️ OpenAPI 3.0 vs 3.1 | nullable 처리 방식이 달라 Swagger UI에서 렌더링 차이가 생길 수 있음. 타겟 도구의 지원 버전 확인 필요 |
| ⚠️ 런타임 오버헤드 | 순수 JSON Schema 방식보다 Zod 파싱 비용이 추가되나, Zod v4 성능 향상으로 실무 병목은 드묾 |
실무에서 자주 만나는 실수들
실수 1: 두 컴파일러 중 하나만 등록
validatorCompiler만 등록하고 serializerCompiler를 빠뜨리면 응답 직렬화가 Zod 스키마를 무시하고 기본 동작으로 처리됩니다.
// 빠지기 쉬운 실수
app.setValidatorCompiler(validatorCompiler);
// serializerCompiler 누락!
// 올바른 형태
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);실수 2: Swagger 플러그인을 라우트 등록 후에 등록
@fastify/swagger는 라우트가 등록되기 전에 먼저 등록되어야 스펙을 올바르게 수집합니다. 순서가 바뀌면 일부 라우트가 문서에서 누락됩니다.
실수 3: boolean 파라미터에 z.coerce.boolean() 사용
앞서 설명한 것처럼 ?includeDeleted=false가 true로 파싱됩니다. z.preprocess로 명시적 변환을 처리하세요.
실수 4: OpenAPI 버전 미지정
기본값이 3.1인 경우 구형 Swagger UI나 클라이언트 도구에서 호환성 문제가 생길 수 있습니다. openapi: "3.0.3"처럼 명시적으로 지정해두는 것이 좋습니다.
마치며
코드 드리프트 문제는 단순히 불편한 게 아닙니다. 타입과 검증이 어긋난 채로 운영에 배포되면, 프론트엔드 팀이 문서와 다른 응답을 받거나 유효하지 않은 데이터가 DB에 저장되는 실질적인 버그로 이어집니다. 이 구조가 팀에 가장 도움이 되는 시점은 사실 개발 초기가 아니라, API 엔드포인트가 수십 개를 넘어가고 여러 명이 동시에 스키마를 수정하기 시작할 때입니다.
운영 경험을 하나 공유하자면, 이 스택을 도입한 뒤 가장 먼저 사라지는 커뮤니케이션 비용은 "OpenAPI 문서가 코드랑 달라요"라는 보고입니다. 스키마가 바뀌는 순간 문서도 바뀌니까, 문서가 최신인지 확인하는 작업 자체가 사라집니다.
반대로, 도입 전에 점검해야 할 부분도 있습니다. z.lazy()를 활용한 재귀 스키마나 복잡한 union 타입이 많다면, OpenAPI 문서에서 의도대로 렌더링되는지 초기에 확인해두는 것이 좋습니다. Zod의 표현력이 OpenAPI 스펙의 표현 범위와 항상 1:1로 매핑되지는 않습니다.
지금 바로 시작해보고 싶다면:
- 패키지 설치:
npm install fastify @fastify/type-provider-zod @fastify/swagger @fastify/swagger-ui zod - 컴파일러 등록:
setValidatorCompiler와setSerializerCompiler를 모두 등록하고,fastifySwagger를 라우트보다 먼저 등록합니다. - 첫 라우트 작성:
withTypeProvider<ZodTypeProvider>().route()에 Zod 스키마를 붙여 등록한 뒤/docs에서 자동 생성된 문서를 확인합니다.
참고 자료
- fastify/fastify-type-provider-zod - GitHub
- Fastify v5 공식 문서 - Type Providers
- Fastify v5 Migration Guide
- Fastify v5 is Here! - Platformatic 블로그
- Fastify v5 공식 발표 - OpenJS Foundation
- fastify-zod-openapi - GitHub
- fastify/fastify-swagger - GitHub
- Generate OpenAPI with Fastify - Speakeasy
- Fastify + Neon + Zod 풀스택 가이드 - Neon Docs
- NestJS vs Fastify 2026 비교 - Encore
- Zod v4 공식 문서