Hono 완벽 가이드 — 초고속 엣지 웹 프레임워크
이 글의 핵심
Hono는 Web Standards(Request/Response, Fetch API)에 맞춰 설계된 초경량·고성능 웹 프레임워크입니다. Node.js 전용 API에 묶이지 않고 Cloudflare Workers, Deno, Bun, AWS Lambda, Vercel 등 엣지·서버리스 런타임에서 동일한 코드 스타일로 동작하도록 만든 것이 큰 특징입니다. 라우터는 Radix Tree 기반으로 구현되어 경로가 많아져도 예측 가능한 성능을 유지하려는 설계 철학이 분명합니다.
이 글에서는 Hono의 핵심 개념과 장점, 라우팅·미들웨어 패턴, Cloudflare Workers·Deno·Bun에서의 실행 방식, D1·Prisma를 이용한 데이터 계층, JWT 인증과 CORS, 그리고 실전 REST API 구축 흐름을 순서대로 다룹니다. 각 코드 블록은 “복사해서 바로 실험할 수 있는 방향”을 목표로 했으나, 시크릿·토큰·CORS 출처는 반드시 환경 변수와 배포 환경 설정으로 분리해야 합니다.
주의: Hono와 각 런타임(Workers 런타임 버전, Bun·Deno 릴리스)은 빠르게 갱신됩니다. 프로덕션 적용 전에는 사용 중인 바인딩 이름(D1, KV 등) 과 Prisma 어댑터 버전을 문서와 릴리스 노트로 다시 확인하십시오.
1. Hono를 선택하는 이유
1.1 엣지 퍼스트 설계
전통적인 Node.js 프레임워크는 http 모듈·스트림·특정 미들웨어 생태계에 최적화된 경우가 많습니다. 반면 Hono는 Fetch API 스타일의 Context 를 중심에 두어, Workers처럼 요청 단위로 격리되는 실행 모델과 잘 맞습니다. 즉 “한 번 작성한 핸들러 로직을 여러 런타임으로 옮기기”가 상대적으로 수월합니다.
1.2 작은 런타임 오버헤드와 예측 가능한 라우팅
번들 크기와 초기화 비용은 엣지에서 곧 콜드 스타트·메모리 한도로 직결됩니다. Hono는 필요한 기능만 골라 쓰는 모듈 구조(예: hono/cors, hono/jwt)를 유지하려 하며, 라우터 구현을 단순한 선형 탐색에만 의존하지 않습니다. API 게이트웨이·BFF·경량 프록시처럼 지연 시간이 민감한 경로에 특히 잘 맞습니다.
1.3 TypeScript와 제네릭 Env
Hono의 Hono<{ Bindings: ... }> 패턴은 Cloudflare Workers의 바인딩 타입(D1, KV, R2 등)을 컴파일 타임에 연결하기 좋습니다. 런타임에서 c.env를 문자열로만 다루다 보면 오타가 프로덕션까지 숨어 들어가기 쉬운데, 이 부분을 줄이는 데 도움이 됩니다.
2. 프로젝트 시작과 최소 앱
2.1 패키지 설치
런타임에 따라 진입 방식은 다르지만, 공통적으로 hono 패키지를 의존성에 추가합니다. 아래는 npm 기준 예시입니다.
npm install hono
2.2 가장 작은 Hono 앱
아래는 라우트 두 개만 정의한 최소 예시입니다. new Hono()로 앱 인스턴스를 만들고, HTTP 메서드별 메서드 체인으로 핸들러를 붙입니다.
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('OK'));
app.get('/health', (c) => c.json({ status: 'ok' }));
export default app;
c는 Context 객체로, 요청 정보 읽기(c.req), 환경 변수·바인딩(c.env), 응답 빌더(c.json, c.text, c.redirect)를 한곳에서 다룹니다. 엣지 런타임에서는 요청당 짧은 수명을 가정하므로, 글로벌 싱글톤에 요청별 상태를 저장하는 안티패턴은 피하는 것이 좋습니다.
3. 라우팅: 경로, 파라미터, 그룹
3.1 동적 세그먼트
:id 형태의 경로 파라미터는 c.req.param('id')로 읽습니다. REST API에서 리소스 단위 식별에 자주 쓰입니다.
import { Hono } from 'hono';
const app = new Hono();
app.get('/users/:id', (c) => {
const id = c.req.param('id');
return c.json({ userId: id });
});
app.get('/posts/:postId/comments/:commentId', (c) => {
return c.json({
postId: c.req.param('postId'),
commentId: c.req.param('commentId'),
});
});
3.2 라우트 그룹과 basePath
접두 경로가 반복되면 basePath로 묶어 가독성을 높일 수 있습니다.
import { Hono } from 'hono';
const api = new Hono().basePath('/api/v1');
api.get('/items', (c) => c.json([]));
api.post('/items', async (c) => {
const body = await c.req.json();
return c.json({ created: true, body }, 201);
});
const app = new Hono();
app.route('/', api);
3.3 app.route로 서브앱 합성
규모가 커지면 도메인별로 Hono 인스턴스를 나눈 뒤 app.route('/users', usersApp) 형태로 합성합니다. 이때 각 서브앱은 자체 미들웨어 스택을 가질 수 있어, 인증이 필요한 경로만 JWT 미들웨어를 얹는 식의 구조가 명확해집니다.
4. 미들웨어: 공통 처리와 실행 순서
4.1 app.use와 경로 패턴
미들웨어는 요청이 핸들러에 도달하기 전후에 실행됩니다. 첫 인자에 경로를 지정하면 해당 prefix에만 적용됩니다.
import { Hono } from 'hono';
import { logger } from 'hono/logger';
const app = new Hono();
app.use('*', logger());
app.use('/api/*', async (c, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
c.header('X-Response-Time', `${ms}ms`);
});
await next()를 호출하지 않으면 이후 체인이 실행되지 않습니다. 인증 실패 시 조기 응답을 보내고 싶다면 return c.json({ error: 'unauthorized' }, 401)처럼 next() 없이 반환하면 됩니다.
4.2 커스텀 컨텍스트 변수
c.set / c.get으로 요청 범위 메타데이터를 넘길 수 있습니다. 예를 들어 JWT 검증 후 사용자 ID를 저장해 하위 핸들러에서 재사용합니다.
import { Hono } from 'hono';
type Variables = { userId: string };
const app = new Hono<{ Variables: Variables }>();
app.use('/protected/*', async (c, next) => {
c.set('userId', 'user-123');
await next();
});
app.get('/protected/profile', (c) => {
const userId = c.get('userId');
return c.json({ userId });
});
5. 런타임별 실행: Cloudflare Workers, Deno, Bun
5.1 Cloudflare Workers
Workers에서는 export default로 fetch 이벤트 핸들러를 노출합니다. Wrangler로 배포할 때 D1·KV 바인딩은 wrangler.toml(또는 wrangler.json)에 정의하고, 타입은 Bindings로 묶습니다.
import { Hono } from 'hono';
type Bindings = {
DB: D1Database;
};
const app = new Hono<{ Bindings: Bindings }>();
app.get('/d1-ping', async (c) => {
const r = await c.env.DB.prepare('SELECT 1 AS ok').first<{ ok: number }>();
return c.json(r);
});
export default app;
로컬 개발 시 wrangler dev가 바인딩을 에뮬레이션합니다. 프로덕션과 동일한 스키마 마이그레이션 절차(D1용 SQL 파일, wrangler d1 migrations apply)를 CI에 포함하는 것이 안전합니다.
5.2 Deno
Deno는 import 맵과 권한 플래그 모델이 다릅니다. 공식 예제 스타일대로 deno run --allow-net 등으로 실행하고, lock 파일·캐시 전략을 팀 규칙으로 정하는 것이 좋습니다. Hono 앱 코드 자체는 Fetch 핸들러만 맞추면 대부분 그대로 옮겨집니다.
import { Hono } from 'npm:hono';
const app = new Hono();
app.get('/', (c) => c.text('Deno + Hono'));
Deno.serve(app.fetch);
5.3 Bun
Bun은 네이티브 서버 API와 호환 레이어가 발전 중입니다. 간단한 경우 Bun.serve에 fetch 핸들러로 Hono를 연결합니다.
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('Bun + Hono'));
Bun.serve({
fetch: app.fetch,
port: 3000,
});
Bun·Node 양쪽을 타깃으로 할 때는 어댑터(@hono/node-server 등) 를 검토해, 로컬 개발은 Node/Bun, 프로덕션은 Workers로 나누는 팀도 많습니다.
6. 데이터베이스: D1과 Prisma
6.1 D1 직접 사용(Prepared Statement)
D1은 SQLite 호환 API를 제공합니다. 사용자 입력을 문자열 연결로 쿼리에 넣지 말고, 반드시 바인딩 파라미터를 사용해야 합니다.
import { Hono } from 'hono';
type Bindings = { DB: D1Database };
const app = new Hono<{ Bindings: Bindings }>();
app.get('/items/:id', async (c) => {
const id = c.req.param('id');
const row = await c.env.DB.prepare('SELECT id, name FROM items WHERE id = ?')
.bind(id)
.first<{ id: string; name: string }>();
if (!row) return c.json({ error: 'not_found' }, 404);
return c.json(row);
});
트랜잭션이 필요하면 D1의 배치 API 문서를 확인해 여러 문장을 원자적으로 실행하는 패턴을 선택합니다.
6.2 Prisma와 엣지(개념 정리)
Prisma를 Workers 등 엣지에서 쓰려면 드라이버·어댑터 조합이 중요합니다. 일반적으로는 다음을 점검합니다.
- 스키마의
datasource가 타깃 DB와 일치하는지(D1은 SQLite 계열) - Prisma Client 생성이 빌드 파이프라인에 포함되는지
- 마이그레이션은 로컬/CI에서 수행하고, Workers에는 쿼리 실행 경로만 올리는지
실제 프로젝트에서는 @prisma/adapter-d1 같은 공식·커뮤니티 어댑터 버전과 Hono의 Bindings 타입을 맞추는 작업이 한 번 필요합니다. 팀에 ORM 경험이 적다면 초기에는 D1 직접 쿼리 + 소규모 리포지토리 함수로 시작하고, 도메인이 안정화된 뒤 Prisma로 이전하는 전략도 흔합니다.
아래는 “클라이언트를 요청마다 생성하지 않도록” 주의해야 한다는 점을 보여 주는 개념 스케치입니다(프로젝트의 Prisma 버전에 맞게 조정하십시오).
import { Hono } from 'hono';
// import { PrismaClient } from '@prisma/client';
// import { PrismaD1 } from '@prisma/adapter-d1';
type Bindings = { DB: D1Database };
const app = new Hono<{ Bindings: Bindings }>();
app.get('/users', async (c) => {
// const adapter = new PrismaD1(c.env.DB);
// const prisma = new PrismaClient({ adapter });
// const users = await prisma.user.findMany({ take: 20 });
// return c.json(users);
return c.json({ message: 'Prisma 어댑터·스키마에 맞게 구현하세요.' });
});
7. JWT 인증
7.1 시크릿과 알고리즘
JWT는 서명 키 유출 시 전체 세션이 위험해지므로, Workers Secrets나 CI가 아닌 런타임 시크릿 저장소에만 두어야 합니다. 알고리즘은 팀 표준(예: HS256 vs EdDSA)을 정하고, 만료 시간·리프레시 전략을 문서화합니다.
7.2 hono/jwt 미들웨어
보호 구간에 jwt 미들웨어를 걸고, 페이로드는 c.get('jwtPayload')로 읽는 패턴이 간단합니다.
import { Hono } from 'hono';
import { jwt } from 'hono/jwt';
type Bindings = { JWT_SECRET: string };
const app = new Hono<{ Bindings: Bindings }>();
app.use('/auth/me', async (c, next) => {
const middleware = jwt({ secret: c.env.JWT_SECRET });
return middleware(c, next);
});
app.get('/auth/me', (c) => {
const payload = c.get('jwtPayload');
return c.json({ payload });
});
운영 환경에서는 쿠키 vs Authorization 헤더 정책, CSRF(브라우저 쿠키 사용 시)까지 함께 설계해야 합니다. 모바일·서버 투 서버 클라이언트라면 Authorization: Bearer가 단순한 경우가 많습니다.
7.3 토큰 발급(개념)
로그인 라우트에서 비밀번호 해시 검증 후 JWT를 발급합니다. 비밀번호 저장은 Argon2/bcrypt 등 검증된 해시 함수를 사용하고, 솔트·페퍼 정책을 명확히 합니다.
import { Hono } from 'hono';
import { sign } from 'hono/jwt';
type Bindings = { JWT_SECRET: string };
const app = new Hono<{ Bindings: Bindings }>();
app.post('/auth/login', async (c) => {
const { email } = await c.req.json<{ email: string; password: string }>();
// 실제로는 사용자 조회 + 비밀번호 검증
const token = await sign({ sub: 'user-1', email }, c.env.JWT_SECRET);
return c.json({ access_token: token });
});
8. CORS
브라우저에서 다른 출처로 API를 호출할 때 CORS 헤더가 필요합니다. Hono는 hono/cors로 선언적으로 설정합니다.
import { Hono } from 'hono';
import { cors } from 'hono/cors';
const app = new Hono();
app.use(
'/api/*',
cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
})
);
app.get('/api/hello', (c) => c.json({ message: 'hello' }));
origin: '*'와 credentials: true는 함께 쓸 수 없습니다. 운영에서는 허용 출처를 명시하고, 프리플라이트(OPTIONS) 응답이 캐시 가능하도록 maxAge를 조정하는 경우도 많습니다.
9. 실전 REST API 예시: 아이템 CRUD
아래는 메모리 저장소를 사용한 교육용 CRUD입니다. 프로덕션에서는 D1 등으로 교체하고, 입력 검증에는 Zod 같은 스키마 검증을 권장합니다.
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
type Item = { id: string; title: string };
const store = new Map<string, Item>();
const app = new Hono();
app.use('*', logger());
app.use(
'/*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
})
);
app.get('/items', (c) => {
return c.json([...store.values()]);
});
app.get('/items/:id', (c) => {
const item = store.get(c.req.param('id'));
if (!item) return c.json({ error: 'not_found' }, 404);
return c.json(item);
});
app.post('/items', async (c) => {
const body = await c.req.json<{ title?: string }>();
if (!body.title) return c.json({ error: 'title_required' }, 400);
const id = crypto.randomUUID();
const item: Item = { id, title: body.title };
store.set(id, item);
return c.json(item, 201);
});
app.put('/items/:id', async (c) => {
const id = c.req.param('id');
const prev = store.get(id);
if (!prev) return c.json({ error: 'not_found' }, 404);
const body = await c.req.json<{ title?: string }>();
const next: Item = { ...prev, ...body, id };
store.set(id, next);
return c.json(next);
});
app.delete('/items/:id', (c) => {
const id = c.req.param('id');
if (!store.has(id)) return c.json({ error: 'not_found' }, 404);
store.delete(id);
return c.json({ ok: true });
});
export default app;
이 예시에서 동시성은 고려하지 않았습니다. Workers+D1로 옮길 때는 적절한 격리 수준과 유니크 제약, 필요 시 낙관적 락(version 컬럼) 을 검토합니다.
10. 오류 처리와 관측 가능성
10.1 일관된 에러 응답
API는 가능하면 문제 유형을 코드로 식별할 수 있게 반환합니다. Hono에서는 app.onError로 전역 핸들러를 붙일 수 있습니다.
import { Hono } from 'hono';
const app = new Hono();
app.onError((err, c) => {
console.error(err);
return c.json({ error: 'internal_error', message: '서버 오류가 발생했습니다.' }, 500);
});
app.get('/boom', () => {
throw new Error('unexpected');
});
엣지에서는 console.log가 플랫폼 로그로 수집됩니다. 민감 정보를 로그에 남기지 않도록 마스킹 규칙을 두십시오.
10.2 요청 ID
분산 추적을 위해 요청 ID 헤더를 읽거나 생성해 응답에 실어 보내면, 클라이언트·서버 로그를 연결하기 쉽습니다.
11. 보안·운영 체크리스트
- HTTPS 전제: 엣지 배포는 대부분 TLS가 기본이지만, 원본 서버와의 연결도 확인합니다.
- 시크릿 회전: JWT 키, API 키는 정기 회전 절차를 둡니다.
- 레이트 리밋: Workers KV나 외부 WAF로 남용 방지를 고려합니다.
- 입력 검증: JSON 본문 크기 제한, 필드 길이, enum 값 검증을 습관화합니다.
- 캐시 헤더: 공개 GET 응답에만
Cache-Control을 신중히 적용합니다.
12. 정리
Hono는 Web Standards에 정렬된 API, 가벼운 런타임 발자국, 라우터·미들웨어 합성이라는 세 밸런스를 맞추기 좋은 프레임워크입니다. Cloudflare Workers에서는 D1과의 조합으로 풀스택을 엣지에 두는 그림이 가능하고, Deno·Bun에서는 로컬 개발·마이크로서비스까지 확장할 수 있습니다. JWT·CORS·REST 패턴은 다른 프레임워크와 개념이 같으므로, 팀의 보안 가이드만 일관되게 적용하면 이식성이 높습니다.
다음 단계로는 OpenAPI 스펙 생성, Zod 기반 요청 검증, 통합 테스트(실제 Workers 런타임에 가까운 환경) 를 붙이면 운영 준비도가 한층 올라갑니다.
자주 묻는 질문
Q. Express에서 Hono로 옮길 때 가장 헷갈리는 점은 무엇인가요?
A. 요청·응답 객체 모델이 Fetch API 중심으로 바뀝니다. 스트림·파일 업로드·쿠키 처리 방식을 런타임 문서와 함께 다시 맞추는 작업이 필요합니다.
Q. Prisma 없이 D1만 써도 되나요?
A. 가능합니다. SQL이 단순하고 팀에 SQL 경험이 있다면 Prepared Statement + 얇은 리포지토리 레이어만으로도 충분한 경우가 많습니다. ORM은 스키마가 커질수록 이점이 큽니다.
Q. JWT를 쿠키에 넣어야 할까요, 헤더에 넣어야 할까요?
A. 브라우저 SPA라면 보안 요구사항(XSS, CSRF, 서브도메인 공유)에 따라 선택이 갈립니다. 모바일·백오피스 API라면 Bearer 헤더가 단순한 경우가 많습니다.