Hono + Zod OpenAPI로 스키마 하나에서 검증과 문서를 함께 유지하기
API를 만들다 보면 어느 순간 이런 상황을 마주치게 됩니다. 코드는 분명 수정했는데 OpenAPI 문서에는 이전 필드명이 그대로 남아 있고, 프론트엔드 팀은 그 문서를 보고 개발을 이어가고 있는 거죠. 혹은 반대로 문서를 먼저 업데이트했는데 실제 코드는 아직 예전 방식 그대로인 경우도 있습니다. 이 현상을 Schema Drift라고 부르는데, 별도의 도구 없이 장기간 운영되는 REST API에서는 거의 불가피하게 발생합니다.
이 글에서 이야기할 @hono/zod-openapi 패턴은 그 구조적 원인을 줄이는 접근법입니다. Zod 스키마 하나에서 TypeScript 타입 추론, 런타임 입력 검증, OpenAPI 3.x 문서 생성이 동시에 파생되기 때문에, 코드와 문서가 동일한 소스에서 나오도록 강제됩니다. 2026년 기준으로 Hono 생태계의 OpenAPI 통합은 크게 두 갈래로 자리잡혀 있어서 — 공식 Zod 전용 경로와 커뮤니티 멀티-밸리데이터 경로 — 팀 상황에 따라 선택지가 달라집니다. 어떤 걸 선택할지, 그리고 실제로 어떻게 쓰는지를 같이 살펴보겠습니다.
Schema Drift는 왜 생기는가
기존 방식에서는 보통 세 가지 결과물을 각각 손으로 관리합니다.
string이었던 필드를 number로 바꿀 때 타입과 검증 코드는 수정했는데 OpenAPI YAML을 빼먹는 일, 개발하다 보면 정말 자주 일어납니다. 혼자 작업할 때는 어떻게든 챙기더라도, 팀 규모가 커지고 PR 속도가 빨라지면 리뷰에서도 놓치기 쉽습니다.
Zod가 왜 이 문제를 해결하는가
Zod는 원래 런타임 검증 라이브러리지만, .parse() 혹은 .safeParse()로 값을 검증하는 동시에 TypeScript 타입을 z.infer<>로 뽑아낼 수 있습니다. 여기에 @hono/zod-openapi가 .openapi() 확장 메서드를 추가해서, 같은 스키마를 OpenAPI 스펙의 components/schemas로도 내보낼 수 있게 됩니다.
결과적으로 변경 지점이 Zod 스키마 하나로 수렴합니다. 물론 스키마 파일을 임포트하지 않거나 별도로 하드코딩된 문서를 병행 관리하면 여전히 분리될 수 있으니, "단일 소스에서 파생된다"는 규율을 팀 내에서 유지하는 것이 전제입니다.
두 가지 통합 경로 비교
2026년 기준으로 Hono에서 OpenAPI를 붙이는 경로는 크게 두 가지입니다.
| 항목 | @hono/zod-openapi |
hono-openapi |
|---|---|---|
| 관리 주체 | Hono 공식 | 커뮤니티 |
| 지원 검증 라이브러리 | Zod 전용 | Zod, Valibot, ArkType, TypeBox 등 |
| 통합 방식 | 앱 클래스 교체 (OpenAPIHono) |
미들웨어 추가 |
| 기존 코드 마이그레이션 | 라우트 재작성 필요 | 점진적 도입 가능 |
| Hono RPC 호환 | 지원 | 부분적 |
| Standard Schema 호환 | Zod v4 통해 간접 | 어댑터별로 직접 |
팀이 Zod를 이미 쓰고 있고 새 프로젝트를 시작하는 상황이라면 @hono/zod-openapi가 자연스러운 선택입니다. 반면 레거시 Hono 앱에 단계적으로 적용하거나, Valibot 같은 다른 검증 라이브러리를 사용 중이라면 hono-openapi가 낫습니다. 이 선택 기준은 뒤의 "어떤 상황에 어떤 선택을" 섹션에서 다시 정리하겠습니다.
@hono/zod-openapi 사용 흐름
설치와 앱 초기화
npm install hono @hono/zod-openapi zod @hono/swagger-uiSwagger UI를 붙일 예정이라면 @hono/swagger-ui도 함께 설치해둡니다. 핵심 변화는 Hono 대신 OpenAPIHono를 앱 클래스로 쓴다는 점입니다.
import { OpenAPIHono } from '@hono/zod-openapi'
const app = new OpenAPIHono()스키마 정의 — 검증과 문서가 한 곳에서
import { z } from '@hono/zod-openapi'
const UserParamSchema = z.object({
id: z.string().min(1).openapi({
param: { name: 'id', in: 'path' },
example: '123',
}),
})
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
}).openapi('User').openapi() 호출이 Zod 스키마에 OpenAPI 메타데이터를 붙이는 부분입니다. example, description, param 위치 등을 여기서 선언합니다.
라우트 정의
import { createRoute } from '@hono/zod-openapi'
const getUserRoute = createRoute({
method: 'get',
path: '/users/{id}',
request: {
params: UserParamSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: '사용자 정보 반환',
},
404: {
content: {
'application/json': {
schema: z.object({ message: z.string() }).openapi('ErrorResponse'),
},
},
description: '사용자를 찾을 수 없음',
},
},
})핸들러 등록 — 타입이 이미 붙어 있음
app.openapi(getUserRoute, async (c) => {
const { id } = c.req.valid('param')
// id는 Zod 검증을 통과한 string 타입으로 확정됨
// 아래는 의사 코드 (예: Prisma, Drizzle 등)
const user = await findUserById(id)
if (!user) {
return c.json({ message: 'User not found' }, 404)
}
return c.json(user, 200)
})c.req.valid('param')으로 꺼내는 값은 Zod 검증을 이미 통과한 타입 안전 객체입니다. 핸들러 안에서 별도 검증 코드를 추가할 필요가 없습니다.
OpenAPI 문서 엔드포인트와 UI
import { swaggerUI } from '@hono/swagger-ui'
app.doc('/doc', {
openapi: '3.0.0',
info: {
title: 'User API',
version: '1.0.0',
},
})
app.get('/ui', swaggerUI({ url: '/doc' }))/doc에 접근하면 위에서 정의한 모든 라우트가 자동으로 포함된 OpenAPI JSON을 돌려줍니다. 서버를 실행하는 것만으로 문서가 항상 최신 상태입니다.
Scalar UI로 바꾸는 경우
Syntax.fm 팟캐스트에서도 Hono + Zod + OpenAPI + Scalar 조합을 소개한 적이 있는데, 기존 Swagger UI 대신 Scalar를 쓰는 사례가 늘고 있습니다. 설정 방법은 간단합니다.
npm install @scalar/hono-api-referenceimport { apiReference } from '@scalar/hono-api-reference'
app.get(
'/reference',
apiReference({
spec: { url: '/doc' },
})
)UI 취향 차이라서 어느 쪽이 낫다고 단정 짓기는 어렵지만, Scalar 쪽이 좀 더 현대적인 디자인을 제공합니다.
기존 앱에 점진적으로 붙이는 hono-openapi
@hono/zod-openapi의 진입 장벽은 기존 Hono 앱을 OpenAPIHono로 갈아야 한다는 점입니다. 저도 처음엔 별거 아니라고 생각했는데, 라우터가 여러 파일에 분산된 실제 프로젝트에서는 꽤 번거로운 작업이 됩니다.
hono-openapi는 이 문제를 미들웨어 방식으로 우회합니다. Zod 어댑터를 쓸 경우 별도의 @hono/zod-openapi를 설치할 필요는 없고, zod-openapi 계열 어댑터에 의존합니다.
npm install hono-openapi zod zod-openapiimport { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { resolver, validator } from 'hono-openapi/zod'
import { z } from 'zod'
const app = new Hono()
const UserSchema = z.object({
id: z.string(),
name: z.string(),
})
app.get(
'/users/:id',
describeRoute({
description: '사용자 조회',
responses: {
200: {
description: '성공',
content: {
'application/json': {
schema: resolver(UserSchema),
},
},
},
},
}),
validator('param', z.object({ id: z.string() })),
(c) => {
const { id } = c.req.valid('param')
return c.json({ id, name: 'Alice' })
}
)기존 Hono 인스턴스를 그대로 두고 라우트별로 describeRoute 미들웨어를 붙이는 방식이라, 팀이 점진적으로 도입할 수 있습니다.
전체 요청 흐름 시각화
앞선 GET /users/{id} 예시를 기준으로 요청 처리 경로와 문서 생성 경로가 어떻게 같은 스키마를 공유하는지 그려보면 아래와 같습니다.
핵심은 요청 경로와 문서 생성 경로가 동일한 Zod 스키마를 공유한다는 구조입니다. 스키마가 바뀌면 두 경로 모두 함께 바뀝니다.
어떤 상황에 어떤 선택을
어느 쪽이 무조건 낫다고 말하긴 어렵고, 팀 상황에 따라 선택지가 달라집니다.
| 상황 | 추천 |
|---|---|
| 새 프로젝트, Zod 이미 사용 중 | @hono/zod-openapi |
| 기존 Hono 앱에 점진적 도입 | hono-openapi |
| Valibot / ArkType 선호 | hono-openapi |
| Hono RPC 병용 필요 | @hono/zod-openapi |
| Cloudflare Workers 등 엣지 배포 | 둘 다 가능, Hono가 1급 지원 |
| 복잡한 OpenAPI 컴포지션 많음 | 어느 쪽이든 부분 수작업 필요 |
선택 흐름을 그림으로 정리하면 아래처럼 갈립니다.
사용하면서 마주치는 트레이드오프
경로를 골랐다면, 실제로 굴려보며 부딪히는 지점들도 미리 알아두면 좋습니다.
OpenAPI 메타데이터가 스키마를 오염시킨다는 문제
.openapi({ example: '...' })를 Zod 스키마에 직접 추가하면, 원래 순수한 검증용 스키마가 OpenAPI 의존성을 갖게 됩니다. 여러 곳에서 재사용하는 공통 스키마에 이 메타데이터를 넣으면 의존 관계가 복잡해질 수 있습니다.
하나의 실용적인 접근은 스키마를 두 레이어로 분리하는 겁니다.
// 순수 검증용 (공유 가능)
const BaseUserSchema = z.object({
id: z.string().min(1),
email: z.string().email(),
})
// OpenAPI 메타데이터 추가 (API 레이어 전용)
const UserApiSchema = BaseUserSchema.openapi('User', {
description: '사용자 정보',
})응답 검증의 활성화 범위
요청 검증은 보안상 기본이지만, 응답 바디까지 Zod로 검증하면 스키마 크기와 요청량에 따라 응답 시간에 오버헤드가 붙습니다. 얼마나 붙는지는 스키마 복잡도, 배열 크기, 런타임 환경(Node.js / Bun / 엣지)에 따라 크게 달라지므로, 자신의 트래픽 프로파일에서 직접 벤치마크해보는 편이 확실합니다.
일반적으로 취하는 전략은 개발/스테이징에서는 응답 검증을 켜서 스키마와 실제 응답 사이의 괴리를 잡아내고, 프로덕션에서는 성능 프로파일을 본 뒤 선택적으로 유지하는 방식입니다.
복잡한 OpenAPI 컴포지션의 경계
@hono/zod-openapi는 z.discriminatedUnion()을 OpenAPI의 oneOf + discriminator로 변환해줍니다. 단순한 판별 유니온은 그대로 잘 표현됩니다. 다만 아래 같은 경우에는 여전히 수작업이 필요할 수 있습니다.
- 판별 키가 없는 일반
z.union()을 특정discriminator매핑과 함께 표현하고 싶은 경우 allOf기반의 상속 관계나 외부 스펙의$ref를 직접 조립해야 하는 경우- 조건부 스키마(예:
if/then/else)가 필요한 경우
이런 경계 상황에서는 Zod 스키마 대신 OpenAPI 객체를 직접 넘기는 편이 깔끔합니다.
responses: {
200: {
content: {
'application/json': {
schema: {
oneOf: [
{ $ref: '#/components/schemas/AdminUser' },
{ $ref: '#/components/schemas/RegularUser' },
],
},
},
},
description: '사용자 타입에 따른 응답',
},
},CI/CD에서 스펙 드리프트 감지
빌드 시점에 OpenAPI 스펙을 파일로 스냅샷 뜨고, PR에서 diff가 생기면 리뷰어의 명시적 승인을 요구하는 방식이 흔히 쓰입니다. 예를 들어 scripts/generate-openapi.ts에서 app.getOpenAPIDocument()를 호출해 JSON을 파일로 쓰고, 이 파일을 커밋 대상으로 관리한 뒤 CI에서 재생성한 결과와 비교해 diff가 있으면 실패시키는 식입니다.
여기서 통과 조건은 "리포지토리에 커밋된 스냅샷이 재생성 결과와 일치하는지"입니다. 스키마를 의도적으로 바꿨다면 스냅샷도 같은 PR에 함께 커밋하도록 강제하는 셈이라, 리뷰어가 실제 API 변경 여부를 diff로 명확하게 확인할 수 있습니다.
Hono RPC와 OpenAPI 병용 전략
@hono/zod-openapi는 Hono의 RPC 모드(hc 함수)와도 함께 쓸 수 있습니다. 여기서 얻는 이득은 청중이 두 부류로 갈릴 때 드러납니다.
- 내부 서비스 간 호출: 같은 리포지토리나 모노레포 안에서 서로 호출하는 서비스라면, 서버의 타입 그 자체를 클라이언트에서 임포트해 쓰는 편이 코드 생성 단계를 없앨 수 있고 IDE에서 바로 타입 시그니처를 볼 수 있습니다.
- 외부 파트너 / 프론트엔드 팀: 서버 타입을 그대로 공유할 수 없거나 다른 언어를 쓰는 상대에게는 OpenAPI 스펙이 계약서 역할을 합니다.
같은 Zod 스키마에서 두 결과물이 파생되므로, 한쪽만 반영되어 서로 어긋나는 상황을 원천적으로 줄일 수 있습니다.
// 서버 타입 추출
type AppType = typeof app
// 내부 서비스에서 RPC 클라이언트 사용 (코드 생성 불필요)
import { hc } from 'hono/client'
const client = hc<AppType>('http://localhost:3000')
const res = await client.users[':id'].$get({ param: { id: '123' } })
// 타입이 자동으로 추론됨외부에는 /doc의 OpenAPI JSON을 공개하고, 내부에서는 RPC 클라이언트를 씁니다. Speakeasy나 Fern 같은 도구로 OpenAPI 스펙에서 언어별 SDK를 생성하는 흐름도 여기서 자연스럽게 이어집니다.
마무리
어느 경로를 선택하든, 스키마를 한 곳에서 관리하는 구조가 장기 운영에서 갖는 실질적 이점은 분명합니다. 문서와 코드가 별도로 관리되면 필드 하나를 바꾸는 작업이 여러 파일로 흩어지고, 리뷰어가 diff에서 놓친 항목은 배포 후 프론트엔드 팀의 온보딩 마찰이나 API 콘솔에서의 재확인 요청으로 되돌아옵니다. 반대로 스키마가 단일 소스로 수렴하면, 리뷰 시 볼 파일 수가 줄고 배포 이후 "문서와 실제 응답이 다르다"는 이슈로 소모되는 디버깅 시간이 줄어듭니다.
@hono/zod-openapi와 hono-openapi 중 무엇을 고르는가는 팀의 현재 코드베이스와 검증 라이브러리 선택에 달려 있고, 정답이 하나로 정해지지는 않습니다. 다만 어느 쪽을 고르든 "스키마 → 타입 / 검증 / 문서"라는 파생 관계를 규율로 유지하는 것이 이 접근의 실질적인 가치입니다.
참고 자료
- Zod OpenAPI - Hono 공식 문서
- Hono OpenAPI - Hono 공식 문서
- hono/zod-openapi GitHub
- How To Generate an OpenAPI Document With Hono - Speakeasy
- Build Self-Documenting APIs with Hono, Zod, and OpenAPI - Victor Li
- Request Validation at the Edge - DEV Community
- Build a documented/type-safe API with Hono, Drizzle, Zod, OpenAPI and Scalar - Syntax.fm
- OpenAPI | HONC Docs
- Introducing Hono OpenAPI - DEV Community
- TypeScript API Contracts That Don't Drift - Medium