Elysia.js + Bun으로 서버-클라이언트 타입을 한 번에 묶는 법
풀스택 TypeScript 프로젝트를 하다 보면 이런 순간이 옵니다. 백엔드에서 응답 필드 이름을 userId에서 id로 바꿨는데, 프론트엔드는 아직도 userId를 참조하고 있고, TypeScript 컴파일은 조용히 통과한다. 그리고 런타임에서 undefined가 터집니다.
tRPC, GraphQL 코드젠, Zod 스키마 공유 등 여러 방법을 써봤는데, 매번 "설정 파일 하나만 더"를 반복하는 패턴이 있습니다. Elysia.js는 이 문제를 근본적으로 다른 방식으로 접근합니다. 별도의 코드 생성 단계도, 공유 스키마 파일도 없이, TypeScript 타입 추론 자체를 통해 서버에서 정의한 타입이 클라이언트까지 전파됩니다.
이 글에서는 Bun + Elysia.js 조합으로 엔드투엔드 타입 안전성을 실제로 구현하는 방법을 살펴봅니다. 모노레포 구성부터 Eden Treaty 연결, OpenAPI 문서 자동화, JWT 인증까지 — 흔히 걸리는 함정도 같이 짚어드립니다.
핵심 개념
Elysia.js가 타입 안전성을 구현하는 방식
대부분의 REST 프레임워크는 타입 안전성을 나중에 붙이는 방식입니다. Express에 express-zod-api를 붙이거나, Fastify에 @fastify/type-provider-zod를 연결하는 식이죠. Elysia는 처음부터 타입 추론을 핵심 설계 원칙으로 삼았습니다.
핵심 트릭은 메서드 체이닝 + 제네릭 타입 누적입니다. app.get('/users', handler)를 호출할 때마다 Elysia는 라우트 정보가 담긴 새 타입의 인스턴스를 반환합니다. 체이닝이 끝난 app의 타입 안에는 모든 라우트의 입력·출력 타입 정보가 담겨 있습니다.
여기서 중요한 구분이 있습니다. 타입 전파는 TypeScript 컴파일 타임에, 유효성 검증은 런타임에 각각 독립적으로 일어납니다. 이 덕분에 코드 생성 단계 없이도 IDE 자동완성과 런타임 안전성을 동시에 얻을 수 있습니다.
Elysia.t — 세 가지를 한 번에 처리하는 스키마 빌더
import { Elysia, t } from 'elysia'
const app = new Elysia()
.get('/users/:id', ({ params }) => {
return { id: params.id, name: 'Alice' }
}, {
params: t.Object({
id: t.String()
}),
response: t.Object({
id: t.String(),
name: t.String()
})
})t.Object()로 정의한 스키마가 동시에 세 가지 역할을 합니다.
| 역할 | 처리 시점 | 결과 |
|---|---|---|
| 런타임 유효성 검증 | 요청 수신 시 | 잘못된 요청 자동 거부 |
| 컴파일 타임 타입 체크 | TypeScript 컴파일 시 | IDE 자동완성 + 타입 에러 검출 |
| OpenAPI 스키마 생성 | 플러그인 등록 시 | Scalar UI 문서 자동화 |
Eden Treaty — REST를 유지하면서 타입 안전성 추가
tRPC도 훌륭하지만, tRPC를 쓰면 REST 구조를 버려야 합니다. trpc.router()로 정의된 프로시저는 /trpc/getUser처럼 단일 엔드포인트로 모이고, 모바일 앱이나 외부 서비스에서 표준 REST 클라이언트로 호출하기 어렵습니다. OpenAPI 문서를 생성하려면 별도 어댑터도 필요합니다.
Eden Treaty는 REST 구조를 그대로 유지하면서 타입 안전성을 추가합니다. 서버의 App 타입만 참조하면 HTTP 메서드와 URL 경로가 그대로 TypeScript 객체 접근으로 매핑됩니다.
// 서버 (apps/api/src/index.ts)
import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/login', ({ body }) => ({
token: `jwt_${body.username}`
}), {
body: t.Object({
username: t.String(),
password: t.String()
}),
response: t.Object({
token: t.String()
})
})
.listen(3000)
export type App = typeof app // ← 이게 핵심// 클라이언트 (apps/web/src/lib/api.ts)
import { treaty } from '@elysiajs/eden'
import type { App } from '../../api/src/index' // 타입만 import
const client = treaty<App>('http://localhost:3000')
const { data, error } = await client.login.post({
username: 'alice',
password: 'secret'
})
// data.token은 string으로 추론됨
// data.nonexistent ← TypeScript 에러 발생URL 경로 파라미터가 있는 경우, Eden은 이를 함수 호출로 표현합니다.
// GET /users/:id → client.users({ id: '123' }).get()
const { data } = await client.users({ id: '123' }).get()
// data의 타입은 서버 response 스키마에서 자동 추론실전 적용
1단계 — 모노레포 구성 및 의존성 설치
Eden Treaty는 타입을 import하는 방식이라 모노레포 구성이 가장 깔끔합니다. 단일 레포에서도 동작하지만 tsconfig 경로 설정이 복잡해지고, 양쪽 앱에서 path alias를 동일하게 해석해야 하는 문제가 생깁니다. Railway의 공식 배포 템플릿도 이 구조를 표준으로 씁니다.
my-app/
├── apps/
│ ├── api/ ← Elysia 서버
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── package.json
│ └── web/ ← Next.js / React
│ ├── src/
│ │ └── lib/api.ts
│ └── package.json
├── package.json ← pnpm workspaces 루트
└── pnpm-workspace.yaml# pnpm-workspace.yaml
packages:
- 'apps/*'# apps/api 디렉토리에서
bun add elysia @elysiajs/openapi @elysiajs/cors @elysiajs/jwt @elysiajs/bearer
# apps/web 디렉토리에서
bun add @elysiajs/edenpnpm workspace를 사용하면 elysia 패키지 버전을 루트 package.json에서 일괄 관리할 수 있습니다. 서버와 클라이언트의 elysia 버전이 달라지면 타입 불일치가 생기는데, workspace로 이 문제를 방지할 수 있습니다.
2단계 — 서버 구성
// apps/api/src/index.ts
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { cors } from '@elysiajs/cors'
// 실제 프로젝트에서는 DB 조회로 교체
const USERS = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
]
const userRoutes = new Elysia({ prefix: '/users' })
.get('/', () => USERS, {
detail: { summary: '전체 사용자 목록 조회', tags: ['Users'] },
response: t.Array(t.Object({
id: t.String(),
name: t.String(),
email: t.String()
}))
})
.get('/:id', ({ params, error }) => {
const user = USERS.find(u => u.id === params.id)
if (!user) return error(404, { message: '사용자를 찾을 수 없습니다' })
return user
}, {
params: t.Object({ id: t.String() }),
response: {
200: t.Object({ id: t.String(), name: t.String(), email: t.String() }),
404: t.Object({ message: t.String() })
},
detail: { summary: '특정 사용자 조회', tags: ['Users'] }
})
.post('/', ({ body }) => ({
id: crypto.randomUUID(),
...body
}), {
body: t.Object({
name: t.String({ minLength: 1 }),
email: t.String({ format: 'email' })
}),
detail: { summary: '사용자 생성', tags: ['Users'] }
})
const app = new Elysia()
.use(cors())
.use(openapi({
documentation: {
info: {
title: 'My API',
version: '1.0.0',
description: 'Elysia.js로 구성된 타입 안전 API'
}
}
}))
.use(userRoutes)
.listen(3000)
console.log('서버 실행 중: http://localhost:3000')
console.log('API 문서: http://localhost:3000/openapi')
export type App = typeof app3단계 — OpenAPI 문서 확인
서버를 실행한 뒤 http://localhost:3000/openapi에 접속하면 Scalar UI 기반 인터랙티브 문서가 즉시 열립니다. Raw JSON 스펙은 /openapi/json으로 받을 수 있어서 Postman 임포트나 모바일 클라이언트용 코드젠 도구에 바로 연결하기 좋습니다.
bun run src/index.ts
# 다른 터미널에서
curl http://localhost:3000/openapi/json | jq '.paths'한 가지 주의할 점이 있습니다. TypeScript가 반환 타입을 추론하더라도, OpenAPI 문서에는 명시적으로 response 스키마를 작성해야 합니다. TypeScript 타입 추론과 OpenAPI 스키마 생성은 서로 다른 파이프라인입니다. 전자는 컴파일 타임 정보이고, 후자는 런타임에 JSON으로 직렬화되는 메타데이터입니다.
예를 들어 Drizzle ORM을 사용하는 경우:
import { db } from './db'
import { products } from './schema'
app.get('/products', async () => {
return db.select().from(products)
}, {
// Drizzle 반환 타입은 TypeScript 컴파일 타임에만 추론됩니다.
// OpenAPI 문서에 반영하려면 response 스키마를 별도로 명시해야 합니다.
response: t.Array(t.Object({
id: t.Number(),
name: t.String(),
price: t.Number()
}))
})4단계 — 클라이언트 구성
// apps/web/src/lib/api.ts
import { treaty } from '@elysiajs/eden'
import type { App } from '../../api/src/index'
const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'
export const api = treaty<App>(baseUrl)// apps/web/src/components/UserList.tsx (Next.js Server Component)
import { api } from '@/lib/api'
export async function UserList() {
const { data, error } = await api.users.get()
if (error) {
return <p>에러: {error.value.message}</p>
}
return (
<ul>
{data.map(user => (
// user.id, user.name, user.email 모두 타입 추론됨
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
)
}동적 경로 파라미터가 있는 경우:
// apps/web/src/components/UserDetail.tsx
import { api } from '@/lib/api'
export async function UserDetail({ id }: { id: string }) {
// GET /users/:id → api.users({ id }).get()
const { data, error } = await api.users({ id }).get()
if (error?.status === 404) {
return <p>{error.value.message}</p>
}
if (error) return <p>알 수 없는 오류</p>
return <div>{data.name} ({data.email})</div>
}// apps/web/src/components/CreateUserForm.tsx (Client Component)
'use client'
import { api } from '@/lib/api'
export function CreateUserForm() {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const { data, error } = await api.users.post({
// formData.get()은 FormDataEntryValue | null을 반환합니다.
// required 속성이 있어도 TypeScript는 이를 보장하지 못하므로
// null 처리를 명시하는 것이 안전합니다.
name: formData.get('name')?.toString() ?? '',
email: formData.get('email')?.toString() ?? ''
})
if (error) {
console.error(error.value)
return
}
console.log('생성됨:', data.id)
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="이름" required />
<input name="email" type="email" placeholder="이메일" required />
<button type="submit">생성</button>
</form>
)
}5단계 — TanStack Query 연동
Eden Treaty만으로도 일반적인 fetch가 가능하지만, 캐싱·리패칭·낙관적 업데이트가 필요하다면 @ap0nia/eden-query를 통해 TanStack Query와 결합할 수 있습니다.
bun add @ap0nia/eden-query @tanstack/react-query// apps/web/src/lib/eden-query.ts
import { createEdenTreatyReactQuery } from '@ap0nia/eden-query/react'
import type { App } from '../../api/src/index'
export const eden = createEdenTreatyReactQuery<App>()
export const edenClient = eden.createClient({
links: [
eden.httpLink({ domain: 'http://localhost:3000' })
]
})훅을 사용하기 전에 반드시 Provider를 래핑해야 합니다. Provider 없이 useQuery를 호출하면 런타임 에러가 발생합니다.
// apps/web/src/app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { eden, edenClient } from '@/lib/eden-query'
const queryClient = new QueryClient()
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<eden.Provider client={edenClient} queryClient={queryClient}>
{children}
</eden.Provider>
</QueryClientProvider>
)
}// apps/web/src/components/UserListWithQuery.tsx
'use client'
import { eden } from '@/lib/eden-query'
export function UserListWithQuery() {
const { data, isLoading } = eden.users.get.useQuery()
const createUser = eden.users.post.useMutation()
if (isLoading) return <p>로딩 중...</p>
return (
<>
<ul>
{data?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button
onClick={() => createUser.mutate({ name: 'Charlie', email: 'charlie@example.com' })}
>
사용자 추가
</button>
</>
)
}6단계 — JWT 인증 플로우
인증을 추가할 때 derive를 사용하면 컨텍스트에 타입 안전하게 사용자 정보를 주입할 수 있습니다.
// apps/api/src/middleware/auth.ts
import { Elysia } from 'elysia'
import { jwt } from '@elysiajs/jwt'
import { bearer } from '@elysiajs/bearer'
export const authMiddleware = new Elysia({ name: 'auth' })
.use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET! }))
.use(bearer())
.derive(async ({ jwt, bearer, error }) => {
const payload = await jwt.verify(bearer)
if (!payload) return error(401, { message: '인증 실패' })
return { user: payload as { id: string; email: string } }
})로그인 엔드포인트와 보호된 라우트는 명확하게 분리됩니다. 로그인 엔드포인트는 토큰을 생성하는 역할이고, authMiddleware(JWT 검증)가 적용되지 않습니다.
// apps/api/src/index.ts
import { Elysia, t } from 'elysia'
import { jwt } from '@elysiajs/jwt'
import { authMiddleware } from './middleware/auth'
// 로그인 엔드포인트 — JWT 검증 미들웨어 적용 안 함
const authRoutes = new Elysia({ prefix: '/auth' })
.use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET! }))
.post('/login', async ({ body, jwt, error }) => {
// 실제 프로젝트에서는 DB에서 사용자 확인 필요
if (body.username !== 'alice' || body.password !== 'secret') {
return error(401, { message: '자격증명 오류' })
}
const token = await jwt.sign({ id: '1', email: 'alice@example.com' })
return { token }
}, {
body: t.Object({ username: t.String(), password: t.String() })
})
// 보호된 라우트 — JWT 검증 미들웨어 적용
const protectedRoutes = new Elysia({ prefix: '/me' })
.use(authMiddleware)
.get('/', ({ user }) => ({ id: user.id, email: user.email }))
// user.id, user.email은 authMiddleware의 derive 타입에서 자동 추론됨
const app = new Elysia()
.use(authRoutes)
.use(protectedRoutes)
.listen(3000)
export type App = typeof app두 흐름의 차이가 한눈에 보입니다.
derive와 resolve의 차이
derive와 resolve 모두 컨텍스트에 값을 주입하지만, 실행 시점과 용도가 다릅니다.
| 구분 | derive |
resolve |
|---|---|---|
| 실행 시점 | beforeHandle 이전, 모든 요청 |
beforeHandle 이후, 라우트 핸들러 직전 |
| 주요 용도 | 요청 컨텍스트 확장 (토큰 파싱, ID 추출) | 의존성 주입, 핸들러 직전 준비 작업 |
| 에러 반환 | 가능 | 가능 |
const app = new Elysia()
// derive: 모든 요청에서 헤더를 파싱해 컨텍스트 추가
.derive(({ headers }) => ({
requestId: headers['x-request-id'] ?? crypto.randomUUID()
}))
// resolve: 핸들러 직전에 타이머 시작
.resolve(async () => ({
startTime: Date.now()
}))
.get('/ping', ({ requestId, startTime }) => ({
requestId,
elapsed: Date.now() - startTime
}))인증 미들웨어처럼 "요청 전체에서 필요한 컨텍스트"는 derive, "특정 라우트 그룹 직전에만 필요한 준비 작업"은 resolve가 적합합니다.
타입 추론이 끊기는 진짜 원인
Elysia를 처음 쓸 때 타입 추론이 갑자기 끊기는 상황이 있는데, 원인은 메서드 체이닝이 아닙니다. 플러그인을 메인 앱에 .use()로 합성하지 않는 것이 진짜 원인입니다.
// ❌ 플러그인을 메인 앱에 합성하지 않고 별도로 내보내면
// Eden이 이 타입을 볼 수 없음
const authPlugin = new Elysia()
.derive(({ headers }) => ({ user: parseUser(headers) }))
export { authPlugin } // App 타입에 포함 안 됨
// ✅ 반드시 메인 앱에서 .use()로 합성해야 함
const app = new Elysia()
.use(authPlugin) // authPlugin 타입이 app 타입에 포함됨
.get('/me', ({ user }) => user) // user 타입 정상 추론
export type App = typeof app // Eden이 user 타입을 볼 수 있음언제 Elysia를, 언제 다른 것을 써야 할까
장점
| 항목 | 내용 |
|---|---|
| 코드 생성 불필요 | 빌드 단계 없이 런타임 타입 추론만으로 동작 |
| REST 구조 유지 | tRPC처럼 특정 API 구조를 강제하지 않음 |
| 단일 소스 검증 | 런타임 검증 + 컴파일 타입 + OpenAPI 문서 동시 처리 |
| 성능 | Elysia 공식 벤치마크에서 Express 대비 높은 처리량 보고. 실제 수치는 워크로드와 환경에 따라 달라집니다 |
| DX | derive/resolve로 100% 타입 안전한 컨텍스트 커스터마이징 |
| 문서 자동화 | @elysiajs/openapi 한 줄로 Scalar UI 즉시 제공 |
단점 및 주의 사항
| 항목 | 내용 | 해결책 |
|---|---|---|
| Eden 버전 일치 필수 | 서버/클라이언트 elysia 버전이 달라지면 타입 불일치 | pnpm workspace로 단일 버전 관리 |
| 경로 별칭 문제 | tsconfig path alias를 양쪽에서 동일하게 해석해야 함 | baseUrl + paths 양쪽 모두 설정 |
| 생태계 규모 | Express/Fastify 대비 서드파티 미들웨어 부족 | 공식 플러그인 위주로 구성 |
| 모바일 클라이언트 | Eden은 TypeScript 전용 | /openapi/json으로 코드젠 활용 |
| OpenAPI 수동 스키마 | 반환 타입 추론과 별개로 문서화는 response 명시 필요 |
라우트 정의 시 response 스키마 습관화 |
마치며
Elysia.js + Bun 조합의 핵심은 단순합니다. 타입을 한 번만 정의하면 런타임 검증, IDE 타입 체크, API 문서화가 동시에 처리됩니다. 코드 생성 단계나 별도 스키마 파일 없이, TypeScript 추론 능력만으로 이걸 달성한다는 점이 다른 접근과 구별됩니다.
2024년 3월 1.0 안정 버전 출시 이후 Thoughtworks Technology Radar에 등재됐고, 주간 npm 다운로드가 46만 건을 넘어서며 빠르게 성장하고 있습니다. 아직 Express/Fastify만큼 생태계가 크진 않지만, 새 TypeScript 풀스택 프로젝트를 시작한다면 충분히 프로덕션 레벨에서 고려할 만합니다.
바로 시작해볼 수 있는 경로를 정리하면 이렇습니다.
5분 체험
bun create elysia my-app
cd my-app && bun dev
# http://localhost:3000 확인모노레포로 확장
Railway의 bun-elysia-react-monorepo 템플릿을 클론하면 서버 + 프론트엔드 + Eden Treaty + 배포 설정이 모두 포함된 구조를 바로 살펴볼 수 있습니다.
기존 tRPC 프로젝트 마이그레이션
공식 마이그레이션 가이드에 라우터, 프로시저, 미들웨어 변환 방법이 정리돼 있습니다.
참고 자료
- Elysia 공식 문서 — 엔드투엔드 타입 안전성
- Eden Treaty 개요 (공식)
- Eden Treaty 설치 가이드
- OpenAPI 플러그인 공식 문서
- OpenAPI 패턴 가이드
- Elysia 블로그 — OpenAPI Type Gen 소개
- Elysia 1.0 릴리스 노트
- Elysia 1.4 Supersymmetry 릴리스 노트
- Thoughtworks Technology Radar — ElysiaJS
- tRPC에서 Elysia로 마이그레이션 가이드
- 공식 모노레포 예제 (SaltyAom)
- Eden + TanStack Query 통합 라이브러리
- Elysia 실사례 데모 앱 (Bun + Docker + Fly.io)
- Railway Bun + Elysia + React 모노레포 템플릿
- ElysiaJS + Next.js + React Query 통합 튜토리얼