#Docker
#aws
#AWS Lambda
#S3
#ECR
Korean
Written by Paul
February 14, 2026
들어가며
billets-server는 Serverless Framework를 통해 AWS Lambda 위에서 운영되는 Fastify 기반 API 서버다. 기존에는 코드를 zip으로 묶고, node_modules를 3개의 Lambda Layer로 쪼개서 S3에 올리는 방식으로 배포했다. 이 글에서는 이 "S3 Layer 쪼개기" 방식을 걷어내고 Docker 컨테이너 이미지 기반 배포로 전환한 과정을 정리한다.
기존 구조: Lambda Layer + S3 Zip 업로드
기존
serverless.yml은 이런 모습이었다.3개의 Layer로 node_modules를 분리해야 했다:
- prisma layer — Prisma Client 바이너리. 플랫폼별 네이티브 엔진 파일을 include/exclude 패턴으로 세밀하게 관리
- nodeModules layer — Prisma, @img를 제외한 나머지 전체 node_modules
- sharpImg layer — Sharp 이미지 처리 라이브러리의 네이티브 바인딩(@img)
무엇이 문제였나
- Layer 분리 관리의 복잡성: 패키지를 추가하거나 업데이트할 때마다 어떤 Layer에 들어가야 하는지, exclude 패턴은 맞는지 신경 써야 했다.
@aws-sdk서브패키지가 어떤 Layer에 속하는지 수동 관리하는 커밋이 실제로 존재했다.
- Lambda Layer 250MB 제한: 압축 해제 기준 250MB 제한 때문에 Layer를 쪼갤 수밖에 없었고, 이 제한에 맞추기 위한 "핵"이 반복됐다.
- 로컬 빌드 의존성: 배포 전에 로컬에서 Layer 디렉토리를 구성하는 스크립트를 돌려야 했다. CI에서도 전체 모노레포
pnpm install이 필요했다.
- 디버깅 어려움: Layer 조합이 맞지 않으면 런타임에 모듈을 못 찾는 에러가 나는데, 어떤 Layer의 어떤 패턴이 문제인지 추적하기 까다로웠다.
PR 설명에 "no more s3 hack"이라고 적은 건 진심이었다.
새로운 구조: Docker 컨테이너 이미지 + ECR
전환 후 serverless.yml
Layer가 전부 사라졌다.
handler 대신 image.uri로 ECR에 푸시된 Docker 이미지를 직접 참조한다. 설정 파일이 49줄에서 5줄로 줄었다.멀티 스테이지 Dockerfile
핵심 설계 결정:
- 빌드 스테이지(node:22-bookworm-slim)에서 모든 빌드를 수행하고, 런타임 스테이지(public.ecr.aws/lambda/nodejs:22)에는 결과물만 복사
pnpm deploy --legacy로 billets-server의 의존성만 격리 추출
- BuildKit
-mount=type=secret으로 GitHub Packages NPM 토큰을 이미지에 남기지 않고 사용
-mount=type=cache로 pnpm store를 캐싱하여 재빌드 속도 개선
tsup 번들링 최적화
Docker 이미지로 전환하면서 tsup 설정도 함께 바꿨다.
noExternal: [/.*/]로 모든 의존성을 하나의 번들 파일에 포함시키되, 네이티브 바이너리가 필요한 @aws-sdk, @prisma/client, sharp만 external로 뺐다. 이렇게 하면 Docker 런타임 스테이지에서 복사해야 할 node_modules가 네이티브 모듈 몇 개로 줄어든다.CI 워크플로우 전환
Before — 정적 AWS 키 + 로컬 배포 스크립트
After — OIDC + Docker Buildx + ECR 푸시
바뀐 점:
항목 | Before | After |
AWS 인증 | 정적 Access Key | OIDC role-to-assume |
배포 단위 | zip + S3 Layer | Docker 이미지 + ECR |
CI 파일 | dev/prod 분리 (2개) | 단일 워크플로우 + stage input |
Actions 버전 | v1~v2 | v3~v4 |
동시성 제어 | 없음 | cancel-in-progress: true |
전환 과정에서의 삽질들
이 마이그레이션의 커밋 히스토리를 보면, 하루 만에 20개 이상의 fix 커밋이 찍혀 있다. 대부분 CI에서 Docker 빌드와 배포 파이프라인을 디버깅한 흔적이다.
주요 삽질 포인트:
- Docker buildx context 경로: 모노레포 루트에 Dockerfile이 있는데 빌드 컨텍스트를 잘못 지정해서
COPY실패
- NPM 토큰 주입: GitHub Packages에서 private 패키지를 받으려면 NPM 토큰이 필요한데, Docker BuildKit의
-mount=type=secret방식에 익숙해지기까지 여러 시행착오
- Serverless 인증 방식 충돌: OIDC로 AWS 인증을 바꿨는데, Serverless Framework 자체 인증은 별도로 필요해서 혼선
- 환경변수 주입: 기존에 20개 이상의 환경변수를 하나씩
echo로.env에 쓰던 방식에서, 단일 시크릿으로 묶는 과정에서 쉘 따옴표 이슈 발생
pnpm deploy와 Prisma:pnpm deploy --legacy로 격리 추출할 때.prisma디렉토리가 포함되지 않아 별도로 복사해야 했다
이 커밋들은 "CI에서 push하고, 실패 로그 보고, 한 줄 고치고, 다시 push"하는 사이클의 산물이다. 로컬에서 Docker 빌드는 되는데 CI에서 안 되는 경우가 대부분이었다.
전환 결과
이점
- Layer 관리 불필요: include/exclude 패턴 지옥에서 해방. 패키지를 추가해도 Dockerfile만 수정하면 된다.
- 일관된 빌드 환경: Docker 이미지 안에서 빌드하므로 "내 로컬에서는 되는데" 문제가 줄어든다.
- 이미지 크기 최적화 용이: 멀티 스테이지 빌드로 빌드 도구 없이 런타임에 필요한 것만 포함.
- 보안 개선: 정적 AWS 키 대신 OIDC, BuildKit secret으로 토큰 노출 방지.
- CI 단순화: 2개 워크플로우 → 1개, 코드량 대폭 감소.
트레이드오프
- Cold start: 컨테이너 이미지 기반 Lambda는 zip 배포 대비 cold start가 길어질 수 있다.
memorySize: 2048과timeout: 30으로 이에 대응했다.
- ECR 관리: 이미지 레지스트리가 추가 인프라로 생겼다. lifecycle policy 설정이 필요하다.
- 빌드 시간: Docker 멀티 스테이지 빌드는 zip 패키징보다 시간이 더 걸릴 수 있다. pnpm store 캐시로 완화했다.
마치며
S3 Layer 방식은 Lambda가 컨테이너 이미지를 지원하기 전에 250MB 제한을 우회하기 위한 현실적인 선택이었다. 하지만 모노레포에서 네이티브 바이너리(Prisma, Sharp)를 포함하는 서버를 운영하다 보니 Layer 관리가 점점 무거워졌다.
Docker ECR 방식으로 전환하면서 배포 설정은 극적으로 단순해졌고, 빌드의 재현성도 높아졌다. 하루 동안의 CI 디버깅 마라톤은 고통스러웠지만, 결과적으로 유지보수 부담이 크게 줄어들었다.