ElysiaJS 1.0 실전 가이드: Bun 네이티브 프레임워크로 타입 안전 REST API 구축하기
Elysia 코드를 처음 보면 가장 당혹스러운 지점이 있습니다. 핸들러 함수 안에서 body.name이 string으로 추론되는데, 그걸 명시한 z.infer<...> 같은 타입 주석이 어디에도 없습니다. 스키마를 정의한 곳이 타입 추론의 시작점이자 런타임 검증의 전부라는 구조가 처음엔 낯섭니다.
ElysiaJS는 Bun 런타임에 최적화된 TypeScript 백엔드 프레임워크로, 2024년 3월 1.0 정식 출시 후 2025년 기준 1.4("Supersymmetry")까지 빠르게 발전해왔습니다. Bun 생태계가 Node.js 대안으로 주목받으면서 ElysiaJS도 실시간 스트리밍, 지도 플랫폼, AI 백엔드 등 다양한 프로덕션 환경에서 검증되고 있습니다.
이 글에서는 세 가지를 다룹니다. 스키마 하나로 런타임 검증과 TypeScript 타입 추론을 동시에 처리하는 원리, 실전 API 구성 패턴, 그리고 Eden Treaty로 Next.js 프론트엔드까지 타입을 별도 코드 생성 없이 공유하는 방법입니다.
타입 안전성이 실제로 어떻게 동작하는가
ElysiaJS의 타입 안전성은 TypeBox를 중심으로 작동합니다. TypeBox는 JSON Schema 기반 타입 빌더로, 여기서 정의한 스키마는 두 가지 역할을 동시에 합니다.
- 런타임: 요청이 들어오면 실제 값을 검증하고 잘못된 요청을 거부합니다.
- 컴파일 타임: TypeScript가 핸들러 함수 내부에서
params,body,query의 타입을 자동으로 추론합니다.
import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/users', ({ body }) => {
// body는 { name: string; age: number } 타입으로 자동 추론
// z.infer<...> 없음, 별도 타입 캐스팅 없음
return { id: crypto.randomUUID(), ...body }
}, {
body: t.Object({
name: t.String({ minLength: 1 }),
age: t.Number({ minimum: 0, maximum: 150 })
})
})
.listen(3000)Zod·Valibot 등 기존 라이브러리도 그대로: Standard Schema
Standard Schema는 Zod, Valibot, ArkType, Effect Schema 등 여러 검증 라이브러리가 공통 인터페이스를 준수하도록 정의한 커뮤니티 주도 명세입니다. ElysiaJS 1.4부터 이 명세를 준수하는 라이브러리라면 TypeBox 대신 그대로 핸들러에 전달할 수 있습니다.
import { Elysia } from 'elysia'
import { z } from 'zod'
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email()
})
const app = new Elysia()
.post('/users', ({ body }) => {
// body는 z.infer<typeof UserSchema> 타입으로 추론됨
return { id: crypto.randomUUID(), ...body }
}, {
body: UserSchema // Zod 스키마를 직접 전달
})팀에 이미 Zod 코드가 많다면 이 경로로 점진적 마이그레이션이 가능합니다.
Eden Treaty: 코드 생성 없는 end-to-end 타입 공유
Eden Treaty는 ElysiaJS의 공식 클라이언트 라이브러리입니다. 서버의 typeof app을 타입으로만 import해서, HTTP 경로를 객체 체인으로 변환하고 완전한 타입 자동완성을 제공합니다.
여기서 import type { App }이 결정적입니다. TypeScript의 type import는 컴파일 후 완전히 삭제됩니다. 서버 코드가 클라이언트 번들에 포함되지 않으면서도, 빌드 타임에는 서버의 타입 정보가 클라이언트에서 온전히 활용됩니다. 번들 크기는 2KB 미만입니다.
// 서버: src/index.ts
import { Elysia, t } from 'elysia'
const app = new Elysia()
.get('/posts/:id', ({ params }) => ({
id: params.id,
title: 'Hello Elysia',
content: '...'
}), {
params: t.Object({ id: t.String() })
})
.post('/posts', ({ body }) => ({
id: crypto.randomUUID(),
...body
}), {
body: t.Object({
title: t.String(),
content: t.String()
})
})
.listen(3000)
export type App = typeof app // 런타임 값이 아닌 타입만 export// 클라이언트: src/client.ts
import { treaty } from '@elysiajs/eden'
import type { App } from '../server/index' // type import — 번들에 포함 안 됨
const api = treaty<App>('localhost:3000')
// /posts/:id → 경로 파라미터는 함수 인자로, HTTP 메서드는 체인 끝 메서드로
const { data, error } = await api.posts({ id: '1' }).get()
// data: { id: string; title: string; content: string } | null
const { data: created } = await api.posts.post({
title: '새 글',
content: '내용'
})
// created: { id: string; title: string; content: string } | null서버에서 필드 이름을 바꾸면 클라이언트에서 즉시 TypeScript 컴파일 에러가 납니다. 런타임 전에 잡을 수 있다는 점이 핵심입니다.
실전 적용
프로젝트 초기 설정
# Bun 설치 (미설치 시)
curl -fsSL https://bun.sh/install | bash
# 프로젝트 생성
bun create elysia my-api
cd my-api
# 공식 플러그인 설치
bun add @elysiajs/swagger @elysiajs/jwt @elysiajs/cors
# Eden Treaty: 단일 앱이라면
bun add @elysiajs/eden
# 모노레포에서 pnpm 워크스페이스를 쓴다면
pnpm --filter web add @elysiajs/eden기본 CRUD 라우터부터
ElysiaJS는 use()로 다른 Elysia 인스턴스를 플러그인처럼 합성합니다. 뒤에 나오는 인증 코드를 읽기 전에 컨텍스트에 값을 추가하는 두 가지 메서드를 구별해두면 이해가 훨씬 쉽습니다.
decorate('키', 값): DB 커넥션처럼 앱 수명 동안 변하지 않는 싱글턴을 주입합니다. 요청마다 실행되지 않습니다.derive(핸들러): 현재 사용자, 세션처럼 요청마다 새로 파생되는 값을 컨텍스트에 추가합니다.
// src/routes/posts.ts
import { Elysia, t } from 'elysia'
export const postsRouter = new Elysia({ prefix: '/posts' })
.get('/', () => ({
posts: [] as { id: string; title: string; content: string }[]
}))
.post('/', ({ body }) => ({
id: crypto.randomUUID(),
...body
}), {
body: t.Object({
title: t.String({ minLength: 1 }),
content: t.String()
})
})
.get('/:id', ({ params }) => ({
id: params.id,
title: '샘플 게시글',
content: '내용'
}), {
params: t.Object({ id: t.String() })
})// src/index.ts
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { swagger } from '@elysiajs/swagger'
import { postsRouter } from './routes/posts'
const app = new Elysia()
.use(cors())
.use(swagger({
documentation: { info: { title: 'My API', version: '1.0.0' } }
}))
.use(postsRouter)
.listen(3000)
console.log(`서버 시작: ${app.server?.hostname}:${app.server?.port}`)
export type App = typeof app인증 플러그인 추가하기
기본 라우터가 익숙해졌다면 인증을 붙여봅니다. 처음 Elysia를 배울 때 가장 많이 실수하는 지점이 여기입니다. derive()는 컨텍스트에 속성을 "추가"하는 용도이고, 요청을 "차단"하는 역할은 onBeforeHandle()이 담당합니다. 두 역할을 derive() 하나에 섞으면 에러 핸들러가 예상대로 동작하지 않을 수 있습니다.
// src/plugins/auth.ts
import { Elysia } from 'elysia'
import { jwt } from '@elysiajs/jwt'
export const authPlugin = new Elysia({ name: 'auth' })
.use(jwt({
name: 'jwt',
secret: process.env.JWT_SECRET!
}))
// derive: 토큰을 파싱해 user를 채우는 역할만 담당
.derive(async ({ jwt, headers }) => {
const token = headers.authorization?.replace('Bearer ', '')
if (!token) return { user: null as { id: string; email: string } | null }
const payload = await jwt.verify(token)
return { user: (payload || null) as { id: string; email: string } | null }
})
// onBeforeHandle: user가 없으면 여기서 요청을 차단
.onBeforeHandle(({ user, error }) => {
if (!user) return error(401, { error: 'Unauthorized' })
})// src/routes/posts.ts (인증 포함 버전)
import { Elysia, t } from 'elysia'
import { authPlugin } from '../plugins/auth'
export const postsRouter = new Elysia({ prefix: '/posts' })
.use(authPlugin)
.get('/', ({ user }) => ({
// authPlugin을 use()한 핸들러에서 user 타입이 자동 전파됨
// onBeforeHandle 이후 TypeScript가 user를 자동으로 non-null로 좁히지 않을 수 있어
// user! 가 필요한 경우가 있습니다
posts: [] as { id: string; title: string; content: string }[],
userId: user!.id
}))
.post('/', ({ body, user }) => ({
id: crypto.randomUUID(),
authorId: user!.id,
...body
}), {
body: t.Object({
title: t.String({ minLength: 1 }),
content: t.String()
})
})
.get('/:id', ({ params }) => ({
id: params.id,
title: '샘플 게시글',
content: '내용'
}), {
params: t.Object({ id: t.String() })
})각 라우터가 authPlugin을 독립적으로 use()하는 구조를 시각적으로 보면 이렇습니다. 루트 앱이 인증을 통제하는 게 아니라, 보호가 필요한 라우터 각자가 플러그인을 가져오는 형태입니다.
에러 처리와 커스텀 에러 타입
import { Elysia, t } from 'elysia'
class NotFoundError extends Error {
constructor(resource: string) {
super(`${resource}을(를) 찾을 수 없습니다`)
this.name = 'NotFoundError'
}
}
const app = new Elysia()
.error({ NotFoundError })
.onError(({ code, error, set }) => {
if (code === 'NotFoundError') {
set.status = 404
return { error: error.message, code: 'NOT_FOUND' }
}
if (code === 'VALIDATION') {
set.status = 422
// error.message에 검증 실패 요약이 포함됩니다
// TypeBox 기준 error.all로 필드별 에러 목록에 접근할 수도 있습니다
return { error: '입력값이 올바르지 않습니다', details: error.message }
}
set.status = 500
return { error: '서버 오류가 발생했습니다' }
})
.get('/posts/:id', ({ params }) => {
if (params.id === '999') throw new NotFoundError('게시글')
return { id: params.id, title: '게시글 제목' }
}, {
params: t.Object({ id: t.String() })
})Next.js App Router와 Eden Treaty 연동
앞서 만든 postsRouter의 GET /posts와 POST /posts 경로가 Eden Treaty 클라이언트 타입에 그대로 반영되는 예시입니다.
// packages/api-client/src/index.ts (공유 패키지)
import { treaty } from '@elysiajs/eden'
import type { App } from '@my-project/api'
export const api = treaty<App>(
process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'
)// apps/web/app/posts/page.tsx (서버 컴포넌트)
import { api } from '@my-project/api-client'
export default async function PostsPage() {
const { data, error } = await api.posts.get() // GET /posts
if (error) return <div>오류: {error.value.error}</div>
// data 타입이 완전히 추론됨 — 명시적 타입 어노테이션 불필요
return (
<ul>
{data.posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}// apps/web/app/posts/new/CreatePostForm.tsx
'use client'
import { api } from '@my-project/api-client'
import { useState } from 'react'
export function CreatePostForm() {
const [title, setTitle] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const { data, error } = await api.posts.post({ title, content: '...' })
// 서버에서 title 필드가 삭제되면 여기서 즉시 타입 에러 발생
if (error) console.error(error.value)
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={e => setTitle(e.target.value)} />
<button type="submit">작성</button>
</form>
)
}Eden Treaty를 서버와 클라이언트가 별도 저장소인 환경에서 쓰려면 주의가 필요합니다. typeof app은 빌드 결과물에서 사라지기 때문에, tsconfig의 "declaration": true와 "emitDeclarationOnly": true로 타입 선언 파일만 포함하는 패키지를 별도로 구성해야 합니다. 모노레포를 쓸 수 없는 환경이라면 이 구성이 전제됩니다.
Drizzle ORM 연동
ElysiaJS와 가장 많이 조합되는 ORM은 Drizzle입니다. decorate()로 DB 인스턴스를 주입하면 싱글턴으로 재사용됩니다.
import { Elysia, t } from 'elysia'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import { Database } from 'bun:sqlite'
import { posts } from './schema'
import { eq } from 'drizzle-orm'
const db = drizzle(new Database('app.db'))
export const postsRouter = new Elysia({ prefix: '/posts' })
.decorate('db', db) // 앱 수명 동안 변하지 않는 싱글턴 — 요청마다 재생성되지 않음
.get('/:id', async ({ params, db, set }) => {
const [post] = await db
.select()
.from(posts)
.where(eq(posts.id, params.id))
.limit(1)
if (!post) {
set.status = 404
return { error: '게시글을 찾을 수 없습니다' }
}
return post // Drizzle 반환 타입이 Eden Treaty 클라이언트까지 자동 전파
}, {
params: t.Object({ id: t.String() })
})전체 요청 흐름
흔한 실수 모음
플러그인 이름 미지정으로 인한 중복 등록
// 이름 없으면 여러 라우터에서 use() 시 중복 등록
const authPlugin = new Elysia()
.use(jwt({ name: 'jwt', secret: 'secret' }))
// name 지정으로 해결
const authPlugin = new Elysia({ name: 'auth' })
.use(jwt({ name: 'jwt', secret: 'secret' }))Eden Treaty에서 경로 파라미터 매핑 혼동
// 잘못된 예
await api.users[':id']({ id: '1' }).get()
// 올바른 예 — 경로 파라미터는 함수 인자로
await api.users({ id: '1' }).get()derive() 안에서 요청 차단 시도
// 잘못된 예 — derive는 속성 추가 용도, 요청 차단이 아님
.derive(async ({ headers, set }) => {
if (!headers.authorization) {
set.status = 401
throw new Error('Unauthorized') // 에러 핸들러 동작 보장 안 됨
}
return { user: /* ... */ }
})
// 올바른 예
.derive(async ({ jwt, headers }) => ({ user: /* 토큰 검증 결과 */ }))
.onBeforeHandle(({ user, error }) => {
if (!user) return error(401, { error: 'Unauthorized' })
})장단점 정리
장점
| 항목 | 내용 |
|---|---|
| 타입 안전성 | 스키마 → 런타임 검증 → TypeScript 타입 단일 소스 동기화. 코드 생성 없이 end-to-end 타입 공유 |
| 성능 | 공식 벤치마크 기준 Bun 환경에서 약 290,000 req/s 수준. Express 대비 약 30배, Fastify 대비 약 5배 (측정 조건 및 출처) |
| Eden Treaty | 2KB 미만으로 프론트엔드 타입 자동완성 제공. 백엔드 필드명 변경 시 프론트엔드에 즉시 컴파일 에러 |
| OpenAPI 자동 생성 | @elysiajs/swagger로 Swagger UI와 OpenAPI 3.0 스펙 자동 생성 |
| 공식 플러그인 | CORS, JWT, WebSocket, OpenTelemetry, GraphQL Yoga 등 공식 지원 |
| Standard Schema | 1.4부터 Zod·Valibot·ArkType 등 팀이 선호하는 검증 라이브러리 자유 선택 가능 |
| 멀티 런타임 | @elysiajs/node로 Node.js, Cloudflare Workers, Vercel Edge 배포 가능 |
고려사항
| 항목 | 내용 |
|---|---|
| 생태계 규모 | 2025년 기준 GitHub Stars 약 18,400 vs Hono 약 30,600. npm 다운로드 수도 Hono 대비 격차 있음 |
| Bun 의존성 | Node.js 어댑터 존재하지만 Bun 특유의 성능 이점은 사라짐. Windows 지원 완전하지 않음 |
| 학습 비용 | derive, decorate, onBeforeHandle, 플러그인 합성 방식 익히는 데 시간 필요 |
| 모노레포 전제 | Eden Treaty는 서버 타입을 직접 import하는 방식. 별도 배포 환경에서는 타입 전용 패키지 구성 필요 |
| 엣지 케이스 대응 | 서드파티 플러그인과 커뮤니티 Q&A가 Hono 대비 적음 |
ElysiaJS vs Hono
| 기준 | ElysiaJS | Hono |
|---|---|---|
| 1차 런타임 | Bun | 멀티 런타임 (CF Workers, Deno, Node 등) |
| 타입 안전 클라이언트 | Eden Treaty (공식, 기능 완성도 높음) | RPC 모드 (있으나 기능 제한적) |
| 생태계 규모 | 성장 중 | 더 크고 성숙 |
| 엣지 배포 | 가능하나 부차적 | 1차 목표 |
| 추천 케이스 | Bun 풀스택 팀, 타입 공유가 핵심 요구사항 | 멀티 런타임·엣지 우선, 생태계 중시 |
어떤 팀에 맞는가
Bun을 이미 쓰고 있고, 백엔드 필드명이 바뀔 때마다 프론트엔드 수정이 반복적인 마찰 지점이라면 ElysiaJS 도입을 권합니다. Eden Treaty가 그 마찰을 컴파일 에러로 바꿔줍니다.
아직 Node.js 팀이라면 Hono를 먼저 검토하세요. 어댑터로 Node.js에서 Elysia를 쓸 수는 있지만 성능 이점이 사라지고, Hono의 멀티 런타임 생태계가 더 성숙해 있습니다.
지금 시작하려면:
bun create elysia my-api로 프로젝트를 만들고bun add @elysiajs/swagger를 붙인 뒤localhost:3000/swagger를 열어보세요. 스키마 정의만으로 Swagger UI가 자동 생성되는 경험이 가장 빠른 진입점입니다.- 간단한 CRUD 라우터를 만들고
export type App = typeof app으로 타입을 꺼낸 뒤, Eden Treaty 클라이언트에서 자동완성이 어떻게 동작하는지 직접 확인해보세요. - Drizzle ORM을 연결하면 DB 스키마 변경이 API 타입과 클라이언트 자동완성까지 전파되는 전체 체인을 경험할 수 있습니다.
참고 자료
- ElysiaJS 공식 문서
- Eden Treaty Overview — ElysiaJS 공식
- End-to-End Type Safety — ElysiaJS 공식
- Elysia 1.4 Supersymmetry 릴리즈 노트
- ElysiaJS Integration with Next.js 공식 가이드
- Standard Schema 명세
- GitHub — elysiajs/elysia
- GitHub — elysiajs/eden
- Elysia vs Hono — Encore.dev 심층 비교
- ElysiaJS + Drizzle ORM REST API Guide 2026 — 1xAPI
- Elysia.js: Type-Safe APIs Without the Mess — Atomic Object
- Hono vs Express vs Fastify vs Elysia 2026 비교 — PkgPulse
- Who uses Elysia at work — GitHub Discussion #1312