serverless-offline 모듈을 통한 서버리스 로컬 개발 환경 만들기
2023년 6월 04일서버리스가 AWS EC2와 같은 서버보다 좋을 수 있습니다. 트래픽이 많이 일어나지 않는 작은 서비스라면 24시간 돌아가는 서버 환경보다 서버리스 환경이 더 경제적입니다. 설명탕 홈페이지를 EC2에서 서버리스 환경으로 옮기면서 인프라 내부적으로 많은 변화가 있었습니다. 현재까지 설명탕을 서버리스 환경으로 운영하는데 큰 불편함이 없습니다. 하지만 설명탕 서비스를 장기적으로 운영하기 위해서는 개발 환경이 필요했습니다. 서버 배포하기 전에 충분한 테스트를 하기 위해서는 개발 환경이 필요합니다. 서버리스가 단어 그 의미처럼 서버가 없다는 개념이라 개발 환경을 어떻게 구성해야 할지 난감했습니다. AWS에서 개발용 서버리스(Lambda, API Gateway)와 DynamoDB를 새롭게 만들어도 되지만, 로컬(혹은 오프라인)에 개발 환경을 만들고 싶었습니다.
서버리스가 아닌 기존의 서버 방식이었다면 로컬에 백엔드, DB 두 개의 컨테이너를 생성하여 개발 환경을 만들었을 텐데 서버리스 환경은 어떻게 기존과 유사하게 개발 환경을 구축해야 할지 감이 오지 않았습니다. 서버리스 로컬 개발 환경을 조사를 하다 serverless-offline 플러그인을 알게 되었습니다. serverless-offline 플러그인을 통해 서버리스 로컬 개발 환경을 만들고 더 나아가 Flutter의 hot reload나 NextJS의 fast refresh 기능 처럼 코드를 수정하면 즉각적으로 수정 사항을 반영하는 환경을 만들어보겠습니다.
서버리스 로컬 개발 환경을 만들기 위해 다음과 같은 순서로 진행됩니다.
- serverless, serverless-offline 설치
serverless.yml
정의- 로컬 DynamoDB 구축
- 로컬 DynamoDB와 Golang 연결
- air 모듈을 통한 fast refresh 구성
serverless-offline 플러그인은 로컬에 AWS의 Lambda, API Gateway 환경을 만들어주는 serverless Node 모듈의 플러그인입니다. serverless 모듈은 AWS Lambda와 같은 서버리스 환경을 쉽게 배포할 수 있도록 도와주는 프레임워크입니다. 즉, serverless-offline 플러그인을 이용하여 로컬에 서버리스 환경을 만들 수 있습니다. 또한 로컬 환경에서 마음껏 테스트한 다음 serverless 모듈을 통해 AWS에 배포를 할 수 있습니다.
serverless-offline 플러그인을 사용하기 위해서는 serverless 모듈을 설치해야합니다. npm
혹은 yarn
을 통해서 serverless 모듈을 편하게 호출하기 위해 전역으로 설치합니다.
BASHnpm i -g serverless yarn global add serverless
그 다음으로 serverless 프로젝트를 생성합니다. 저는 Golang을 사용한 Lambda 환경이 필요하여 aws-go 템플릿을 선택했습니다. 선택 가능한 템플릿 목록은 이 링크를 참고해주세요.
BASHserverless create -t aws-go -p [PATH]
생성된 프로젝트를 살펴보면 serverless.yml
파일을 찾을 수 있습니다. serverless.yml
파일은 AWS CloudFormation과 유사한 문법으로 이루어져 있습니다. Lambda, API Gateway를 콘솔에서 설정하듯이 함수와 이벤트에 대해 정의할 수 있습니다. serverless.yml
에 쓰이는 key property 목록은 이 링크를 참고해주세요. 제가 쓸 key property는 service
, frameworkVersion
, provider
, functions
, custom
, plugins
총 6개입니다. 간단하게 하나씩 정의한 key property에 대해 알아보겠습니다.
YAMLservice: local
frameworkVersion: "3"
provider:
name: aws
runtime: go1.x
region: ap-northeast-2
environment:
ENV: local
AWS_REGION: ap-notheast-2
DYNAMODB_TABLE: TEST_TABLE
DYNAMODB_PK: TEST_PK
functions:
main:
handler: main
events:
- http:
path: /post
method: get
- http:
path: /post/{postId}
method: get
custom:
serverless-offline:
useDocker: true
noTimeout: true
reloadHandler: true
plugins:
- serverless-offline
provider
는 실행할 환경을 정의합니다. AWS Lambda 콘솔에서 설정할 수 있는 항목과 유사하게 runtime
, environment
, memorySize
, timeout
와 같은 항목을 설정할 수 있습니다.
functions
는 Lambda 함수와 이벤트를 정의합니다. 실행할 handler
이름과 Lambda 함수를 실행할 이벤트(API Gateway HTTP API 혹은 REST API)를 지정할 수 있습니다.
custom
은 serverless 모듈의 플러그인에 적용할 CLI 옵션을 정의할 수 있습니다. serverless-offline 플러그인을 사용할 것이므로 serverless-offline 플러그인에 적용할 CLI 옵션을 넣어줍니다. serverless-offline 플러그인에 적용할 수 있는 CLI 옵션 목록은 이 링크를 참고해주세요. 제가 설정한 옵션의 설명은 아래에서 설명하도록 하겠습니다.
마지막으로 plugins
에는 serverless 모듈과 연동할 플러그인을 정의할 수 있습니다. serveless-offline 플러그인을 쓸 거라 serverless-offline 모듈을 설치하고 serverless.yml
파일에 plugins
항목에 정의해 줍니다.
BASHnpm i -D serverless-offline yarn add -D serverless-offline
serverless.yml
에 설정을 완료했다면 아래의 명령어로 serverless 모듈을 실행할 수 있습니다.
BASHserverless offline start
serverless-offline 플러그인을 통해 서버리스 로컬 환경을 만드는 방법은 2가지가 있습니다. 프로세스를 통해 실행하거나(useChildProcesses
옵션) 컨테이너를 통해 실행하는 방법(useDocker
옵션)이 있습니다. 프로세스를 통해 서버리스 로컬 환경을 구성해도 되지만 serverless-offline 플러그인의 버그인지 2번 이상 요청을 보내면 handler 쪽에 이상이 생겨 환경 구성에 어려움이 있었습니다. 따라서 프로세스가 아닌 컨테이너를 통해 환경을 구성했습니다.
개발 환경이라 timeout 조건은 없앴고 나중에 hot reload와 같은 기능을 쓰기 위해 매 요청마다 새로운 handler를 만드는 reloadHandler
옵션을 추가했습니다. useDocker
, reloadHandler
옵션을 주면 매 요청마다 handler(Golang 빌드물, 위 예시에서는 main)를 가져와 새로운 컨테이너로 만들고 실행하는 모습을 볼 수 있습니다.
하지만 reloadHandler
옵션을 주면 요청마다 새로운 컨테이너를 만들므로 cold start 처럼 응답이 다소 느립니다. 프론트엔드 쪽을 집중적으로 테스트하고 싶거나 잦은 서버리스 코드 변경이 없다면 이 옵션을 해제하는 것이 좋습니다.

reloadHandler
옵션을 활성화하면 요청마다 컨테이너가 생기니 로컬 자원을 심하게 낭비하지 않는가에 대한 의문이 들 수 있겠습니다. 저 또한 같은 생각을 했습니다. severless-offline 플러그인의 기능인지 lambda 도커 이미지의 설정인지 일정 시간이 지나면 자동으로 컨테이너를 삭제하여 심각한 자원 소모는 없을 거라 판단했습니다. 조금 아쉬웠던 점은 serverless-offline 플러그인은 Golang 이미지를 2년 전에 만들었던 lambci/lambda:go1.x 이미지를 사용합니다. Amazon에서 제공하는 정식적인 이미지가 존재하고 관리도 꾸준히 하고있어서 이 이미지를 썼으면 어땠을까 하는 점이 있었습니다.
위 과정으로 서버리스 로컬 환경을 만들었지만 DB 환경도 필요합니다. serverless-dynamodb-local 플러그인을 통해서 로컬 DynamoDB 환경을 만들 수 있지만 따로 컨테이너로 관리하는 방식을 선택했습니다. 로컬 DynamoDB 환경을 위해 AWS docs를 참고하여 다음과 같이 docker-compose.yml
를 작성했습니다.
YAMLversion: "3.5"
services:
dynamodb:
image: "amazon/dynamodb-local:1.21.0"
container_name: dynamodb
working_dir: /home/dynamodblocal
volumes:
- "./local/docker/dynamodb/data:/home/dynamodblocal/data"
ports:
- 8000:8000
environment:
- AWS_REGION=ap-northeast-2
- AWS_ACCESS_KEY_ID=local
- AWS_SECRET_ACCESS_KEY=local
- AWS_SESSION_TOKEN=local
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
DynamoDB에 테스트할 데이터가 필요하니 AWS CLI를 활용하여 테이블을 만들고 데이터도 넣어줍니다.
BASHaws dynamodb create-table --table-name TEST_TABLE \ --attribute-definitions \ AttributeName=TEST_PK,AttributeType=S \ AttributeName=TEST_SK,AttributeType=S \ --key-schema \ AttributeName=PK,KeyType=HASH \ AttributeName=SK,KeyType=RANGE \ --provisioned-throughput ReadCapacityUnits=10,WriteCapacityUnits=10 \ --endpoint-url http://localhost:8000 aws dynamodb batch-write-item \ --request-items file://seed.json \ --endpoint-url http://localhost:8000 cat seed.json { "TEST_TABLE": [ { "PutRequest": { "Item": { "TEST_PK": { "S": "post" }, "TEST_SK": { "S": "test1" }, "Content": { "S": "hello" }, "CreatedAt": { "S": "2023-05-01" }, "Title": { "S": "test1" } } } } ] }
로컬 DynamoDB와 연결하기 위해 Golang 코드도 다음과 같이 수정이 필요합니다. 이 때 주의할 점은 DynamoDB를 컨테이너로 실행하고 있으니 주소를 MacOS 기준으로 localhost
가 아닌 host.docker.internal
주소로 적어야합니다.
GOfunc init() {
var cfg aws.Config
var err error
if env == "local" {
customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
if service == dynamodb.ServiceID {
return aws.Endpoint{
URL: "http://host.docker.internal:8000",
}, nil
}
// returning EndpointNotFoundError will allow the service to fallback to it's default resolution
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
})
credentials := credentials.StaticCredentialsProvider{
Value: aws.Credentials{
AccessKeyID: "local",
SecretAccessKey: "local",
SessionToken: "local",
},
}
region = "ap-northeast-2"
tableName = "TEST_TABLE"
pk = "TEST_PK"
cfg, err = config.LoadDefaultConfig(
context.TODO(),
config.WithRegion(region),
config.WithEndpointResolverWithOptions(customResolver),
config.WithCredentialsProvider(credentials),
)
} else {
cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
}
errCheck("Unable to load Dynamodb config", err)
db = *dynamodb.NewFromConfig(cfg)
}
야호! serverless-offline 모듈과 DynamoDB 컨테이너를 통해 로컬 서버리스 환경을 만들었습니다. 위의 구성으로 테스트해 보니 서버리스 로컬 개발 환경으로 만족스러웠습니다. 하지만 하나의 아쉬움이 있었습니다. 서버리스 코드를 수정하고 반영된 서버리스 상태를 보기 위해서는 몇 가지의 과정을 수동으로 처리해 줘야 했습니다.
- 서버리스 코드 수정
- 빌드
- serverless-offline 중지
- serverless-offline 시작
위 과정을 한두 번 하는 건 괜찮지만, 계속된 수동 작업을 반복하면서 이 과정들을 자동화하고 싶은 마음이 굴뚝같았습니다. 따라서 Flutter의 hot realod나 NextJS의 fast refresh와 유사한 효과를 내기 위해 추가적인 작업을 진행했습니다.
새로운 서버리스 코드가 추가되면 자동으로 빌드 작업을 하기 위해 air 모듈을 사용했습니다. air 모듈은 실시간으로 Golang 코드 변경을 감지하고 감지 됐을 때 어떤 동작을 하게 할 건지에 대한 정의할 수 있습니다. air 모듈을 사용하기 위해 자동 빌드를 적용하고 싶은 Golang 프로젝트에서 air 모듈을 설치하고 초기화를 진행합니다.
BASHgo install github.com/cosmtrek/air@latest air init
air init
명령어로 초기화를 하면 .air.toml
설정 파일이 생성됩니다. 저희는 .air.toml
내용에서 [build]의 cmd
부분을 수정해주면 됩니다. 이 부분을 수정하면 Golang 코드를 수정했을 때 어떤 명령어를 실행할지에 대한 수정을 할 수 있습니다. 전체적인 .air.toml
에 대해서는 이 링크를 참고해주세요.
TOML[build]
cmd = "GOOS=linux CGO_ENABLED=0 go build -o local/main ."
참고로 제 프로젝트 디렉터리 구조는 다음과 같습니다.
BASH> tree -a -L 2 . . ├── .air.toml ├── main.go ├── docker # 서버리스 로컬 개발 환경을 위한 도커 모음 │ ├── dynamodb │ └── golang ├── internal # 서버리스 Golang 코드 모음 └── local # serverless 모듈을 통해 서버리스 개발 환경을 위한 코드 모음
air 모듈의 도움으로 코드 수정할 때마다 새로운 Golang 빌드물을 만들 수 있게 되었습니다. 마지막으로 고려해야 할 점이 있습니다. 저희는 서버리스 로컬 개발 환경을 컨테이너로 돌립니다. 따라서 컨테이너에 이 새로운 빌드물을 적용해야 합니다. 이것을 가능하게 하기 위해 볼륨을 엮는 방법 등을 시도해보았습니다만, 이런 저런 삽질 끝에 앞서 말한 reloadHandler
옵션을 활성화하여 요청마다 새로운 컨테이너를 만들어 적용하는 방법을 선택했습니다. 다만 이 방법은 아무래도 새 컨테이너를 만드는 시간이 필요하니 저처럼 성질 급한 개발자한테는 조금은 답답한 속도일 수 있겠습니다.
서버리스 로컬 개발 환경 구축을 위한 과정을 하나의 스크립트를 통해 실행할 수 있도록 만들었습니다. 로컬 개발 환경을 원할 때마다 스크립트를 실행하여 개발 환경을 만들도록 구성했습니다.
BASH#!/bin/sh ### Run DynamoDB container docker compose -p dynamodb-local up -d ### Enable golang build automation air & ### Run serverless cd local serverless offline start & ### Wait until the processes are finished trap "kill $!" INT wait $! ### Clean up docker compose -p dynamodb-local down docker ps -a --filter "status=exited" --filter "ancestor=lambci/lambda:go1.x" -q | xargs -r docker rm
최종적으로 serverless 모듈, serverless-offline 플러그인, air 모듈, 컨테이너를 통해 서버리스 로컬 개발 환경을 만들었습니다. 덕분에 로컬에서 편하게 프론트엔드, 백엔드 테스트할 수 있게 되었습니다. 서버리스 로컬 개발 환경에 대한 코드는 GitHub에서 볼 수 있으니 참고하시면 좋을 거 같습니다. 참, 그리고 serverless 모듈에 배포 기능도 있어서 이 기능 또한 활용하면 좋을 거 같습니다.