요약
1.99GB였던 Next.js Docker 이미지를 멀티스테이지 빌드와 .dockerignore 도입으로 1.01GB까지 줄여 약 49% 경량화
문제
Next.js로 도커 이미지로 빌드하여 ECR에 푸쉬하여 사용하고 있었다. 하지만 기존에는 도커 이미지가 약 2GB로 크기가 커서, 빌드 시간이 오래 걸리고 ECR 업로드/다운로드 속도도 느렸다. 또 ECR에 저장할 때는 1GB당 월 0.1달러씩 부과하며, 트래픽에도 요금을 받고 있었기 때문에 용량을 더 줄이고자 했다.
해결 1 : 멀티 스테이지 도입
// 기존 Dockerfile
FROM node:version-alpine
ENV TZ=Asia/Seoul
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
기존의 도커파일이다. 싱글 스테이지로 빌드하고 있어서 빌드 도구, 테스트 파일, 소스코드, .git 등도 실행 이미지에 남게 되어 용량도 커지고 보안적인 문제가 발생한다. 그래서 이를 멀티 스테이지로 빌드 부분과 실행 부분을 분리하였다.
# 1. 빌드 단계
FROM node:version AS builder
# 작업 디렉토리 설정
WORKDIR /app
# 종속성 설치
COPY package.json package-lock.json ./
RUN npm install
# 전체 소스 복사 후 빌드
COPY . .
RUN npm run build
# 2. 실행 단계 (경량화된 실행 환경)
FROM node:version-alpine AS runner
# 타임존 설정
ENV TZ=Asia/Seoul
# 작업 디렉토리
WORKDIR /app
# 실행에 필요한 파일만 복사
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
# 포트 설정
EXPOSE 3000
# 실행 명령
CMD ["npm", "start"]
먼저 builder로 빌드를 진행하고, 빌드 후 필요한 파일만 runner로 옮겨와서 alpine으로 이미지를 구웠다. 빌드 후 builder는 폐기된다. 이를 통해 1.99GB -> 1.21GB로 용량이 감소했다.
해결 2 : .dockerignore 도입
.dockerignore 파일은 COPY . . 시 Docker가 읽는 컨텍스트에서 제외할 파일과 디렉터리를 지정하여, 불필요한 항목이 이미지에 포함되지 않도록 도와준다.
// .dockerignore
# node_modules는 컨테이너에서 설치됨
node_modules
# Next.js 빌드 결과물은 build 단계에서 새로 생성됨
.next
out
# Git, 환경 설정, 빌드 도구 등 불필요한 파일
.git
.gitignore
*.log
.vscode
.idea
*.tsbuildinfo
.DS_Store
# 테스트 및 문서
coverage
__tests__
docs
1.21GB → 1.01GB로 용량이 줄었다. 하지만 builder는 폐기되는데 COPY가 없는 runner에서는 왜 용량이 줄었는지 찾아보았다.
용량이 줄어든 이유
처음에는 builder 측에서 npm install을 하면 /app/node_modules가 생기는데, COPY --from=builder /app/node_modules ./node_modules 로 node_modules가 중복으로 생겨서, 이로 인해 용량이 늘어난 줄 알았다.
하지만 중복의 디렉토리명을 추가하면 에러가 나지 않을까? 해서 찾아보니 도커는 명령어 별로 레이어를 구성하는데, 이후 명령으로 덮어써서 에러는 안 나고, node_modules가 레이어 별로 존재하기 때문에 용량이 늘어난 거인줄 알았다. 이는 로컬의 node_modules의 용량은 460MB였는데 레이어는 압축이 되기 때문에 납득이 될.... 뻔했다.
문제는 레이어가 늘어난 부분은 builder여서, runner 측 이미지에는 영향을 주지 않는데도 용량이 늘어났다는 거였다. 그래서 dive라는 도커 이미지 분석 툴을 이용해서 .dockerignore 적용 전후의 이미지를 분석해보았다.

.dockerignore 적용 전이다. /app 디렉터리에는 node_modules 하나만 있다.

.dockerignore 적용 후이다. 역시 /app 디렉토리에는 node_modules 하나만 있다.
하지만 다른 점이 하나 있다.
COPY node_modules 부분이 적용 전보다 약 148MB 더 커졌고,
COPY .next 부분이 적용 전보다 약 53MB 더 커졌다.
그래서 가설을 하나 세워봤다. 로컬의 node_modules와 .next를 가져왔고, 이게 용량이 더 크며, 이후 npm install 및 npm run build 시에는 이미 node_modules와 .next가 있으니 중복되는 것들은 덮어쓰기가 된 것. 이 포스팅 이전에 깃을 넘나들면서 의존성이 뒤섞여 설치하느라 충분히 용량이 커질만했다.
이 가설을 토대로 node_modules와 .next를 제거 후 도커 이미지를 빌드하니..... 1.01GB로 줄었다 ㅋㅋ
결론
- 1.99GB → 1.01GB로 이미지 49% 절감
- 배포 속도 향상 (업로드/다운로드 속도 향상, 컨테이너 실행 속도 향상)
- 저장 비용 절감 (ECR 저장 비용, 트래픽 비용 절감)
- 또한 ECR은 Docker 이미지를 레이어 단위로 gzip 압축하여 저장하므로, 실제 저장 용량은 약 293MB까지 감소 (1.01GB → 293.33MB)


'TIL' 카테고리의 다른 글
TIL #127 : Axios Interceptor 도입으로 인증 공통화 및 141줄 절감 (0) | 2025.04.07 |
---|---|
TIL #126 : SWR 도입으로 7개 페이지에서 93줄 코드 절감 (0) | 2025.04.07 |
TIL #124 : Spring Data Redis에서 Lua Script 사용하기 (1) | 2024.11.18 |
TIL #123 : 배치 작업에는 꼭 정렬 하기 (0) | 2024.11.17 |
TIL #122 : 읽기전용/쓰기전용DB를 @Transactional의 readOnly로 구분하기 (0) | 2024.11.16 |
요약
1.99GB였던 Next.js Docker 이미지를 멀티스테이지 빌드와 .dockerignore 도입으로 1.01GB까지 줄여 약 49% 경량화
문제
Next.js로 도커 이미지로 빌드하여 ECR에 푸쉬하여 사용하고 있었다. 하지만 기존에는 도커 이미지가 약 2GB로 크기가 커서, 빌드 시간이 오래 걸리고 ECR 업로드/다운로드 속도도 느렸다. 또 ECR에 저장할 때는 1GB당 월 0.1달러씩 부과하며, 트래픽에도 요금을 받고 있었기 때문에 용량을 더 줄이고자 했다.
해결 1 : 멀티 스테이지 도입
// 기존 Dockerfile
FROM node:version-alpine
ENV TZ=Asia/Seoul
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
기존의 도커파일이다. 싱글 스테이지로 빌드하고 있어서 빌드 도구, 테스트 파일, 소스코드, .git 등도 실행 이미지에 남게 되어 용량도 커지고 보안적인 문제가 발생한다. 그래서 이를 멀티 스테이지로 빌드 부분과 실행 부분을 분리하였다.
# 1. 빌드 단계
FROM node:version AS builder
# 작업 디렉토리 설정
WORKDIR /app
# 종속성 설치
COPY package.json package-lock.json ./
RUN npm install
# 전체 소스 복사 후 빌드
COPY . .
RUN npm run build
# 2. 실행 단계 (경량화된 실행 환경)
FROM node:version-alpine AS runner
# 타임존 설정
ENV TZ=Asia/Seoul
# 작업 디렉토리
WORKDIR /app
# 실행에 필요한 파일만 복사
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
# 포트 설정
EXPOSE 3000
# 실행 명령
CMD ["npm", "start"]
먼저 builder로 빌드를 진행하고, 빌드 후 필요한 파일만 runner로 옮겨와서 alpine으로 이미지를 구웠다. 빌드 후 builder는 폐기된다. 이를 통해 1.99GB -> 1.21GB로 용량이 감소했다.
해결 2 : .dockerignore 도입
.dockerignore 파일은 COPY . . 시 Docker가 읽는 컨텍스트에서 제외할 파일과 디렉터리를 지정하여, 불필요한 항목이 이미지에 포함되지 않도록 도와준다.
// .dockerignore
# node_modules는 컨테이너에서 설치됨
node_modules
# Next.js 빌드 결과물은 build 단계에서 새로 생성됨
.next
out
# Git, 환경 설정, 빌드 도구 등 불필요한 파일
.git
.gitignore
*.log
.vscode
.idea
*.tsbuildinfo
.DS_Store
# 테스트 및 문서
coverage
__tests__
docs
1.21GB → 1.01GB로 용량이 줄었다. 하지만 builder는 폐기되는데 COPY가 없는 runner에서는 왜 용량이 줄었는지 찾아보았다.
용량이 줄어든 이유
처음에는 builder 측에서 npm install을 하면 /app/node_modules가 생기는데, COPY --from=builder /app/node_modules ./node_modules 로 node_modules가 중복으로 생겨서, 이로 인해 용량이 늘어난 줄 알았다.
하지만 중복의 디렉토리명을 추가하면 에러가 나지 않을까? 해서 찾아보니 도커는 명령어 별로 레이어를 구성하는데, 이후 명령으로 덮어써서 에러는 안 나고, node_modules가 레이어 별로 존재하기 때문에 용량이 늘어난 거인줄 알았다. 이는 로컬의 node_modules의 용량은 460MB였는데 레이어는 압축이 되기 때문에 납득이 될.... 뻔했다.
문제는 레이어가 늘어난 부분은 builder여서, runner 측 이미지에는 영향을 주지 않는데도 용량이 늘어났다는 거였다. 그래서 dive라는 도커 이미지 분석 툴을 이용해서 .dockerignore 적용 전후의 이미지를 분석해보았다.

.dockerignore 적용 전이다. /app 디렉터리에는 node_modules 하나만 있다.

.dockerignore 적용 후이다. 역시 /app 디렉토리에는 node_modules 하나만 있다.
하지만 다른 점이 하나 있다.
COPY node_modules 부분이 적용 전보다 약 148MB 더 커졌고,
COPY .next 부분이 적용 전보다 약 53MB 더 커졌다.
그래서 가설을 하나 세워봤다. 로컬의 node_modules와 .next를 가져왔고, 이게 용량이 더 크며, 이후 npm install 및 npm run build 시에는 이미 node_modules와 .next가 있으니 중복되는 것들은 덮어쓰기가 된 것. 이 포스팅 이전에 깃을 넘나들면서 의존성이 뒤섞여 설치하느라 충분히 용량이 커질만했다.
이 가설을 토대로 node_modules와 .next를 제거 후 도커 이미지를 빌드하니..... 1.01GB로 줄었다 ㅋㅋ
결론
- 1.99GB → 1.01GB로 이미지 49% 절감
- 배포 속도 향상 (업로드/다운로드 속도 향상, 컨테이너 실행 속도 향상)
- 저장 비용 절감 (ECR 저장 비용, 트래픽 비용 절감)
- 또한 ECR은 Docker 이미지를 레이어 단위로 gzip 압축하여 저장하므로, 실제 저장 용량은 약 293MB까지 감소 (1.01GB → 293.33MB)


'TIL' 카테고리의 다른 글
TIL #127 : Axios Interceptor 도입으로 인증 공통화 및 141줄 절감 (0) | 2025.04.07 |
---|---|
TIL #126 : SWR 도입으로 7개 페이지에서 93줄 코드 절감 (0) | 2025.04.07 |
TIL #124 : Spring Data Redis에서 Lua Script 사용하기 (1) | 2024.11.18 |
TIL #123 : 배치 작업에는 꼭 정렬 하기 (0) | 2024.11.17 |
TIL #122 : 읽기전용/쓰기전용DB를 @Transactional의 readOnly로 구분하기 (0) | 2024.11.16 |