Logo

Elysia: Bun을 위한 인체 공학적 웹 프레임워크

Bun이 Node.js를 잇는 차세대 자바스크립트 런타임으로 급 부상하면서, Bun을 위해서 탄생한 웹 프레임워크인 Elysia도 큰 관심을 받고 있습니다. 이번 포스팅에서는 Elysia의 주요 특징과 사용법, 그리고 Elysia가 다른 웹 프레임워크와 어떻게 차별화되는지에 대해서 살펴보겠습니다.

Elysia란?

Express 프레임워크가 Node.js 생태계에서 대표 웹 프레임워크를 담당하고 있다면 Elysia는 Bun 생태계에서 비슷한 역할과 인지도를 갖고 있는 하고 있는 웹 프레임워크입니다.

물론 간단한 웹 서버를 개발할 때는 Bun에서 제공하는 HTTP 서버만으로도 충분할 때가 많지만, Elysia는 웹 서버 개발에 필요한 왠만한 기능이 모두 내장되어 있어서 단순한 서버 개발 뿐만 아니라 복잡한 서버 개발까지 사용할 수 있는 확장성이 띄어난 웹 프레임워크입니다.

타입스크립트를 바로 실행하는 Bun과 마찬가지로 Elysia도 타입스크립트에 매우 친화적인 API를 제공하면서도 개발자가 피곤하게 타입스크립트를 많이 작성하지 않도록 매우 인체 공학적으로 설계가 되어 있습니다. 다시 말해서, 다른 웹 프레임워크처럼 개발자에게 번거로운 타이핑(typing)을 강요하지 않고, 강력한 타입 추론을 통해서 코드 편집기에서 훌륭한 자동 완성(autocomplete)과 정적 타입 체킹을 제공합니다. 그래서 Elysia를 쓰면 마치 순수한(Vanilla) 자바스크립트를 쓰는 듯한 산뜻한 개발자 경험을 하면서도, 버그가 적은 견고한 서버 애플리케이션을 작성할 수 있습니다.

아래 예제 코드를 보시면 Elysia의 API가 얼마나 아름답고 직관적인지 실감이 나실 겁니다. 딱히 Elysia를 배우시지 않으신 분도 코드만 보면 대강 웹 서버가 어떻게 동작하는지 감을 잡을 수 있을 정도니까요.

import { Elysia } from "elysia";

new Elysia()
  .get("/", "Hello World")
  .get("/image", Bun.file("mika.webp"))
  .get("/stream", function* () {
    yield "Hello";
    yield "World";
  })
  .ws("/realtime", {
    message(ws, message) {
      ws.send("got:" + message);
    },
  })
  .listen(3000);

Elysia는 개발자 경험 뿐만 아니라 성능 측면에서도 기존 웹 프레임워크를 압도하는 모습을 보여줍니다. 벤치마크 결과를 보면 Elysia는 초당 Express보다 무려 21배, Fastify보다 6배 많은 요청을 처리하는 것으로 알려져 있습니다. 지나친 단순화이지만 Node.js에서 Express 익스프레스 서버 인스턴스로 처리할 수 있는 트래픽을, Bun에서는 1개의 Elysia 서버 인스턴스로 처리할 수 있다는 말입니다. 단순히 런타임과 프레임워크를 바꿔서 이 정도의 성능 향상을 이룰 수 있다니, Bun과 Elysia는 정말 매력적인 조합이 아닐 수 없습니다.

Elysia Benchmark

Elysia 프로젝트 시작하기

Elysia를 사용하려면 우선적으로 Bun이 설치되어 있어야 합니다. Bun을 설치하는 방법은 별도 포스팅을 참고하세요.

Elysia는 손쉽게 프로젝트를 만들 수 있도록 프로젝트를 쉽게 구성할 수 있도록 도와주는 명령줄 도구를 제공합니다. bun create elysia 명령어로 실행이 가능하며 뒤에 프로젝트를 생성할 디렉토리 이름만 명시해주면 됩니다.

> bun create elysia our-elysia

$ bun install
bun install v1.1.30 (7996d06b)

+ bun-types@1.1.30
+ elysia@1.1.21

9 packages installed [27.00ms]

[36.00ms] bun install


[67.00ms] git

[382.00ms] bun create elysia

Come hang out in bun's Discord: https://bun.sh/discord

-----

A local git repository was created for you and dependencies were installed automatically.

Created elysia project successfully

# To get started, run:

  cd our-elysia
  bun run src/index.ts

터미널에서 시키는데로 프로젝트 폴더에 들어가서 bun dev 명령어로 웹 서버를 개발 모드로 실행합니다.

> cd our-elysia
> bun dev
$ bun run --watch src/index.ts
🦊 Elysia is running at localhost:3000

터미널 창을 하나 더 띄우고 curl 명령어로 localhost:3000에 접속해보면 Hello Elysia라는 문자열이 뜰 것입니다.

> curl localhost:3000
Hello Elysia%

기본 라우팅

src 폴더에 있는 index.ts 파일을 열어보면 아주 간단한 Elysia 서버 코드가 있는데요. GET / 엔드포인트로 요청이 들어오면 Hello Elysia 문자열을 응답하도록 구현이 되어 있습니다.

index.ts
import { Elysia } from "elysia";

const app = new Elysia().get("/", () => "Hello Elysia").listen(3000);

console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

코드를 보면 get() 함수를 통해서 GET 요청을 처리하고 있다는 것을 알 수 있는데요. get() 함수는 첫 번째 인자로 경로를 받고, 두 번째 인자로 요청 처리 함수(request handler)나 단순한 값을 받습니다.

예를 들어, GET /hi 엔드포인트로 요청이 들어오면 손 모양의 이모지를 응답하도록 코드를 추가해볼까요?

index.ts
const app = new Elysia()
  .get("/", () => "Hello Elysia")
  .get("/hi", "👋")  .listen(3000);

터미널에서 curl 명령어로 추가한 엔드포인트를 테스트해보면 잘 동작합니다.

> curl localhost:3000/hi
👋%

Elysia를 사용할 때 함수 체이닝(chaining)을 통해서 여러 메서드를 연쇄 호출할 수 있는데요. 코드가 간단 명료하게 읽힐 뿐만 아니라 직접 코드를 써보시면 정말 편하다는 것을 느끼게 되실 겁니다.

HTTP 메서드

REST API를 구현하려면 GET 방식 뿐만 아니라, POST, PUT, PATCH, DELETE와 같은 다양한 HTTP 메서드로 들어오는 요청도 처리할 수 있어야겠죠? Elysia는 get() 함수와 유사하게, 각 HTTP 메서드에 대응하는 post(), put(), delete()와 같은 함수를 제공합니다.

index.ts
const app = new Elysia()
  .get("/hi", () => "get 👋")  .post("/hi", () => "post 👋")  .put("/hi", () => "put 👋")  .delete("/hi", () => "delete 👋")  .listen(3000);

터미널에서 동일한 /hi 경로를 다른 메서드로 호출해보면, 그에 대응하는 응답이 옵니다.

> curl localhost:3000/hi
get 👋%
> curl -X POST localhost:3000/hi
post 👋%
> curl -X PUT localhost:3000/hi
put 👋%
> curl -X DELETE localhost:3000/hi
delete 👋%

터미널 상에서 간편하게 사용할 수 있는 HTTP 클라이언트인 curl 커맨드에 대해서는 관련 포스팅을 참고하세요.

쿼리 스트링

서버 측에서 필터링(filter), 페이지네이션(pagination), 정렬(sort)과 같은 작업을 하러면 쿼리 스트링(query string)을 통해 검색 파라미터를 받아야 합니다.

클라이언트에서 명시한 검색 파라미터는 자바스크립트 객체로 변환되어 요청 처리 함수의 query 속성으로 넘어오는데요.

예를 들어, 클라이언트에서 보낸 쿼리 스트링을 그대로 JSON 형태로 돌려주는 GET /countries 엔드포인트를 구현해보겠습니다.

index.ts
import { Elysia, t } from "elysia";

const app = new Elysia().get("/countries", ({ query }) => query).listen(3000);

3개의 검색 파라미터를 쿼리 스트링에 넣어 호출을 해보면, JSON 형태로 변환된 객체가 응답이 됩니다.

> curl "localhost:3000/countries?region=Asia&page=1&sort=code"
{"region":"Asia","page":"1","sort":"code"}%

경로 변수

REST API를 디자인할 때 클라이언트가 URL 경로 안에 식별자(ID)를 넣어서 서버로 부터 특정 자원을 요청하도록 하는 경우가 많은데요.

경로 안에 : 기호로 시작하는 문자열을 설정해주면, 요청 처리 함수의 params 속성으로 넘어옵니다.

예를 들어, 클라이언트가 국가 코드를 경로 변수로 보낼 수 있도록 GET /countries/:code 엔드포인트를 구현해보겠습니다. 요청 처리 함수에서는 국가 코드를 읽어와서 그대로 JSON 형태로 응답합니다.

index.ts
const app = new Elysia()
  .get("/countries/:code", ({ params: { code } }) => ({    code,  }))  .listen(3000);

경로 안에 KR을 명시하여 API를 호출해보면, 예상했던 JSON 객체가 응답됩니다.

> curl localhost:3000/countries/KR
{"code":"KR"}%

요청 바디

클라이언트에서 요청 바디로 송신한 데이터는 요청 처리 함수의 body 속성으로 넘어옵니다.

예를 들어, 요청 바디로 넘어온 국가 데이터를 그대로 응답하도록 POST /countries 엔드포인트를 구현해보겠습니다.

index.ts
const app = new Elysia()
  .post("/countries", ({ body: country }) => country)  .listen(3000);

curl 명령어의 -d 옵션으로 국가 데이터를 요청 바디로 명시해줍니다. 이 때, -H 옵션으로 Content-type 헤더를 application/json으로 설정해주셔야 합니다.

> curl -X POST localhost:3000/countries -d '{"code": "KR", "name": "Korea"}' -H 'Content-type: application/json'
{"code":"KR","name":"Korea"}%

유효성 검증

요청 바디를 처리할 때는 클라이언트에서 옮바른 형태로 데이터를 보내는지 유효성 검증을 하는 것이 서버 측 데이터 무결성 측면에서 중요한데요.

post() 함수의 세 번째 인자로 서버에서 기대하는 요청 바디의 형태를 명시할 수 있습니다. 이 때, elysia 패키지에서 불러온 t를 사용하여 스키마(schema)를 정의합니다.

index.ts
import { Elysia, t } from "elysia";
const app = new Elysia()
  .post("/countries", ({ body: country }) => country, {
    body: t.Object({      code: t.Union([t.Literal("KR"), t.Literal("US"), t.Literal("CA")]),      name: t.String(),    }),  })
  .listen(3000);

이번에는 code 속성 대신에 id 속성을 사용해서 국가 데이터를 보내볼까요? 유효성 검증이 실패하여 오류가 응답이 될 것입니다.

> curl -X POST localhost:3000/countries -d '{"id": "KR", "name": "Korea"}' -H 'Content-type: application/json'
{
  "type": "validation",
  "on": "body",
  "summary": "Property 'code' is missing",
  "property": "/code",
  "message": "Required property",
  "expected": {
    "code": "KR",
    "name": ""
  },
  "found": {
    "name": "Korea"
  },
  "errors": [
    {
      "type": 45,
      "schema": {
        "anyOf": [
          {
            "const": "KR",
            "type": "string"
          },
          {
            "const": "US",
            "type": "string"
          },
          {
            "const": "CA",
            "type": "string"
          }
        ]
      },
      "path": "/code",
      "message": "Required property",
      "summary": "Property 'code' is missing"
    },
    {
      "type": 62,
      "schema": {
        "anyOf": [
          {
            "const": "KR",
            "type": "string"
          },
          {
            "const": "US",
            "type": "string"
          },
          {
            "const": "CA",
            "type": "string"
          }
        ]
      },
      "path": "/code",
      "message": "Expected union value",
      "summary": "Property 'code' should be one of: 'string', 'string', 'string'"
    }
  ]
}%

이와 같이 런타임(실행 시점)에 유효성 검증이 될 뿐 아니라, 코드 데이터에서 country 변수 위에 마우스 커서를 올려보면 타입스크립트로 타이핑이 되어 있는 것을 볼 수 있습니다.

country: {
  code: "KR" | "US" | "CA";
  name: string;
}

개발자가 직접 타입스크립트를 선언을 않아도 Elysia를 통해 유효성 검증과 타입 선언이라는 두 마리의 토끼를 잡을 수 있는 것이지요.

예외 처리

200 OK가 아닌 예외 상태가 발생했을 때는 요청 처리 함수의 error 속성을 사용하여 상태 코드와 오류 메세지를 명시해줄 수 있습니다.

예를 들어, 클라이언트가 호출하면 무조건 예외가 발생하도록 GET /error 엔드포인트를 구현해보겠습니다.

index.ts
const app = new Elysia()  .get("/error", ({ error }) => error(500, "서버 내부 문제"))
  .listen(3000);

curl 명령어의 -i 옵션으로 응답 헤더까지 출력해보면 명시해준 상태 코드와 오류 메시지를 볼 수 있습니다.

> curl localhost:3000/error -i
HTTP/1.1 500 Internal Server Error
content-type: text/plain;charset=utf-8
Date: Mon, 14 Oct 2024 14:51:33 GMT
Content-Length: 20

서버 내부 문제%

헤더 처리

HTTP 헤더를 통해서 클라이언트와 서버가 데이터를 주고 받는 경우도 있는데요. 대표적인 예로 쿠키(Cookie) 들 수 있습니다.

요청 헤더는 headers 속성을 통해서 읽을 수 있고, 응답 헤더는 set.headers 함수를 통해서 쓸 수 있습니다.

예를 들어, cookie 요청 헤더를 읽어오고, set-cookie 응답 헤더를 써주는 GET /cookie 엔드포인트를 구현해보겠습니다.

const app = new Elysia()
  .get("/cookie", ({ headers, set }) => {
    set.headers["set-cookie"] = "a=2";
    return {
      cookie: headers["cookie"],
    };
  })
  .listen(3000);

curl 명령어의 -H 옵션으로 Cookie 헤더 설정해주면 그 헤더 값이 그대로 응답 바디로 돌아옵니다. 서버에서 설정해준 set-cookie 값도 응답 헤더로 표시될 겁니다.

> curl localhost:3000/cookie -H "Cookie: a=1" -i
HTTP/1.1 200 OK
set-cookie: a=2
Content-Type: application/json;charset=utf-8
Date: Mon, 14 Oct 2024 15:33:58 GMT
Content-Length: 16

{"cookie":"a=1"}%

문서화

Elysia는 코드만 작성하면 자동으로 Open API 규격에 맞는 문서를 추출해주는데요. Swagger와 통합하여 아름다운 API 문서를 웹으로 볼 수도 있습니다.

우선 @elysiajs/swagger 패키지를 설치합니다.

> bun add @elysiajs/swagger
bun add v1.1.30 (7996d06b)

installed @elysiajs/swagger@1.1.5

7 packages installed [920.00ms]

그 다음 swagger 함수를 불러온 후 use() 함수를 통해서 호출만 해주면 됩니다.

index.ts
import { Elysia } from "elysia";
import { swagger } from "@elysiajs/swagger";
const app = new Elysia()
  .use(swagger())  .get("/hi", () => "get 👋")
  .post("/hi", () => "post 👋")
  .put("/hi", () => "put 👋")
  .delete("/hi", () => "delete 👋")
  // ... 다른 요청 처리 함수 호출
  .listen(3000);

console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

이제 브라우저에서 http://localhost:3000/swagger를 여시면 Swagger UI가 나올 것입니다. 터미널에서 curl 명령어로 API를 호출하는 대신에 Swagger UI를 통해서 좀 더 편리하게 API를 호출해볼 수 있습니다.

마치면서

지금까지 Elysia의 수려하고 직관적인 API를 통해서 어떻게 웹 서버를 구현할 수 있는지 간단한 실습을 통해서 알아보았습니다.

Node.js가 2009년에 나왔고 Express가 2010년에 나와서 정말 오랫동안 웹 서버 시장을 지배해왔습니다. 그 동안 자바스크립트 생태계에서는 너무나 많은 진화가 있었죠? 처음부터 TypeScript와 ES Modules 기반으로 설계된 기술로 넘어가야 할 때가 다가오고 있는 것 같습니다.

본 포스팅이 차세대 런타임 Bun과 차세대 웹 프레임워크를 Elysia을 활용한 모던 웹 개발에 도움이 되었으면 좋겠습니다.