Hono 완전 가이드 | Edge에 최적화된 초고속 웹 프레임워크
이 글의 핵심
Express를 대체하는 차세대 Edge 웹 프레임워크 Hono. Cloudflare Workers·Vercel·Deno·Bun 등 모든 런타임에서 작동하며, Express보다 10배 빠르고 TypeScript 타입 안전성을 제공합니다.
이 글의 핵심
Hono는 Express보다 10배 빠른 Edge 웹 프레임워크입니다. Cloudflare Workers·Vercel·Deno·Bun 등 모든 런타임에서 작동하며, TypeScript 타입 안전성과 미들웨어 시스템으로 생산성을 극대화합니다. 번들 크기는 12KB에 불과합니다.
목차
- Hono의 핵심 아키텍처 (Web Standard·라우터)
- Hono란?
- 성능 벤치마크와 해석
- Cloudflare Workers 최적화
- 미들웨어 체인
- JWT 인증
- Zod 통합
- Edge Runtime 전략
- 프로덕션 배포
- 타입 안정성
Hono의 핵심 아키텍처: Web Standard와 초경량 라우터
Hono가 “가볍다”고 말할 때, 그 의미는 단순히 npm 패키지 용량이 작다는 뜻만이 아닙니다. Node.js 전용 IncomingMessage/ServerResponse 모델에 얽매이지 않고, Request / Response / URL / Headers로 표준화된 Web Standard API 위에서 동작하도록 설계되었습니다. 그 결과 같은 애플리케이션 엔트리(app.fetch)를 Cloudflare Workers, Deno, Bun, Node(어댑터) 등으로 그대로 옮길 수 있습니다.
라우터는 사전에 트리를 매우 무겁게 만들기보다, 경로·메서드 조합에 맞는 정규식·매처를 최소로 유지하는 쪽에 가깝고, 동적 파라미터(:id, *)는 텍스트 한 번 훑는 수준의 비용으로 해석됩니다. 개인적으로 사이드 프로젝트 API를 Express에서 Hono·Workers로 옮겼을 때, 첫엔 “라우트 등록이 단순해 보이는데 괜찮나” 싶었지만, 트래픽이 커질수록 “엣지에서 동일한 코드가 돈다”는 이점이 체감됐습니다. 특히 정적 경로 + 동적 경로를 app.route로 쪼개면 인지 부하(팀이 읽기 쉬움)와 빌드/배포 단위를 동시에 맞출 수 있었습니다.
미들웨어는 Express와 비슷하게 (c, next) => 형태지만, 컨텍스트 c가 Request/Response의 얇은 래퍼라서 await next() 이후에 이어지는 후처리(응답 시간 측정, 공통 헤더, 감사 로그)에 적합합니다. 이는 Edge에서 요청이 짧고 상태가 흩어지는 환경에서 특히 중요합니다. 아래에서 다루듯, “체인”을 팩토리로 모듈화해 두면 팀 합의 없이도 동작 단위를 재사용하기 좋습니다.
Hono란?
Hono(炎, 일본어로 “불꽃”)는 2022년 Yusuke Wada가 개발한 Edge 우선 웹 프레임워크입니다.
🚀 핵심 특징
1. 모든 런타임 지원
// 같은 코드가 모든 런타임에서 작동
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('Hello!'));
// Cloudflare Workers
export default app;
// Deno
Deno.serve(app.fetch);
// Bun
export default { port: 3000, fetch: app.fetch };
// Node.js
serve(app);
2. Express보다 10배 빠름
벤치마크 (초당 요청):
Express: 25,000 req/s
Fastify: 65,000 req/s
Hono: 250,000 req/s
3. TypeScript 타입 안전성
// 라우트와 응답 타입이 자동 추론됨
app.get('/user/:id', (c) => {
const id = c.req.param('id'); // string으로 추론
return c.json({ id, name: 'Alice' });
});
4. 가벼움
Express: 210KB
Fastify: 450KB
Hono: 12KB
Hono 시작하기
Cloudflare Workers
# 프로젝트 생성
npm create hono@latest my-app
# 템플릿 선택: cloudflare-workers
cd my-app
npm install
npm run dev
// src/index.ts
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => {
return c.text('Hello Hono!');
});
export default app;
Node.js
npm install hono @hono/node-server
// index.ts
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Node.js!'));
serve({
fetch: app.fetch,
port: 3000,
});
console.log('Server running at http://localhost:3000');
Bun
bun install hono
// index.ts
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Bun!'));
export default {
port: 3000,
fetch: app.fetch,
};
라우팅
기본 라우트
import { Hono } from 'hono';
const app = new Hono();
// GET
app.get('/hello', (c) => c.text('GET /hello'));
// POST
app.post('/submit', (c) => c.text('POST /submit'));
// PUT
app.put('/update', (c) => c.text('PUT /update'));
// DELETE
app.delete('/remove', (c) => c.text('DELETE /remove'));
// 여러 메서드
app.on(['GET', 'POST'], '/multi', (c) => {
return c.text(`Method: ${c.req.method}`);
});
// 모든 메서드
app.all('/any', (c) => c.text('Any method'));
export default app;
경로 매개변수
// /user/:id
app.get('/user/:id', (c) => {
const id = c.req.param('id');
return c.json({ userId: id });
});
// /post/:id/comment/:commentId
app.get('/post/:id/comment/:commentId', (c) => {
const { id, commentId } = c.req.param();
return c.json({ postId: id, commentId });
});
// 선택적 매개변수
app.get('/user/:id?', (c) => {
const id = c.req.param('id') || 'default';
return c.text(`User: ${id}`);
});
// 와일드카드
app.get('/files/*', (c) => {
return c.text(`Path: ${c.req.path}`);
});
쿼리 파라미터
// /search?q=hello&page=2
app.get('/search', (c) => {
const q = c.req.query('q'); // 'hello'
const page = c.req.query('page'); // '2'
// 또는 한 번에
const { q, page } = c.req.query();
return c.json({ query: q, page });
});
요청/응답
JSON 요청 처리
app.post('/user', async (c) => {
const body = await c.req.json();
return c.json({
message: 'User created',
user: body,
}, 201);
});
// FormData
app.post('/upload', async (c) => {
const formData = await c.req.formData();
const file = formData.get('file');
return c.json({ filename: file?.name });
});
// Text
app.post('/text', async (c) => {
const text = await c.req.text();
return c.text(`Received: ${text}`);
});
응답 타입
// JSON
app.get('/json', (c) => c.json({ message: 'Hello' }));
// Text
app.get('/text', (c) => c.text('Hello'));
// HTML
app.get('/html', (c) => c.html('<h1>Hello</h1>'));
// Redirect
app.get('/redirect', (c) => c.redirect('/new-path'));
// 커스텀 응답
app.get('/custom', (c) => {
return new Response('Custom', {
status: 200,
headers: { 'X-Custom': 'Header' },
});
});
미들웨어
내장 미들웨어
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt';
import { compress } from 'hono/compress';
const app = new Hono();
// 로깅
app.use('*', logger());
// CORS
app.use('*', cors({
origin: 'https://example.com',
credentials: true,
}));
// 압축
app.use('*', compress());
// JWT 인증
app.use('/api/*', jwt({
secret: 'my-secret-key',
}));
app.get('/api/protected', (c) => {
const payload = c.get('jwtPayload');
return c.json({ user: payload });
});
export default app;
커스텀 미들웨어
// 실행 시간 측정
const timing = () => {
return async (c, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
c.res.headers.set('X-Response-Time', `${ms}ms`);
};
};
app.use('*', timing());
// 인증 미들웨어
const auth = () => {
return async (c, next) => {
const token = c.req.header('Authorization');
if (!token) {
return c.json({ error: 'Unauthorized' }, 401);
}
// 토큰 검증...
c.set('userId', 123);
await next();
};
};
app.use('/api/*', auth());
실전 프로젝트: REST API
// src/index.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const app = new Hono();
// In-memory DB (실제로는 D1 사용)
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
// GET /users
app.get('/users', (c) => {
return c.json(users);
});
// GET /users/:id
app.get('/users/:id', (c) => {
const id = parseInt(c.req.param('id'));
const user = users.find(u => u.id === id);
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
return c.json(user);
});
// POST /users (Zod 검증)
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post('/users', zValidator('json', createUserSchema), (c) => {
const body = c.req.valid('json');
const newUser = {
id: users.length + 1,
...body,
};
users.push(newUser);
return c.json(newUser, 201);
});
// PATCH /users/:id
app.patch('/users/:id', async (c) => {
const id = parseInt(c.req.param('id'));
const body = await c.req.json();
const user = users.find(u => u.id === id);
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
Object.assign(user, body);
return c.json(user);
});
// DELETE /users/:id
app.delete('/users/:id', (c) => {
const id = parseInt(c.req.param('id'));
const index = users.findIndex(u => u.id === id);
if (index === -1) {
return c.json({ error: 'User not found' }, 404);
}
users.splice(index, 1);
return c.json({ success: true });
});
export default app;
중첩 라우팅
// users.ts
import { Hono } from 'hono';
const usersApp = new Hono();
usersApp.get('/', (c) => c.json({ users: [] }));
usersApp.get('/:id', (c) => c.json({ id: c.req.param('id') }));
usersApp.post('/', (c) => c.json({ created: true }));
// posts.ts
const postsApp = new Hono();
postsApp.get('/', (c) => c.json({ posts: [] }));
postsApp.get('/:id', (c) => c.json({ id: c.req.param('id') }));
// index.ts
const app = new Hono();
app.route('/users', usersApp);
app.route('/posts', postsApp);
export default app;
Hono vs Express vs Fastify
| 기능 | Hono | Express | Fastify |
|---|---|---|---|
| 속도 | ⚡⚡⚡ 초고속 | ⚡ 보통 | ⚡⚡ 빠름 |
| Edge 지원 | ✅ 완벽 | ❌ 불가 | ❌ 불가 |
| TypeScript | ✅ 기본 | ⚠️ 수동 | ✅ 기본 |
| 번들 크기 | 12KB | 210KB | 450KB |
| 미들웨어 | ✅ | ✅ | ✅ |
| 생태계 | 🌱 새로움 | 🌳 성숙 | 🌿 성장 중 |
Express·Fastify와의 성능 벤치마크 해석
온라인에 돌아다니는 “Hono 250,000 req/s, Express 25,000 req/s” 식의 숫자는 하드웨어, OS, HTTP/1.1 vs HTTP/2, keep-alive, 바디 유무, 런타임(V8 vs Bun), 워밍업 여부에 크게 흔들립니다. 절대값보다 “같은 조건에서의 상대 비교”를 보는 편이 안전합니다.
일반적으로 Fastify는 Node.js 위에서 스키마 기반 직렬화와 효율적인 라우팅으로 Express보다 우위에 서고, Hono는 Web API 네이티브이기 때문에 Workers·Deno·Bun 같은 환경에서 프레임워크 오버헤드 대비 처리량이 좋게 나오는 사례가 많습니다. 반대로 Node에서만 비교할 때는 @hono/node-server·어댑터 오버헤드와 앱 코드(ORM, JSON 파싱)가 병목이 되어 “프레임워크 차이 < 앱 구조 차이”로 수렴하기도 합니다.
개인 경험으로는, 엣지에 올릴 API는 “순수 라우터 성능”보다 KV/D1/외부 API RTT, 직렬화, 캐시 히트율이 지배적이었습니다. 그럼에도 Hono를 고른 이유는 같은 코드를 로컬(Bun)과 프로덕션(Workers)에서 맞출 수 있어 회귀 테스트·스테이징 비용이 줄었기 때문입니다. 벤치는 wrk/k6로 자신의 엔드포인트를 찍어 보는 것이 가장 설득력 있습니다.
Cloudflare Workers 최적화 실전 예제
Workers는 CPU 시간, 서브리퀘스트, 메모리가 전부 “짧고 예측 가능”해야 합니다. Hono는 하나의 fetch 핸들러에 얹는 형태이므로, 무거운 작업은 waitUntil로 로깅/비필수 부가 작업을 넘기고, 익답은 가능한 한 빨리 돌려주는 전략이 효과적입니다.
// wrangler.jsonc 바인딩 가정: MY_KV, USERS (D1) 등
import { Hono } from 'hono';
import { cors } from 'hono/cors';
type Bindings = { MY_KV: KVNamespace };
const app = new Hono<{ Bindings: Bindings }>();
app.use('/*', cors({ origin: ['https://app.example.com'] }));
// 1) 경로·메서드 상에서 캐시 힌트(정적/준정적)
app.get('/config/public', async (c) => {
const r = await c.env.MY_KV.get('public-config', { type: 'text' });
return c.text(r ?? '{}', 200, {
'Cache-Control': 'public, max-age=60, s-maxage=60',
});
});
// 2) “짧은 본문, 빠른 JSON” — 바디는 한 번만 파싱
app.post('/echo', async (c) => {
const t0 = performance.now();
const body = await c.req.json<{ name: string }>();
c.executionCtx?.waitUntil(
(async () => {
// 비동기로만 필요한 감사/치수 — 응답을 막지 않음
await c.env.MY_KV.put(`audit:last`, JSON.stringify({ at: t0, name: body.name }));
})()
);
return c.json({ ok: true, ms: performance.now() - t0 });
});
export default app;
Cache-Control: 엣지·브라우저 캐시를 명시하지 않으면 기대와 다르게 동작할 수 있습니다.waitUntil: 응답 후 완료해도 되는 I/O에만 쓰고, 인증/권한 판정은 반드시await체인 안에서 끝냅니다.- 불필요한
JSON.stringify반복은 Workers에서도 누적됩니다. 응답 직전에만 직렬화하세요.
미들웨어 체인 구현 패턴
팩토리로 감싸면(환경·역할·테넌트 주입) 라우트 파일이 깔끔해집니다. next() 누락, double response는 미들웨어 초심자에게 흔한 실수이므로, 조기 return 규칙을 팀 규칙으로 두는 것이 좋습니다.
import { Hono, MiddlewareHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
type Variables = { tenantId: string };
const app = new Hono<{ Variables: Variables }>();
// 경로 파라미터(:tid)를 Variables에 주입 — 후속 핸들러는 c.get('tenantId')만 보면 됨
const resolveTenant: MiddlewareHandler = async (c, next) => {
const tid = c.req.param('tid');
if (!tid) {
throw new HTTPException(400, { message: 'Missing tenant' });
}
c.set('tenantId', tid);
await next();
};
const requireJson = (): MiddlewareHandler => {
return async (c, next) => {
const ct = c.req.header('content-type') ?? '';
if (c.req.method !== 'GET' && !ct.includes('application/json')) {
throw new HTTPException(415, { message: 'JSON only' });
}
await next();
};
};
// 체인: tenant → 콘텐트 타입 → 핸들러
app.post(
'/t/:tid/items',
resolveTenant,
requireJson(),
async (c) => {
const body = await c.req.json();
return c.json({ tenantId: c.get('tenantId'), item: body });
}
);
Hono 4.x에서 자주 쓰는 베스트 프랙티스는 라우트 그룹별 app.use('/api', chain...)로 공통 전제조건(인증·로그)을 묶는 것과, 에러는 HTTPException + onError 핸들러로 모으는 것입니다(개인적으로는 500/4xx JSON 포맷을 onError에서 통일해 프런트와 계약을 맞췄습니다).
JWT 인증 실전 구현
시크릿을 코드에 박지 말고 wrangler secret / 환경 변수로 주입하세요. 아래는 Hono jwt 미들웨어를 /api에만 씌우는 최소 구성입니다(프로덕션에서는 키 로테이션·만료·aud/iss/search를 반드시 점검).
import { Hono } from 'hono';
import { jwt, sign } from 'hono/jwt';
type Variables = { jwtPayload: { sub: string; role: string } };
type Bindings = { JWT_SECRET: string };
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();
// 로그인(예시) — 실제로는 비밀번호 해시, 레이트 리밋, CSRF/쿠키 정책 필요
app.post('/auth/login', async (c) => {
const { sub } = await c.req.json<{ sub: string }>();
const token = await sign(
{ sub, role: 'user', exp: Math.floor(Date.now() / 1000) + 60 * 15 },
c.env.JWT_SECRET
);
return c.json({ access_token: token, token_type: 'Bearer' });
});
app.use('/api/*', async (c, next) => {
const m = jwt({ secret: c.env.JWT_SECRET });
return m(c, next);
});
app.get('/api/me', (c) => {
const p = c.get('jwtPayload');
return c.json({ sub: p.sub, role: p.role });
});
export default app;
보안 상식: JWT를 쿠키 httpOnly로 넣을지, Authorization 헤더로 둘지는 XSS/CSRF 트레이드오프가 다릅니다. 모바일·SPA라면 BFF 패턴, 엣지 API라면 짧은 만료 + 리프레시 전략을 문서화하는 것이 좋습니다. 저는 스테이징에서만 긴 만료를 쓰고, 프로덕션은 15분 이하로 맞추는 편이었습니다.
Zod와의 통합(유효성 검증)
@hono/zod-validator는 쿼리·헤더·JSON·form에 동일한 패턴을 적용할 수 있어 팀 룰을 만들기 쉽습니다. safeParse 대신 zValidator의 훅에서 로깅/메트릭을 넣을 수도 있습니다.
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const app = new Hono();
const listQuery = z.object({
page: z.coerce.number().min(1).default(1),
q: z.string().optional(),
});
const create = z.object({
title: z.string().min(1).max(200),
tags: z.array(z.string()).max(20).default([]),
});
app.get('/articles', zValidator('query', listQuery), (c) => {
const q = c.req.valid('query');
return c.json({ page: q.page, q: q.q ?? null });
});
app.post('/articles', zValidator('json', create), (c) => {
const body = c.req.valid('json');
return c.json({ id: crypto.randomUUID(), ...body });
});
팁: Edge에서 Zod는 편리하지만, 아주 뜨거운 경로에서는 최소 스키마로 두고, 필드가 많은 DTO는 버전 분리(스키마 v1/v2)로 호환을 관리하세요. 한 번 스키마 변경으로 모바일 구버전이 깨진 적이 있어, 서버·클라 합의된 x-api-version 헤더를 두기도 했습니다.
Edge Runtime 활용 전략
- 지리적으로 가까운 데이터:
Cache API+ originless로 정적·준정적을 엣지에 두고, 권한이 필요한 쓰기만 원본으로. - DB 접근: D1, Hyperdrive(외부 DB 가속) 등 런타임에 맞는 경로를 설계(개인 프로젝트는 읽기 집계를 KV, 캐시 실패 시 D1).
- 서브리퀘스트 합집: N번의
fetch를 하나의 엔드포인트에서Promise.all로 묶되, 타임아웃·부분 실패를 명시적 JSON으로 돌리기(사용자 경험은 “느리지만 502가 아닌 200+ 부분 실패”가 나을 때가 있습니다). - 시크릿/키: 런타임에만 존재하도록 바인딩을 사용(리포에
.dev.vars는 커밋하지 않기).
실제 프로덕션 배포 경험
배포 파이프라인은 main 머지 → Cloudflare Pages/Workers 자동 배포, PR 프리뷰는 별도 환경에 동일 wrangler 스택을 두었습니다. 가장 힘들었던 이슈는 “로컬에선 됐는데 Workers에선 1101/타임아웃”류였고, 원인은 대부분 (1) fetch에 대한 누락된 await, (2) Date.now에 의한 테스트 플라키, (3) 환경 바인딩 이름 불일치였습니다. 구조적 로그(trace id, cf-ray 헤더를 그대로 클라이언트에 노출하지는 않고 내부 상관 관계 ID로)를 한 줄 JSON으로 남기는 습관이 생겼습니다.
롤백은 Workers 버전으로 곧장 되돌릴 수 있어 마음이 편했고, 카나리를 넣지 못한 소규모 서비스라도 이전 배포를 손쉽게 복원할 수 있었습니다(팀이면 트래픽 분할까지 강화하세요).
타입 안정성 확보 팁
Hono<Env>제네릭에Bindings(Workers 비밀·KV),Variables(인증 토큰 파싱 결과)를 명시하세요.c.get/c.env의 any 지옥이 줄어듭니다.- 라우트 마다
c타입이 넓어지는 것이 부담이면, 핸들러를 함수로 분리할 때Context에 제네릭을 넣어 좁힌 컨텍스트로 전달하세요. - 공유 DTO는
z.infer<typeof schema>로 Zod + 타입을 한곳에 두고, OpenAPI까지 가려면hono-openapi등을 검토(저는 문서·코드 싱크를 중시해 스키마 단일 소스로 유지). strict+noUncheckedIndexedAccess:c.req.query('page')가string | undefined임을 타입이 강제하므로, 기본값 처리를 빼먹기 어렵습니다.
import { Hono } from 'hono';
type Env = {
Bindings: { COUNTER: KVNamespace };
Variables: { userId: string };
};
const app = new Hono<Env>();
app.get('/count', async (c) => {
const n = (await c.env.COUNTER.get('n')) ?? '0';
return c.text(n);
});
핵심 정리
✅ Hono의 장점
- 압도적인 속도: Express보다 10배 빠름
- Edge 최적화: Cloudflare Workers·Vercel Edge
- 멀티 런타임: Deno·Bun·Node.js 모두 지원
- TypeScript 우선: 완벽한 타입 안전성
- 가벼움: 12KB 번들 크기
🚀 다음 단계
- Hono 공식 문서에서 심화 학습
- Hono GitHub에서 소스 코드 탐색
- Discord에서 커뮤니티 참여
시작하기:
npm create hono@latest로 5분 만에 프로젝트를 시작하고, Express보다 10배 빠른 API 서버를 만드세요! 🚀