Back

Docker 빌드가 왜 이렇게 느린 걸까? 멀티스테이지 Dockerfile 최적화 완전 정복

Docker 빌드 기다려본 적 있으시죠? 코드 딱 한 줄 고치고 push 했는데, CI가 15분 동안 끙끙대는 거 보면서 멍하니 있어본 경험 말이에요. 오늘만 벌써 다섯 번째라면... 네, 저도 그랬습니다.

사실 느린 Docker 빌드는 개발자들이 흔히 겪으면서도 "원래 그런 거 아냐?"하고 넘어가는 문제 중 하나예요. 근데 이게 쌓이면 생산성이 꽤 심하게 깎이거든요. 간단한 버그 수정이 왜 한 시간짜리 작업이 되는 건지 생각해보신 적 있나요?

여기서 희소식 하나: 대부분의 느린 Docker 빌드는 고칠 수 있습니다. 그것도 꽤 많이요. 제대로 된 방법을 쓰면 빌드 시간을 80-95%까지 줄일 수 있어요. 15분짜리가 1분 안에 끝난다고 생각해보세요.

이 글에서는 Docker 빌드가 왜 느려지는지 근본 원인부터 파헤치고, 실제로 빌드 시간을 확 줄이는 방법들을 하나씩 살펴보겠습니다.

느린 빌드, 생각보다 비싸다

본격적으로 들어가기 전에, 빌드 시간이 왜 중요한지 한번 따져볼게요.

숫자로 보면 이렇습니다

10명짜리 개발팀이 있다고 해볼게요:

  • 하루에 1인당 평균 5번 커밋
  • 커밋마다 15분짜리 빌드 실행
  • 하루 총 750분, 대략 12시간 반을 빌드 기다리는 데 씀
  • 1년이면 3,125시간 — 이거 풀타임 개발자 1.5명 연봉이에요

이걸 2분으로 줄이면? 3,125시간이 417시간으로 뚝 떨어져요. 채용 없이 인력 효율을 확 올리는 셈이죠.

집중력 문제도 있어요

빌드 기다리는 동안 뭐하세요? 슬랙 확인? 유튜브? 어차피 금방 끝나니까 새 작업 시작하긴 애매하고, 그렇다고 15분을 그냥 버리긴 아깝고...

연구에 따르면 작업 중단 후 다시 집중하는 데 평균 23분이 걸린다고 해요. 15분 빌드가 끝나고 돌아오면 이미 리듬이 끊긴 상태인 거죠. 이런 게 하루에 몇 번씩 반복되면, 실제로 코딩하는 시간이 얼마 안 됩니다.

Docker 캐시, 이것만 알면 됩니다

Docker 빌드 최적화의 핵심은 캐시예요. 이거 이해 못 하면 아무리 Dockerfile 만져봐야 소용없습니다.

Docker 빌드가 돌아가는 원리

docker build 하면 Docker는 Dockerfile을 위에서 아래로 한 줄씩 실행해요. 근데 매번 처음부터 다 실행하는 게 아니라:

  1. 각 명령어(FROM, RUN, COPY 등)를 순서대로 봄
  2. "어? 이거 전에 했던 거네?" → 캐시된 레이어 재활용
  3. "이건 뭔가 달라졌네?" → 그 명령어부터 전부 다시 실행

중요한 건 세 번째예요. 한 곳에서 캐시가 깨지면 그 아래는 전부 새로 빌드해야 함. Dockerfile 3번째 줄에서 캐시가 무효화되면, 4, 5, 6번째 줄도 다 다시 돌아가요. 내용이 안 바뀌었어도요.

캐시는 언제 깨지나요?

명령어캐시가 깨지는 조건
RUN명령어 문자열 자체가 바뀌었을 때
COPY복사할 파일 내용이나 권한이 바뀌었을 때
ADDCOPY랑 같음 + URL 내용이 바뀌었을 때
ARG인자 값이 바뀌었을 때
ENV환경변수 값이 바뀌었을 때

제일 문제되는 게 COPY예요. Dockerfile 첫 부분에 COPY . . 박아두면, README 한 글자만 고쳐도 그 뒤에 있는 npm install이 처음부터 다시 돌아갑니다.

Dockerfile 작성할 때 흔히 저지르는 실수 7가지

빌드가 느린 Dockerfile에는 패턴이 있어요. 이것들만 피해도 확 빨라집니다.

실수 1: 소스 코드부터 통째로 복사

# ❌ 이렇게 하면 안 됩니다 FROM node:20 WORKDIR /app COPY . . # 뭐든 바뀌면 아래 전부 다시 실행 RUN npm install # 매번 node_modules 처음부터 RUN npm run build

README.md 오타 하나 고쳤을 뿐인데 npm install이 5분 동안 돌아가요. 의존성이 수백 개인 프로젝트에서 이러면 진짜 답 없어요.

# ✅ 이렇게 바꾸세요 FROM node:20 WORKDIR /app COPY package*.json ./ # package.json만 먼저 RUN npm install # 의존성 안 바뀌면 캐시됨 COPY . . # 그 다음 소스 복사 RUN npm run build

package.json이 그대로면 npm install은 캐시에서 가져오고, 소스만 새로 복사해서 빌드합니다. 이것만 해도 체감 엄청 나요.

실수 2: devDependencies를 프로덕션까지 끌고 가기

# ❌ 프로덕션 이미지에 TypeScript, Jest 다 들어감 FROM node:20 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build CMD ["node", "dist/server.js"]

빌드할 때만 쓰는 패키지들이 최종 이미지에 다 들어가 있어요. 용량도 커지고 보안상으로도 안 좋습니다.

# ✅ 멀티스테이지로 분리 FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY --from=builder /app/dist ./dist CMD ["node", "dist/server.js"]

빌드는 builder 스테이지에서 하고, 프로덕션 이미지에는 실제 돌아갈 것만 넣어요. 이미지 크기도 줄고 빌드도 빨라져요.

실수 3: .dockerignore 안 쓰기

.dockerignore 없으면 docker build 할 때 node_modules, .git 폴더까지 전부 Docker 데몬으로 올라가요. 수백 MB를 매번 전송하는 거죠.

# .dockerignore - 이거 꼭 만드세요
node_modules
.git
*.md
.env*
.vscode
coverage
dist
*.log
.DS_Store

특히 node_modules는 어차피 이미지 안에서 새로 설치할 거잖아요. 보낼 필요가 없습니다.

실수 4: 무거운 베이스 이미지 사용

# ❌ 풀 이미지는 무겁습니다 FROM node:20 # 1.1GB FROM python:3.11 # 1.0GB
# ✅ slim이나 alpine 쓰세요 FROM node:20-slim # 240MB FROM node:20-alpine # 140MB FROM python:3.11-slim # 150MB FROM python:3.11-alpine # 52MB

단, alpine은 musl libc를 써서 일부 네이티브 모듈이 안 돌 수 있어요. 프로덕션 쓰기 전에 꼭 테스트하세요.

실수 5: apt-get을 따로따로 실행

# ❌ 레이어가 불필요하게 많아짐 RUN apt-get update RUN apt-get install -y curl RUN apt-get install -y git RUN apt-get clean
# ✅ 한 번에 묶어서 실행 RUN apt-get update && \ apt-get install -y --no-install-recommends curl git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*

레이어 수도 줄고, clean까지 같은 레이어에서 해야 실제로 용량이 줄어요.

실수 6: BuildKit 안 쓰기

Docker 23.0부터 BuildKit이 기본인데, 구버전 쓰거나 설정이 꺼져있는 환경도 많아요.

# 환경변수로 활성화 export DOCKER_BUILDKIT=1 docker build . # 또는 daemon.json에서 { "features": { "buildkit": true } }

BuildKit 쓰면:

  • 독립적인 스테이지가 병렬로 빌드됨
  • 캐시가 더 똑똑해짐
  • 빌드 시크릿을 안전하게 전달 가능
  • 캐시 마운트 기능 사용 가능 (이게 진짜 핵심)

실수 7: 버전 명시 안 하기

# ❌ 매번 최신 버전 받음 → 캐시 안 됨 RUN curl -L https://example.com/latest/binary -o /usr/bin/binary
# ✅ 버전 고정하면 캐시됨 ARG BINARY_VERSION=1.2.3 RUN curl -L https://example.com/v${BINARY_VERSION}/binary -o /usr/bin/binary

명시적인 버전이 있으면 Docker가 이전에 받은 걸 재활용할 수 있어요.

BuildKit 캐시 마운트 — 진짜 빨라지는 건 여기서부터

여기까지가 기본이고, 진짜 빌드 시간을 확 줄이려면 캐시 마운트를 써야 해요.

패키지 매니저 캐시 유지하기

# Node.js FROM node:20 WORKDIR /app COPY package*.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci # Go FROM golang:1.21 WORKDIR /app COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download # Python FROM python:3.11 WORKDIR /app COPY requirements.txt ./ RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt # Rust FROM rust:1.73 WORKDIR /app COPY Cargo.toml Cargo.lock ./ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ cargo build --release

--mount=type=cache는 해당 디렉토리를 빌드 간에 유지해요. 첫 빌드에서 다운받은 패키지를 다음 빌드에서 재활용하는 거죠. npm, pip, Go 모듈 다운로드 시간이 거의 0에 가까워져요.

컴파일 캐시도 가능

일부 언어는 컴파일러 캐시도 활용할 수 있어요:

# C/C++ with ccache FROM gcc:12 RUN apt-get update && apt-get install -y ccache ENV PATH="/usr/lib/ccache:${PATH}" WORKDIR /app COPY . . RUN --mount=type=cache,target=/root/.ccache \ make -j$(nproc)

빌드 간 캐시 공유

기본적으로 캐시 마운트는 빌드 단위로 격리되는데, 공유할 수도 있어요:

RUN --mount=type=cache,target=/root/.npm,id=npm-cache,sharing=shared \ npm ci

sharing=shared를 쓰면 동시에 돌아가는 빌드들이 같은 캐시를 읽고 쓸 수 있습니다.

빌드 결과물 캐시도 가능

# Next.js 빌드 캐시 RUN --mount=type=cache,target=/app/.next/cache \ npm run build

Next.js는 .next/cache에 이전 빌드 정보를 저장해요. 이걸 유지하면 변경된 부분만 다시 빌드해서 훨씬 빨라집니다.

멀티스테이지 빌드 제대로 쓰기

멀티스테이지의 진짜 목적은 단순히 이미지 크기를 줄이는 것만이 아니에요. 병렬 빌드캐시 분리가 핵심입니다.

병렬 빌드 패턴

# 스테이지 1: 의존성 설치 FROM node:20 AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # 스테이지 2: 빌드 (deps 완료 후) FROM deps AS builder COPY . . RUN npm run build # 스테이지 3: 테스트 (deps 완료 후, builder와 병렬) FROM deps AS tester COPY . . RUN npm test # 스테이지 4: 린트 (deps 완료 후, builder/tester와 병렬) FROM deps AS linter COPY . . RUN npm run lint # 최종: 프로덕션 이미지 FROM node:20-slim AS production WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=deps /app/node_modules ./node_modules CMD ["node", "dist/server.js"]

BuildKit은 builder, tester, linter를 자동으로 병렬 실행해요. 세 개가 순차로 돌면 6분인데 병렬이면 2분에 끝날 수 있어요.

조건부 빌드

ARG BUILD_ENV=production FROM node:20 AS base WORKDIR /app COPY package*.json ./ RUN npm ci FROM base AS development COPY . . CMD ["npm", "run", "dev"] FROM base AS production-builder COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY --from=production-builder /app/dist ./dist COPY --from=production-builder /app/node_modules ./node_modules CMD ["node", "dist/server.js"]

이렇게 빌드하세요:

docker build --target development -t myapp:dev . docker build --target production -t myapp:prod .

CI/CD에서 캐시 살리기

로컬에서는 캐시가 남아있지만, CI 환경은 매번 새 컨테이너에서 돌잖아요. 그래서 별도 설정이 필요합니다.

GitHub Actions

name: Build on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: myapp:latest cache-from: type=gha cache-to: type=gha,mode=max

type=gha는 GitHub Actions 내장 캐시를 사용해요. 빌드 레이어가 캐시에 저장되어서 다음 빌드 때 재활용됩니다.

GitLab CI

build: stage: build image: docker:24 services: - docker:dind variables: DOCKER_BUILDKIT: 1 script: - docker build --cache-from $CI_REGISTRY_IMAGE:cache --build-arg BUILDKIT_INLINE_CACHE=1 -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -t $CI_REGISTRY_IMAGE:cache . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker push $CI_REGISTRY_IMAGE:cache

--cache-from으로 레지스트리에서 캐시를 가져오고, BUILDKIT_INLINE_CACHE=1로 푸시하는 이미지에 캐시 메타데이터를 포함시켜요.

빌드 캐시 서비스 활용

팀 규모가 크면 전용 캐시 서비스를 고려해볼 수도 있어요:

# BuildKit의 원격 캐시 백엔드 사용 docker buildx build \ --cache-from type=registry,ref=myregistry/myapp:buildcache \ --cache-to type=registry,ref=myregistry/myapp:buildcache,mode=max \ -t myapp:latest .

언어별 최적화 전략

Node.js / JavaScript

FROM node:20-alpine AS deps WORKDIR /app # npm ci로 결정론적 설치 COPY package*.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci --prefer-offline FROM deps AS builder COPY . . # Next.js 빌드 캐시 RUN --mount=type=cache,target=/app/.next/cache \ npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # 필요한 파일만 복사 COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static CMD ["node", "server.js"]

Next.js는 .next/cache에 빌드 아티팩트가 저장되어서, 이걸 유지하면 rebuild가 훨씬 빨라져요.

Python

FROM python:3.11-slim AS builder # 빌드 의존성 설치 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # 가상환경 생성 RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . RUN --mount=type=cache,target=/root/.cache/pip \ pip install --no-compile -r requirements.txt FROM python:3.11-slim AS runtime COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" WORKDIR /app COPY . . CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]

가상환경을 builder에서 만들고 runtime으로 복사하면, 빌드 도구 없이 깔끔한 이미지가 됩니다.

Go

Go는 정적 바이너리를 만드는 데 최적화되어 있어요:

FROM golang:1.21-alpine AS builder WORKDIR /app # 의존성 먼저 다운로드 (캐시용) COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server . # 최소 런타임 - 바이너리만 FROM scratch COPY --from=builder /app/server /server COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ ENTRYPOINT ["/server"]

최종 이미지가 20MB도 안 되는 경우가 많아요. 바이너리랑 TLS 인증서만 들어있거든요.

Rust

FROM rust:1.73 AS planner WORKDIR /app RUN cargo install cargo-chef COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM rust:1.73 AS cacher WORKDIR /app RUN cargo install cargo-chef COPY --from=planner /app/recipe.json recipe.json RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ cargo chef cook --release --recipe-path recipe.json FROM rust:1.73 AS builder WORKDIR /app COPY --from=cacher /app/target target COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY . . RUN cargo build --release FROM debian:bookworm-slim AS runtime COPY --from=builder /app/target/release/myapp /usr/local/bin/ CMD ["myapp"]

cargo-chef를 써서 의존성을 따로 컴파일하면, 캐시 효율이 엄청 좋아져요.

빌드 성능 측정하기

측정 안 하면 최적화도 못 해요. 빌드를 프로파일링하는 방법들입니다:

BuildKit 타이밍 확인

# BuildKit으로 빌드하면서 타이밍 출력 DOCKER_BUILDKIT=1 docker build --progress=plain . 2>&1 | tee build.log # 로그에서 타이밍 파싱 grep "DONE\|CACHED" build.log

docker history 명령어

docker history myapp:latest --format "{{.Size}}\t{{.CreatedBy}}"

각 레이어별 크기와 명령어가 나와서, 어디가 비대한지 바로 보여요.

빌드 시간 벤치마크 스크립트

#!/bin/bash # benchmark-build.sh iterations=5 echo "Docker 빌드 벤치마킹 중..." # 캐시 클리어 docker builder prune -f >/dev/null 2>&1 # 콜드 빌드 (캐시 없음) start=$(date +%s.%N) docker build -t myapp:bench . >/dev/null 2>&1 end=$(date +%s.%N) cold_time=$(echo "$end - $start" | bc) echo "콜드 빌드: ${cold_time}s" # 웜 빌드 total=0 for i in $(seq 1 $iterations); do start=$(date +%s.%N) docker build -t myapp:bench . >/dev/null 2>&1 end=$(date +%s.%N) duration=$(echo "$end - $start" | bc) total=$(echo "$total + $duration" | bc) echo "웜 빌드 $i: ${duration}s" done avg=$(echo "scale=2; $total / $iterations" | bc) echo "평균 웜 빌드: ${avg}s"

실제 적용 사례: 14분 → 47초

실제로 React 모노레포 프로젝트에서 적용한 과정이에요.

처음 상태

FROM node:18 WORKDIR /app COPY . . RUN npm install RUN npm run build EXPOSE 3000 CMD ["npx", "serve", "-s", "build"]
  • 빌드 시간: 14분 23초 (10회 평균)
  • 이미지 크기: 1.8GB

문제점:

  • npm install 전에 COPY . .
  • .dockerignore 없음
  • 풀 node 이미지 (1.1GB)
  • 멀티스테이지 없음 (devDependencies가 프로덕션에 포함)

1단계: .dockerignore + 복사 순서 수정

FROM node:18 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build ...
# .dockerignore
node_modules
.git
build
coverage
  • 빌드 시간: 6분 12초 (57% 감소)
  • 코드만 바꿨을 때: 2분 8초

2단계: 멀티스테이지 적용

FROM node:18-slim AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS builder COPY . . RUN npm run build FROM node:18-slim AS runner WORKDIR /app RUN npm install -g serve COPY --from=builder /app/build ./build EXPOSE 3000 CMD ["serve", "-s", "build"]
  • 빌드 시간: 4분 45초 (처음 대비 67% 감소)
  • 이미지 크기: 450MB

3단계: BuildKit 캐시 마운트

FROM node:18-slim AS deps WORKDIR /app COPY package*.json ./ RUN --mount=type=cache,target=/root/.npm npm ci FROM deps AS builder COPY . . RUN npm run build FROM nginx:alpine AS runner COPY --from=builder /app/build /usr/share/nginx/html EXPOSE 80
  • 빌드 시간: 1분 52초 (처음 대비 87% 감소)
  • 캐시 있을 때: 47초 (97% 감소!)
  • 이미지 크기: 42MB

최종 비교

항목수정 전수정 후개선폭
콜드 빌드14분 23초1분 52초87% ↓
캐시 빌드14분 23초47초97% ↓
이미지 크기1.8GB42MB98% ↓
월 CI 비용~$180~$1293% ↓

자주 겪는 문제들

"캐시가 왜 안 먹히죠?"

  1. CI 환경 문제: 매번 새 러너에서 돌아서 캐시가 없음 → 레지스트리 기반 캐시 사용
  2. BuildKit 꺼져있음: docker info | grep buildkit으로 확인
  3. ARG 값 변경: ARG가 바뀌면 그 이후 레이어 전부 캐시 무효화
  4. 시간 기반 명령어: RUN date 같은 건 매번 무효화됨
# ❌ 매번 캐시 깨짐 RUN echo "빌드 시간: $(date)" > /build-info.txt # ✅ 빌드 인자로 분리 ARG BUILD_TIME RUN echo "빌드 시간: $BUILD_TIME" > /build-info.txt

"멀티스테이지 썼는데 더 느려요"

BuildKit이 꺼져있으면 병렬 빌드 안 돼요. 확인해보세요:

# BuildKit 상태 확인 docker info | grep buildkit

"CI에서 캐시 마운트가 안 돼요"

영속 마운트는 빌더에 로컬이에요. 외부 캐시를 써야 합니다:

- name: Build run: | docker buildx build \ --cache-from type=gha \ --cache-to type=gha,mode=max \ -t myapp .

체크리스트

빌드 최적화 전에 이것들 확인하세요:

  • DOCKER_BUILDKIT=1 설정됨
  • .dockerignore 있음 (node_modules, .git 제외)
  • 의존성 파일 먼저 복사, 소스는 나중에
  • slim 또는 alpine 베이스 이미지 사용
  • 멀티스테이지로 빌드/런타임 분리
  • --mount=type=cache 사용
  • CI에서 캐시 설정 완료
  • RUN 명령어 최소화 및 결합
  • 외부 의존성 버전 고정
  • 빌드 시간 측정 완료

마무리

Docker 빌드 최적화는 한번 해두면 계속 효과를 보는 작업이에요. 이 글에서 다룬 내용들—복사 순서, 멀티스테이지, BuildKit 캐시 마운트—만 적용해도 빌드 시간이 확 줄어듭니다.

빠른 빌드는 단순히 시간 절약만이 아니에요:

  • 피드백 루프가 짧아져서 개발 속도 상승
  • CI 비용 절감
  • 더 자주 배포하는 문화로 전환 가능
  • 개발자 경험 향상 — 기다림 줄고, 배포 늘고

쉬운 것부터 시작하세요. .dockerignore 추가하고, 복사 순서 바꾸고, BuildKit 켜고. 그것만으로도 체감될 거예요. 그 다음에 멀티스테이지와 캐시 마운트를 추가하세요. 각 변경마다 측정하는 거 잊지 마시고요.

Dockerfile에 숨어있던 속도가 얼마나 되는지 놀랄 수도 있어요.

빌드가 빨라야 진짜 개발이죠.

dockerdevopsoptimizationcicdcontainersbuildkitperformance

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요