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 공식 블로그)
단일 스테이지 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
문제가 보인다:
베이스 이미지 910MB: node:22는 Debian 기반 풀 이미지. Python, gcc, make 등 빌드 도구가 전부 들어 있다
devDependencies 245MB: TypeScript, ESLint, Webpack 등 빌드 시에만 필요한 패키지가 프로덕션 이미지에 그대로 남아 있다
소스 코드: 빌드 결과물(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가지:
FROM node:22 AS builder: 첫 스테이지에 이름을 붙인다. 빌드 도구(TypeScript, Webpack 등)는 여기서만 쓰인다
FROM node:22-slim: 실행 스테이지는 slim 이미지를 쓴다. Debian 기반이지만 불필요한 시스템 패키지가 제거된 버전(~200MB)
COPY --from=builder: 빌드 결과물과 package.json만 가져온다. 소스 코드, devDependencies, 빌드 캐시는 전부 버려진다
멀티스테이지 빌드의 파일 흐름: 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
~200MB
Debian 기반, apt 사용 가능
여전히 불필요한 패키지 포함
node:22-alpine
~50MB
musl 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개 스테이지로 나뉜다:
deps: 의존성만 설치. package.json이 바뀌지 않으면 캐시가 재사용된다
builder: 소스 코드 복사 + 빌드. deps에서 node_modules를 가져오므로 npm install을 다시 하지 않는다
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 시 이미지에 포함될 수 있다.
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 scout나 trivy를 넣어 빌드 시 자동 검사
💡 팁: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.js에 output: "standalone"을 설정해야 .next/standalone 디렉토리가 생성된다. 이 설정 없이 빌드하면 COPY 단계에서 파일을 찾지 못해 실패한다.
GitHub Actions에서 멀티스테이지 빌드 자동화
멀티스테이지 Dockerfile을 CI/CD에 연결해야 실무에서 의미가 있다. GitHub Actions 워크플로우에서 빌드, 캐싱, 레지스트리 푸시를 설정한다.
Docker Buildx: BuildKit 기반 빌더. 멀티스테이지 병렬 빌드, 캐시 내보내기를 지원한다
cache-from/to: type=gha: GitHub Actions 캐시를 Docker 레이어 캐시로 사용. 두 번째 빌드부터 의존성 설치 레이어를 건너뛴다
Trivy 스캔: 빌드 후 자동으로 CVE 검사. CRITICAL/HIGH 발견 시 워크플로우를 실패시켜 취약한 이미지가 배포되는 것을 방지한다
💡 팁:cache-to: type=gha,mode=max의 mode=max는 최종 스테이지뿐 아니라 중간 스테이지의 레이어도 캐시한다. 멀티스테이지 빌드에서 이 설정이 없으면 builder 스테이지가 매번 처음부터 실행된다.
자주 하는 실수와 해결법
멀티스테이지 빌드를 처음 적용할 때 자주 만나는 문제와 해결 방법을 정리한다.
⚠️ 실수 1 — npm install vs npm ci:npm install은 package-lock.json을 업데이트할 수 있다. Docker 빌드에서는 반드시 npm ci를 사용해 lock 파일 기준으로 정확한 버전을 설치해야 한다. 재현 가능한 빌드의 기본이다.
⚠️ 실수 2 — COPY . .의 타이밍:.dockerignore에 node_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