Hono로 엣지 API 만들기: 라우팅·미들웨어, Workers·Deno·Bun 배포, D1·JWT·CORS
이 글의 핵심
Hono는 Fetch API 표준 위에 만든 작은 웹 프레임워크라 같은 코드가 Cloudflare Workers, Deno, Bun, Node에서 돌아갑니다. 이 글은 라우팅·미들웨어 기본기에서 시작해 D1 쿼리와 batch 트랜잭션, Prisma D1 어댑터와 마이그레이션 흐름, 2026년 보안 권고 이후 alg를 반드시 지정해야 하는 JWT 미들웨어, CORS, RPC 클라이언트 타입 공유, 그리고 Workers에서 전역 변수가 요청 사이에 공유되지 않아 생기는 버그까지 실무에서 부딪히는 순서대로 다룹니다.
이 글의 핵심
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 어댑터 버전을 문서와 릴리스 노트로 다시 확인하십시오.
Hono를 선택하는 이유
엣지 퍼스트 설계
전통적인 Node.js 프레임워크는 http 모듈·스트림·특정 미들웨어 생태계에 최적화된 경우가 많습니다. 반면 Hono는 Fetch API 스타일의 Context 를 중심에 두어, Workers처럼 요청 단위로 격리되는 실행 모델과 잘 맞습니다. 즉 “한 번 작성한 핸들러 로직을 여러 런타임으로 옮기기”가 상대적으로 수월합니다.
작은 런타임 오버헤드와 예측 가능한 라우팅
번들 크기와 초기화 비용은 엣지에서 곧 콜드 스타트·메모리 한도로 직결됩니다. Hono는 필요한 기능만 골라 쓰는 모듈 구조(예: hono/cors, hono/jwt)를 유지하려 하며, 라우터 구현을 단순한 선형 탐색에만 의존하지 않습니다. API 게이트웨이·BFF·경량 프록시처럼 지연 시간이 민감한 경로에 특히 잘 맞습니다.
TypeScript와 제네릭 Env
Hono의 Hono<{ Bindings: ... }> 패턴은 Cloudflare Workers의 바인딩 타입(D1, KV, R2 등)을 컴파일 타임에 연결하기 좋습니다. 런타임에서 c.env를 문자열로만 다루다 보면 오타가 프로덕션까지 숨어 들어가기 쉬운데, 이 부분을 줄이는 데 도움이 됩니다.
프로젝트 시작과 최소 앱
패키지 설치
런타임에 따라 진입 방식은 다르지만, 공통적으로 hono 패키지를 의존성에 추가합니다. 아래는 npm 기준 예시입니다.
npm install hono
가장 작은 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)를 한곳에서 다룹니다. 엣지 런타임에서는 요청당 짧은 수명을 가정하므로, 글로벌 싱글톤에 요청별 상태를 저장하는 안티패턴은 피하는 것이 좋습니다.
라우팅: 경로, 파라미터, 그룹
동적 세그먼트
: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'),
});
});
라우트 그룹과 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);
app.route로 서브앱 합성
규모가 커지면 도메인별로 Hono 인스턴스를 나눈 뒤 app.route('/users', usersApp) 형태로 합성합니다. 이때 각 서브앱은 자체 미들웨어 스택을 가질 수 있어, 인증이 필요한 경로만 JWT 미들웨어를 얹는 식의 구조가 명확해집니다.
미들웨어: 공통 처리와 실행 순서
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() 없이 반환하면 됩니다.
커스텀 컨텍스트 변수
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 });
});
런타임별 실행: Cloudflare Workers, Deno, Bun
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에 포함하는 것이 안전합니다.
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);
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로 나누는 팀도 많습니다.
데이터베이스: D1과 Prisma
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);
});
트랜잭션은 SQLite처럼 BEGIN/COMMIT을 직접 보내는 방식이 아니라 batch()로 합니다. 배치에 넣은 문장들은 하나의 트랜잭션으로 실행되어, 중간 문장이 실패하면 전체가 롤백됩니다.
app.post('/orders', async (c) => {
const { itemId, qty } = await c.req.json<{ itemId: string; qty: number }>();
const id = crypto.randomUUID();
await c.env.DB.batch([
c.env.DB.prepare('UPDATE items SET stock = stock - ? WHERE id = ? AND stock >= ?').bind(qty, itemId, qty),
c.env.DB.prepare('INSERT INTO orders (id, item_id, qty) VALUES (?, ?, ?)').bind(id, itemId, qty),
]);
return c.json({ id }, 201);
});
한계도 분명합니다. 배치는 문장 목록을 미리 정해서 보내는 것이라, “먼저 조회하고 그 결과를 보고 다음 쿼리를 결정하는” 대화형 트랜잭션은 할 수 없습니다. 위 예제도 재고가 부족하면 UPDATE가 0행을 바꾸고 INSERT는 그대로 성공합니다. 이런 조건부 로직은 INSERT ... SELECT ... WHERE처럼 한 문장 안에 조건을 넣거나, 배치 결과의 meta.changes를 확인해 보상 쿼리를 보내는 식으로 설계해야 합니다. PostgreSQL에서 쓰던 “조회 후 갱신” 흐름을 그대로 옮기면 동시 요청에서 재고가 음수가 되는 식의 버그가 생기기 쉬워서, 저는 D1에서는 조건을 가능한 한 한 문장 안에 넣는 쪽으로 쿼리를 짭니다.
Prisma와 엣지(개념 정리)
Prisma를 Workers 등 엣지에서 쓰려면 드라이버·어댑터 조합이 중요합니다. 일반적으로는 다음을 점검합니다.
- 스키마의
datasource가 타깃 DB와 일치하는지(D1은 SQLite 계열) - Prisma Client 생성이 빌드 파이프라인에 포함되는지
- 마이그레이션은 로컬/CI에서 수행하며, Workers에는 쿼리 실행 경로만 올리는지
실제 프로젝트에서는 @prisma/adapter-d1 같은 공식·커뮤니티 어댑터 버전과 Hono의 Bindings 타입을 맞추는 작업이 한 번 필요합니다. 팀에 ORM 경험이 적다면 초기에는 D1 직접 쿼리 + 소규모 리포지토리 함수로 시작하며, 도메인이 안정화된 뒤 Prisma로 이전하는 전략도 흔합니다.
연결 코드는 단순합니다. D1 바인딩은 요청 핸들러 안에서만 c.env로 받을 수 있으므로, 클라이언트도 요청 컨텍스트에서 만듭니다. 미들웨어에서 한 번 만들어 c.set으로 넘기면 핸들러마다 반복하지 않아도 됩니다.
import { Hono } from 'hono';
import { PrismaClient } from '@prisma/client';
import { PrismaD1 } from '@prisma/adapter-d1';
type Bindings = { DB: D1Database };
type Variables = { prisma: PrismaClient };
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();
app.use('*', async (c, next) => {
c.set('prisma', new PrismaClient({ adapter: new PrismaD1(c.env.DB) }));
await next();
});
app.get('/users', async (c) => {
const users = await c.get('prisma').user.findMany({ take: 20 });
return c.json(users);
});
Prisma 버전에 따라 제너레이터 설정(prisma-client-js와 새 prisma-client)과 import 경로가 다르므로, 생성된 클라이언트 경로에 맞춰 import를 바꿔야 합니다.
마이그레이션은 로컬 개발 DB처럼 prisma migrate dev로 적용할 수 없습니다. 공식 흐름은 wrangler d1 migrations create로 빈 마이그레이션 파일을 만들고, prisma migrate diff로 스키마 차이를 SQL로 뽑아 그 파일에 쓴 다음, wrangler d1 migrations apply로 로컬(--local)과 원격(--remote)에 적용하는 것입니다. 또 D1은 대화형 트랜잭션이 없어서 Prisma의 $transaction(async (tx) => ...) 콜백 형태는 쓸 수 없고, 배열 형태의 $transaction([...])만 가능합니다.
JWT 인증
시크릿과 알고리즘
JWT는 서명 키 유출 시 전체 세션이 위험해지므로, Workers Secrets나 CI가 아닌 런타임 시크릿 저장소에만 두어야 합니다. 알고리즘은 팀 표준(예: HS256 vs EdDSA)을 정하며, 만료 시간·리프레시 전략을 문서화합니다.
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, alg: 'HS256' });
return middleware(c, next);
});
app.get('/auth/me', (c) => {
const payload = c.get('jwtPayload');
return c.json({ payload });
});
alg: 'HS256'을 빠뜨리지 마세요. 2026년 1월에 공개된 보안 권고(CVE-2026-22817, CVE-2026-22818)는 검증 알고리즘이 신뢰할 수 없는 토큰 헤더의 alg 값에 영향받는 경로가 있어 알고리즘 혼동 공격으로 토큰 위조가 가능했다는 내용이었고, Hono 4.11.4부터 JWT 미들웨어는 alg를 명시적으로 요구합니다. 인터넷의 예제 대부분은 그 이전 코드라 jwt({ secret })만 쓰고 있어서, 의존성을 올린 뒤 인증이 전부 실패하면 이것부터 확인하세요. JWKS로 공개키를 받아 검증하는 jwk 미들웨어도 같은 이유로 허용 알고리즘을 명시해야 합니다. 토큰을 발급할 때 쓰는 알고리즘과 검증 쪽 alg가 같아야 한다는 점도 함께 기억해 두면 됩니다.
운영 환경에서는 쿠키 vs Authorization 헤더 정책, CSRF(브라우저 쿠키 사용 시)까지 함께 설계해야 합니다. 모바일·서버 투 서버 클라이언트라면 Authorization: Bearer가 단순한 경우가 많습니다.
토큰 발급(개념)
로그인 라우트에서 비밀번호 해시 검증 후 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 now = Math.floor(Date.now() / 1000);
const token = await sign(
{ sub: 'user-1', email, iat: now, exp: now + 60 * 15 }, // exp를 넣지 않으면 만료 없는 토큰이 됨
c.env.JWT_SECRET,
'HS256'
);
return c.json({ access_token: token });
});
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를 조정하는 경우도 많습니다.
실전 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;
이 코드를 wrangler dev로 로컬에서 돌리면 잘 동작합니다. 그런데 그대로 Workers에 배포하면 “방금 만든 아이템이 조회되다가 안 되다가 한다”는 증상이 나옵니다. 모듈 최상위의 store는 isolate 하나의 메모리에만 존재하는데, Workers는 요청을 여러 데이터센터와 여러 isolate에 나눠 처리하고 isolate는 언제든 내려갈 수 있기 때문입니다. POST를 처리한 isolate와 다음 GET을 받은 isolate가 다르면 데이터가 없습니다. Node 서버 한 대로 운영하던 습관이 가장 먼저 깨지는 지점이고, 로컬에서는 재현되지 않아서 원인을 찾기도 어렵습니다. 요청 사이에 유지돼야 하는 상태는 D1, KV, Durable Objects 같은 저장소에 두고, 모듈 최상위 변수는 설정값이나 캐시처럼 “사라져도 괜찮은 것”에만 쓰세요.
같은 이유로 예시의 cors({ origin: '*' })도 공개 API가 아니라면 8절처럼 허용 출처를 명시해야 합니다. D1로 옮긴 뒤에는 유니크 제약과, 필요하면 version 컬럼을 이용한 낙관적 락으로 동시 수정을 막습니다.
입력 검증, RPC 클라이언트, 스트리밍, 테스트
위 CRUD 예시는 요청 본문을 그대로 믿고 있습니다. 실서비스에서는 검증 미들웨어를 핸들러 앞에 두는 것이 기본입니다. @hono/zod-validator를 쓰면 스키마에 맞지 않는 요청은 핸들러에 닿기 전에 400으로 돌려보내고, 핸들러 안의 c.req.valid("json")은 스키마에서 추론된 타입을 갖습니다.
import { z } from "zod"
import { zValidator } from "@hono/zod-validator"
const createItem = z.object({ name: z.string().min(1), price: z.number().positive() })
const items = new Hono()
.post("/", zValidator("json", createItem), (c) => {
const body = c.req.valid("json") // { name: string; price: number }
return c.json(body, 201)
})
.get("/:id", (c) => c.json({ id: c.req.param("id") }))
export type ItemsApp = typeof items
이렇게 만든 라우트의 타입을 그대로 프런트엔드로 넘길 수 있는 것이 Hono의 RPC 기능입니다. hc<ItemsApp>(baseUrl)로 만든 클라이언트는 경로, 파라미터, 요청 본문, 응답 타입을 모두 서버 정의에서 가져오므로, 서버에서 필드 이름을 바꾸면 클라이언트 코드가 컴파일 단계에서 깨집니다. 주의할 점은 타입이 메서드 체이닝을 통해서만 누적된다는 것입니다. const app = new Hono(); app.get(...); app.post(...)처럼 문장을 나눠 쓰면 typeof app에 라우트 정보가 남지 않아 클라이언트 타입이 전부 풀립니다. 라우트를 파일별로 나눌 때도 각 서브앱은 체이닝으로 만들고 app.route("/items", items)로 합쳐야 합니다.
import { hc } from "hono/client"
const api = hc<ItemsApp>("https://api.example.com/items")
const res = await api[":id"].$get({ param: { id: "42" } })
const data = await res.json() // 서버 반환 타입이 그대로 추론됨
LLM 응답처럼 결과를 조금씩 내보내야 할 때는 hono/streaming의 stream이나 streamSSE를 씁니다. 표준 Web Streams 위에 만들어져 있어 Workers, Deno, Bun 어디서든 같은 코드로 동작합니다. 테스트는 서버를 띄우지 않고 app.request("/items/42")로 Fetch 요청을 흉내 내면 되므로 Vitest에서 밀리초 단위로 돌릴 수 있고, D1 같은 Workers 바인딩이 필요한 코드는 @cloudflare/vitest-pool-workers로 로컬 Workers 환경에서 테스트합니다.
import { streamSSE } from "hono/streaming"
app.get("/events", (c) => streamSSE(c, async (s) => {
for (let i = 0; i < 3; i++) { await s.writeSSE({ data: `tick ${i}` }); await s.sleep(1000) }
}))
// vitest
const res = await app.request("/items/42")
expect(res.status).toBe(200)
Workers에서 node:crypto나 node:buffer를 쓰는 라이브러리를 가져오면 모듈을 찾지 못한다는 에러가 나는데, wrangler.toml에 compatibility_flags = ["nodejs_compat"]를 추가하면 해결됩니다. 다만 같은 일을 Web Crypto 같은 표준 API로 할 수 있다면 그쪽이 번들도 작고 다른 런타임으로 옮기기도 쉽습니다.
오류 처리와 관측 가능성
일관된 에러 응답
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가 플랫폼 로그로 수집됩니다. 민감 정보를 로그에 남기지 않도록 마스킹 규칙을 두십시오.
요청 ID
분산 추적을 위해 요청 ID 헤더를 읽거나 생성해 응답에 실어 보내면, 클라이언트·서버 로그를 연결하기 쉽습니다.
보안·운영 체크리스트
- HTTPS 전제: 엣지 배포는 대부분 TLS가 기본이지만, 원본 서버와의 연결도 확인합니다.
- 시크릿 회전: JWT 키, API 키는 정기 회전 절차를 둡니다.
- 레이트 리밋: Workers KV나 외부 WAF로 남용 방지를 고려합니다.
- 입력 검증: JSON 본문 크기 제한, 필드 길이, enum 값 검증을 습관화합니다.
- 캐시 헤더: 공개 GET 응답에만
Cache-Control을 신중히 적용합니다.
Workers 한도에서 자주 걸리는 것
- CPU 시간: 무료 플랜은 요청당 CPU 시간이 10ms로 매우 짧습니다.
fetch로 외부 API를 기다리는 시간은 CPU 시간에 들어가지 않지만, 큰 JSON 파싱이나 bcrypt 같은 비밀번호 해시는 금방 한도를 넘깁니다. 비밀번호 해시는 Web Crypto의 PBKDF2처럼 런타임이 네이티브로 처리하는 API를 쓰는 편이 현실적입니다. - 요청 본문 크기와 번들 크기: 플랜별 상한이 있으니, 큰 파일 업로드는 R2 presigned URL로 클라이언트가 직접 올리게 하는 편이 낫습니다.
waitUntil: 응답을 먼저 보내고 로그 전송 같은 후처리를 하려면c.executionCtx.waitUntil(promise)를 씁니다.await없이 promise를 던져 두기만 하면 응답 직후 작업이 중간에 끊길 수 있습니다.
정확한 수치는 플랜과 시기에 따라 바뀌므로 Cloudflare의 Workers Limits 문서를 기준으로 확인하세요.
자주 묻는 질문
Q. Express에서 Hono로 옮길 때 가장 헷갈리는 점은 무엇인가요?
A. 요청·응답 객체 모델이 Fetch API 중심으로 바뀝니다. 스트림·파일 업로드·쿠키 처리 방식을 런타임 문서와 함께 다시 맞추는 작업이 필요합니다.
Q. Prisma 없이 D1만 써도 되나요?
A. 가능합니다. SQL이 단순하고 팀에 SQL 경험이 있다면 Prepared Statement + 얇은 리포지토리 레이어만으로도 충분한 경우가 많습니다. ORM은 스키마가 커질수록 이점이 큽니다.
Q. JWT를 쿠키에 넣어야 할까요, 헤더에 넣어야 할까요?
A. 브라우저 SPA라면 보안 요구사항(XSS, CSRF, 서브도메인 공유)에 따라 선택이 갈립니다. 모바일·백오피스 API라면 Bearer 헤더가 단순한 경우가 많습니다.
같이 보면 좋은 글
- Prisma로 타입 안전한 DB 접근: 스키마, 마이그레이션, 관계 쿼리, 트랜잭션, 블로그 예제
- Edge Computing 실전 가이드 | Cloudflare Workers· Vercel Edge
- Bun 1.1에서 바뀐 것: 내장 번들러·테스트·Node 호환
- Cloudflare Workers 입문
- Astro + Cloudflare Pages 블로그 스택 분석 | Vercel·Netlify