ElysiaJS + Eden Treaty: `typeof` 하나로 서버에서 클라이언트까지 TypeScript 타입 연결하기
백엔드 API를 TypeScript로 만들고 프론트엔드에서 같은 타입을 써야 할 때 선택지는 꽤 다양합니다. tRPC 같은 RPC 레이어를 도입하거나, GraphQL 코드젠을 빌드 파이프라인에 끼워 넣거나, gRPC를 쓰거나, OpenAPI 스펙에서 타입을 생성하거나. 각 접근법에는 트레이드오프가 있습니다. tRPC는 타입 안전성이 탁월하지만 REST 엔드포인트를 외부에 노출해야 할 때 어색해집니다. GraphQL 코드젠은 CI 파이프라인에 스크립트가 추가되고, 스키마와 코드 사이 동기화가 어긋날 때 골치아픕니다.
ElysiaJS와 Eden Treaty의 접근법은 다릅니다. export type App = typeof app 한 줄로 서버-클라이언트 타입이 연결된다는 설명이 처음엔 너무 단순해 보일 수 있습니다. 그런데 그 단순함이 맞습니다. 코드 생성 단계가 없고, 별도 스키마 파일도 없습니다. TypeScript가 이미 알고 있는 제네릭 타입을 네트워크 경계까지 확장하는 것이 전부입니다.
이 글에서는 ElysiaJS + Bun으로 REST API를 설계하고 Eden Treaty로 클라이언트 타입 추론을 연결하는 전체 흐름을 다룹니다. tRPC와 무엇이 다른지, 모노레포에서 어떻게 타입을 공유하는지, 성능 수치와 실제 프로덕션 사이의 거리도 함께 살펴봅니다.
이 글을 읽고 나면 typeof 기반 E2E 타입 추론이 동작하는 원리, Bun Workspaces 기반 모노레포에서 서버-클라이언트 타입을 공유하는 구조, 그리고 ElysiaJS v1.4의 Standard Schema 지원이 기존 Zod 프로젝트에 어떤 의미인지 파악할 수 있습니다.
typeof로 타입이 흐르는 구조
tRPC와 무엇이 다른가
tRPC와 ElysiaJS + Eden Treaty의 핵심 차이는 타입 전파 방식과 API 설계 제약입니다.
| tRPC | ElysiaJS + Eden Treaty | |
|---|---|---|
| 타입 전파 방식 | 공유 라우터 타입 AppRouter |
export type App = typeof app |
| API 구조 | RPC 스타일 강제 | REST HTTP 메서드 그대로 사용 |
| 코드 생성 | 불필요 | 불필요 |
| 런타임 | Node.js, Edge 등 다양 | Bun 최적화 (Workers는 실험적) |
| 검증 라이브러리 | Zod·Valibot·Yup 등 지원 | TypeBox 내장 + Standard Schema (v1.4+) |
| OpenAPI 지원 | 제한적 | @elysiajs/swagger로 자동 생성 |
tRPC도 코드 생성 없이 타입 안전성을 제공하지만, API를 RPC 스타일로 설계해야 한다는 전제가 있습니다. RESTful 엔드포인트를 외부 클라이언트(모바일 앱, 서드파티)에 노출해야 하는 상황이라면 tRPC 위에 별도 REST 레이어를 만드는 수고가 생깁니다. ElysiaJS는 일반 HTTP 메서드를 그대로 쓰면서, 그 타입 정보를 Eden Treaty가 읽어 클라이언트 객체를 만들어줍니다.
타입 전파는 TypeScript의 typeof 연산자에서 시작합니다. Elysia 인스턴스 자체가 라우트·파라미터·응답 타입을 담은 제네릭 타입이기 때문에, typeof app만으로 서버의 모든 라우트 정보가 타입으로 포착됩니다. 이 타입을 treaty<App>(url)에 주입하면 컴파일 타임에 네트워크 경계가 연결됩니다.
빌드 단계도, 추가 스크립트도 없습니다. 타입 파일 하나가 그 역할을 전부 합니다.
Eden Treaty가 URL을 객체 트리로 바꾸는 방식
Eden Treaty는 서버의 라우트 경로를 객체 트리로 변환합니다. 경로 구분자 /가 .으로, HTTP 메서드가 마지막 함수 이름으로 바뀝니다.
GET /user→client.user.get()GET /user/:id→client.user({ id: '123' }).get()POST /user→client.user.post({ name, email })
경로 파라미터(/:id)는 함수 호출로, 쿼리 파라미터는 .get({ query: { ... } })의 query 키로, 요청 본문은 .post(body) 첫 번째 인자로 각각 구분됩니다. Eden Treaty가 서버 스키마에서 각 파라미터의 위치를 파악하기 때문에 클라이언트에서 타입을 잘못 넣으면 컴파일 에러가 납니다. 응답 타입뿐 아니라 에러 타입도 추론되어 error.status로 HTTP 상태 코드를 구분할 수 있습니다.
Elysia.t와 Standard Schema (v1.4+)
Elysia의 내장 검증은 TypeBox 기반의 t 객체를 씁니다. v1.4 이전에는 프로젝트에 Zod 스키마가 있어도 Elysia 라우트 안에서는 t.Object(...)로 다시 정의해야 하는 중복이 있었습니다.
// v1.4 이전: 기존 Zod 스키마를 그대로 쓸 수 없어 별도 재작성 필요
const userSchema = z.object({ name: z.string(), email: z.string().email() })
// 아래처럼 다시 작성해야 했음
body: t.Object({ name: t.String(), email: t.String({ format: 'email' }) })v1.4부터는 Standard Schema 인터페이스를 통해 Zod·Valibot 스키마를 그대로 Elysia 라우트에 넣을 수 있습니다.
import { z } from 'zod'
const userSchema = z.object({
name: z.string(),
email: z.string().email()
})
app.post('/user', ({ body }) => body, { body: userSchema })
// Zod 스키마가 검증과 OpenAPI 문서화에 그대로 활용됨기존 Zod 기반 프로젝트를 Elysia로 이전할 때 스키마를 다시 쓰는 비용이 없어진 셈입니다. 다만 v1.4 이전 코드베이스라면 t.Object로의 재작성 비용이 실제로 존재하므로, 이전 계획에 반영하는 편이 좋습니다.
실전 적용
1단계: 프로젝트 구조와 Elysia 서버 설정
Bun Workspaces를 활용한 모노레포 구조를 씁니다. apps/api가 서버, apps/web이 React 클라이언트, packages/shared가 타입 공유 레이어입니다.
mkdir my-app && cd my-app
bun init -y
mkdir -p apps/api apps/web packages/shared루트 package.json에 Workspaces를 설정합니다.
{
"name": "my-app",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}apps/api에 Elysia를 설치합니다.
cd apps/api && bun add elysia @elysiajs/swagger서버 정의는 app.ts에, 진입점(.listen() 호출)은 index.ts에 분리합니다. 이렇게 하면 테스트에서 서버를 실제로 띄우지 않고 app 인스턴스만 가져올 수 있습니다.
// apps/api/src/app.ts
import { Elysia, t } from 'elysia'
import { swagger } from '@elysiajs/swagger'
const userRoutes = new Elysia({ prefix: '/user' })
.get('/:id', ({ params }) => ({
id: params.id,
name: 'Alice',
createdAt: new Date().toISOString()
}), {
params: t.Object({ id: t.String() }),
detail: { summary: '사용자 조회', tags: ['user'] }
})
.post('/', ({ body }) => ({
// Bun은 Web API crypto를 전역으로 제공하므로 별도 import 없이 사용 가능
id: crypto.randomUUID(),
...body
}), {
body: t.Object({
name: t.String({ minLength: 1 }),
email: t.String({ format: 'email' })
}),
detail: { summary: '사용자 생성', tags: ['user'] }
})
export const app = new Elysia()
.use(swagger({ path: '/docs' }))
.use(userRoutes)
export type App = typeof app// apps/api/src/index.ts (진입점 전용)
import { app } from './app'
app.listen(3000)
console.log('서버가 http://localhost:3000 에서 실행 중입니다')@elysiajs/swagger를 use()하는 것만으로 /docs에 OpenAPI 3.0 문서가 자동 생성됩니다. 검증 스키마가 곧 문서의 단일 진실 소스가 됩니다.
2단계: 공유 타입 패키지 설정
apps/api/package.json에 패키지 이름을 "api"로 지정하면, shared 패키지가 상대 경로 대신 패키지 이름으로 참조할 수 있습니다.
// apps/api/package.json
{
"name": "api",
"main": "./src/app.ts"
}// packages/shared/package.json
{
"name": "shared",
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"api": "workspace:*"
}
}// packages/shared/src/index.ts
export type { App } from 'api'상대 경로(../../apps/api/src/app)를 하드코딩하면 디렉터리 구조가 조금만 바뀌어도 깨집니다. 워크스페이스 패키지 이름으로 참조하면 이 문제를 피할 수 있습니다.
apps/web/package.json에 의존성을 추가합니다.
{
"dependencies": {
"shared": "workspace:*",
"@elysiajs/eden": "latest"
}
}3단계: Eden Treaty 클라이언트 연결
// apps/web/src/lib/api.ts
import { treaty } from '@elysiajs/eden'
import type { App } from 'shared'
export const client = treaty<App>('http://localhost:3000')이제 클라이언트에서 완전한 타입 추론과 함께 API를 호출할 수 있습니다.
// apps/web/src/components/UserProfile.tsx
import { client } from '../lib/api'
async function fetchUser(id: string) {
const { data, error } = await client.user({ id }).get()
// { id }는 경로 파라미터 /:id에 매핑됨
if (error) {
// error.status: number, error.value: unknown
console.error(`에러 ${error.status}:`, error.value)
return null
}
// data: { id: string; name: string; createdAt: string }
// 서버 응답 타입이 그대로 추론됨
return data
}
async function createUser(name: string, email: string) {
const { data, error } = await client.user.post({ name, email })
// body 타입이 맞지 않으면 컴파일 에러 발생
return { data, error }
}쿼리 파라미터가 필요한 경우에는 .get({ query: { name: 'Alice' } }) 형태로 전달합니다. 경로 파라미터(함수 호출), 쿼리(query 키), 요청 본문(첫 번째 인자)이 각각 구분되며, 서버 스키마 선언에서 그 위치를 결정합니다.
4단계: decorate와 Drizzle ORM으로 데이터베이스 연결
데이터베이스 클라이언트처럼 애플리케이션 수명 동안 고정된 값은 .decorate()로 컨텍스트에 주입합니다. .derive()는 요청마다 달라지는 값(예: 인증된 사용자 정보)에 씁니다. 싱글턴 DB 인스턴스에 .derive()를 쓰면 매 요청마다 불필요한 재할당이 발생합니다.
// apps/api/src/plugins/db.ts
import { Elysia } from 'elysia'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import { Database } from 'bun:sqlite'
import * as schema from '../schema'
const sqlite = new Database('app.db')
const db = drizzle(sqlite, { schema })
export const withDb = new Elysia()
.decorate('db', db) // 요청마다 재생성하지 않고 싱글턴 주입// apps/api/src/routes/user.ts
import { Elysia, t } from 'elysia'
import { withDb } from '../plugins/db'
export const userRoutes = new Elysia({ prefix: '/user' })
.use(withDb)
.get('/:id', async ({ params, db, error }) => {
// db: BunSQLiteDatabase 타입 자동 추론
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, params.id)
})
if (!user) return error(404, 'Not Found')
// error() 헬퍼를 써야 Eden Treaty의 에러 타입 추론이 유지됨
// new Response(...)를 직접 반환하면 클라이언트의 error 타입 추론이 깨짐
return user
}, {
params: t.Object({ id: t.String() })
})error() 헬퍼는 컨텍스트에서 구조 분해해서 씁니다. raw new Response('Not Found', { status: 404 })를 반환하면 Eden Treaty가 에러 타입을 추론하지 못하는 경로가 생깁니다.
5단계: WebSocket 채널과 단위 테스트
Eden Treaty는 WebSocket도 같은 타입 추론 아래 지원합니다.
// 서버 - app.ts에 추가
export const app = new Elysia()
.ws('/chat', {
body: t.Object({ message: t.String() }),
response: t.Object({ from: t.String(), message: t.String() }),
message(ws, body) {
ws.send({ from: 'server', message: body.message })
}
})// 클라이언트
const chat = client.chat.subscribe()
chat.send({ message: '안녕하세요' })
// { message: string } 타입 강제
chat.on('message', ({ data }) => {
// data: { from: string; message: string }
console.log(data.from, data.message)
})단위 테스트에서는 app.ts에서 인스턴스를 가져와 treaty(app)에 직접 주입합니다. index.ts가 아닌 app.ts에서 가져오기 때문에 .listen()이 호출되지 않아 포트 충돌이 생기지 않습니다.
// apps/api/src/app.test.ts
import { describe, it, expect } from 'bun:test'
import { treaty } from '@elysiajs/eden'
import { app } from './app' // index.ts가 아닌 app.ts — listen 없음
const client = treaty(app)
describe('User API', () => {
it('사용자를 생성할 수 있습니다', async () => {
const { data, error } = await client.user.post({
name: 'Bob',
email: 'bob@example.com'
})
expect(error).toBeNull()
expect(data?.name).toBe('Bob')
})
})장단점과 선택 기준
장점
| 항목 | 내용 |
|---|---|
| 코드 생성 불필요 | typeof 추론으로 타입 전파, 빌드 파이프라인 단순 |
| REST 표준 유지 | RPC 구조 강제 없음, OpenAPI 문서 자동 생성 |
| 타입 완전성 | decorate·derive 등 전 라이프사이클에서 타입 안전 |
| Standard Schema (v1.4+) | 기존 Zod·Valibot 스키마 재사용 가능 |
| 내장 기능 | 파일 업로드, WebSocket, OpenAPI 문서화 기본 제공 |
| 테스트 편의성 | 인스턴스 직접 주입으로 서버 없이 통합 테스트 가능 |
실제 고려사항
| 항목 | 내용 |
|---|---|
| Bun 런타임 종속 | Lambda Node 레이어, 일부 PaaS에서 제약 발생 |
| Workers 지원 실험적 | Cloudflare Workers 등 엣지 환경 배포는 아직 불안정 |
| 생태계 규모 | tRPC·Express 대비 커뮤니티가 작고, 엣지 케이스 레퍼런스 부족 |
| 벤치마크와 현실 차이 | 합성 벤치마크 |
| v1.4 이전 마이그레이션 | Zod 스키마를 Elysia.t로 재작성하는 비용 존재 |
이런 이유로, 팀 설득 시에는 성능 수치보다 코드 생성 없는 E2E 타입 안전성과 빠른 개발 사이클을 도입 근거로 잡는 편이 더 현실적입니다.
실무에서 자주 겪는 문제
런타임 코드를 shared에 포함시키는 문제
shared 패키지에서 import { app }처럼 인스턴스를 가져오면 클라이언트 번들에 서버 코드가 포함될 수 있습니다. export type과 import type을 일관되게 써야 합니다.
// 위험: 런타임 의존성이 클라이언트 번들에 포함될 수 있음
import { app } from 'api'
// 안전: 타입만 가져옴
import type { App } from 'api'Eden Treaty와 직접 fetch 혼용
Eden Treaty 클라이언트를 일부 라우트에만 쓰고 나머지는 fetch로 직접 호출하면 타입 안전성이 깨지는 구간이 생깁니다. 클라이언트를 일관되게 사용하는 것이 좋습니다.
어떤 상황에서 선택하나
tRPC는 Bun 위에서도 동작하므로 런타임이 아니라 API 설계 스타일로 선택합니다. Hono 역시 Bun에서 잘 동작하지만, 멀티 런타임 유연성이 필요할 때 더 적합합니다.
마치며
ElysiaJS + Eden Treaty의 핵심은 복잡한 추상화를 추가하는 게 아니라, TypeScript가 이미 가진 typeof 추론을 네트워크 경계까지 확장하는 것입니다. 코드 생성 없이, RPC 레이어 없이, REST 설계를 그대로 유지하면서 서버-클라이언트 타입이 연결됩니다. tRPC가 "타입 안전한 RPC"라면, Elysia + Eden Treaty는 "타입 안전한 REST"에 가깝습니다.
시작해보고 싶다면 세 단계로 나눠볼 수 있습니다. 첫째, bun create elysia my-api로 단일 서버를 만들고 export type App = typeof app이 어떤 타입을 내보내는지 IDE에서 확인합니다. 둘째, @elysiajs/eden을 설치하고 같은 레포에서 treaty(app)으로 인스턴스를 직접 주입해 타입 추론이 동작하는 것을 눈으로 확인합니다. 셋째, 모노레포로 구조를 분리하고 packages/shared를 통해 타입이 실제 네트워크 경계를 넘어 연결되는 흐름을 잡습니다. 각 단계가 짧고 명확해서 하루 이틀이면 전체 그림을 파악할 수 있습니다.
Bun 런타임 종속과 생태계 성숙도는 실제 고려 사항입니다. Lambda Node 레이어처럼 Bun을 지원하지 않는 인프라가 있다면 Hono가 더 현실적인 선택일 수 있습니다. 반대로 Bun을 쓸 수 있는 환경이라면, 코드 생성 없는 E2E 타입 안전성과 빠른 개발 사이클은 매력적인 조합입니다.
참고 자료
- Eden Treaty Overview — ElysiaJS 공식 문서
- End-to-End Type Safety Overview — ElysiaJS 공식 문서
- Migrate from tRPC — ElysiaJS 공식 문서
- Elysia 1.4 릴리즈 블로그 — ElysiaJS
- Elysia 1.0 릴리즈 블로그 — ElysiaJS
- ElysiaJS GitHub 저장소
- Eden GitHub 저장소
- ElysiaJS Monorepo 예제 — SaltyAom
- TanStack Start + Elysia + Better Auth 모노레포 스타터
- 종합 벤치마크: Bun+ElysiaJS vs Node+Express
- tRPC vs Hono vs Elysia 비교 — Zenn
- Elysia.js + Bun: Type-Safe APIs Without the Mess — Atomic Object