spinny:~/writing $ vim hono-framework-guide.md
1~2수년간 JavaScript 웹 프레임워크 선택은 트레이드오프 수용을 의미했습니다. Express는 보편적이지만 느리고 Node에 묶여 있고, Fastify는 빠르지만 Node 전용, Next.js는 풍부하지만 무겁습니다. 엣지 런타임 — Cloudflare Workers, Deno Deploy, Bun, Vercel Edge — 이 등장하자 이 프레임워크들은 한계를 드러냈습니다. 호환되지 않는 의존성, 거대한 번들, Node의 `req`/`res`에 묶인 API.3~4[Hono](https://hono.dev) (일본어로 "불꽃" 🔥) 는 현대적인 답입니다. `Request`, `Response`, `fetch` 라는 웹 표준 위에 완전히 구축된 14KB 미만의 프레임워크로, JavaScript 런타임이 있는 곳이라면 어디서든 동작합니다. 동일한 코드가 Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify, AWS Lambda에 변경 없이 배포됩니다.5~6## 왜 Hono인가7~8Hono는 세 가지를 다른 누구보다 잘합니다.9~101. **성능.** `RegExpRouter`는 모든 라우트 패턴을 단일 정규식으로 컴파일하여 전통적인 라우터의 선형 루프를 피합니다. 벤치마크는 초당 40만 ops를 넘어 Hono를 JavaScript 생태계에서 가장 빠른 라우터 중 하나로 만듭니다.112. **이식성.** 웹 표준이라는 것은 Node 의존성 제로를 의미합니다. 동일한 `app.fetch`가 Cloudflare Worker에서 default export 되고, `Bun.serve`에 전달되고, Deno 서버에 마운트되고, `@hono/node-server`로 어댑트됩니다.123. **TypeScript 우선 DX.** 리터럴 타입으로 추론되는 path 파라미터, 엔드투엔드 타입 안전 RPC 클라이언트, 입출력 타입을 추론하는 검증기. 자동완성은 거의 텔레파시 수준입니다.13~14```mermaid15graph LR16 Client[Client] -->|Request| App[app.fetch]17 App --> MW1[Middleware 1]18 MW1 --> MW2[Middleware 2]19 MW2 --> Router[RegExpRouter]20 Router --> Handler[Route Handler]21 Handler --> Context[c.json / c.text]22 Context -->|Response| Client23 App -.->|deploy| CF[Cloudflare Workers]24 App -.->|deploy| Bun[Bun]25 App -.->|deploy| Deno[Deno]26 App -.->|deploy| Node[Node.js]27 App -.->|deploy| Vercel[Vercel]28```29~30## 시작하기31~32가장 빠른 길은 공식 스타터로, 선택한 런타임용으로 프로젝트를 스캐폴딩합니다.33~34```bash35npm create hono@latest my-api36cd my-api37npm install38npm run dev39```40~41스타터는 사용할 템플릿을 묻습니다: `cloudflare-workers`, `bun`, `deno`, `nodejs`, `vercel`, `aws-lambda`, `nextjs` 등. 적절한 설정과 배포 스크립트를 선택합니다. 빠르게 시도하려면 단일 파일에서도 시작할 수 있습니다:42~43```typescript44// src/index.ts45import { Hono } from 'hono'46~47const app = new Hono()48~49app.get('/', (c) => c.text('Hello Hono!'))50~51export default app52```53~54Cloudflare Workers에서는 이것으로 충분합니다. Bun에서는: `Bun.serve({ fetch: app.fetch, port: 3000 })`. Node에서는: `@hono/node-server`의 `serve({ fetch: app.fetch })`. 표면은 동일합니다.55~56## 라우팅57~58라우트는 HTTP 동사 메서드로 선언하고 파라미터, 와일드카드, 정규식을 지원합니다.59~60```typescript61import { Hono } from 'hono'62~63const app = new Hono()64~65app.get('/', (c) => c.text('Home'))66app.get('/posts/:id', (c) => {67 const id = c.req.param('id') // string, 타입화됨68 return c.json({ id })69})70app.get('/posts/:id/comments/:commentId', (c) => {71 const { id, commentId } = c.req.param()72 return c.json({ id, commentId })73})74app.get('/files/*', (c) => c.text('Wildcard'))75app.post('/posts', async (c) => {76 const body = await c.req.json()77 return c.json({ created: body }, 201)78})79```80~81파라미터는 리터럴 타입으로 추론됩니다. TypeScript는 패턴에 `:id`를 선언한 경우에만 `c.req.param('id')`가 `string`을 반환한다는 것을 압니다. 이름을 잘못 쓰면 컴파일 타임 에러입니다.82~83### 라우트 그룹화84~85`app.route()`는 서브 애플리케이션을 모듈로 구성하게 해주며, 각각 자체 prefix를 가집니다.86~87```typescript88// routes/posts.ts89import { Hono } from 'hono'90~91const posts = new Hono()92posts.get('/', (c) => c.json({ posts: [] }))93posts.get('/:id', (c) => c.json({ id: c.req.param('id') }))94export default posts95~96// src/index.ts97import { Hono } from 'hono'98import posts from './routes/posts'99~100const app = new Hono()101app.route('/posts', posts)102```103~104중첩 라우트는 루트 앱의 base path와 타입을 상속받으므로 RPC 클라이언트가 전체 구조를 봅니다.105~106## Context 객체107~108각 핸들러는 `c` — 현재 요청의 Context — 를 받습니다. 입력을 읽고 출력을 생성하기 위해 배워야 할 유일한 API입니다.109~110```typescript111app.post('/echo', async (c) => {112 // 읽기113 const userAgent = c.req.header('User-Agent')114 const page = c.req.query('page')115 const body = await c.req.json()116 const env = c.env // 바인딩 (Cloudflare의 KV, D1, secrets)117~118 // 미들웨어와 핸들러 간 공유 변수119 c.set('requestId', crypto.randomUUID())120 const id = c.get('requestId')121~122 // 응답123 c.header('X-Request-Id', id)124 c.status(200)125 return c.json({ userAgent, page, body, id })126})127```128~129주요 응답 메서드는 `c.text`, `c.json`, `c.html`, `c.body` (raw), `c.redirect`. 모두 두 번째 인자로 상태 코드를 받습니다.130~131## 미들웨어: 어니언 모델132~133미들웨어는 핸들러 전후로 코드를 실행할 수 있는 `(c, next) => ...` 함수입니다. 합성하면 고전적인 어니언 모델을 형성합니다. 처음 등록된 미들웨어가 가장 먼저 시작하고 가장 나중에 끝납니다.134~135```typescript136import { Hono } from 'hono'137import { logger } from 'hono/logger'138import { cors } from 'hono/cors'139import { secureHeaders } from 'hono/secure-headers'140~141const app = new Hono()142~143app.use('*', logger())144app.use('*', secureHeaders())145app.use('/api/*', cors({ origin: 'https://spinny.dev' }))146~147app.use('*', async (c, next) => {148 const start = performance.now()149 await next()150 c.header('X-Response-Time', `${performance.now() - start}ms`)151})152```153~154`await next()`는 다음 미들웨어로 제어를 넘깁니다. 그 이후의 모든 코드는 핸들러가 결과를 생성한 후 응답 단계에서 실행됩니다. `next()` 전에 `Response`를 반환하면 체인을 단축합니다.155~156### 내장 미들웨어157~158Hono는 풍부한 프로덕션 준비 미들웨어를 제공하며 `hono/...`에서 임포트할 수 있습니다:159~160| 미들웨어 | 용도 |161|-----------|---------|162| `logger` | 메서드, 경로, 상태, 소요시간의 구조화된 로그 |163| `cors` | origin, 메서드, 헤더로 설정 가능한 CORS |164| `csrf` | origin 기반 CSRF 보호 |165| `secureHeaders` | CSP, HSTS, X-Frame-Options 설정 |166| `bearerAuth` / `basicAuth` | 즉시 사용 가능한 Bearer/Basic 인증 |167| `jwt` | `jose`로 JWT 검증/서명 |168| `etag` | ETag 생성 및 304 처리 |169| `cache` | Web Cache API 캐싱 |170| `compress` | 응답 gzip/deflate |171| `bodyLimit` | 임계값 초과 body 거부 |172| `timing` | 프로파일링용 Server-Timing 헤더 |173~174### 타입 안전한 커스텀 미들웨어175~176`Context`를 타입화된 변수로 확장하려면 `createMiddleware`를 사용하세요:177~178```typescript179import { createMiddleware } from 'hono/factory'180~181type AuthVars = { userId: string; role: 'user' | 'admin' }182~183export const requireAuth = createMiddleware<{ Variables: AuthVars }>(184 async (c, next) => {185 const token = c.req.header('Authorization')?.replace('Bearer ', '')186 if (!token) return c.json({ error: 'Unauthorized' }, 401)187~188 const payload = await verifyJwt(token)189 c.set('userId', payload.sub)190 c.set('role', payload.role)191 await next()192 }193)194~195// 사용196app.get('/me', requireAuth, (c) => {197 const userId = c.var.userId // string, 타입화됨198 return c.json({ userId })199})200```201~202미들웨어 다운스트림에서 `c.var.userId`는 캐스트 없이 타입화됩니다. 이는 전체 체인에 전파됩니다.203~204## Zod로 검증205~206`@hono/zod-validator`는 Zod를 요청 사이클에 연결합니다. 스키마를 정의하고 라우트에 적용하면 이미 검증되고 타입화된 입력을 받습니다.207~208```typescript209import { Hono } from 'hono'210import { zValidator } from '@hono/zod-validator'211import { z } from 'zod'212~213const createPost = z.object({214 title: z.string().min(1).max(200),215 body: z.string().min(1),216 tags: z.array(z.string()).default([]),217})218~219app.post(220 '/posts',221 zValidator('json', createPost),222 (c) => {223 const data = c.req.valid('json') // 스키마로 타입화224 return c.json({ ok: true, post: data }, 201)225 }226)227```228~229body가 검증에 실패하면 Hono는 핸들러를 호출하기도 전에 Zod 에러와 함께 400을 응답합니다. `query`, `param`, `header`, `cookie`, `form`도 검증할 수 있습니다.230~231## RPC: 엔드투엔드 타입 안전 클라이언트232~233Hono를 진정으로 차별화하는 기능이 **RPC 모드**입니다. 서버에서 앱 타입을 export 하고, `hc` 클라이언트가 import 하면, 코드 생성이나 OpenAPI 없이 경로, 쿼리, body, 헤더, 응답을 포함한 완전한 자동완성을 얻습니다.234~235```typescript236// server.ts237import { Hono } from 'hono'238import { zValidator } from '@hono/zod-validator'239import { z } from 'zod'240~241const app = new Hono()242 .get('/posts/:id', (c) =>243 c.json({ id: c.req.param('id'), title: 'Hello' })244 )245 .post(246 '/posts',247 zValidator('json', z.object({ title: z.string(), body: z.string() })),248 (c) => c.json({ ok: true }, 201)249 )250~251export type AppType = typeof app252export default app253```254~255```typescript256// client.ts257import { hc } from 'hono/client'258import type { AppType } from './server'259~260const client = hc<AppType>('https://api.spinny.dev')261~262const res = await client.posts[':id'].$get({ param: { id: '42' } })263if (res.ok) {264 const data = await res.json() // { id: string, title: string }265 console.log(data.title)266}267~268const created = await client.posts.$post({269 json: { title: '안녕', body: 'Hono는 불꽃' }, // 검증됨270})271```272~273서버에서 라우트 이름을 변경하면 CI에서 클라이언트 TypeScript가 즉시 깨집니다. tRPC와 같은 이점이지만 표준 HTTP 위에서, 특정 미들웨어 없이, 작은 번들로 동작합니다.274~275### 상태 코드 식별276~277다른 상태를 반환하면 클라이언트가 자동으로 식별합니다.278~279```typescript280.get('/posts/:id', (c) => {281 const post = findPost(c.req.param('id'))282 if (!post) return c.json({ error: 'not found' }, 404)283 return c.json({ post }, 200)284})285```286~287```typescript288const res = await client.posts[':id'].$get({ param: { id } })289if (res.status === 404) {290 const { error } = await res.json() // { error: string }291}292if (res.status === 200) {293 const { post } = await res.json() // { post: Post }294}295```296~297## 라우터와 성능298~299Hono는 다른 트레이드오프를 가진 다섯 가지 라우터를 제공합니다. 기본값은 `SmartRouter`로, 시작 시 어떤 라우터가 라우트에 가장 잘 맞는지 측정하고 거기에 고정합니다.300~301| 라우터 | 강점 | 사용 시기 |302|--------|-----------|-------------|303| `RegExpRouter` | 최고 속도, 컴파일된 정규식 | 대부분 API의 기본값 |304| `TrieRouter` | 모든 패턴 지원 | RegExp가 처리할 수 없는 복잡한 패턴 |305| `SmartRouter` | 자동으로 최적 선택 | 권장 기본값 |306| `LinearRouter` | 초고속 등록 | 원샷 워커, 콜드 스타트가 중요한 경우 |307| `PatternRouter` | 최소 번들 (<15KB) | 극도의 크기 제약 |308~309콜드 스타트가 빈번한 무상태 워커의 경우 `LinearRouter`는 초기 컴파일 비용을 건너뛰고 등록과 매칭을 측정할 때 `find-my-way`보다 33배 빠릅니다.310~311```typescript312import { Hono } from 'hono'313import { LinearRouter } from 'hono/router/linear-router'314~315const app = new Hono({ router: new LinearRouter() })316```317~318## 멀티 런타임 배포319~320동일한 `export default app`이 진입 파일 하나만 바꿔서 타겟을 전환합니다.321~322### Cloudflare Workers323~324```typescript325// src/index.ts326import { Hono } from 'hono'327~328type Bindings = { MY_KV: KVNamespace; DB: D1Database }329const app = new Hono<{ Bindings: Bindings }>()330~331app.get('/cache/:key', async (c) => {332 const value = await c.env.MY_KV.get(c.req.param('key'))333 return c.json({ value })334})335~336export default app337```338~339배포: `npx wrangler deploy`. KV/D1/R2/Queues 바인딩은 네이티브.340~341### Bun342~343```typescript344import { Hono } from 'hono'345const app = new Hono()346app.get('/', (c) => c.text('Bun + Hono'))347~348Bun.serve({ fetch: app.fetch, port: 3000 })349```350~351### Node.js352~353```typescript354import { serve } from '@hono/node-server'355import { Hono } from 'hono'356~357const app = new Hono()358app.get('/', (c) => c.text('Node + Hono'))359~360serve({ fetch: app.fetch, port: 3000 })361```362~363### Deno364~365```typescript366import { Hono } from 'jsr:@hono/hono'367const app = new Hono()368app.get('/', (c) => c.text('Deno + Hono'))369Deno.serve(app.fetch)370```371~372### Vercel373~374```typescript375// api/[[...route]].ts376import { Hono } from 'hono'377import { handle } from 'hono/vercel'378~379const app = new Hono().basePath('/api')380app.get('/hello', (c) => c.json({ msg: 'Hello from Vercel' }))381~382export const GET = handle(app)383export const POST = handle(app)384```385~386같은 비즈니스 코드, 같은 라우팅, 같은 미들웨어. 어댑터만 바뀝니다.387~388## 실전 예시: 인증과 DB가 있는 REST API389~390조각들을 모읍니다. Cloudflare Workers + D1 위의 블로그 API, JWT 인증, 검증, RPC 포함.391~392```typescript393import { Hono } from 'hono'394import { jwt } from 'hono/jwt'395import { logger } from 'hono/logger'396import { cors } from 'hono/cors'397import { zValidator } from '@hono/zod-validator'398import { z } from 'zod'399~400type Bindings = { DB: D1Database; JWT_SECRET: string }401type Variables = { jwtPayload: { sub: string } }402~403const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()404~405app.use('*', logger())406app.use('/api/*', cors({ origin: 'https://spinny.dev', credentials: true }))407~408const auth = (c: any, next: any) =>409 jwt({ secret: c.env.JWT_SECRET })(c, next)410~411const api = app.basePath('/api')412~413api.get('/posts', async (c) => {414 const { results } = await c.env.DB415 .prepare('SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50')416 .all()417 return c.json({ posts: results })418})419~420api.post(421 '/posts',422 auth,423 zValidator('json', z.object({424 title: z.string().min(1).max(200),425 body: z.string().min(1),426 })),427 async (c) => {428 const { title, body } = c.req.valid('json')429 const userId = c.var.jwtPayload.sub430 const result = await c.env.DB431 .prepare('INSERT INTO posts (title, body, author_id) VALUES (?, ?, ?) RETURNING id')432 .bind(title, body, userId)433 .first<{ id: number }>()434 return c.json({ id: result?.id }, 201)435 }436)437~438api.onError((err, c) => {439 console.error(err)440 return c.json({ error: 'Internal error' }, 500)441})442~443export type AppType = typeof api444export default app445```446~447React 클라이언트는 완전한 타입 안전성으로 소비합니다:448~449```typescript450import { hc } from 'hono/client'451import type { AppType } from '../api/src/index'452~453const api = hc<AppType>(import.meta.env.VITE_API_URL)454~455const res = await api.posts.$post({456 json: { title: '2026년의 Hono', body: '...' },457}, {458 headers: { Authorization: `Bearer ${token}` },459})460```461~462## 테스트463~464`app.request()`는 HTTP 서버를 띄우지 않고 라우트를 테스트하게 해줍니다. 프로덕션에서 실행하는 것과 같은 경로가 메모리에서 실행됩니다.465~466```typescript467import { describe, it, expect } from 'vitest'468import app from '../src/index'469~470describe('GET /api/posts', () => {471 it('포스트 목록을 반환한다', async () => {472 const res = await app.request('/api/posts')473 expect(res.status).toBe(200)474 const body = await res.json()475 expect(body.posts).toBeInstanceOf(Array)476 })477})478```479~480Cloudflare Workers에서는 `@cloudflare/vitest-pool-workers`가 모의 바인딩이 있는 실제 Worker 내부에서 동일한 테스트를 실행합니다 — 최대한의 현실성, 제로 배포.481~482## 모범 사례483~484### 1. 라우트 정의를 체이닝485~486```typescript487const app = new Hono()488 .get('/posts', handler1)489 .post('/posts', handler2)490 .get('/posts/:id', handler3)491```492~493체이닝은 모든 라우트마다 `app`의 타입을 최신으로 유지하며, 이는 RPC 클라이언트에 필수입니다. 별도 라인의 `app.get(...)`로 따로 정의하면 추론이 깨집니다.494~495### 2. 구현이 아닌 타입을 export496~497클라이언트는 앱이 아닌 `AppType`을 import 해야 합니다. `import type`이 프론트엔드 빌드에 백엔드 코드가 포함되지 않도록 보장합니다.498~499### 3. 도메인당 하나의 라우터500~501`posts`용 서브 앱, `users`용, `webhooks`용. `app.route()`로 구성하고 각자 자신의 미들웨어를 가집니다. 구조는 메가 파일 없이 확장됩니다.502~503### 4. 항상 경계에서 검증504~505모든 외부 입력 (body, query, header)은 `zValidator`를 거쳐야 합니다. 다운스트림 데이터를 신뢰하지 마세요. 런타임 검증 없는 TypeScript 캐스트조차 잠재된 버그입니다.506~507### 5. 글로벌 클라이언트가 아닌 바인딩에 의존508~509Cloudflare에서는 `c.env`를 통해 KV/D1/R2에 접근합니다. 글로벌 싱글톤 없음, Worker 간 지속되는 연결 없음. 무상태 모델은 기능이지 제한이 아닙니다.510~511### 6. 라우터 최적화 전 측정512~513기본 `SmartRouter`는 95%의 경우에 적합합니다. 프로파일링 후 실제 병목을 본 다음에만 라우터를 전환하세요.514~515## 결론516~517Hono는 2026년 TypeScript로 엣지 지원 API를 구축하는 사실상의 표준이 되었습니다. 웹 표준, 성능, 타입 안전성, 이식성의 결합은 전통적인 프레임워크를 가로막던 문제 — 런타임 락인, 무거운 번들, 클라이언트와 서버 사이의 깨지기 쉬운 타입 시스템 — 을 정확히 해결합니다.518~519Cloudflare Workers의 마이크로서비스, Node 모놀리스의 Express 교체, Vercel의 함수, Bun의 API에 사용할 것입니다. 같은 지식이 어디서나 전이됩니다 — 그것이 향후 몇 년 동안 견고한 투자가 되는 이유입니다.520~521> **시작 체크리스트:**522>523> - [x] `npm create hono@latest`로 런타임 템플릿을 선택524> - [x] 체이닝으로 라우트 정의 (`.get(...).post(...)`)525> - [x] `logger`, `cors`, `secureHeaders`를 글로벌 미들웨어로 추가526> - [x] `@hono/zod-validator`로 모든 입력 검증527> - [x] `AppType`을 export 하고 타입 안전한 `hc` 클라이언트로 API 소비528> - [x] HTTP 서버 없이 `app.request()`로 테스트 작성529> - [x] `wrangler deploy` (CF), `vercel deploy` 또는 런타임의 번들러로 배포530~
NORMAL · hono-framework-guide.md [readonly]530 lines · :q to close