TechFeedTechFeed
Cloud & DevOps

Docker 멀티스테이지 빌드 실전 가이드 — 이미지 크기 90% 줄이기

Docker 이미지가 1GB를 넘는다면 멀티스테이지 빌드로 해결할 수 있다. 단일 스테이지 문제 분석부터 Alpine/Distroless 적용, GitHub Actions CI/CD 연동, 보안 체크리스트까지 단계별로 실습한다.

Docker 이미지를 빌드했더니 1.2GB? Node.js 앱 하나에 이 용량은 과하다. 멀티스테이지 빌드를 쓰면 같은 앱을 100~200MB로 줄일 수 있다. 이 가이드에서는 실제 Dockerfile을 단계별로 리팩토링하면서, 이미지 크기가 어디서 불어나고 어떻게 잘라내는지 직접 확인한다.

단순히 "멀티스테이지 쓰세요"가 아니라, 왜 각 단계가 필요한지, 어떤 레이어가 용량을 먹는지 EXPLAIN하며 진행한다.

※ 이 글은 2026년 3월 기준, Docker 27.x / Node.js 22 LTS 기반으로 작성됐습니다.

이미지 크기가 왜 중요한가

Docker 이미지 크기는 단순한 디스크 용량 문제가 아니다. 배포 속도, CI/CD 파이프라인 비용, 보안 공격 면적에 직접 영향을 준다.

  • 배포 속도: 1.2GB 이미지를 ECR/GCR에 push하고 pull하는 시간 vs 150MB — 차이가 분 단위로 난다
  • CI/CD 비용: GitHub Actions 러너는 분당 과금. 이미지 빌드/푸시 시간이 3배 늘면 비용도 3배
  • 보안: 이미지에 포함된 패키지가 많을수록 CVE 노출 면적이 넓어진다. node:22 풀 이미지에는 수백 개의 시스템 패키지가 들어 있다
  • 콜드 스타트: Kubernetes, ECS, Cloud Run에서 새 Pod/Task를 띄울 때 이미지 pull 시간이 콜드 스타트의 상당 부분을 차지한다
Docker 이미지 크기별 배포 시간 비교 차트
Docker 이미지 크기에 따른 배포 파이프라인 소요 시간 비교 (출처: Docker 공식 블로그)

단일 스테이지 Dockerfile의 문제 확인

먼저 전형적인 Node.js 프로젝트의 단일 스테이지 Dockerfile을 보자. 많은 프로젝트가 이 형태로 시작한다.

단일 스테이지 Dockerfile (문제가 있는 버전)
FROM node:22 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build EXPOSE 3000 CMD ["node", "dist/index.js"]

이 Dockerfile을 빌드하고 크기를 확인해 보자.

이미지 빌드 후 크기 확인
docker build -t my-app:single . docker images my-app:single # REPOSITORY TAG SIZE # my-app single 1.24GB

1.24GB. 어디서 이렇게 불어났을까? docker history로 레이어별 크기를 확인한다.

레이어별 크기 분석
docker history my-app:single # IMAGE CREATED SIZE COMMENT # a1b2c3d4e5f6 2 minutes ago 12.8MB CMD ["node", "dist/index.js"] # b2c3d4e5f6a1 2 minutes ago 0B EXPOSE 3000 # c3d4e5f6a1b2 2 minutes ago 85.2MB RUN npm run build # d4e5f6a1b2c3 2 minutes ago 4.7MB COPY . . # e5f6a1b2c3d4 3 minutes ago 245MB RUN npm install # f6a1b2c3d4e5 3 minutes ago 18.5KB COPY package*.json # ... ... 910MB node:22 base image

문제가 보인다:

  1. 베이스 이미지 910MB: node:22는 Debian 기반 풀 이미지. Python, gcc, make 등 빌드 도구가 전부 들어 있다
  2. devDependencies 245MB: TypeScript, ESLint, Webpack 등 빌드 시에만 필요한 패키지가 프로덕션 이미지에 그대로 남아 있다
  3. 소스 코드: 빌드 결과물(dist/)만 있으면 되는데, 원본 src/도 포함됐다

멀티스테이지 빌드 기본 구조

멀티스테이지 빌드의 핵심은 간단하다: 빌드 환경과 실행 환경을 분리한다. 하나의 Dockerfile 안에 여러 FROM을 쓰고, 이전 스테이지에서 필요한 파일만 복사한다.

2-스테이지 Dockerfile (빌드 + 실행 분리)
# Stage 1: 빌드 FROM node:22 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Stage 2: 실행 FROM node:22-slim WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/package*.json ./ RUN npm ci --omit=dev EXPOSE 3000 CMD ["node", "dist/index.js"]

핵심 변경점 3가지:

  1. FROM node:22 AS builder: 첫 스테이지에 이름을 붙인다. 빌드 도구(TypeScript, Webpack 등)는 여기서만 쓰인다
  2. FROM node:22-slim: 실행 스테이지는 slim 이미지를 쓴다. Debian 기반이지만 불필요한 시스템 패키지가 제거된 버전(~200MB)
  3. COPY --from=builder: 빌드 결과물과 package.json만 가져온다. 소스 코드, devDependencies, 빌드 캐시는 전부 버려진다
Docker 멀티스테이지 빌드 아키텍처 다이어그램 — 빌드 스테이지에서 실행 스테이지로 필요한 파일만 복사하는 흐름
멀티스테이지 빌드의 파일 흐름: builder에서 dist/만 최종 이미지로 전달 (출처: Docker Docs)
멀티스테이지 빌드 후 크기 비교
docker build -t my-app:multi . docker images my-app # REPOSITORY TAG SIZE # my-app single 1.24GB # my-app multi 285MB ← 77% 감소

1.24GB → 285MB. 아직 끝이 아니다. node:22-slim 대신 Alpine을 쓰면 더 줄일 수 있다.

Alpine과 Distroless로 극한까지 줄이기

실행 스테이지의 베이스 이미지를 바꾸면 크기를 한 단계 더 줄일 수 있다. 대표적인 선택지 3가지를 비교한다.

베이스 이미지크기특징주의점
node:22-slim~200MBDebian 기반, apt 사용 가능여전히 불필요한 패키지 포함
node:22-alpine~50MBmusl libc, apk 패키지 매니저native addon 호환성 이슈 가능
gcr.io/distroless/nodejs22-debian12~35MB셸 없음, Node.js 바이너리만디버깅 어려움, exec 불가
Alpine 기반 3-스테이지 Dockerfile
# Stage 1: 의존성 설치 FROM node:22-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # Stage 2: 빌드 FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build # Stage 3: 실행 FROM node:22-alpine AS runner WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 appuser COPY --from=builder /app/dist ./dist COPY --from=builder /app/package*.json ./ RUN npm ci --omit=dev && npm cache clean --force USER appuser EXPOSE 3000 CMD ["node", "dist/index.js"]

이 Dockerfile은 3개 스테이지로 나뉜다:

  1. deps: 의존성만 설치. package.json이 바뀌지 않으면 캐시가 재사용된다
  2. builder: 소스 코드 복사 + 빌드. deps에서 node_modules를 가져오므로 npm install을 다시 하지 않는다
  3. runner: 프로덕션 의존성만 설치하고 빌드 결과물을 복사. 비root 사용자로 실행

결과를 확인하자.

최종 이미지 크기 비교
docker images my-app # REPOSITORY TAG SIZE # my-app single 1.24GB # my-app multi 285MB # my-app alpine 127MB ← 90% 감소
⚠️ Alpine 주의점: bcrypt, sharp, canvas 등 native C++ addon을 쓰는 패키지는 Alpine에서 빌드 실패할 수 있다. 이 경우 빌드 스테이지에 RUN apk add --no-cache python3 make g++를 추가하거나, slim 이미지를 사용하는 것이 안전하다.

레이어 캐싱을 활용한 빌드 속도 최적화

멀티스테이지 빌드에서 레이어 순서는 빌드 속도에 직접적 영향을 준다. Docker는 각 명령어를 레이어로 캐싱하는데, 변경된 레이어 이후의 모든 레이어가 무효화된다.

핵심 원칙: 변경 빈도가 낮은 레이어를 위에, 높은 레이어를 아래에 배치한다.

잘못된 순서 vs 올바른 순서
# ❌ 잘못된 순서 — 소스 변경 시 npm install부터 다시 실행 COPY . . RUN npm ci RUN npm run build # ✅ 올바른 순서 — 소스 변경해도 npm install 캐시 유지 COPY package*.json ./ RUN npm ci COPY . . RUN npm run build

.dockerignore 파일도 빌드 성능과 이미지 크기에 영향을 준다. 불필요한 파일이 빌드 컨텍스트에 포함되면 전송 시간이 늘어나고, COPY 시 이미지에 포함될 수 있다.

.dockerignore 권장 설정
node_modules dist .git .github *.md .env* coverage .nyc_output .vscode .idea Dockerfile docker-compose*.yml .dockerignore
Docker 레이어 캐싱 동작 원리 — 변경된 레이어 이후 캐시가 무효화되는 과정
Docker 레이어 캐싱: package.json이 변경되지 않으면 npm ci 레이어가 재사용된다 (출처: Docker Docs)

프로덕션 이미지 보안 체크리스트

이미지 크기를 줄이는 것 자체가 보안 개선이다. 패키지가 적을수록 취약점도 적다. 추가로 아래 항목을 반드시 적용한다.

보안 강화 Dockerfile 패턴
# 비root 사용자 생성 및 전환 RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 appuser USER appuser # 읽기 전용 파일시스템 (docker run 시) # docker run --read-only --tmpdir /tmp my-app:alpine # 헬스체크 추가 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })" # 취약점 스캔 # docker scout cves my-app:alpine # trivy image my-app:alpine

각 항목이 왜 중요한지:

  • 비root 실행: 컨테이너 탈출 취약점 발생 시 호스트 권한 획득 방지. USER 지시자 없이 빌드하면 root로 실행된다
  • 읽기 전용 파일시스템: 런타임에 파일 변조 공격 방지. /tmp만 쓰기 허용
  • HEALTHCHECK: 오케스트레이터(K8s, ECS)가 컨테이너 상태를 판단하는 데 사용. 응답 없는 좀비 컨테이너를 자동 재시작
  • 취약점 스캔: CI/CD에 docker scouttrivy를 넣어 빌드 시 자동 검사
💡 팁: docker scout cves는 Docker Desktop 포함 무료 도구다. CI에서 docker scout cves --exit-code --only-severity critical,high를 실행하면 심각한 취약점 발견 시 빌드를 실패시킬 수 있다.

Next.js, Go, Python 프로젝트별 실전 Dockerfile

Node.js 외에도 멀티스테이지 빌드는 모든 컴파일 언어에서 강력하다. 프로젝트 유형별 실전 Dockerfile을 비교한다.

Next.js standalone 모드 Dockerfile
# Stage 1: 의존성 FROM node:22-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # Stage 2: 빌드 FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build # Stage 3: 실행 (standalone output) FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 CMD ["node", "server.js"] # next.config.js에 output: "standalone" 설정 필수
Go API 서버 Dockerfile (최종 이미지 ~15MB)
# Stage 1: 빌드 FROM golang:1.23-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server # Stage 2: 실행 FROM scratch COPY --from=builder /app/server /server COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ EXPOSE 8080 ENTRYPOINT ["/server"]

Go 프로젝트에서는 FROM scratch(빈 이미지)를 실행 스테이지로 쓸 수 있다. Go가 정적 바이너리를 만들기 때문이다. 최종 이미지 크기가 10~20MB로 떨어진다. HTTPS 요청이 필요하면 CA 인증서만 복사하면 된다.

Python 프로젝트도 멀티스테이지의 혜택이 크다. 빌드 시 C 확장 컴파일에 필요한 gcc, python-dev 등이 수백 MB를 차지하기 때문이다.

Python FastAPI Dockerfile
# Stage 1: 빌드 (wheel 생성) FROM python:3.12-slim AS builder WORKDIR /app RUN pip install --no-cache-dir poetry COPY pyproject.toml poetry.lock ./ RUN poetry export -f requirements.txt -o requirements.txt --without-hashes RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt # Stage 2: 실행 FROM python:3.12-slim WORKDIR /app COPY --from=builder /wheels /wheels RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels COPY . . RUN useradd --system appuser USER appuser EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
⚠️ Next.js standalone 주의: next.config.jsoutput: "standalone"을 설정해야 .next/standalone 디렉토리가 생성된다. 이 설정 없이 빌드하면 COPY 단계에서 파일을 찾지 못해 실패한다.

GitHub Actions에서 멀티스테이지 빌드 자동화

멀티스테이지 Dockerfile을 CI/CD에 연결해야 실무에서 의미가 있다. GitHub Actions 워크플로우에서 빌드, 캐싱, 레지스트리 푸시를 설정한다.

GitHub Actions 워크플로우 (.github/workflows/docker.yml)
name: Build and Push Docker Image 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 Container 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@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Scan for vulnerabilities uses: aquasecurity/trivy-action@master with: image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }} severity: CRITICAL,HIGH exit-code: 1

핵심 포인트:

  • Docker Buildx: BuildKit 기반 빌더. 멀티스테이지 병렬 빌드, 캐시 내보내기를 지원한다
  • cache-from/to: type=gha: GitHub Actions 캐시를 Docker 레이어 캐시로 사용. 두 번째 빌드부터 의존성 설치 레이어를 건너뛴다
  • Trivy 스캔: 빌드 후 자동으로 CVE 검사. CRITICAL/HIGH 발견 시 워크플로우를 실패시켜 취약한 이미지가 배포되는 것을 방지한다
💡 팁: cache-to: type=gha,mode=maxmode=max는 최종 스테이지뿐 아니라 중간 스테이지의 레이어도 캐시한다. 멀티스테이지 빌드에서 이 설정이 없으면 builder 스테이지가 매번 처음부터 실행된다.

자주 하는 실수와 해결법

멀티스테이지 빌드를 처음 적용할 때 자주 만나는 문제와 해결 방법을 정리한다.

⚠️ 실수 1 — npm install vs npm ci: npm install은 package-lock.json을 업데이트할 수 있다. Docker 빌드에서는 반드시 npm ci를 사용해 lock 파일 기준으로 정확한 버전을 설치해야 한다. 재현 가능한 빌드의 기본이다.
⚠️ 실수 2 — COPY . .의 타이밍: .dockerignorenode_modules를 넣지 않으면 로컬의 node_modules가 빌드 컨텍스트에 포함된다. 빌드 컨텍스트 전송만 수십 초가 걸리고, 이미지에도 포함될 수 있다.
⚠️ 실수 3 — 환경변수 유출: 빌드 스테이지에서 ARG로 전달한 시크릿이 docker history에 노출된다. --mount=type=secret을 사용하거나, 실행 스테이지에서만 런타임 환경변수로 전달해야 한다. 예: RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

마지막으로, dive 도구로 최종 이미지의 레이어를 시각적으로 분석할 수 있다.

dive로 이미지 레이어 분석
# dive 설치 (macOS) brew install dive # 이미지 분석 dive my-app:alpine # CI에서 효율성 검사 (낭비된 공간이 임계값 초과 시 실패) CI=true dive my-app:alpine --ci-config .dive-ci.yml
Docker멀티스테이지 빌드Docker 이미지 최적화AlpineDistrolessCI/CDGitHub Actions컨테이너DevOps

관련 도구

관련 포스트

Docker Compose vs Kubernetes — 실무에서 언제 무엇을 쓸까2026-03-20Docker Compose vs Kubernetes — 언제 무엇을 선택할까2026-03-22프로덕션 배포 체크리스트 — 서비스 출시 전 반드시 점검해야 할 42개 항목2026-03-25Kubernetes 프로덕션 운영 가이드 — 리소스 관리, HPA, Probe, 장애 대응 실전 총정리2026-04-07