@codegenie/serverless-express
Express나 NestJS 같은 Node.js 웹 프레임워크로 만든 서버를 AWS Lambda에서 실행하고 싶으면 문제가 하나 생긴다. 이 프레임워크들은 HTTP 요청을 req, res 객체로 처리하는데, Lambda는 HTTP 요청이 아니라 이벤트 객체를 받는다. API Gateway가 HTTP 요청을 받아서 Lambda에 전달할 때 { httpMethod, path, headers, body, queryStringParameters, ... } 형태의 JSON으로 변환하기 때문이다.
즉, Express 앱이 이해하는 형식과 Lambda가 주는 형식이 다르다. 이 간극을 메워주는 것이 @codegenie/serverless-express다.
원래의 serverless-express
이 라이브러리의 원래 이름은 @vendia/serverless-express였고, 그 전에는 aws-serverless-express라는 AWS 공식 라이브러리였다. AWS가 만들었다가 Vendia라는 회사에 이관했고, 다시 CodeGenie로 이관된 것이다. npm에서 aws-serverless-express나 @vendia/serverless-express를 볼 수 있는데, 모두 같은 계보의 라이브러리이고 현재 활발히 유지보수되는 건 @codegenie/serverless-express다.
npm install @codegenie/serverless-express
핵심 동작 원리
이 라이브러리가 하는 일은 크게 세 단계다.
1단계: Lambda 이벤트 → HTTP 요청으로 변환
API Gateway(또는 ALB, Lambda Function URL)가 전달하는 이벤트 객체를 Express가 이해할 수 있는 HTTP 요청으로 변환한다.
API Gateway Event Express Request
{ {
httpMethod: 'POST', → method: 'POST',
path: '/users', → url: '/users',
headers: { ... }, → headers: { ... },
body: '{"name":"J"}', → body: '{"name":"J"}',
queryStringParameters: {} → query: {}
} }
이 변환은 단순한 필드 매핑이 아니다. API Gateway v1(REST API)과 v2(HTTP API)의 이벤트 형식이 다르고, ALB 이벤트도 형식이 다르다. 라이브러리가 이벤트 소스를 자동으로 감지해서 적절한 변환을 수행한다.
2단계: Express 앱 실행
변환된 HTTP 요청을 Express(또는 NestJS, Koa, Fastify 등) 앱에 전달해서 미들웨어 체인과 라우터를 정상적으로 실행시킨다. 프레임워크 입장에서는 일반 HTTP 서버에서 실행되는 것과 완전히 동일하게 동작한다.
3단계: HTTP 응답 → Lambda 응답으로 변환
Express가 res.send()나 res.json()으로 보내는 응답을 다시 API Gateway가 이해하는 형식으로 변환한다.
Express Response API Gateway Response
{ {
statusCode: 200, → statusCode: 200,
headers: { ... }, → headers: { ... },
body: '{"id":1}' → body: '{"id":1}',
} isBase64Encoded: false
}
바이너리 응답(이미지, PDF 등)인 경우 자동으로 Base64 인코딩을 적용하고 isBase64Encoded: true로 설정한다.
기본 사용법
가장 단순한 형태는 이렇다.
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app'; // Express 앱
export const handler = serverlessExpress({ app });
serverlessExpress()에 Express 앱 인스턴스를 넘기면 Lambda 핸들러 함수가 반환된다. 이 핸들러를 Lambda의 진입점으로 export하면 끝이다.
NestJS와 함께 사용
NestJS는 내부적으로 Express(또는 Fastify)를 HTTP 어댑터로 사용한다. getHttpAdapter().getInstance()로 내부 Express 인스턴스를 꺼낼 수 있다.
import { NestFactory } from '@nestjs/core';
import serverlessExpress from '@codegenie/serverless-express';
import { Callback, Context, Handler } from 'aws-lambda';
import { AppModule } from './app.module';
let server: Handler;
async function bootstrap(): Promise<Handler> {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['*'],
});
await app.init();
const expressApp = app.getHttpAdapter().getInstance();
return serverlessExpress({ app: expressApp }) as Handler;
}
export const handler: Handler = async (
event: any,
context: Context,
callback: Callback,
) => {
server = server ?? (await bootstrap());
return server(event, context, callback);
};
여기서 중요한 패턴이 두 가지 있다.
Cold Start 최적화: 싱글턴 부트스트랩
server 변수를 모듈 스코프에 선언하고, 첫 호출에서만 bootstrap()을 실행한다. Lambda는 컨테이너를 재사용하기 때문에(Warm Start), 두 번째 호출부터는 이미 초기화된 NestJS 앱을 바로 사용할 수 있다. NestJS의 모듈 초기화, DI 컨테이너 구성, DB 연결 등은 한 번만 실행된다.
First Invocation (Cold Start)
→ bootstrap() 실행 (NestJS 초기화, 1~3초)
→ server에 캐싱
→ 요청 처리
Second Invocation (Warm Start)
→ server가 이미 존재
→ 바로 요청 처리 (수십 ms)
app.init() vs app.listen()
일반 서버에서는 app.listen(3000)으로 HTTP 서버를 시작하지만, Lambda에서는 app.init()만 호출한다. HTTP 서버를 실제로 바인딩할 포트가 없기 때문이다. init()은 NestJS의 모듈 초기화, 프로바이더 의존성 주입, 라이프사이클 훅(onModuleInit 등)을 실행하되 포트 바인딩은 하지 않는다.
이벤트 소스별 지원
serverless-express는 다양한 AWS 서비스에서 트리거되는 이벤트를 자동으로 인식한다.
| 이벤트 소스 | 이벤트 형식 | 자동 감지 |
|---|---|---|
| API Gateway v1 (REST) | event.httpMethod 존재 | ✅ |
| API Gateway v2 (HTTP) | event.requestContext.http 존재 | ✅ |
| ALB (Application Load Balancer) | event.requestContext.elb 존재 | ✅ |
| Lambda Function URL | v2와 동일한 형식 | ✅ |
대부분의 경우 설정 없이 자동으로 동작하지만, 명시적으로 지정할 수도 있다.
serverlessExpress({
app: expressApp,
eventSourceRoutes: {
'AWS_API_GATEWAY_V2': '/',
'AWS_ALB': '/alb',
}
});
주요 옵션
binarySettings
바이너리 응답(이미지, PDF 등)을 처리하는 방식을 설정한다. API Gateway는 바이너리 데이터를 직접 전달할 수 없기 때문에 Base64 인코딩이 필요하다.
serverlessExpress({
app: expressApp,
binarySettings: {
isBinary: ({ headers }) => {
const contentType = headers['content-type'] || '';
return contentType.startsWith('image/') ||
contentType === 'application/pdf';
}
}
});
기본적으로 content-type 헤더를 보고 바이너리 여부를 판단하지만, 커스텀 로직을 넣을 수 있다.
resolutionMode
Lambda 응답을 반환하는 시점을 제어한다.
serverlessExpress({
app: expressApp,
resolutionMode: 'PROMISE' // 기본값
});
- PROMISE: Express 응답이 완료되면 Promise를 resolve한다. 대부분의 경우 이 모드면 충분하다.
- CALLBACK: 레거시 콜백 패턴을 사용한다.
- CONTEXT: Lambda context.succeed()를 사용한다 (구버전 호환).
respondWithErrors
에러 발생 시 상세 정보를 응답에 포함할지 결정한다.
serverlessExpress({
app: expressApp,
respondWithErrors: process.env.NODE_ENV !== 'production'
});
개발 환경에서는 디버깅을 위해 켜두고, 프로덕션에서는 반드시 꺼야 한다. 스택 트레이스나 내부 경로가 노출될 수 있기 때문이다.
serverless-offline과 함께 사용
로컬 개발 시 serverless-offline으로 Lambda를 에뮬레이션하면 이벤트 형식이 실제 API Gateway와 미묘하게 다를 수 있다. 대표적인 이슈가 빈 경로 문제다.
export const handler: Handler = async (event, context, callback) => {
// serverless-offline에서 빈 path가 올 수 있음
if (!event.path || event.path === '') {
event.path = '/';
}
server = server ?? (await bootstrap());
return server(event, context, callback);
};
이런 종류의 호환성 이슈는 serverless-offline의 GitHub Issues에서 추적할 수 있다.
대안 비교
직접 매핑 vs serverless-express
Lambda 핸들러에서 직접 요청을 파싱하고 라우팅하는 방법도 있다.
// 직접 매핑 방식
export const handler = async (event) => {
if (event.httpMethod === 'GET' && event.path === '/users') {
const users = await getUsers();
return { statusCode: 200, body: JSON.stringify(users) };
}
// ... 모든 라우트를 수동으로 처리
};
이 방식은 단순한 API에서는 괜찮지만, 미들웨어(인증, 로깅, CORS), 에러 핸들링, 요청 파싱 등을 모두 직접 구현해야 한다. Express 생태계의 미들웨어를 활용할 수 없다.
각 라우트를 별도 Lambda로 분리
# serverless.yml
functions:
getUsers:
handler: handlers/users.getAll
events:
- http:
path: /users
method: get
createUser:
handler: handlers/users.create
events:
- http:
path: /users
method: post
이 방식은 각 함수가 독립적으로 스케일링되고 콜드 스타트가 빠르다는 장점이 있다. 하지만 NestJS처럼 모듈/DI/미들웨어 체계가 정교한 프레임워크에서는 코드를 라우트별로 쪼개기가 어렵고, 공통 로직의 중복이 생긴다.
serverless-express의 위치
serverless-express는 기존 Express/NestJS 애플리케이션을 최소한의 변경으로 Lambda에 배포하는 데 최적화되어 있다. "서버리스 네이티브"로 처음부터 설계하는 게 아니라, 이미 있는 서버 앱을 서버리스로 옮기는 마이그레이션 시나리오에서 가장 빛난다.
단점은 단일 Lambda에 모든 라우트가 들어가므로 번들 크기가 커지고, 라우트별 독립 스케일링이 안 된다는 것이다. 하지만 대부분의 중소규모 API에서는 이게 문제가 되지 않는다.
내부 구조 이해
serverless-express가 내부적으로 하는 일을 좀 더 자세히 보면 이렇다.
Lambda Invocation
│
├─ 1. 이벤트 소스 감지 (API GW v1? v2? ALB?)
│
├─ 2. 이벤트 → Node.js http.IncomingMessage 변환
│ - 메모리 내에서 가상 소켓 생성
│ - HTTP 요청 문자열을 소켓에 write
│
├─ 3. Express 앱이 요청 처리
│ - 미들웨어 체인 실행
│ - 라우트 매칭 & 핸들러 실행
│ - res.send() / res.json() 호출
│
├─ 4. http.ServerResponse → Lambda 응답 변환
│ - statusCode, headers, body 추출
│ - 바이너리면 Base64 인코딩
│
└─ 5. Promise resolve (Lambda에 응답 반환)
핵심은 실제 HTTP 서버를 띄우지 않는다는 것이다. 메모리 내에서 가상의 요청/응답 쌍을 만들어서 Express에 주입한다. 이 덕분에 포트 바인딩 없이도 Express의 전체 미들웨어 스택이 정상적으로 동작한다.
정리
@codegenie/serverless-express는 Express/NestJS 앱을 Lambda에서 실행하기 위한 어댑터- API Gateway 이벤트 ↔ HTTP 요청/응답 양방향 변환을 자동으로 처리
- 다양한 이벤트 소스(API GW v1/v2, ALB, Function URL) 자동 감지
- 싱글턴 부트스트랩 패턴으로 Cold Start 영향 최소화
- 기존 서버 앱의 서버리스 마이그레이션에 최적