2026년 08월 21일

Docker Image 용량을 1GB에서 100MB 이하로 줄인 Multi-stage Build 실전 가이드

프로젝트를 컨테이너로 배포하다 보면 어느 순간 Docker 이미지 용량이 예상보다 훨씬 커져 있는 상황을 맞닥뜨리게 된다. docker images 명령어를 실행했더니 이미지 하나가 1.2GB를 차지하고 있었다. CI/CD 파이프라인에서 이미지를 pull하는 시간만 몇 분이 걸렸고, 운영 서버 디스크도 빠르게 채워졌다. 이 문제를 해결하기 위해 Multi-stage Build를 도입했고, 최종적으로 같은 애플리케이션을 80MB대 이미지로 만들 수 있었다.

이 글에서는 그 과정에서 직접 확인한 원인 분석부터 Dockerfile 수정, 최종 결과까지 실무 관점에서 정리한다.

왜 Docker 이미지가 1GB를 넘었을까

이미지 용량이 커지는 원인은 생각보다 단순하다. 가장 흔한 케이스는 빌드 도구와 런타임이 같은 이미지 안에 공존하는 것이다.

Node.js 기반 프로젝트를 예로 들면, node:18 베이스 이미지 위에서 npm install로 의존성을 설치하고, TypeScript를 컴파일한 뒤 그 결과물을 그대로 이미지에 담는 방식이 흔히 쓰인다. 이때 문제가 되는 건 다음과 같다.

  • node_modules 디렉터리 전체가 이미지에 포함됨 (devDependencies 포함)
  • TypeScript 컴파일러, ESLint, 테스트 도구 등 빌드 전용 패키지가 그대로 남음
  • 베이스 이미지 자체가 무거운 Debian 계열(node:18 기본 태그)인 경우
  • .dockerignore 설정 없이 불필요한 파일까지 COPY됨

docker history <이미지명> 명령어로 레이어별 용량을 확인했더니, npm install 레이어 하나가 600MB 이상을 차지하고 있었다. 그 안에는 실행에 전혀 필요 없는 빌드 도구들이 가득했다.

Multi-stage Build의 핵심 원리

Multi-stage Build는 하나의 Dockerfile 안에서 여러 개의 FROM 구문을 사용해 빌드 단계를 분리하는 방식이다. 각 스테이지는 독립적인 환경에서 실행되며, 이전 스테이지에서 필요한 파일만 선택적으로 복사해 올 수 있다.

핵심은 이것이다. 빌드에 필요한 도구들은 최종 이미지에 포함되지 않는다. 컴파일, 패키지 설치, 테스트 실행 같은 과정은 빌드 스테이지에서 처리하고, 실제로 서버를 실행하는 데 필요한 결과물만 최종 스테이지로 가져온다.

기본 구조는 다음과 같다.

# 빌드 스테이지
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 실행 스테이지
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]

여기서 --from=builder가 핵심 구문이다. 이전 스테이지(builder)에서 특정 경로의 파일만 가져오기 때문에, 빌드 도구 전체가 딸려오지 않는다.

실제 적용: 1.2GB → 80MB 과정

베이스 이미지 변경

기존 Dockerfile은 node:18을 베이스로 사용하고 있었다. 이 이미지는 Debian 기반으로 약 950MB 수준이다. 실행 스테이지에서는 node:18-alpine으로 교체했다. Alpine Linux 기반의 이 이미지는 약 170MB로 훨씬 가볍다.

더 나아가 node:18-alpine 대신 node:18-slim도 선택지가 될 수 있는데, 프로젝트에서 네이티브 모듈을 사용한다면 Alpine보다 slim이 안정적인 경우가 있다. Alpine은 musl libc를 사용하기 때문에 일부 네이티브 패키지와 호환성 문제가 생길 수 있다.

devDependencies 제거

node_modules를 그대로 복사하면 개발 의존성까지 전부 포함된다. 이를 해결하는 방법은 두 가지다.

방법 1: 프로덕션 전용으로 재설치

FROM node:18-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist

빌드 결과물(dist)은 builder 스테이지에서 가져오고, node_modules는 실행 스테이지에서 프로덕션 패키지만 새로 설치하는 방식이다.

방법 2: npm prune 활용

COPY --from=builder /app/node_modules ./node_modules
RUN npm prune --production

이미 설치된 node_modules에서 devDependencies를 제거하는 방법이지만, 방법 1이 더 깔끔하게 동작하는 경우가 많다.

.dockerignore 정비

.dockerignore 파일이 제대로 설정되지 않으면 COPY . . 명령어가 불필요한 파일을 모두 이미지 레이어에 포함시킨다. 다음 항목들은 기본적으로 제외하는 것이 좋다.

node_modules
.git
.env
*.log
dist
.DS_Store
README.md

특히 node_modules를 .dockerignore에 추가하지 않으면 로컬 환경의 node_modules가 이미지에 그대로 들어갈 수 있다. 이 경우 로컬 OS에 맞게 컴파일된 네이티브 모듈이 컨테이너 환경에서 동작하지 않는 문제까지 생긴다.

레이어 캐시 최적화

Multi-stage Build와 별개로, 레이어 순서를 의도적으로 구성하면 빌드 시간도 크게 줄어든다. package.json과 package-lock.json을 먼저 복사하고 npm ci를 실행한 다음, 나머지 소스 코드를 복사하는 순서가 중요하다.

COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

이렇게 하면 소스 코드만 변경됐을 때 npm ci 레이어가 캐시에서 재사용된다. package.json이 바뀌지 않는 한 의존성 설치 단계를 건너뛸 수 있어서, 반복 빌드 속도가 눈에 띄게 빨라진다.

최종 Dockerfile 구조

위 내용을 반영한 Node.js 기반 TypeScript 프로젝트의 Dockerfile 전체 구조다.

# ---- 빌드 스테이지 ----
FROM node:18 AS builder
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ---- 실행 스테이지 ----
FROM node:18-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --from=builder /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

몇 가지 포인트를 짚어두자면, npm cache clean --force를 설치 직후에 같은 RUN 명령 안에서 실행하면 npm 캐시가 별도 레이어로 남지 않는다. USER node는 루트 권한 없이 애플리케이션을 실행하도록 해서 보안 측면에서도 개선이 된다.

이 구조를 적용한 결과, 기존 1.2GB였던 이미지가 82MB로 줄었다.

Java / Spring Boot에 적용하는 경우

Java 기반 프로젝트도 마찬가지 원리가 적용된다. Gradle이나 Maven으로 빌드한 JAR 파일만 최종 이미지에 포함시키면 된다.

# ---- 빌드 스테이지 ----
FROM gradle:8-jdk17 AS builder
WORKDIR /app
COPY . .
RUN gradle bootJar --no-daemon

# ---- 실행 스테이지 ----
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

gradle:8-jdk17 이미지는 빌드 도구 포함으로 상당히 무겁지만, 실행 스테이지에서는 JRE만 포함된 eclipse-temurin:17-jre-alpine을 사용한다. JDK 전체가 아닌 JRE만 사용하는 것만으로도 용량 차이가 크다.

Spring Boot 3.x 이상에서는 Layered JAR 기능을 함께 활용하면 Docker 레이어 캐시 효율을 더 높일 수 있는데, 이는 의존성 레이어와 애플리케이션 코드 레이어를 분리해 코드 변경 시에만 해당 레이어를 갱신하게 만든다.

이미지 용량 확인과 검증 방법

Docker 이미지를 최적화한 뒤에는 실제로 용량이 줄었는지, 레이어 구성이 의도한 대로 되었는지 확인하는 과정이 필요하다.

기본 용량 확인

docker images <이미지명>

레이어별 상세 분석

docker history <이미지명> --no-trunc

각 레이어의 용량과 어떤 명령어로 만들어졌는지 확인할 수 있다. 예상보다 큰 레이어가 있다면 해당 RUN 명령에서 캐시나 임시 파일이 제거되지 않은 경우가 많다.

dive 도구 활용

dive는 Docker 이미지 레이어를 시각적으로 분석해주는 오픈소스 CLI 도구다. 각 레이어에서 어떤 파일이 추가, 수정, 삭제됐는지 파일 단위로 확인할 수 있어서 불필요하게 포함된 파일을 찾는 데 유용하다.

dive <이미지명>

주의해야 할 점

Multi-stage Build를 도입할 때 몇 가지 실수가 반복적으로 나타난다.

환경 변수 관리: 빌드 스테이지에서 설정한 환경 변수는 실행 스테이지로 자동으로 넘어오지 않는다. 실행 스테이지에서 필요한 환경 변수는 별도로 ENV 명령어로 다시 선언해야 한다.

파일 권한 문제: --from 옵션으로 파일을 복사할 때 원본 파일의 권한이 그대로 따라온다. USER node처럼 비루트 사용자로 실행하는 경우, 복사한 파일에 읽기 권한이 있는지 확인해야 한다.

Alpine 호환성: Alpine 이미지는 용량이 가볍지만, musl libc 기반이라 glibc를 필요로 하는 네이티브 모듈과 충돌이 생길 수 있다. bcrypt, sharp 같은 패키지를 사용할 때 에러가 발생한다면 Alpine 대신 slim 태그를 고려해볼 만하다.

빌드 캐시 무효화 조건 파악: 소스 코드 파일 하나만 바뀌어도 COPY . . 이후의 모든 레이어가 다시 빌드된다. 자주 변경되는 파일과 그렇지 않은 파일을 COPY 순서로 분리하는 습관이 빌드 시간 단축에 직결된다.

이미지 용량 문제는 배포 속도, 디스크 비용, 보안(공격 표면 최소화) 모두에 영향을 미친다. Multi-stage Build는 설정 자체는 복잡하지 않으면서도 효과가 확실한 방법이라, 한 번 Dockerfile 구조를 잡아두면 이후 관리 부담도 크지 않다.