이 글은 실제 프로덕션 환경에서 Docker 빌드 시간을 197초에서 50초로 단축한 경험을 바탕으로 작성되었습니다.

목차

  1. Docker 빌드가 느린 이유
  2. BuildKit과 레이어 캐시
  3. 최적화 기법 1: 레이어 캐시 순서 최적화
  4. 최적화 기법 2: 캐시 마운트
  5. 최적화 기법 3: 원격 캐시 (CI/CD)
  6. 최적화 기법 4: 네이티브 빌드
  7. 실전 적용: pnpm 모노레포 Dockerfile
  8. docker-compose 최적화
  9. 결과 및 정리

Docker 빌드가 느린 이유

Docker 빌드가 느린 주요 원인들:

원인설명
캐시 미활용매번 처음부터 빌드
비효율적인 레이어 순서자주 변경되는 파일이 앞에 위치
에뮬레이션 빌드다른 아키텍처로 빌드 시 QEMU 사용
불필요한 파일 복사node_modules, .git 등 포함

이 문제들을 해결하기 위한 핵심 전략은 캐시 활용네이티브 빌드입니다.


BuildKit과 레이어 캐시

BuildKit이란?

BuildKit은 Docker의 차세대 빌드 엔진으로, 캐시를 더 효율적으로 사용할 수 있게 해줍니다.

# BuildKit 활성화 (Docker 18.09+)
DOCKER_BUILDKIT=1 docker build .
 
# 또는 Dockerfile 최상단에 명시
# syntax=docker/dockerfile:1

레이어 캐시의 원리

Docker는 Dockerfile의 각 명령어마다 레이어를 생성하고, 캐시 키를 통해 재사용 여부를 판단합니다.

FROM node:lts-slim          # 레이어 1
COPY package.json ./        # 레이어 2 (package.json 내용 기반 캐시 키)
RUN pnpm install            # 레이어 3 (레이어 2의 캐시 키 + 명령어)
COPY . .                    # 레이어 4 (모든 파일 내용 기반 캐시 키)
RUN pnpm build              # 레이어 5

핵심 포인트: 특정 레이어의 캐시가 무효화되면, 그 이후의 모든 레이어도 무효화됩니다.


최적화 기법 1: 레이어 캐시 순서 최적화

문제: 비효율적인 Dockerfile

# ❌ 나쁜 예: 소스 코드 변경 시 의존성도 재설치
FROM node:lts-slim
WORKDIR /app
 
COPY . .                              # 소스 코드 포함
RUN pnpm install --frozen-lockfile    # 매번 재설치
RUN pnpm build

소스 코드가 변경될 때마다 COPY . .의 캐시가 무효화되어, pnpm install도 매번 다시 실행됩니다.

해결: 의존성 파일 먼저 복사

# ✅ 좋은 예: 의존성과 소스 코드 분리
FROM node:lts-slim
WORKDIR /app
 
# 1단계: 의존성 파일만 먼저 복사 (변경 빈도 낮음)
COPY package.json pnpm-lock.yaml ./
 
# 2단계: 의존성 설치 (package.json 변경 시에만 재실행)
RUN pnpm install --frozen-lockfile
 
# 3단계: 소스 코드 복사 (변경 빈도 높음)
COPY . .
 
# 4단계: 빌드
RUN pnpm build

효과: 소스 코드만 변경 시 1~2단계는 캐시 히트 → 빌드 시간 50-70% 단축

pnpm 모노레포에서의 적용

모노레포에서는 각 워크스페이스의 package.json도 먼저 복사해야 합니다:

# 루트 의존성 파일
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
 
# 각 워크스페이스의 package.json만 복사
COPY packages/utils/package.json ./packages/utils/
COPY packages/ui/package.json ./packages/ui/
COPY apps/web/package.json ./apps/web/
COPY apps/api/package.json ./apps/api/
 
# 의존성 설치
RUN pnpm install --frozen-lockfile
 
# 소스 코드 복사
COPY packages/ ./packages/
COPY apps/ ./apps/
 
# 빌드
RUN pnpm build

최적화 기법 2: 캐시 마운트

캐시 마운트란?

RUN 명령 실행 시 생성되는 임시 데이터(패키지 캐시 등)를 빌더 내부에 저장해두고 재사용하는 기법입니다.

# 일반적인 RUN 명령
RUN pnpm install  # 매번 패키지 다운로드
 
# 캐시 마운트 사용
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    pnpm install  # 캐시된 패키지 재사용

작동 원리

┌─────────────────────────────────────────────────┐
│                  BuildKit Builder               │
│  ┌─────────────────────────────────────────┐    │
│  │         캐시 볼륨 디렉토리                  │    │
│  │   /root/.local/share/pnpm/store         │    │
│  └──────────────────┬──────────────────────┘    │
│                     │ 마운트                      │
│  ┌──────────────────▼──────────────────────┐    │
│  │         임시 빌드 컨테이너                   │    │
│  │   RUN pnpm install 실행                  │    │
│  └─────────────────────────────────────────┘    │
└─────────────────────────────────────────────────┘

pnpm 캐시 마운트 적용

# syntax=docker/dockerfile:1
FROM node:lts-slim
 
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
 
# pnpm store 캐시 마운트
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile
 
COPY . .
 
# turbo 캐시도 마운트 가능
RUN --mount=type=cache,target=/app/.turbo \
    pnpm build

권한 문제 해결

non-root 유저로 실행할 경우, 캐시 마운트에 uid/gid를 지정해야 합니다:

# node 유저의 uid는 보통 1000
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,uid=1000,gid=1000 \
    pnpm install --frozen-lockfile

캐시 마운트의 한계

장점단점
패키지 다운로드 시간 절감로컬 빌드에서만 효과
이미지 크기에 영향 없음CI 환경에서는 매번 새 VM → 캐시 없음
설정이 간단함Docker 18.09+ 필요

최적화 기법 3: 원격 캐시 (CI/CD)

왜 원격 캐시가 필요한가?

GitHub Actions 같은 CI 환경에서는 매번 새로운 VM이 생성되므로, 로컬 캐시를 재사용할 수 없습니다.

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Build 1   │    │   Build 2   │    │   Build 3   │
│  (새 VM)    │     │  (새 VM)    │    │   (새 VM)    │
│  캐시 없음    │    │  캐시 없음    │    │   캐시 없음    │
└─────────────┘    └─────────────┘    └─────────────┘

원격 캐시 적용

레이어 캐시를 외부 저장소(Docker Hub, ECR, GitHub Actions Cache)에 저장합니다:

docker buildx build \
  --cache-from type=registry,ref=myregistry/myapp:cache \
  --cache-to type=registry,ref=myregistry/myapp:cache,mode=max \
  -t myregistry/myapp:latest \
  .

GitHub Actions 캐시 사용

# .github/workflows/build.yml
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: myregistry/myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

캐시 타입 비교

타입저장 위치용량 제한속도
type=registryDocker Registry무제한중간
type=ghaGitHub Actions10GB빠름
type=s3AWS S3무제한중간

최적화 기법 4: 네이티브 빌드

에뮬레이션 빌드의 문제

GitHub Actions의 ubuntu-latest는 AMD64(x86_64) 아키텍처입니다. ARM64 서버(AWS Graviton 등)용 이미지를 빌드하려면 QEMU 에뮬레이션을 사용해야 합니다.

# ❌ 느린 방법: 에뮬레이션 빌드
runs-on: ubuntu-latest
steps:
  - name: Set up QEMU
    uses: docker/setup-qemu-action@v3
  - name: Build
    run: docker buildx build --platform linux/arm64 .

문제: 에뮬레이션으로 인해 빌드 시간이 3-10배 증가

네이티브 빌드 적용

# ✅ 빠른 방법: ARM 러너에서 네이티브 빌드
runs-on: ubuntu-24.04-arm  # ARM64 러너 사용
steps:
  - name: Build
    run: docker buildx build .  # 에뮬레이션 불필요

러너 선택 가이드

타겟 서버추천 러너비고
AMD64 (x86_64)ubuntu-latest기본값
ARM64 (Graviton)ubuntu-24.04-armGitHub 제공 ARM 러너
멀티 플랫폼Self-hosted 또는 각각 빌드비용/시간 트레이드오프

실전 적용: pnpm 모노레포 Dockerfile

최적화 전 (197초 소요)

FROM node:lts-slim
 
RUN npm install -g pnpm
WORKDIR /opt/app
 
# 모든 파일 한 번에 복사
COPY . .
 
RUN pnpm install --frozen-lockfile
RUN pnpm build

최적화 후 (50~77초 소요)

# syntax=docker/dockerfile:1
FROM node:lts-slim
 
RUN npm install -g pnpm
WORKDIR /opt/app
 
# 1단계: 의존성 파일만 먼저 복사 (COPY --chown으로 권한 설정)
COPY --chown=node:node pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
 
# 2단계: 각 워크스페이스의 package.json 복사
COPY --chown=node:node packages/utils/package.json ./packages/utils/
COPY --chown=node:node packages/ui/package.json ./packages/ui/
COPY --chown=node:node apps/web/package.json ./apps/web/
COPY --chown=node:node apps/api/package.json ./apps/api/
 
# 3단계: 의존성 설치 (캐시 마운트 + 권한 설정)
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,uid=1000,gid=1000 \
    pnpm install --frozen-lockfile
 
# 4단계: 소스 코드 복사
COPY --chown=node:node packages/ ./packages/
COPY --chown=node:node apps/ ./apps/
 
# non-root 유저로 전환
USER node
 
# 5단계: 빌드 (turbo 캐시 마운트)
RUN --mount=type=cache,target=/opt/app/.turbo,uid=1000,gid=1000 \
    pnpm build

최적화 포인트 정리

기법적용 위치효과
레이어 순서 최적화package.json 먼저 복사소스 변경 시 install 스킵
캐시 마운트pnpm store, turbo 캐시패키지 다운로드 생략
COPY —chown모든 COPY 명령chown -R 제거로 속도 향상
uid/gid 지정캐시 마운트 옵션권한 문제 방지

docker-compose 최적화

문제: 컨테이너 시작 시 중복 빌드

# ❌ 나쁜 예: 이미 빌드된 이미지인데 또 빌드
services:
  app:
    image: myapp:latest
    command: /bin/sh -c "pnpm install && pnpm build && pnpm start"

해결: 이미지 내 빌드 결과 사용

# ✅ 좋은 예: 빌드된 이미지 그대로 실행
services:
  app:
    image: myapp:latest
    command: pnpm start

Volume Bind 고려사항

개발 환경: Volume bind로 hot reload 활용

# 개발용 docker-compose.yml
services:
  app:
    image: myapp:latest
    command: pnpm dev
    volumes:
      - ./apps/web:/opt/app/apps/web  # 소스 코드 바인딩

프로덕션 환경: 이미지 내 빌드 결과 사용 (불변 인프라)

# 프로덕션용 docker-compose.yml
services:
  app:
    image: myapp:latest
    command: pnpm start
    volumes:
      # 로그, 업로드 파일 등만 바인딩
      - ./logs:/app/logs

빌드 순서 보장

services:
  base:
    build:
      context: .
      dockerfile: Dockerfile
    image: myapp:latest
 
  web:
    image: myapp:latest
    depends_on:
      base:
        condition: service_completed_successfully
    command: pnpm start

결과 및 정리

실제 측정 결과

항목최적화 전최적화 후개선율
첫 이미지 빌드197초77초61%
이미지 재사용 시198초50초75%
이미지 용량7.73GB5.45GB30%

최적화 체크리스트

  • # syntax=docker/dockerfile:1 추가 (BuildKit 활성화)
  • package.json 등 의존성 파일 먼저 COPY
  • 소스 코드는 마지막에 COPY
  • --mount=type=cache 로 pnpm store 캐시
  • COPY --chown으로 권한 설정 (chown -R 제거)
  • CI 환경에서 원격 캐시 사용
  • 타겟 서버와 동일한 아키텍처에서 빌드
  • docker-compose에서 중복 빌드 제거

추가 고려사항

  1. .dockerignore 활용: node_modules, .git, logs 등 제외
  2. 멀티스테이지 빌드: 빌드 도구를 최종 이미지에서 제외
  3. 베이스 이미지 선택: alpine vs slim vs 일반 이미지

참고 자료