기존 백엔드 코드를 Next.js에서 NestJS로 분리하고 AWS SAM으로 배포하는 업무를 맡게 되었습니다.
이 과정에서 NestJS와 SAM 각각에 대해서는 참고할 수 있는 자료가 어느정도 있었지만, 이 둘을 함께 사용한 케이스는 거의 없었습니다.
그로 인해 발생한 에러를 수많은 시행착오 끝에 해결하고 빌드와 배포에 성공하였기에, 이 조합으로 serverless application을 배포할 누군가에게는 도움이 되었으면 하는 마음에 이번 포스트의 주제로 선정하였습니다.
nest/cli만 설치해주면 즉시 새로운 NestJS 프로젝트를 시작할 수 있습니다.
$ npm i -g @nestjs/cli
$ nest new nestjs-sam # project name
프로젝트를 생성하면 아래처럼 NestJS에서 미리 만들어둔 구조의 boilerplate로 생성됩니다.
.
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
├── src
│ ├── app.controller.spec.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ └── main.ts
├── test
│ ├── app.e2e-spec.ts
│ └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
공식문서에는 Serverless Framework로 된 예제만 있고 SAM 예제가 없었기 때문에 둘이 많이 다른 것으로 착각했지만, 오히려 초기 세팅이 거의 비슷하여 공식문서에서도 충분히 도움을 받을 수 있습니다. (처음에 이것을 몰라 시간을 많이 낭비했습니다.🥲)
SAM CLI를 설치했다면 배포 시 AWS 리소스 설정을 위해 루트 디렉터리에 template.yaml
파일을 생성해 아래와 같이 입력합니다.
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: A starter AWS Lambda function.
Resources:
ExampleFunction:
Type: 'AWS::Serverless::Function'
Properties:
Handler: dist/lambda.handler
Runtime: nodejs14.x
Description: A starter AWS Lambda function.
MemorySize: 128
Timeout: 10
Events:
Api:
Type: Api
Properties:
Path: /
Method: GET
Outputs:
WebEndpoint:
Description: "API Gateway endpoint URL for Prod stage"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
이렇게만 설정해도 배포할 때 SAM이 알아서 Lambda와 API Gateway와의 연결 등의 작업을 대신해 줍니다. 물론 좀 더 세밀한 설정(커스텀 도메인 추가나 Authorizer 설정 등)을 위해 직접 template.yaml
에 추가해 줄 수도 있습니다.
그리고 Lambda로 서버를 돌릴 때는 handler 함수를 통해 전달되도록 할 예정이기 때문에 lambda.ts
라는 파일을 main.ts
와 같은 경로에 추가해 주도록 하겠습니다. 이 때 필요한 패키지를 다음과 같이 설치합니다.
$ npm i @vendia/serverless-express aws-lambda
$ npm i -D @types/aws-lambda
그리고 serverless-express 패키지가 원활하게 동작할 수 있도록 ts-config.json
에 "esModuleInterop": true
를 추가해 줍니다.
이제 lambda.ts
와 main.ts
를 작성합니다.
// lambda.ts
import { bootstrap } from "./main";
import serverlessExpress from "@vendia/serverless-express";
import { Callback, Context, Handler } from "aws-lambda";
let server: Handler;
export async function handler(event: any, context: Context, callback: Callback) {
const app = await bootstrap();
await app.init();
const expressApp = app.getHttpAdapter().getInstance();
server = server ?? serverlessExpress({ app: expressApp });
return server(event, context, callback);
}
// main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
export async function bootstrap() {
const app = await NestFactory.create(AppModule);
return app;
}
async function run() {
const nestApp = await bootstrap();
await nestApp.listen(3000);
}
run();
공식문서의 예제와 다른 점은 run()
함수가 추가되었다는 점입니다.
이 함수가 없으면 개발할 때도 SAM CLI로 서버를 띄워서 작업해야 하는데, 아무래도 로컬에서 직접 띄워서 하는 것보다는 시간도 걸리고 번거롭다는 느낌이 들어 추가하게 되었습니다.
여기까지 왔으니 우선 로컬에서 잘 실행되는지 테스트해보겠습니다.
npm run start:dev
# 다른 터미널에서
curl http://localhost:3000/
이제 빌드와 배포를 하면 되는데, 그 전에 빌드 시 생성되는 .aws-sam
을 인식하지 못하도록 .gitignore
에 추가해 줍니다.
SAM에서는 로컬에서 실제로 Lambda를 돌리는 것처럼 테스트할 수 있는 도구를 제공해 줍니다.
이를 위해서는 SAM build 과정이 필요한데, 이 빌드 과정부터가 난관이었습니다.
우선 위에서 작성한 코드를 토대로 NestJS 코드를 npm run build
로 빌드한 후 sam build
를 실행하면 빌드 자체에는 문제가 없어 보입니다. 그러나 테스트를 위해 sam local start-api
를 실행하고 curl로 request를 보내면 다음과 같은 이상한 에러가 나오는 것을 확인할 수 있습니다.
가장 핵심이 되는 부분은 에러 메시지의 Error: Cannot find module 'lambda'
인데, SAM build에서 생성된 .aws-sam
폴더를 보면 위에서 생성한 NestJS 앱의 모든 데이터가 transpile 없이 들어가 있는 것을 볼 수 있습니다.
이는 template.yaml
에서 설정한 handler의 경로와도 다를뿐더러, SAM은 .ts파일을 읽을 수 없기 때문에 경로를 수정하더라도 여전히 에러가 발생하게 됩니다. 이를 해결하기 위한 방법을 몇가지 소개해 보고자 합니다.
NestJS 공식문서의 serverless로 배포하기에서도 webpack 관련 내용을 찾을 수 있습니다. 그런데 SAM에서는 적용이 안 되는 부분이 있어, 🐶고생한 끝에 빌드에 성공할 수 있었습니다.
@nestjs/cli가 있다면 이미 dependency로 webpack이 설치되어 있기 때문에 따로 설치할 패키지는 없습니다.
우선 webpack.config.js
를 root 디렉터리에 생성하여 아래와 같이 작성합니다.
const path = require('path');
module.exports = (options, webpack) => {
const lazyImports = [
'@nestjs/microservices/microservices-module',
'@nestjs/websockets/socket-module',
];
return {
entry: './src/main',
target: 'node',
...options,
externals: [],
module: {
rules: [
{
test: /\.ts?$/,
use: {
loader: 'ts-loader',
options: { transpileOnly: true },
},
exclude: /node_modules/,
},
],
},
output: {
path: path.resolve(__dirname, 'dist'),
libraryTarget: 'commonjs2',
},
plugins: [
...options.plugins,
new webpack.IgnorePlugin({
checkResource(resource) {
if (lazyImports.includes(resource)) {
try {
require.resolve(resource);
} catch (err) {
return true;
}
}
return false;
},
}),
],
};
};
webpack을 사용하게 되면 template.yaml
에서 설정한 handler 경로에서 문제가 발생합니다. 왜냐하면 webpack으로 코드를 번들링 하게 되면 main.js
파일 하나로 나오게 되는데, 저희는 lambda.ts
에서 handler를 설정했기 때문입니다. 이에 따라 main.js
에서는 handler를 import 할 수 없다는 에러가 발생했습니다. (template.yaml
에서 handler 경로를 수정하더라도 발생)
따라서 위에서 나눴던 main.ts
에 lambda.ts
를 합치는 방식으로 해결하게 되었습니다.
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import serverlessExpress from '@vendia/serverless-express';
import { Callback, Context, Handler } from 'aws-lambda';
export async function bootstrap() {
const app = await NestFactory.create(AppModule);
return app;
}
let server: Handler;
export async function handler(
event: any,
context: Context,
callback: Callback,
) {
const app = await bootstrap();
await app.init();
const expressApp = app.getHttpAdapter().getInstance();
server = server ?? serverlessExpress({ app: expressApp });
return server(event, context, callback);
}
async function run() {
const nestApp = await bootstrap();
await nestApp.listen(3000);
}
if (!['PRODUCTION', 'STAGING'].includes(process.env.ENV)) {
run();
}
맨 아래의 if 문은, 로컬에서 SAM으로 테스트할 경우 환경변수로 막아주지 않으면 handler(), run() 모두 실행되기 때문에 추가하게 되었습니다.
위의 변경 사항과 더불어, SAM local에서는 template에 있는 환경변수만 적용되기 때문에(출처) template.yaml
에 이와 관련된 내용을 추가합니다.
...(생략)
Resources:
ExampleFunction:
Type: 'AWS::Serverless::Function'
Properties:
CodeUri: ./dist
Handler: main.handler
Runtime: nodejs14.x
Description: A starter AWS Lambda function.
MemorySize: 128
Timeout: 10
Environment:
Variables:
ENV: STAGING
Events:
Api:
Type: Api
Properties:
Path: /
Method: GET
...(생략)
이것으로 빌드와 테스트 준비는 끝났습니다. 아래의 커맨드로 NestJS와 SAM 모두 빌드해줍니다.
$ nest build --webpack
$ sam build
그리고 sam local start-api
커맨드를 실행하면 로컬에서 lambda가 돌아가는 것처럼 테스트할 수 있습니다. (Docker 필요)
⚠️ webpack을 사용할 경우 로컬에서 SAM 없이 서버를 돌릴 때 반드시 --webpack 옵션을 넣어주어야 합니다. 그러지 않으면 webpack으로 빌드한
main.js
가dist
에서 사라지게 됩니다.
이를 방지하려면package.json
에서 {"start:dev": "nest start --watch"}로 되어 있는 것을 {"start:dev": "nest start --webpack --watch"}로 수정하면 됩니다.
SAM에서는 makefile을 통한 빌드 방식도 제공하고 있습니다. webpack을 사용할 수 없는 경우 채택하는 방법인데, 빌드 속도가 느리다는 점과 node_modules도 같이 가져와야 하므로 배포 시 매우 무거워진다는 단점이 있습니다.
특히 압축 해제 시 250MB 이상일 경우 배포 자체가 안되고 다른 방법을 사용해야 하므로 주의가 필요합니다. 다만 이 문제에 대해서는 밑에서 다룰 layer 사용으로 해결이 가능합니다.
makefile을 사용하는 경우 webpack과 다르게 기존의 lambda.ts
를 수정할 필요는 없습니다.
우선은 template.yaml
파일에 빌드 방식을 추가해 줍니다.
...생략
Resources:
ExampleFunction:
Type: 'AWS::Serverless::Function'
Metadata:
BuildMethod: makefile
Properties:
Handler: dist/lambda.handler
Runtime: nodejs14.x
Description: A starter AWS Lambda function.
MemorySize: 128
Timeout: 10
Events:
Api:
Type: Api
Properties:
Path: /
Method: GET
...생략
BuildMethod로 makefile을 사용한다고 지정했으니 어떤 커맨드를 사용할지 Makefile을 통해 설정합니다.
.PHONY: build-lambda-common build-ApiFunction
build-ExampleFunction:
$(MAKE) HANDLER=src/main.ts build-lambda-common
build-lambda-common:
npm install
rm -rf dist
nest build
cp -r dist "$(ARTIFACTS_DIR)/"
cp -r node_modules "$(ARTIFACTS_DIR)/"
여기서 ARTIFACTS_DIR
은 빌드되는 아티팩트(여기서는 Lambda)의 디렉터리를 뜻합니다. .aws-sam/build
에서 보이는 디렉터리들이 모두 ARTIFACTS_DIR
입니다.
이것으로 빌드와 테스트 준비는 끝났습니다. 아래의 커맨드로 NestJS와 SAM 모두 빌드해줍니다.
$ npm run build
$ sam build
그리고 sam local start-api
커맨드를 실행하면 로컬에서 lambda가 돌아가는 것처럼 테스트할 수 있습니다. (Docker 필요)
이 방법은 베타버전이기 때문에 아직 production에 적용하기는 어려울 것으로 보여 시도하지는 않았습니다.
다만 공식문서에 상세한 설명이 나와 있기 때문에 이를 참고하면서 작성할 수 있으리라 생각합니다.
makefile을 통해 빌드하게 되면 .aws-sam
에는 node_modules도 함께 들어가게 됩니다. 이렇게 되면 배포할 용량이 커지므로 프로젝트가 확장될수록 배포에 오랜 시간이 걸리게 됩니다.
그렇기 때문에 Lambda에서는 이미 Lambda layer(계층)를 통해 필요한 패키지와 의존성을 분리할 수 있는 기능을 제공하고 있습니다.
SAM에서도 이를 사용할 수 있으며, 다음과 같은 과정을 거치게 됩니다.
우선 template.yaml
에서 layer를 생성할 수 있도록 세팅해 줍니다.
# Resources 아래 추가
Resources:
ExampleFunction:
Type: 'AWS::Serverless::Function'
Metadata:
BuildMethod: makefile
Properties:
Handler: dist/lambda.handler
Runtime: nodejs14.x
Description: A starter AWS Lambda function.
MemorySize: 128
Timeout: 10
Events:
Api:
Type: Api
Properties:
Path: /
Method: GET
Layers:
- !Ref RuntimeDependenciesLayer
RuntimeDependenciesLayer:
Type: AWS::Serverless::LayerVersion
Metadata:
BuildMethod: makefile
Properties:
Description: Runtime dependencies for Lambdas
ContentUri: ./
CompatibleRuntimes:
- nodejs14.x
RetentionPolicy: Retain
다음으로 Makefile에도 layer를 생성하는 커맨드를 추가합니다.
.PHONY: build-lambda-common build-ApiFunction build-RuntimeDependenciesLayer
build-ExampleFunction:
$(MAKE) HANDLER=src/main.ts build-lambda-common
build-lambda-common:
npm install
rm -rf dist
nest build
cp -r dist "$(ARTIFACTS_DIR)/"
build-RuntimeDependenciesLayer:
mkdir -p "$(ARTIFACTS_DIR)/nodejs"
cp package.json package-lock.json "$(ARTIFACTS_DIR)/nodejs/"
npm install --production --prefix "$(ARTIFACTS_DIR)/nodejs/"
rm "$(ARTIFACTS_DIR)/nodejs/package.json"
위에서 build-lambda-common
에 있었던 cp -r node_modules "$(ARTIFACTS_DIR)/"
는 삭제하고 아래에 별도로 layer를 빌드하는 커맨드를 추가하였습니다.
추가한 커맨드의 내용은 아래와 같습니다.
이렇게 빌드한 후 배포하게 되면 layer에 dependency가 분리된 상태로 배포되어 Lambda의 코드 용량을 확연히 줄일 수 있습니다.
배포 자체는 sam deploy
한 줄로도 실행 가능하고, 공식문서의 튜토리얼에도 잘 나와 있는 부분이라 상세하게 설명하지는 않겠습니다.
다만 배포할 때 --guided
옵션을 주고 배포하면 여러 질문이 나오게 되는데, 그 중 Save arguments to configuration file
****에 Yes를 입력하면 배포 후에 samconfig.toml
파일이 생성됩니다. 여기에는 배포 시에 설정한 내용들이 담겨있어 이후에 배포할 때는 --guided
옵션 없이 배포하면 SAM이 해당 파일을 인식해 자동으로 배포하게 됩니다.
또한 CI/CD를 위해 변경 사항에 대한 확인 과정을 없애고 samconfig.toml
에 있는 내용대로 배포되도록 Confirm changes before deploy
에는 No를 입력합니다. 그 외 배포시 줄 수 있는 옵션은 공식문서에서 확인할 수 있습니다.
처음에는 기존 코드를 옮기기만 하면 되겠다고 생각해 가벼운 마음으로 임했지만, NestJS와 SAM으로 배포하는 경우 참고할 수 있는 자료가 부족하여 맨땅에 헤딩하듯 진행하게 되었습니다.
하지만 이번 일을 통해 Typescript, NestJS 같은 새로운 언어/프레임워크와 더불어 SAM, Cloudformation을 비롯한 AWS infrastructure까지도 폭넓게 공부할 수 있어서, 개발자로서 한 층 성장할 수 있는 계기가 되었습니다.
Documentation | NestJS - A progressive Node.js framework
Custom domain 설정이 필요한 경우, 아래 자료를 참조하면 됩니다. (일본어이나 template.yaml만 확인하면 됨)