Hono + Bun + Drizzle로 만드는 타입 안전 REST API — DB 스키마 하나로 프론트엔드까지
백엔드 API를 짜다 보면 꼭 이런 상황이 옵니다. DB 컬럼 하나 이름 바꿨더니 — user_name → username — TypeScript 에러는 없는데 런타임에서 undefined가 쏟아지는 거죠. 알고 보면 ORM 모델, 요청 검증 스키마, API 응답 타입, 프론트엔드 fetch 코드까지 네 군데를 수동으로 맞춰줘야 했는데 하나를 빠뜨린 것. 저도 이 패턴에 한두 번 데인 게 아닙니다.
Hono + Bun + Drizzle 스택의 핵심은 DB 스키마 정의 하나가 요청 검증 → API 핸들러 반환값 → 프론트엔드 RPC 클라이언트까지 네 개 레이어를 일관되게 관통한다는 점입니다. 기존 Node.js + Express + Prisma 조합과 비교했을 때 가장 큰 차이는 "타입을 맞춰야 한다"는 행위 자체가 구조적으로 사라진다는 거예요. 타입이 깨질 여지를 줄이는 게 아니라, 깨질 자리 자체를 없애버리는 방식입니다. 이 글에서는 세 도구가 어떻게 맞물려 돌아가는지, 그리고 실제로 붙여보면 어디서 막히는지를 솔직하게 풀어보겠습니다.
Node.js + Express + Prisma 조합을 써봤다면 비교가 훨씬 직관적으로 다가올 거예요.
핵심 개념
세 도구가 만드는 타입 흐름 — 목적지 먼저 보기
세 도구를 개별적으로 설명하기 전에, 이 스택이 무엇을 지향하는지 한눈에 볼 수 있습니다.
Drizzle 스키마 (테이블 정의)
↓ ($inferSelect / $inferInsert)
TypeScript 타입 (자동 추출)
↓ (drizzle-zod)
Zod 검증 스키마 (자동 생성)
↓ (@hono/zod-validator)
Hono 핸들러 — 요청 바디 타입 안전
↓ (c.json())
API 응답 타입 추론
↓ (export typeof route)
Hono RPC 클라이언트 — 자동완성 + 반환 타입컬럼 이름이나 타입이 바뀌면 컴파일러가 이 체인 전체에서 영향을 받는 위치를 한 번에 짚어줍니다. 이게 이 스택이 주는 핵심 가치입니다. 이제 각 도구를 살펴보겠습니다.
Hono — 런타임을 가리지 않는 초경량 웹 프레임워크
Hono는 Web Standards(Fetch API) 기반의 웹 프레임워크입니다. 코어 번들 크기가 14KB에 불과하고, Node.js·Bun·Deno·Cloudflare Workers 어디서든 동일한 코드로 실행됩니다.
Express와 비교했을 때 가장 눈에 띄는 차이가 빌트인 RPC 기능이에요. hc() 클라이언트를 쓰면 서버 라우터에서 내보낸 타입을 그대로 가져다 쓸 수 있어서, 코드 생성 단계가 따로 없습니다.
| 비교 항목 | Express | Hono |
|---|---|---|
| 번들 크기 | ~500KB | ~14KB |
| 빌트인 RPC | 없음 | 있음 (hc()) |
| 런타임 이식성 | Node.js 전용 | 모든 런타임 |
| TypeScript 지원 | 별도 설정 필요 | 빌트인 |
Hono RPC는 tRPC, gRPC와 어떻게 다른가? — tRPC는 TypeScript 프로젝트에서 타입 안전 API를 만드는 유사한 접근이지만, REST HTTP로 노출하려면 별도 어댑터가 필요합니다. gRPC는 Protobuf 기반 바이너리 프로토콜로 언어 간 호환성이 강점이지만 브라우저에서 직접 사용하기 까다롭습니다. Hono RPC는 HTTP REST 엔드포인트를 그대로 유지하면서 TypeScript 타입만 공유합니다. 기존 REST 클라이언트와의 호환성을 유지하면서 타입 안전성만 얹고 싶을 때 가장 적합합니다.
Bun — All-in-One JavaScript 런타임
Bun은 JavaScriptCore 엔진 기반의 런타임으로, 서버 실행·번들러·패키지 매니저·테스트 러너를 하나로 묶어놓은 도구입니다. Node.js 환경에서 ts-node + esbuild + npm 조합을 따로 관리해본 경험이 있다면, 그 의존성 퍼즐을 하나로 해결해준다는 게 어떤 의미인지 바로 감이 올 거예요.
| 기능 | Node.js 생태계 | Bun |
|---|---|---|
| 런타임 | Node.js | Bun |
| 패키지 관리 | npm / pnpm / yarn | bun (내장) |
| 번들러 | esbuild / webpack | bun build (내장) |
| TypeScript 실행 | ts-node / tsx | 기본 지원 |
| 테스트 | Jest / Vitest | bun test (내장) |
| SQLite | better-sqlite3 별도 설치 | 내장 드라이버 |
bun install은 캐시 없는 clean install 환경 기준으로 npm 대비 7~25배 빠른 패키지 설치 속도를 기록합니다(Strapi 벤치마크 기준). CI 파이프라인에서 이 차이가 체감되는 수준이에요. 다만 실제 API 서버 성능에서는 DB I/O가 병목이 돼 이 격차가 상당히 좁혀지는 경우가 많습니다 — 이 스택을 선택하는 주된 이유는 성능보다 개발 경험과 운영 단순성에 있다고 보는 게 현실적입니다.
Drizzle ORM — SQL스러운 타입 안전 ORM
Drizzle은 "ORM이면서 SQL을 숨기지 않겠다"는 철학을 가진 라이브러리입니다. Prisma가 자체 SDL로 스키마를 정의하는 것과 달리, Drizzle은 TypeScript 코드로 스키마를 작성하고 거기서 바로 타입을 추론합니다.
// schema.ts — Bun 빌트인 SQLite 기준
// PostgreSQL이라면 drizzle-orm/pg-core의 pgTable로 동일하게 적용됩니다
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
content: text('content'),
})
// 스키마에서 TypeScript 타입 자동 추출
export type Post = typeof posts.$inferSelect
export type NewPost = typeof posts.$inferInsert
// Zod 검증 스키마도 자동 생성 — 비즈니스 규칙은 두 번째 인자에서 추가
export const insertPostSchema = createInsertSchema(posts, {
title: (schema) => schema.title.min(1).max(100),
})
export const selectPostSchema = createSelectSchema(posts)
$inferSelect/$inferInsert— Drizzle이 테이블 정의를 분석해 SELECT 결과와 INSERT 입력 각각에 맞는 TypeScript 타입을 자동으로 만들어주는 헬퍼입니다. 컬럼이notNull()인지 여부,autoIncrement설정 여부 등을 반영해id같은 컬럼이 INSERT 타입에서 optional로 처리되는 것도 자동으로 됩니다.
서버리스·엣지 환경에서 Prisma 대신 Drizzle이 선택되는 주된 이유가 콜드스타트 문제입니다. 실제로 Vercel Edge에 Prisma를 올렸다가 초기화 시간 때문에 곤욕을 치른 뒤 Drizzle로 갈아탄 경험이 있는데, Prisma는 Rust로 작성된 바이너리 쿼리 엔진을 번들에 포함해야 해서 함수 크기가 크게 늘어납니다. Drizzle은 순수 TypeScript라 이 문제가 없습니다.
실전 적용
예시 1: 포스트 CRUD API 구성
프로젝트를 처음 시작할 때의 파일 구조부터 잡아보겠습니다.
src/
├── db.ts # Drizzle 클라이언트 초기화
├── schema.ts # 단일 진실 소스
├── app.ts # Hono 라우터
└── index.ts # 진입점// db.ts
import { drizzle } from 'drizzle-orm/bun-sqlite'
import { Database } from 'bun:sqlite'
const sqlite = new Database('dev.db')
export const db = drizzle(sqlite)// app.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { db } from './db'
import { posts, insertPostSchema } from './schema'
import { eq } from 'drizzle-orm'
const app = new Hono()
const postsRoute = app
.get('/posts', async (c) => {
const result = await db.select().from(posts)
return c.json(result) // Post[] 타입 자동 추론
})
.get(
'/posts/:id',
// z.coerce.number()로 파싱하면 NaN이 그대로 통과하는 문제를 방지할 수 있습니다
zValidator('param', z.object({ id: z.coerce.number() })),
async (c) => {
const { id } = c.req.valid('param') // number 타입 보장
const [post] = await db.select().from(posts).where(eq(posts.id, id))
if (!post) return c.json({ error: 'Not found' }, 404)
return c.json(post)
}
)
.post('/posts', zValidator('json', insertPostSchema), async (c) => {
const body = c.req.valid('json') // 검증 완료된 타입 안전 바디
const [created] = await db.insert(posts).values(body).returning()
return c.json(created, 201)
})
// app이 아닌 postsRoute를 export하는 이유:
// .get().post() 체이닝으로 만들어진 라우터에 엔드포인트별 타입 정보가 담겨 있어서
// AppType이 RPC 클라이언트의 자동완성 목록을 정확히 반영합니다
export type AppType = typeof postsRoute
export default app| 코드 위치 | 역할 |
|---|---|
zValidator('param', z.object({ id: z.coerce.number() })) |
경로 파라미터 파싱 + NaN 방지를 동시에 처리 |
zValidator('json', insertPostSchema) |
런타임 요청 검증 + 타입 추론 동시 처리 |
c.req.valid('json') |
검증 완료가 보장된 타입 안전 접근 |
export type AppType |
RPC 클라이언트용 타입 공유 진입점 |
예시 2: 프론트엔드 RPC 클라이언트 연결
모노레포 구조나 TypeScript Project References가 있다면 서버 타입을 그대로 import할 수 있습니다. 처음엔 이게 정말 되나 싶었는데, 실제로 써보면 자동완성이 엔드포인트 목록·파라미터 타입·응답 타입까지 전부 잡아주는 게 체감됩니다.
// client.ts (프론트엔드)
import { hc } from 'hono/client'
import type { AppType } from '../server/app' // 타입만 import
const client = hc<AppType>('http://localhost:3000')
// GET /posts — 자동완성 + Post[] 반환 타입
const postsRes = await client.posts.$get()
const posts = await postsRes.json() // Post[]
// GET /posts/:id — 파라미터 타입 체크
const postRes = await client.posts[':id'].$get({
param: { id: '1' }
})
// POST /posts — 요청 바디 타입 체크
const createRes = await client.posts.$post({
json: { title: '새 글', content: '내용' }
})
const newPost = await createRes.json() // Post
import type이 번들 크기에 실제로 영향을 주는가? — 결론부터 말하면 "예". 서버 코드를import type이 아닌 일반import로 가져오면 esbuild나 Vite가 DB 연결 코드(bun:sqlite,drizzle-orm)를 클라이언트 번들에 포함시키려 합니다. 브라우저 환경에서 실행할 수 없는 네이티브 모듈을 끌고 오는 거라 빌드 오류로 이어지는 경우도 있어요. 이 실수를 한 번 겪어보면import type을 습관화하게 됩니다.
Hono RPC를 쓸 때 실제로 마주치는 타입 추론의 한계도 알아두면 좋습니다. 유니온 타입으로 응답을 분기하거나(if (condition) return c.json(typeA) else return c.json(typeB)), 중첩 라우터를 app.route()로 합성할 때 TypeScript 추론이 깨지는 케이스가 있습니다. 이런 경우에는 응답 타입을 명시적으로 지정하거나 단순한 라우터 구조를 유지하는 쪽이 RPC의 타입 안전성을 지키는 데 유리합니다.
예시 3: OpenAPI 문서 자동 생성 (독립적 대안 접근)
이 예시는 앞의 app.ts와 독립적인 대안 접근 방식입니다. Hono 대신 OpenAPIHono를 사용하는 구조로 타입 계층이 달라지기 때문에, 프로젝트 초반에 두 방식 중 하나를 선택하는 것이 좋습니다. @hono/zod-openapi를 추가하면 Zod 스키마 정의에서 OpenAPI 3.1 문서가 자동으로 만들어집니다.
import { OpenAPIHono, createRoute } from '@hono/zod-openapi'
import { selectPostSchema, insertPostSchema } from './schema'
import { db } from './db'
import { posts } from './schema'
const app = new OpenAPIHono()
const createPostRoute = createRoute({
method: 'post',
path: '/posts',
request: {
body: { content: { 'application/json': { schema: insertPostSchema } } },
},
responses: {
201: {
content: { 'application/json': { schema: selectPostSchema } },
description: '생성된 포스트',
},
},
})
app.openapi(createPostRoute, async (c) => {
const body = c.req.valid('json')
const [post] = await db.insert(posts).values(body).returning()
return c.json(post, 201)
})
// /doc 에서 OpenAPI JSON, /ui 에서 Scalar UI 제공
app.doc('/doc', { openapi: '3.1.0', info: { title: 'Posts API', version: '1.0' } })Swagger UI 대신 Scalar를 붙이면 훨씬 보기 좋은 API 문서 페이지가 됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 엔드-투-엔드 타입 안전성 | DB 스키마 변경 시 컴파일러가 영향받는 모든 핸들러를 즉시 표시 |
| 코드 생성 불필요 | tRPC·GraphQL과 달리 별도 생성 단계 없이 타입 공유 |
| 운영 단순성 | Bun All-in-One 구조로 빌드 도구 의존성 최소화 |
| 자동 API 문서화 | @hono/zod-openapi + Scalar로 OpenAPI 문서 자동 생성 |
| 런타임 이식성 | 코드 변경 없이 Cloudflare Workers, Vercel Edge 배포 가능 |
| 서버리스 친화성 | 순수 TypeScript 기반으로 콜드스타트 부담 없음 |
이 중에서 실무에서 가장 체감되는 건 역시 "엔드-투-엔드 타입 안전성"입니다. DB 컬럼 이름을 바꿀 때 IDE가 영향받는 모든 지점을 한 번에 보여주는 경험은, 한번 해보면 이전 방식으로 돌아가기 싫어집니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 실제 성능 격차 축소 | 합성 벤치마크 4배 차이가 DB 포함 실환경에서 크게 줄어듦 | 성능보다 DX·운영 단순성 중심으로 선택 근거 재설정 |
| Zod 타입 혼합 복잡도 | 대규모 코드베이스에서 네이티브 타입과 Zod 타입이 혼재 | drizzle-zod로 타입 추출 위치를 schema.ts 한 곳으로 집중 |
| 복잡한 쿼리 한계 | 집계·폴리모픽 관계가 많은 도메인에서 이점 감소 | 복잡한 쿼리는 raw SQL과 병행 사용 |
| Bun 호환성 | 일부 네이티브 Node.js 모듈 미지원 | 의존성 확인 후 도입, 필요 시 Node.js 호환 모드 사용 |
| 모노레포 필요 | Hono RPC 타입 공유는 같은 레포 또는 Project References 환경에서 가장 매끄러움 | 처음부터 모노레포로 구성하거나 타입 패키지 분리 |
| RPC 타입 추론 한계 | 유니온 응답 타입, 중첩 라우터 합성 시 추론이 깨지는 케이스 존재 | 단순한 라우터 구조 유지 또는 응답 타입 명시 |
솔직히 말하면 "모노레포 필요"가 팀 도입에서 가장 자주 걸리는 부분입니다. 기존 REST API 클라이언트가 따로 있는 상황에서 RPC 타입 공유만을 위해 레포 구조를 바꾸는 건 쉬운 결정이 아니거든요. 이미 모노레포로 운영 중이거나 새로 프로젝트를 시작하는 상황이라면 이 스택의 장점이 온전히 발휘됩니다.
실무에서 가장 흔한 실수
-
insertPostSchema를 추가 검증 없이 그대로 쓰기 —createInsertSchema가 생성하는 기본 스키마는 DB 제약(notNull, 타입)만 반영합니다. 비즈니스 규칙(최소 길이, 허용 값 범위 등)은 두 번째 인자 콜백에서 직접 추가하는 것이 좋습니다.schema.ts한 곳에 모아두면 나중에 찾기도 쉽습니다. -
AppType을import로 가져오기 — 프론트엔드에서 서버 모듈을import type이 아닌 일반import로 가져오면 DB 연결 코드가 클라이언트 번들에 포함될 수 있습니다. 빌드 오류가 나지 않더라도 번들 크기가 불필요하게 커집니다. -
벤치마크 수치를 그대로 기대치로 삼기 — "Bun이 Node.js보다 4배 빠르다"는 합성 벤치마크 수치입니다. 실제 애플리케이션에서는 DB I/O와 JSON 직렬화가 병목이 돼 격차가 크게 줄어듭니다. 팀에 이 스택을 소개할 때 성능 수치를 앞세우면 나중에 실망이 생길 수 있어요 — 개발 경험과 운영 단순성을 주된 이유로 내세우는 편이 훨씬 설득력이 있습니다.
마치며
Hono + Bun + Drizzle 스택의 진짜 가치는 속도보다 "스키마 하나를 바꿨을 때 컴파일러가 뭘 고쳐야 하는지 전부 알려준다"는 구조적 안전망에 있습니다.
직접 경험해보는 가장 빠른 방법은 작은 프로젝트로 시작하는 것입니다.
-
Bun 설치 후 프로젝트 초기화 — 공식 문서의 설치 가이드를 따라 Bun을 설치하고,
bun create hono my-app으로 Hono 스타터 프로젝트를 생성할 수 있습니다. 런타임 선택 시bun을 선택하면 됩니다. -
Drizzle 스키마 작성 후
drizzle-zod연결 —bun add drizzle-orm drizzle-zod @hono/zod-validator를 실행하고,schema.ts에 테이블을 정의한 뒤createInsertSchema로 Zod 스키마를 뽑아보면 타입 흐름이 바로 눈에 들어옵니다. -
스키마를 바꿔서 타입 안전망 직접 체감하기 —
AppType을 export하고 프론트엔드에서hc<AppType>()로 클라이언트를 만든 뒤, 스키마 컬럼 이름을 하나 바꿔보시면 됩니다. 컴파일러가 프론트엔드 코드까지 무엇을 고쳐야 하는지 즉시 알려주는 그 경험이, 이 스택을 선택하는 이유를 가장 직접적으로 설명해줍니다.
참고 자료
- Hono 공식 문서 — Bun 시작하기
- Hono RPC 공식 가이드
- Drizzle ORM 공식 사이트
- Drizzle ORM Zod 통합 문서
- Why Bun + Hono + Drizzle + SQLite is the Ultimate Stack for AI Agent Development | Zenn
- Drizzle + Zod: A Type-Safe API in 200 Lines | DEV Community
- Build a documented type-safe API with Hono, Drizzle, Zod, OpenAPI and Scalar | Syntax.fm
- Hono RPC vs tRPC vs ts-rest: Type-Safe APIs 2026 | PkgPulse
- Bun vs Node.js in 2026: Benchmarks & Migration Guide | Strapi
- Hono — Ending the TypeScript War Between Frontend and Backend | Medium