수년간 JavaScript 웹 프레임워크 선택은 트레이드오프 수용을 의미했습니다. Express는 보편적이지만 느리고 Node에 묶여 있고, Fastify는 빠르지만 Node 전용, Next.js는 풍부하지만 무겁습니다. 엣지 런타임 — Cloudflare Workers, Deno Deploy, Bun, Vercel Edge — 이 등장하자 이 프레임워크들은 한계를 드러냈습니다. 호환되지 않는 의존성, 거대한 번들, Node의 req/res에 묶인 API.
Hono (일본어로 "불꽃" 🔥) 는 현대적인 답입니다. Request, Response, fetch 라는 웹 표준 위에 완전히 구축된 14KB 미만의 프레임워크로, JavaScript 런타임이 있는 곳이라면 어디서든 동작합니다. 동일한 코드가 Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify, AWS Lambda에 변경 없이 배포됩니다.
왜 Hono인가
Hono는 세 가지를 다른 누구보다 잘합니다.
- 성능.
RegExpRouter는 모든 라우트 패턴을 단일 정규식으로 컴파일하여 전통적인 라우터의 선형 루프를 피합니다. 벤치마크는 초당 40만 ops를 넘어 Hono를 JavaScript 생태계에서 가장 빠른 라우터 중 하나로 만듭니다. - 이식성. 웹 표준이라는 것은 Node 의존성 제로를 의미합니다. 동일한
app.fetch가 Cloudflare Worker에서 default export 되고,Bun.serve에 전달되고, Deno 서버에 마운트되고,@hono/node-server로 어댑트됩니다. - TypeScript 우선 DX. 리터럴 타입으로 추론되는 path 파라미터, 엔드투엔드 타입 안전 RPC 클라이언트, 입출력 타입을 추론하는 검증기. 자동완성은 거의 텔레파시 수준입니다.
시작하기
가장 빠른 길은 공식 스타터로, 선택한 런타임용으로 프로젝트를 스캐폴딩합니다.
npm create hono@latest my-api cd my-api npm install npm run dev
스타터는 사용할 템플릿을 묻습니다: cloudflare-workers, bun, deno, nodejs, vercel, aws-lambda, nextjs 등. 적절한 설정과 배포 스크립트를 선택합니다. 빠르게 시도하려면 단일 파일에서도 시작할 수 있습니다:
// src/index.ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default app
Cloudflare Workers에서는 이것으로 충분합니다. Bun에서는: Bun.serve({ fetch: app.fetch, port: 3000 }). Node에서는: @hono/node-server의 serve({ fetch: app.fetch }). 표면은 동일합니다.
라우팅
라우트는 HTTP 동사 메서드로 선언하고 파라미터, 와일드카드, 정규식을 지원합니다.
import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Home')) app.get('/posts/:id', (c) => { const id = c.req.param('id') // string, 타입화됨 return c.json({ id }) }) app.get('/posts/:id/comments/:commentId', (c) => { const { id, commentId } = c.req.param() return c.json({ id, commentId }) }) app.get('/files/*', (c) => c.text('Wildcard')) app.post('/posts', async (c) => { const body = await c.req.json() return c.json({ created: body }, 201) })
파라미터는 리터럴 타입으로 추론됩니다. TypeScript는 패턴에 :id를 선언한 경우에만 c.req.param('id')가 string을 반환한다는 것을 압니다. 이름을 잘못 쓰면 컴파일 타임 에러입니다.
라우트 그룹화
app.route()는 서브 애플리케이션을 모듈로 구성하게 해주며, 각각 자체 prefix를 가집니다.
// routes/posts.ts import { Hono } from 'hono' const posts = new Hono() posts.get('/', (c) => c.json({ posts: [] })) posts.get('/:id', (c) => c.json({ id: c.req.param('id') })) export default posts // src/index.ts import { Hono } from 'hono' import posts from './routes/posts' const app = new Hono() app.route('/posts', posts)
중첩 라우트는 루트 앱의 base path와 타입을 상속받으므로 RPC 클라이언트가 전체 구조를 봅니다.
Context 객체
각 핸들러는 c — 현재 요청의 Context — 를 받습니다. 입력을 읽고 출력을 생성하기 위해 배워야 할 유일한 API입니다.
app.post('/echo', async (c) => { // 읽기 const userAgent = c.req.header('User-Agent') const page = c.req.query('page') const body = await c.req.json() const env = c.env // 바인딩 (Cloudflare의 KV, D1, secrets) // 미들웨어와 핸들러 간 공유 변수 c.set('requestId', crypto.randomUUID()) const id = c.get('requestId') // 응답 c.header('X-Request-Id', id) c.status(200) return c.json({ userAgent, page, body, id }) })
주요 응답 메서드는 c.text, c.json, c.html, c.body (raw), c.redirect. 모두 두 번째 인자로 상태 코드를 받습니다.
미들웨어: 어니언 모델
미들웨어는 핸들러 전후로 코드를 실행할 수 있는 (c, next) => ... 함수입니다. 합성하면 고전적인 어니언 모델을 형성합니다. 처음 등록된 미들웨어가 가장 먼저 시작하고 가장 나중에 끝납니다.
import { Hono } from 'hono' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { secureHeaders } from 'hono/secure-headers' const app = new Hono() app.use('*', logger()) app.use('*', secureHeaders()) app.use('/api/*', cors({ origin: 'https://spinny.dev' })) app.use('*', async (c, next) => { const start = performance.now() await next() c.header('X-Response-Time', `${performance.now() - start}ms`) })
await next()는 다음 미들웨어로 제어를 넘깁니다. 그 이후의 모든 코드는 핸들러가 결과를 생성한 후 응답 단계에서 실행됩니다. next() 전에 Response를 반환하면 체인을 단축합니다.
내장 미들웨어
Hono는 풍부한 프로덕션 준비 미들웨어를 제공하며 hono/...에서 임포트할 수 있습니다:
| 미들웨어 | 용도 |
|---|---|
logger | 메서드, 경로, 상태, 소요시간의 구조화된 로그 |
cors | origin, 메서드, 헤더로 설정 가능한 CORS |
csrf | origin 기반 CSRF 보호 |
secureHeaders | CSP, HSTS, X-Frame-Options 설정 |
bearerAuth / basicAuth | 즉시 사용 가능한 Bearer/Basic 인증 |
jwt | jose로 JWT 검증/서명 |
etag | ETag 생성 및 304 처리 |
cache | Web Cache API 캐싱 |
compress | 응답 gzip/deflate |
bodyLimit | 임계값 초과 body 거부 |
timing | 프로파일링용 Server-Timing 헤더 |
타입 안전한 커스텀 미들웨어
Context를 타입화된 변수로 확장하려면 createMiddleware를 사용하세요:
import { createMiddleware } from 'hono/factory' type AuthVars = { userId: string; role: 'user' | 'admin' } export const requireAuth = createMiddleware<{ Variables: AuthVars }>( async (c, next) => { const token = c.req.header('Authorization')?.replace('Bearer ', '') if (!token) return c.json({ error: 'Unauthorized' }, 401) const payload = await verifyJwt(token) c.set('userId', payload.sub) c.set('role', payload.role) await next() } ) // 사용 app.get('/me', requireAuth, (c) => { const userId = c.var.userId // string, 타입화됨 return c.json({ userId }) })
미들웨어 다운스트림에서 c.var.userId는 캐스트 없이 타입화됩니다. 이는 전체 체인에 전파됩니다.
Zod로 검증
@hono/zod-validator는 Zod를 요청 사이클에 연결합니다. 스키마를 정의하고 라우트에 적용하면 이미 검증되고 타입화된 입력을 받습니다.
import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const createPost = z.object({ title: z.string().min(1).max(200), body: z.string().min(1), tags: z.array(z.string()).default([]), }) app.post( '/posts', zValidator('json', createPost), (c) => { const data = c.req.valid('json') // 스키마로 타입화 return c.json({ ok: true, post: data }, 201) } )
body가 검증에 실패하면 Hono는 핸들러를 호출하기도 전에 Zod 에러와 함께 400을 응답합니다. query, param, header, cookie, form도 검증할 수 있습니다.
RPC: 엔드투엔드 타입 안전 클라이언트
Hono를 진정으로 차별화하는 기능이 RPC 모드입니다. 서버에서 앱 타입을 export 하고, hc 클라이언트가 import 하면, 코드 생성이나 OpenAPI 없이 경로, 쿼리, body, 헤더, 응답을 포함한 완전한 자동완성을 얻습니다.
// server.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const app = new Hono() .get('/posts/:id', (c) => c.json({ id: c.req.param('id'), title: 'Hello' }) ) .post( '/posts', zValidator('json', z.object({ title: z.string(), body: z.string() })), (c) => c.json({ ok: true }, 201) ) export type AppType = typeof app export default app
// client.ts import { hc } from 'hono/client' import type { AppType } from './server' const client = hc<AppType>('https://api.spinny.dev') const res = await client.posts[':id'].$get({ param: { id: '42' } }) if (res.ok) { const data = await res.json() // { id: string, title: string } console.log(data.title) } const created = await client.posts.$post({ json: { title: '안녕', body: 'Hono는 불꽃' }, // 검증됨 })
서버에서 라우트 이름을 변경하면 CI에서 클라이언트 TypeScript가 즉시 깨집니다. tRPC와 같은 이점이지만 표준 HTTP 위에서, 특정 미들웨어 없이, 작은 번들로 동작합니다.
상태 코드 식별
다른 상태를 반환하면 클라이언트가 자동으로 식별합니다.
.get('/posts/:id', (c) => { const post = findPost(c.req.param('id')) if (!post) return c.json({ error: 'not found' }, 404) return c.json({ post }, 200) })
const res = await client.posts[':id'].$get({ param: { id } }) if (res.status === 404) { const { error } = await res.json() // { error: string } } if (res.status === 200) { const { post } = await res.json() // { post: Post } }
라우터와 성능
Hono는 다른 트레이드오프를 가진 다섯 가지 라우터를 제공합니다. 기본값은 SmartRouter로, 시작 시 어떤 라우터가 라우트에 가장 잘 맞는지 측정하고 거기에 고정합니다.
| 라우터 | 강점 | 사용 시기 |
|---|---|---|
RegExpRouter | 최고 속도, 컴파일된 정규식 | 대부분 API의 기본값 |
TrieRouter | 모든 패턴 지원 | RegExp가 처리할 수 없는 복잡한 패턴 |
SmartRouter | 자동으로 최적 선택 | 권장 기본값 |
LinearRouter | 초고속 등록 | 원샷 워커, 콜드 스타트가 중요한 경우 |
PatternRouter | 최소 번들 (<15KB) | 극도의 크기 제약 |
콜드 스타트가 빈번한 무상태 워커의 경우 LinearRouter는 초기 컴파일 비용을 건너뛰고 등록과 매칭을 측정할 때 find-my-way보다 33배 빠릅니다.
import { Hono } from 'hono' import { LinearRouter } from 'hono/router/linear-router' const app = new Hono({ router: new LinearRouter() })
멀티 런타임 배포
동일한 export default app이 진입 파일 하나만 바꿔서 타겟을 전환합니다.
Cloudflare Workers
// src/index.ts import { Hono } from 'hono' type Bindings = { MY_KV: KVNamespace; DB: D1Database } const app = new Hono<{ Bindings: Bindings }>() app.get('/cache/:key', async (c) => { const value = await c.env.MY_KV.get(c.req.param('key')) return c.json({ value }) }) export default app
배포: npx wrangler deploy. KV/D1/R2/Queues 바인딩은 네이티브.
Bun
import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Bun + Hono')) Bun.serve({ fetch: app.fetch, port: 3000 })
Node.js
import { serve } from '@hono/node-server' import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Node + Hono')) serve({ fetch: app.fetch, port: 3000 })
Deno
import { Hono } from 'jsr:@hono/hono' const app = new Hono() app.get('/', (c) => c.text('Deno + Hono')) Deno.serve(app.fetch)
Vercel
// api/[[...route]].ts import { Hono } from 'hono' import { handle } from 'hono/vercel' const app = new Hono().basePath('/api') app.get('/hello', (c) => c.json({ msg: 'Hello from Vercel' })) export const GET = handle(app) export const POST = handle(app)
같은 비즈니스 코드, 같은 라우팅, 같은 미들웨어. 어댑터만 바뀝니다.
실전 예시: 인증과 DB가 있는 REST API
조각들을 모읍니다. Cloudflare Workers + D1 위의 블로그 API, JWT 인증, 검증, RPC 포함.
import { Hono } from 'hono' import { jwt } from 'hono/jwt' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' type Bindings = { DB: D1Database; JWT_SECRET: string } type Variables = { jwtPayload: { sub: string } } const app = new Hono<{ Bindings: Bindings; Variables: Variables }>() app.use('*', logger()) app.use('/api/*', cors({ origin: 'https://spinny.dev', credentials: true })) const auth = (c: any, next: any) => jwt({ secret: c.env.JWT_SECRET })(c, next) const api = app.basePath('/api') api.get('/posts', async (c) => { const { results } = await c.env.DB .prepare('SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50') .all() return c.json({ posts: results }) }) api.post( '/posts', auth, zValidator('json', z.object({ title: z.string().min(1).max(200), body: z.string().min(1), })), async (c) => { const { title, body } = c.req.valid('json') const userId = c.var.jwtPayload.sub const result = await c.env.DB .prepare('INSERT INTO posts (title, body, author_id) VALUES (?, ?, ?) RETURNING id') .bind(title, body, userId) .first<{ id: number }>() return c.json({ id: result?.id }, 201) } ) api.onError((err, c) => { console.error(err) return c.json({ error: 'Internal error' }, 500) }) export type AppType = typeof api export default app
React 클라이언트는 완전한 타입 안전성으로 소비합니다:
import { hc } from 'hono/client' import type { AppType } from '../api/src/index' const api = hc<AppType>(import.meta.env.VITE_API_URL) const res = await api.posts.$post({ json: { title: '2026년의 Hono', body: '...' }, }, { headers: { Authorization: `Bearer ${token}` }, })
테스트
app.request()는 HTTP 서버를 띄우지 않고 라우트를 테스트하게 해줍니다. 프로덕션에서 실행하는 것과 같은 경로가 메모리에서 실행됩니다.
import { describe, it, expect } from 'vitest' import app from '../src/index' describe('GET /api/posts', () => { it('포스트 목록을 반환한다', async () => { const res = await app.request('/api/posts') expect(res.status).toBe(200) const body = await res.json() expect(body.posts).toBeInstanceOf(Array) }) })
Cloudflare Workers에서는 @cloudflare/vitest-pool-workers가 모의 바인딩이 있는 실제 Worker 내부에서 동일한 테스트를 실행합니다 — 최대한의 현실성, 제로 배포.
모범 사례
1. 라우트 정의를 체이닝
const app = new Hono() .get('/posts', handler1) .post('/posts', handler2) .get('/posts/:id', handler3)
체이닝은 모든 라우트마다 app의 타입을 최신으로 유지하며, 이는 RPC 클라이언트에 필수입니다. 별도 라인의 app.get(...)로 따로 정의하면 추론이 깨집니다.
2. 구현이 아닌 타입을 export
클라이언트는 앱이 아닌 AppType을 import 해야 합니다. import type이 프론트엔드 빌드에 백엔드 코드가 포함되지 않도록 보장합니다.
3. 도메인당 하나의 라우터
posts용 서브 앱, users용, webhooks용. app.route()로 구성하고 각자 자신의 미들웨어를 가집니다. 구조는 메가 파일 없이 확장됩니다.
4. 항상 경계에서 검증
모든 외부 입력 (body, query, header)은 zValidator를 거쳐야 합니다. 다운스트림 데이터를 신뢰하지 마세요. 런타임 검증 없는 TypeScript 캐스트조차 잠재된 버그입니다.
5. 글로벌 클라이언트가 아닌 바인딩에 의존
Cloudflare에서는 c.env를 통해 KV/D1/R2에 접근합니다. 글로벌 싱글톤 없음, Worker 간 지속되는 연결 없음. 무상태 모델은 기능이지 제한이 아닙니다.
6. 라우터 최적화 전 측정
기본 SmartRouter는 95%의 경우에 적합합니다. 프로파일링 후 실제 병목을 본 다음에만 라우터를 전환하세요.
결론
Hono는 2026년 TypeScript로 엣지 지원 API를 구축하는 사실상의 표준이 되었습니다. 웹 표준, 성능, 타입 안전성, 이식성의 결합은 전통적인 프레임워크를 가로막던 문제 — 런타임 락인, 무거운 번들, 클라이언트와 서버 사이의 깨지기 쉬운 타입 시스템 — 을 정확히 해결합니다.
Cloudflare Workers의 마이크로서비스, Node 모놀리스의 Express 교체, Vercel의 함수, Bun의 API에 사용할 것입니다. 같은 지식이 어디서나 전이됩니다 — 그것이 향후 몇 년 동안 견고한 투자가 되는 이유입니다.
시작 체크리스트:
npm create hono@latest로 런타임 템플릿을 선택- 체이닝으로 라우트 정의 (
.get(...).post(...))logger,cors,secureHeaders를 글로벌 미들웨어로 추가@hono/zod-validator로 모든 입력 검증AppType을 export 하고 타입 안전한hc클라이언트로 API 소비- HTTP 서버 없이
app.request()로 테스트 작성wrangler deploy(CF),vercel deploy또는 런타임의 번들러로 배포