Cloudflare Workers로 Express.js 스타일 API Gateway 프레임워크 만들기

swhan
Workers에서 사용 가능한 Express.js 스타일의 API Gateway 프레임워크 개발기를 공유합니다.
개발 배경
하루 100,000 요청까지 무료인 Cloudflare Workers를 이용해 간단한 블로그 방문자 카운팅 API 서버를 구축해서 운영하던 중 인기글, 댓글, 뉴스레터 구독자 관리, 방문자 통계 등 관리해야 하는 API가 늘어나는 상황에서 switch 문으로 모든 요청들 처리하기에는 한계에 도달했습니다.
Workers에서 Node 런타임을 지원하긴 하지만 아직 불안해서 Express.js를 사용할 수 없고, 클라우드플레어 소속 개발자가 만든 Hono라는 프레임워크도 있지만 당시 검색해 볼 생각조차 하지 않은 바람에 얼떨결에 저만의 프레임워크를 만들게 되었습니다.
작동 방식을 최대한 단순히 설명하기 위해 코드들이 많이 생략되었는데요, 전체 코드 및 사용법은 Github day1swhan/cf-worker-api-gateway에서 확인하실 수 있습니다.
설계 원칙
Workers가 콜드 스타트를 0으로 만든 방법을 보시면 TLS Handshake 단계에서 런타임을 준비하는 것을 알 수 있는데요, 이렇게 콜드스타트 없애려고 노력하는 개발자들을 존중하기 위한 저만의 설계 원칙은 딱 두 개입니다.
-
파일 하나로 사용: 메모리 부담되지 않으면서 누구나 간편하게 복사 & 붙여넣기로 사용 가능하고 작동 방식을 쉽게 파악 가능
-
Express.js 스타일: JavaScript 개발자라면 익숙한 Express.js 스타일의 라우터, 미들웨어와 유사하게 설계해서 최소한의 학습으로 기능 확장 가능
작동 방식
// index.ts
import { WorkerAPIGateway } from "./router";
const app = new WorkerAPIGateway<Env>();
app.get("/", (req, context) => {
return Response.json({ message: "hello world" });
});
app.get("/user/:id", (req, context) => {
const { params, query, cookie, env, ctx } = context;
return Response.json({ params, query, cookie });
});
export default app.export() satisfies ExportedHandler<Env>;
curl -i -H 'cookie: uid=98765' \
'http://localhost:8787/user/12345?hello=world'
HTTP/1.1 200 OK
...
{
"params": { "id": "12345" },
"query": { "hello": "world" },
"cookie": { "uid": "98765" }
}
초기화 후 사용할 Method에 경로, 최종 응답 핸들러 선언하는 방식은 Express.js를 사용해 보신 개발자라면 익숙하실 텐데요, 특징들은 다음과 같이 정리할 수 있습니다.
-
API 서버에서 자주 사용하는 Cookie를 미들웨어 필요 없도록 기본적으로 파싱해서 제공
-
Binding(env): R2, KV Store 같은 Cloudflare Developer Platform 자원 사용 가능
-
Context(ctx): waitUntil 함수에 DB Update 같이 응답에 포함될 필요 없는 요소들 넘겨서 사용자에게 blocking 없이 응답 가능
-
미들웨어: 사용자의 HTTP request, response 요소들을 제어 가능
내부 구조
GET /user/:id
경로의 요청을 처리한다는 가정으로 중요 타입, 라우터, 미들웨어 요소들을 알아보겠습니다.
Type
// router.ts
export type Token = { type: "static"; value: string } | { type: "param"; name: string };
export type Handler<Env> = (req: Request, context: Context<Env>) => Response | Promise<Response>;
export type Middleware<Env> = (next: Handler<Env>) => Handler<Env>;
export type MiddlewareWithPrefix<Env> = { prefix: string; middlewares: Middleware<Env>[] };
export type Router<Env> = {
method: Method;
pathname: string;
tokens: Token[];
handler: Handler<Env>;
};
-
Token: 라우터 초기화 시 정적 요소(user)는 static, 동적 요소(id)는 param으로 미리 토큰화 시켜놓으면 라우팅 정책을 빠르게 비교 가능합니다.
-
Handler: HTTP Request, Context(param, query, cookie, env, ctx) 객체를 인자로 받아서 최종 HTTP Response를 반환하는 함수
-
Middleware: Handler 함수를 반환하는 함수. 인자로 다음에 실행할 Handler 함수를 받음. 최종 Handler 함수 합성할 때 사용됨. 아래에서 자세히 설명
-
MiddlewareWithPrefix: prefix 별 사용할 미들웨어를 등록
-
Router: 사용할 라우팅 정책에 맞는(HTTP Method, Path) Handler 함수를 담아두는 객체. 1번에서 토큰화된 경로를 통해 사용자 요청 비교 후 정책과 일치하는 핸들러 함수를 실행
Middleware
미들웨어 작동 방식을 구현할 때 AWS SDK for JavaScript의 Middleware Stack 설계 방식을 참고했는데요, 미들웨어 함수가 어떻게 작동하는지 그림으로 먼저 확인해 보겠습니다.
이렇게 미들웨어는 사용자 HTTP 요청을 받아서 순서대로 흘려보내고, 라우터에서 선언한 핸들러 함수에서 반환된 HTTP 응답을 역순으로 돌려보내는 방식으로 작동합니다.
사용자가 prefix 별 사용할 Middleware를 선언하면
// index.ts
import { type Middleware } from "../router";
const middlewareA: Middleware<Env> = (next) => async (req, context) => {
console.log("before: middlewareA");
const response = await next(req, context);
console.log("after: middlewareA");
return response;
};
const middlewareB: Middleware<Env> = (next) => async (req, context) => {
console.log("before: middlewareB");
const response = await next(req, context);
console.log("after: middlewareB");
return response;
};
app.use("/", middlewareA);
app.use("/user", middlewareB);
내부적으로 아래와 같이 미들웨어 스택에 등록됩니다.
// router.ts
export class WorkerAPIGateway<Env> {
...
private middlewareStack: MiddlewareWithPrefix<Env>[] = [];
...
use(prefix: string, ...middlewares: Middleware<Env>[]) {
if (!middlewares.length) {
throw new Error("use(prefix, ...middlewares): at least one middleware required");
}
const prefix = normalizePrefix(prefix);
this.middlewareStack.push({ prefix, middlewares });
return this;
}
}
이전 포스팅에서 소개해 드린 중복 방문자 처리, CORS 정책 적용한 블로그 방문자 카운팅 API 서버 구축기에서는 미들웨어가 아닌, 함수를 이용하여 필요한 기능들을 구현하였는데요, 이제 prefix 기반으로 공통으로 사용할 미들웨어로 만들어 놓으면 사용자 HTTP 요청마다 라우팅 정책 비교 후 prefix 일치하는 미들웨어들을 꺼내와서 순서대로 처리할 수 있게 됩니다.
내부적으로 어떻게 작동하는지 라우터 부분을 살펴보겠습니다.
Router
라우팅 정책에 맞는 최종 Handler 함수를 사용하기 위해서는 미들웨어 함수들과 사용자가 라우터에서 선언한 핸들러 함수를 합성해야 하는데요, 합성되었다는 표현은 middlewareStack에 들어간 미들웨어 함수들에게 각각 다음에 실행할 함수들을 인자로(next) 전달했다는 의미입니다.
코드로 보는 게 이해가 더 쉬우니 하나씩 살펴보겠습니다.
const composeMiddleware = <Env>(middlewares: Middleware<Env>[], finalHandler: Handler<Env>): Handler<Env> => {
const handler = middlewares.reduceRight((next, middleware) => {
return middleware(next);
}, finalHandler);
return handler;
};
const middlewares: Middleware<Env>[] = [middlewareA, middlewareB];
const userHandler: Handler<Env> = (req, context) => {
return Response.json({ ... });
};
const handler: Handler<Env> = composeMiddleware(middlewares, userHandler);
// handler(req, context) => HTTP Response
이렇게 최종 handler 함수를 실행하면 아래처럼 미들웨어 함수들이 순서대로 실행되는 것을 보실 수 있습니다.
# handler(req) == middlewareA(middlewareB(userHandler))(req)
middlewareA
middlewareB
userHandler 실행(return response)
middlewareB
middlewareA
이제 최종 Handler 실행을 결정하는 라우팅 정책이 어떻게 평가되는지 알아보겠습니다.
GET /user/1234
요청을 처리할 수 있도록 라우팅 정보, 사용자 핸들러 함수를 추가해주면
// index.ts
...
app.get("/user/:id", (req, context) => {
return Response.json({ ... });
});
내부적으로 위에서 설명한 Router 타입으로 표준화 시켜서 routes 배열에 담아놓는데요
// router.ts
export class WorkerAPIGateway<Env> {
private routes: Router<Env>[] = [];
...
private add(method: Method, path: string, handler: Handler<Env>) {
// TrailingSlash 제거 => /user/:id/ => /user/:id
const pathname = normalizePath(path);
// 경로 비교하기 편하도록 토큰화
// [{ type: "static", value: "user" }, { type: "param", name: "id" }]
const tokens = tokenize(pathname);
this.routes.push({ method, pathname, tokens, handler });
return this;
}
}
이제 사용자 요청(GET /user/1234
)이 들어오면 routes 배열을 루프 돌면서 token 정보 비교 후 매칭되면 최종 Handler 응답을, 매칭되는 정보가 없으면 404 응답을 돌려주게 됩니다.
// router.ts
...
export class WorkerAPIGateway<Env> {
// 표준화되어서 등록된 라우팅 정보
private routes: Router<Env>[] = [
...
{
method: "GET",
pathname: "/user/:id",
tokens: [
{ type: "static", value: "user" },
{ type: "param", name: "id" },
],
handler: 'Function', // 사용자가 등록한 최종 핸들러 함수
},
];
private middlewareStack: MiddlewareWithPrefix<Env>[] = [middlewareA, middlewareB];
...
export(): ExportedHandler<Env> {
return {
fetch: (req, env, ctx) => this.handler(req, env, ctx),
}
}
private async handler(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const method = req.method.toUpperCase() as Method;
const { pathname: path, search: rawQueryString } = new URL(req.url);
const query = parseQueryString(rawQueryString);
const cookie = parseCookie(req.headers.get("cookie") || "");
...
// GET /user/124
const pathname = normalizePath(path);
// ["user", "1234"]
const parts = split(pathname);
for (const route of this.routes) {
// 라우팅 정보 매칭 => { id: "1234" } / 매칭 실패 => null
const params = matchTokens(route.tokens, parts);
if (!params) continue;
if (route.method !== method) continue;
const middlewares: Middleware<Env>[] = this.middlewareStack
// prefix 일치하는 미들웨어만 가져옴
.filter((mw) => pathnameStartsWith(pathname, mw.prefix))
// 전역 미들웨어부터 실행
.sort((a, b) => a.prefix.length - b.prefix.length)
.flatMap((mw) => mw.middlewares);
const context: Context<Env> = { env, ctx, params, query, cookie };
const userHandler: Handler<Env> = async (req, context) => route.handler(req, context);
const handler = composeMiddleware<Env>(middlewares, userHandler);
try {
return await handler(req, context);
} catch (err) {
if (this.onErrorHandler) {
return await this.onErrorHandler(req, context, err);
}
console.log(err);
return Response.json({ message: "Internal Server Error" }, { status: 500 });
}
}
// 일치하는 라우팅 정보 없어도 prefix 일치하는 미들웨어는 합성됨.
const middlewares: Middleware<Env>[] = this.middlewareStack
.filter((mw) => pathnameStartsWith(pathname, mw.prefix))
.sort((a, b) => a.prefix.length - b.prefix.length)
.flatMap((mw) => mw.middlewares);
const notFoundHandler: Handler<Env> = async (req, context) => {
const body = { message: "Not Found" };
return Response.json(body, { status: 404 });
};
const context: Context<Env> = { env, ctx, params: {}, query, cookie };
const handler = composeMiddleware<Env>(middlewares, notFoundHandler);
return handler(req, context);
}
}
정상적으로 작동하는지 테스트 한번 해보면
curl 'http://localhost:8787'
before: middlewareA
after: middlewareA
curl 'http://localhost:8787/user/1234'
before: middlewareA
before: middlewareB
after: middlewareB
after: middlewareA
이렇게 prefix마다 등록된 미들웨어 함수들이 순서대로 작동하는 것을 보실 수 있습니다.
마무리
어쩌다 보니 검색하기 귀찮아한 죄로 Worekrs에서 사용할 수 있는 나만의 API Gateway 프레임워크를 만들게 되었는데요, 앞으로 JWT 인증, Rate limiting 기능도 추가해 보고, 검색의 생활화를 다짐하며 포스팅 마무리하겠습니다.
더보기
댓글