Hono RPC와 Zod로 엣지 런타임에서 서버 타입을 클라이언트까지 흘리기 — codegen 없는 end-to-end 타입 안전 API
OpenAPI 스펙 파일을 유지하다가 서버와 클라이언트 타입이 어긋난 경험, 한 번쯤은 있으실 거라 생각합니다. 저도 한동안 openapi-typescript로 타입을 생성하고, CI에 생성 스크립트를 넣고, PR마다 생성 파일이 커밋에 섞이는 방식으로 일했는데 — 솔직히 번거로웠습니다. 서버 코드를 바꾸고 클라이언트 타입 재생성을 까먹으면 런타임에서야 터지고, 누군가 스펙 파일 업데이트를 빠뜨리면 정적 분석이 무력화됐죠.
Hono RPC는 이 문제를 codegen 단계 자체를 없애는 방식으로 접근합니다. 서버의 라우트 타입을 TypeScript 타입으로 직접 내보내고, 클라이언트에서 그 타입을 import type으로 소비하면 끝입니다. 런타임에 추가되는 것도 없고, 별도의 스키마 언어도 없습니다. Zod가 런타임 검증과 컴파일 타임 타입 추론을 동시에 담당하기 때문에 스키마 하나가 진실의 단일 소스가 됩니다.
Hono는 Cloudflare Workers를 포함한 엣지 런타임 프레임워크로 사용 폭이 꾸준히 넓어지고 있습니다(추세는 npm trends에서 직접 확인해볼 수 있습니다). 이 글에서는 Hono RPC와 Zod를 조합해 엣지 환경에서 end-to-end 타입 안전 API 계층을 구성하는 방법을 다룹니다.
타입이 흐르는 구조
핵심은 타입이 단방향으로 흐른다는 점입니다. Zod 스키마에서 시작해 라우트 타입으로 축적되고, 그 타입 시그니처만 클라이언트로 넘어갑니다.
1단계 — zValidator로 입력 스키마 선언
@hono/zod-validator 미들웨어는 Zod 스키마를 라우트에 직접 바인딩합니다. 요청이 들어오면 런타임에서 스키마를 검증하고, 핸들러 안에서는 이미 검증을 통과한 데이터를 c.req.valid()로 꺼낼 수 있습니다. 이 값의 정적 타입은 Zod 스키마에서 추론되므로 캐스팅이 필요 없습니다.
2단계 — AppType 내보내기
라우트 변수에 typeof를 적용해 export type으로 내보냅니다. 실제 런타임 코드가 아니라 TypeScript 타입만 공유되는 지점입니다.
3단계 — hc<AppType>(baseUrl)으로 클라이언트 생성
hono/client의 hc 함수에 AppType을 제네릭으로 넘기면 엔드포인트 경로, HTTP 메서드, 요청 바디, 응답 형태 전부에 자동완성이 붙습니다.
요청이 들어와 검증되고 응답이 나가는 런타임 흐름은 이렇습니다. 타입 좁히기는 컴파일 타임 개념이므로 다이어그램에는 등장하지 않습니다.
컴파일 타임에는 별개의 일이 일어납니다. zValidator의 반환 타입 시그니처가 라우트 타입에 누적되고, 핸들러 안에서 c.req.valid('json')을 호출하면 TypeScript가 그 자리에서 Zod 스키마 기반 타입을 노출합니다.
Cloudflare Workers에서 서버 세팅
먼저 패키지를 설치합니다.
npm install hono @hono/zod-validator zod
npm install -D wrangler서버 라우트 작성
// src/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
const app = new Hono()
const routes = app
.get('/users', async (c) => {
const users = [{ id: '1', name: 'Alice', email: 'alice@example.com' }]
return c.json(users)
})
.post(
'/users',
zValidator('json', CreateUserSchema),
async (c) => {
const { name, email } = c.req.valid('json')
const newUser = { id: crypto.randomUUID(), name, email }
return c.json(newUser, 201)
}
)
.get(
'/users/:id',
zValidator('param', z.object({ id: z.string() })),
async (c) => {
const { id } = c.req.valid('param')
return c.json({ id, name: 'Alice', email: 'alice@example.com' })
}
)
export type AppType = typeof routes
export default approutes 변수에 메서드 체이닝으로 라우트를 쌓고, typeof routes를 AppType으로 내보내는 패턴이 핵심입니다. .get, .post 같은 체이닝 메서드는 매 호출마다 확장된 라우트 정보를 담은 새 타입을 반환하는데, new Hono() 시점의 app에는 그 정보가 아직 없습니다. 그래서 체이닝 결과를 별도 변수에 담아 typeof를 걸어야 합니다. (뒤에서 살펴볼 .route() 조합 케이스는 조금 다릅니다.)
클라이언트에서 소비하기
// client.ts
import { hc } from 'hono/client'
import type { AppType } from './index'
const client = hc<AppType>('https://api.example.com')
const usersRes = await client.users.$get()
const users = await usersRes.json()
const createRes = await client.users.$post({
json: { name: 'Bob', email: 'bob@example.com' }
})
const userRes = await client.users[':id'].$get({
param: { id: '1' }
})
const user = await userRes.json()import type { AppType }을 빠뜨리면 나중에 번들에 서버 코드가 섞일 수 있습니다. 이 부분은 트레이드오프 섹션에서 더 다룹니다.
배포 설정
Cloudflare Workers 배포에는 최소한의 wrangler.toml이 필요합니다.
# wrangler.toml
name = "my-hono-api"
main = "src/index.ts"
compatibility_date = "2026-08-01"package.json은 이렇게 구성합니다.
{
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
}
}Bun에서 로컬로 띄우기
Bun은 파일이 export default { fetch } 형태의 객체를 내보내면 자동으로 HTTP 서버를 띄웁니다. Hono의 app은 .fetch 메서드를 가지므로 이 규약과 호환되지만, Bun이 이 규약을 지원하기 때문에 동작하는 것이지 아무 default export나 서버가 되는 것은 아닙니다.
명시적으로 포트 등을 지정하고 싶다면 Bun.serve를 감싸는 편이 안전합니다.
// src/dev.ts
import app from './index'
export default {
port: 3000,
fetch: app.fetch,
}{
"scripts": {
"dev:bun": "bun run src/dev.ts",
"dev:workers": "wrangler dev",
"deploy": "wrangler deploy"
}
}hc 클라이언트는 런타임에 무관하므로 프론트엔드 코드는 어느 환경에 배포하든 그대로입니다. 환경 변수로 API URL만 갈아 끼우면 됩니다.
Turborepo 모노레포에서 AppType 격리하기
백엔드와 프론트엔드가 분리된 모노레포라면 AppType을 공유하는 방법을 조금 더 신경 써야 합니다. 서버 코드(ORM, 환경 변수 접근 등)가 클라이언트 번들에 유입되는 것을 차단하면서도 타입 공유는 유지해야 하기 때문입니다.
권장하는 패키지 구조입니다.
apps/
api/ # Hono on Cloudflare Workers or Bun
web/ # Next.js or React
packages/
api-types/ # AppType만 격리 내보내기// packages/api-types/src/index.ts
export type { AppType } from '@myapp/api'// packages/api-types/package.json
{
"name": "@myapp/api-types",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@myapp/api": "workspace:*"
}
}이 패키지는 런타임 코드를 포함하지 않으므로 번들 크기에 영향을 주지 않습니다. apps/web에서는 이렇게 사용할 수 있습니다.
// apps/web/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@myapp/api-types'
export const apiClient = hc<AppType>(
process.env.NEXT_PUBLIC_API_URL!
)Next.js 15 + React Query 연동
apiClient를 React Query의 queryFn에 연결하면 서버 응답 타입이 useQuery의 반환 타입까지 자동으로 전파됩니다.
// apps/web/app/users/page.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api'
export default function UsersPage() {
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await apiClient.users.$get()
return res.json()
}
})
if (isLoading) return <div>로딩 중...</div>
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}별도의 fetch 래퍼나 OpenAPI 클라이언트 생성 단계 없이, 서버 라우트를 바꾸면 클라이언트 타입이 즉시 반영됩니다.
트레이드오프와 대안
Hono RPC가 모든 상황에 최선은 아닙니다. 선택 전에 고려해볼 지점들을 정리했습니다.
도구 비교 (2026년 기준)
| Hono RPC | tRPC | oRPC | ts-rest | |
|---|---|---|---|---|
| 코드 생성 필요 여부 | 불필요 | 불필요 | 불필요 | 불필요 |
| OpenAPI 스펙 자동 생성 | 서드파티(hono-openapi) |
미지원 | 기본 지원 | 기본 지원 |
| REST URL 유지 | O | X (단일 엔드포인트 스타일) | O | O |
| Node.js 전용 의존성 유무 | 없음 | 없음 | 없음 | 없음 |
| 클라이언트 런타임 방식 | fetch 기반 | fetch 기반 | fetch 기반 | fetch 기반 |
| 검증기 결합 | Standard Schema 지원 | Zod/Valibot 등 | Standard Schema 지원 | Zod 등 |
네 도구 모두 fetch를 기반으로 하기 때문에 엣지 런타임 자체에는 큰 진입 장벽이 없습니다. 실질적인 차이는 어떤 검증기/미들웨어 조합을 쓸 수 있는가, REST URL을 그대로 노출할 것인가, OpenAPI가 필요한가에서 갈립니다. 외부 API를 공개하거나 써드파티 클라이언트가 OpenAPI 스펙을 소비해야 한다면 oRPC나 ts-rest가 자연스럽습니다. 순수 TypeScript 모노레포에서 미들웨어·플러그인 생태계가 중요하다면 tRPC가 더 성숙합니다.
실무에서 자주 밟는 함정
함정 1 — export type 빠뜨리기
저도 처음엔 이 차이를 가볍게 봤다가 번들 분석 결과에서 Drizzle ORM 코드가 클라이언트 번들에 섞여 있는 것을 발견하고 당황했습니다.
// 위험: 런타임 코드까지 내보내질 수 있음
export { AppType }
// 안전: 타입만 내보냄
export type { AppType }클라이언트에서도 마찬가지입니다.
// 위험
import { AppType } from './server'
// 안전
import type { AppType } from './server'함정 2 — 대규모 라우트에서 TypeScript 컴파일 시간 증가
Hono는 라우트마다 Context 타입을 순차적으로 확장하는 구조입니다. 라우트 수가 많아질수록 TypeScript 컴파일 시간이 급격히 늘어날 수 있습니다. GitHub Issue #3869에서 빌드가 수 분 단위로 늘어난 사례가 논의됐습니다.
대응은 라우터를 기능 단위로 분리하는 것입니다.
// src/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
export const usersRoutes = new Hono()
.get('/', async (c) => c.json([]))
.post('/', zValidator('json', CreateUserSchema), async (c) => {
const data = c.req.valid('json')
return c.json({ id: crypto.randomUUID(), ...data }, 201)
})// src/index.ts
import { Hono } from 'hono'
import { usersRoutes } from './routes/users'
const app = new Hono()
.route('/users', usersRoutes)
export type AppType = typeof app
export default app여기서는 typeof app을 그대로 씁니다. 앞의 단일 파일 예시와 규칙이 달라 보이지만, 차이는 명확합니다. .route()는 서브 라우터의 타입을 상위 app 인스턴스의 타입에 즉시 합성해서 반환하므로, app 변수 자체에 최종 라우트 타입이 실려 있습니다. 반면 .get, .post를 여러 번 이어붙일 때는 new Hono() 시점의 app에는 아직 라우트 정보가 없으므로 체이닝 결과(routes)에 typeof를 걸어야 합니다. 즉 "라우트 확장이 최종적으로 어느 식별자에 담기는가"가 판단 기준입니다.
빌드 자체는 TypeScript Project References를 활용해 증분 빌드로 더 줄일 수 있습니다.
함정 3 — "Type instantiation is excessively deep" 오류
이 오류는 TypeScript가 라우트 체이닝을 통해 누적된 조건부/재귀 타입을 특정 깊이 이상 풀지 못할 때 발생합니다. strict 모드와 직접적 인과관계는 없습니다. 실제 원인은 대개 (a) 지나치게 깊게 쌓인 체이닝, (b) 응답 타입에 재귀적 스키마가 물려 있는 경우, (c) 클라이언트 측 hc<AppType>에서 라우트 수가 임계점을 넘은 경우입니다. 함정 2에서 소개한 라우터 분리와 Project References 조합이 가장 확실한 완화책입니다.
함정 4 — 복잡한 응답 타입 재추출
ORM 릴레이션 결과처럼 중첩 타입이 복잡할 때는 InferResponseType 유틸리티로 응답 타입을 뽑아낼 수 있습니다.
import type { InferResponseType } from 'hono/client'
import type { AppType } from '@myapp/api-types'
const client = hc<AppType>('https://api.example.com')
type CreateUserResponse = InferResponseType<
typeof client.users.$post,
201
>tsconfig 체크리스트
"strict": true— 정확한 타입 추론과 null 안전을 위해 필요. 컴파일 속도와는 별개 이슈이지만, 이걸 끄면c.req.valid()의 타입 추론이 예상과 어긋날 수 있습니다."moduleResolution": "bundler"또는"nodenext"—hono/client서브패스 export 해석에 필요."target": "ES2022"이상 — 엣지 런타임(Workers, Bun) 실행 환경과 정렬.
Hono 공식 Best Practices에서도 strict 모드를 별도 항목으로 권장합니다.
Standard Schema로 검증기 갈아 끼우기
2026년 기준으로 흥미로운 변화 중 하나는 Standard Schema 명세입니다. Zod, Valibot, ArkType 메인테이너가 공동으로 만든 이 인터페이스는 스키마 라이브러리들이 공통의 형태로 파싱 결과를 노출하도록 규정합니다. Hono 진영에서는 @hono/standard-validator를 통해 이 명세를 만족하는 어떤 검증기라도 라우트에 붙일 수 있습니다.
가벼운 번들이 절실한 엣지 환경에서는 Zod 대신 Valibot으로 바꿔서 클라이언트 사이드 스키마 재사용 시 번들 크기를 크게 줄이는 선택지가 생깁니다.
import { Hono } from 'hono'
import { sValidator } from '@hono/standard-validator'
import * as v from 'valibot'
const CreateUserSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
email: v.pipe(v.string(), v.email()),
})
const app = new Hono()
.post('/users', sValidator('json', CreateUserSchema), (c) => {
const { name, email } = c.req.valid('json')
return c.json({ id: crypto.randomUUID(), name, email }, 201)
})Hono RPC 쪽에서 봤을 때 라우트 타입 시그니처는 검증기 종류와 무관하게 유지됩니다. hc<AppType> 소비 방식도 그대로입니다.
정리하며 — 다음에 시도해볼 것
Hono RPC + Zod 조합의 값어치는 스키마 하나로 런타임 검증과 컴파일 타임 타입을 동시에 확보한다는 데 있습니다. codegen 단계가 사라지므로 서버 라우트 변경이 클라이언트 타입에 즉시 반영되고, 콜드 스타트에 민감한 엣지 환경과 궁합이 좋습니다. Hono 코어의 번들 크기는 매우 작지만, 실제 스택에는 Zod(minified 기준 v3 약 57KB, v4에서 더 작아짐)나 검증기 미들웨어가 함께 계상된다는 점은 기억해둘 만합니다.
지금 프로젝트에 얹어보려는 분에게 권할 만한 다음 단계는 셋입니다.
- 기존 라우트 하나를
zValidator+AppType+hc조합으로 이식해서, 클라이언트에서 컴파일 오류가 어디에서 뜨는지 관찰하기. - 서버 코드가 클라이언트 번들에 유입되지 않는지 번들 분석기(
@next/bundle-analyzer등)로 확인하기. - Zod가 무거워 부담된다면
@hono/standard-validator+ Valibot 조합으로 검증기 교체를 시도해서 번들 크기 차이 실측하기.
외부에 공개할 API가 필요해지는 시점이 오면 hono-openapi로 스펙을 붙이거나, oRPC/ts-rest로 이관을 검토하는 것도 자연스러운 확장 경로입니다.
참고 자료
- Hono 공식 문서 - RPC 가이드
- Hono 공식 문서 - Validation
- Hono 공식 문서 - Stacks
- Hono 공식 문서 - Best Practices
- Yusuke Wada - Hey, this is Hono's RPC
- Standard Schema 명세
@hono/standard-validator미들웨어- Catalin's Tech - Hono RPC And TypeScript Project References
- GitHub Issue #3869 - Hono Type Inference is taking too long during builds
- Fiberplane Blog - Hacking Hono: The Ins and Outs of Validation Middleware
- JSR - @hono/zod-validator
- npm trends - hono