본문으로 건너뛰기
Docker multi-stage build optimization guide cover

# Docker 멀티스테이지 빌드 완벽 가이드: 이미지 크기를 10분의 1로 줄이는 방법

Table of Contents

“1.2GB짜리 이미지를 매번 배포할 수는 없잖아요.”

Node.js 애플리케이션을 Docker로 빌드했더니 이미지가 1GB를 넘는 경우, 흔히 겪는 상황입니다. 빌드 도구, 개발 의존성, 소스 코드가 전부 포함되어 있기 때문입니다. 배포 시간은 길어지고, 저장소 비용은 늘어나고, 보안 취약점도 증가합니다.

멀티스테이지 빌드는 이 문제를 해결하는 Docker의 핵심 기능입니다. 빌드에 필요한 것과 실행에 필요한 것을 분리하여, 최종 이미지에는 실행에 필요한 최소한만 포함시킵니다.

멀티스테이지 빌드란?

멀티스테이지 빌드는 하나의 Dockerfile에 여러 개의 FROM 명령을 사용하는 기법입니다. 각 FROM은 새로운 빌드 스테이지를 시작하며, 이전 스테이지에서 필요한 파일만 복사해올 수 있습니다.

기본 구조

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

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

핵심은 COPY --from=builder입니다. 이전 스테이지에서 빌드된 결과물만 가져옵니다.

이미지 크기 비교

방식이미지 크기포함된 내용
싱글스테이지~1.2GBNode.js, npm, 소스코드, devDependencies, 빌드 도구
멀티스테이지~150MBNode.js Alpine, 빌드 결과물, production dependencies

8배 차이가 납니다.

왜 이미지 크기가 중요한가?

1. 배포 속도

CI/CD 파이프라인에서 이미지를 레지스트리에 푸시하고, Kubernetes 노드에서 풀 받는 시간이 크게 줄어듭니다.

1.2GB 이미지: 푸시 45초 + 풀 60초 = 105초
150MB 이미지: 푸시 6초 + 풀 8초 = 14초

배포마다 90초 절약, 하루 10번 배포하면 15분입니다.

2. 저장소 비용

AWS ECR, GCR, Docker Hub 모두 저장 용량에 따라 비용이 발생합니다. 이미지가 작을수록 비용이 줄어듭니다.

3. 보안

이미지에 포함된 패키지가 적을수록 **공격 표면(attack surface)**이 줄어듭니다. 빌드 도구, 컴파일러, 불필요한 시스템 유틸리티가 없으면 취약점도 없습니다.

4. 시작 시간

이미지가 작으면 컨테이너 시작 시간도 빨라집니다. Kubernetes에서 Pod 스케일 아웃 시 중요합니다.

언어별 최적화 패턴

Node.js / TypeScript

# 스테이지 1: 의존성 설치
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json./
RUN npm ci --only=production

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

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

ENV NODE_ENV=production

# 비root 사용자로 실행
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=deps /app/node_modules./node_modules
COPY --from=builder /app/dist./dist
COPY --from=builder /app/package.json./

USER nextjs

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

포인트:

  • deps 스테이지에서 production 의존성만 설치
  • builder 스테이지에서 전체 의존성으로 빌드
  • 최종 이미지에 devDependencies 미포함

Go

Go는 정적 바이너리를 생성하므로 scratch 이미지를 사용할 수 있습니다:

# 스테이지 1: 빌드
FROM golang:1.22-alpine AS builder

WORKDIR /app

# 의존성 먼저 (캐시 활용)
COPY go.mod go.sum./
RUN go mod download

COPY..

# 정적 바이너리 빌드
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o main.

# 스테이지 2: 실행 (scratch = 빈 이미지)
FROM scratch

COPY --from=builder /app/main /main

# SSL 인증서 (HTTPS 호출 시 필요)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/main"]

결과: 약 10-15MB 이미지

-ldflags="-w -s"는 디버그 정보를 제거하여 바이너리 크기를 줄입니다.

:::tip scratch 이미지는 셸도 없어서 디버깅이 어렵습니다. 디버깅이 필요하면 alpine 또는 distroless를 사용하세요. :::

Python

# 스테이지 1: 빌드
FROM python:3.12-slim AS builder

WORKDIR /app

# 가상환경 생성
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# 의존성 설치
COPY requirements.txt.
RUN pip install --no-cache-dir -r requirements.txt

# 스테이지 2: 실행
FROM python:3.12-slim AS runner

WORKDIR /app

# 가상환경 복사
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY..

# 비root 사용자
RUN useradd --create-home appuser
USER appuser

CMD ["python", "main.py"]

포인트:

  • 가상환경 전체를 복사하여 의존성 재설치 불필요
  • --no-cache-dir로 pip 캐시 제거

Java / Spring Boot

# 스테이지 1: 빌드
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app

# Gradle wrapper와 설정 먼저 복사 (캐시 활용)
COPY gradlew.
COPY gradle gradle
COPY build.gradle.kts settings.gradle.kts./

# 의존성 다운로드
RUN./gradlew dependencies --no-daemon

# 소스 코드 복사 및 빌드
COPY src src
RUN./gradlew bootJar --no-daemon

# JAR 레이어 추출 (Spring Boot 2.3+)
RUN java -Djarmode=layertools -jar build/libs/*.jar extract

# 스테이지 2: 실행
FROM eclipse-temurin:21-jre-alpine AS runner

WORKDIR /app

# 비root 사용자
RUN addgroup --system --gid 1001 spring
RUN adduser --system --uid 1001 spring

# 레이어별로 복사 (변경 빈도 낮은 순)
COPY --from=builder /app/dependencies/./
COPY --from=builder /app/spring-boot-loader/./
COPY --from=builder /app/snapshot-dependencies/./
COPY --from=builder /app/application/./

USER spring

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

포인트:

  • JDK(빌드) → JRE(실행)로 전환
  • Spring Boot의 레이어 추출로 캐시 효율 극대화
  • 의존성 레이어는 거의 변경되지 않으므로 캐시 적중률 높음

Rust

# 스테이지 1: 빌드
FROM rust:1.75-alpine AS builder

RUN apk add --no-cache musl-dev

WORKDIR /app

# 의존성 먼저 빌드 (캐시 활용)
COPY Cargo.toml Cargo.lock./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src

# 실제 소스 빌드
COPY src src
RUN touch src/main.rs # 타임스탬프 갱신
RUN cargo build --release

# 스테이지 2: 실행
FROM scratch

COPY --from=builder /app/target/release/myapp /myapp

ENTRYPOINT ["/myapp"]

결과: 약 5-10MB 이미지

Rust도 Go처럼 정적 바이너리를 생성하므로 scratch 이미지 사용 가능합니다.

빌드 캐시 최적화

멀티스테이지 빌드의 효과를 극대화하려면 레이어 캐싱을 이해해야 합니다.

캐시 작동 원리

Docker는 각 명령을 레이어로 저장합니다. 이전 레이어가 변경되지 않았으면 캐시를 사용합니다.

# 레이어 1: 변경 드묾
COPY package.json./

# 레이어 2: package.json 변경 시만 재실행
RUN npm ci

# 레이어 3: 소스 코드 자주 변경
COPY..

# 레이어 4: 소스 변경 시 재실행
RUN npm run build

원칙: 변경이 적은 것을 먼저 복사하세요.

나쁜 예 vs 좋은 예

# 나쁜 예: 소스 변경 시 npm ci도 재실행
COPY..
RUN npm ci
RUN npm run build

# 좋은 예: package.json 변경 시만 npm ci 재실행
COPY package*.json./
RUN npm ci
COPY..
RUN npm run build

###.dockerignore 설정

불필요한 파일을 제외하여 빌드 컨텍스트를 줄이고, 캐시 무효화를 방지합니다:

#.dockerignore
node_modules
dist
.git
.gitignore
*.md
.env*
coverage
.nyc_output
*.log
Dockerfile
docker-compose*.yml

특히 중요: node_modules를 제외하지 않으면, 로컬 변경이 캐시를 무효화합니다.

BuildKit 캐시 마운트

Docker BuildKit을 사용하면 패키지 매니저 캐시를 영구적으로 유지할 수 있습니다:

# syntax=docker/dockerfile:1

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json./

# npm 캐시를 마운트로 유지
RUN --mount=type=cache,target=/root/.npm \
 npm ci

COPY..
RUN npm run build

BuildKit 활성화:

DOCKER_BUILDKIT=1 docker build -t myapp.

보안 강화 패턴

1. 비root 사용자 실행

FROM node:20-alpine

# 사용자 생성
RUN addgroup --system --gid 1001 appgroup
RUN adduser --system --uid 1001 appuser

WORKDIR /app

COPY --chown=appuser:appgroup..

USER appuser

CMD ["node", "index.js"]

2. Distroless 이미지 사용

Google의 distroless 이미지는 셸, 패키지 매니저가 없어 보안이 강화됩니다:

FROM node:20-alpine AS builder
WORKDIR /app
COPY..
RUN npm ci && npm run build

FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist./dist
COPY --from=builder /app/node_modules./node_modules
CMD ["dist/main.js"]

3. 시크릿 처리

빌드 시 필요한 시크릿(npm token 등)이 이미지에 남지 않도록 합니다:

# syntax=docker/dockerfile:1

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json./

# 시크릿을 마운트로 전달 (이미지에 저장 안됨)
RUN --mount=type=secret,id=npm_token \
 NPM_TOKEN=$(cat /run/secrets/npm_token) \
 npm ci

COPY..
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist./dist
CMD ["node", "dist/main.js"]

빌드 명령:

docker build --secret id=npm_token,src=.npmrc -t myapp.

실전 최적화 체크리스트

이미지 크기 분석

# 이미지 히스토리로 레이어별 크기 확인
docker history myapp:latest

# dive 도구로 상세 분석
docker run --rm -it \
 -v /var/run/docker.sock:/var/run/docker.sock \
 wagoodman/dive:latest myapp:latest

최적화 체크리스트

  • 멀티스테이지 빌드 사용
  • Alpine 또는 slim 베이스 이미지 사용
  • [ ].dockerignore 설정
  • 변경 빈도 낮은 레이어 먼저 복사
  • production 의존성만 포함
  • 비root 사용자 실행
  • 불필요한 파일 제거 (apt clean, rm -rf /var/lib/apt/lists/*)
  • BuildKit 캐시 마운트 활용

최적화 전후 비교 예시

항목최적화 전최적화 후
베이스 이미지node:20 (1.1GB)node:20-alpine (135MB)
의존성전체 (180MB)production만 (45MB)
소스 코드전체 복사빌드 결과만
최종 크기1.4GB180MB

CI/CD 통합

GitHub Actions 예시

name: Build and Push

on:
 push:
 branches: [main]

jobs:
 build:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4

 - name: Set up Docker Buildx
 uses: docker/setup-buildx-action@v3

 - name: Login to Registry
 uses: docker/login-action@v3
 with:
 registry: ghcr.io
 username: ${{ github.actor }}
 password: ${{ secrets.GITHUB_TOKEN }}

 - name: Build and Push
 uses: docker/build-push-action@v5
 with:
 context:.
 push: true
 tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
 cache-from: type=gha
 cache-to: type=gha,mode=max

cache-fromcache-to로 GitHub Actions 캐시를 활용하면 빌드 시간이 크게 단축됩니다.

정리

Docker 멀티스테이지 빌드의 핵심 포인트:

  1. 빌드와 실행을 분리하세요. 빌드 도구는 최종 이미지에 필요 없습니다.

  2. Alpine 또는 distroless를 사용하세요. 기본 이미지 크기가 10배 이상 차이납니다.

  3. 레이어 캐싱을 이해하세요. 변경이 적은 것을 먼저 복사하면 빌드 시간이 크게 줄어듭니다.

  4. .dockerignore를 반드시 설정하세요. node_modules, .git 등을 제외합니다.

  5. 보안을 신경 쓰세요. 비root 사용자, 시크릿 마운트를 활용합니다.

1.2GB 이미지를 150MB로 줄이면, 배포 속도, 비용, 보안 모두 개선됩니다. 이미 운영 중인 서비스가 있다면, Dockerfile을 멀티스테이지로 리팩토링하는 것부터 시작해보세요. 생각보다 간단하지만 효과는 극적입니다.

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트