[인턴] Docker build-common 제거로 CI 빌드 시간 14분 → 4분으로 단축하기

들어가며

CI 파이프라인에서 백엔드 이미지 빌드가 평균 14분 걸리고 있었다.

특히 작은 비즈니스 로직 수정 한 줄을 반영하는데도 전체 빌드가 처음부터 다시 돌아가는 상황이 반복됐다. 캐시가 거의 동작하지 않는다는 의미였다. 원인을 파악하고 나서 구조를 뜯어고쳤고, 결과적으로 빌드 시간을 약 4분 이내로 단축했다. 이번 글은 그 과정을 기록한다.


기존 구조: build-common 공유 이미지

기존 파이프라인은 build-common이라는 공유 Docker 이미지를 중심으로 구성되어 있었다.

build-common (공통 빌드 베이스)
  ├── 전체 소스코드 복사 (COPY ./ ./)
  ├── 의존성 설치 (yarn install)
  └── 전체 빌드 (tsc)

          ↓ FROM build-common AS base

app-backend       app-frontend      app-...
  (run stage)       (run stage)       (run stage)

아이디어 자체는 합리적이다. 모노레포에서 의존성 설치와 TypeScript 빌드는 모든 앱에 공통이다. 한 번 만들어두고 재사용하면 효율적이어야 한다.

문제는 이 구조가 Docker 레이어 캐시와 궁합이 매우 나쁘다는 점이었다.


문제: COPY ./ ./가 캐시를 무효화하는 구조

Docker는 레이어 단위로 캐시를 관리한다. 특정 레이어의 입력이 바뀌면 그 이후 레이어는 전부 캐시 미스가 된다.

build-common의 핵심 레이어는 아래와 같다.

# build-common/Dockerfile
FROM node:20-slim AS builder

WORKDIR /app

COPY ./ ./          # ← 문제의 레이어
RUN yarn install
RUN yarn build

COPY ./ ./모노레포 루트 전체를 복사한다.

모노레포 특성상 루트 아래에는 수많은 앱과 공통 패키지가 있다. 어느 앱의 파일 하나라도 변경되면COPY 레이어의 체크섬이 달라지고, 이후 yarn install, yarn build까지 전부 캐시 미스가 된다.

[캐시 무효화 흐름]

apps/frontend/src/some-component.tsx 수정
        ↓
COPY ./ ./ 레이어 → 체크섬 변경 → 캐시 MISS
        ↓
yarn install → 캐시 MISS (수백 개 패키지 재설치)
        ↓
yarn build (tsc) → 캐시 MISS (전체 재컴파일)
        ↓
app-backend / app-frontend / ... 전부 재빌드

백엔드만 배포하는 상황인데, 프론트엔드 파일이 바뀌어도 백엔드가 처음부터 다시 빌드됐다. 반대도 마찬가지였다. 공통 이미지 하나가 모든 앱의 캐시를 동시에 날리는 구조였다.

캐시가 동작하는 상황

아이러니하게도 build-common 캐시가 유효한 경우는 아무것도 바뀌지 않았을 때뿐이다.

변경 사항 COPY ./ ./ 캐시 결과
변경 없음 HIT 빠름 ✅
앱 A 소스 수정 MISS 전체 재빌드 ❌
앱 B 소스 수정 MISS 전체 재빌드 ❌
패키지 추가 MISS 전체 재빌드 ❌

실무에서 “아무것도 안 바뀐 채로” 배포하는 경우는 없다. build-common은 사실상 항상 캐시 미스였다.


해결: 자립형 Dockerfile로 전환

접근 방식을 바꿨다. build-common을 제거하고, 각 앱이 독립적으로 빌드되도록 전환했다.

[이전]
build-common → app-backend
             → app-frontend
             → ...

[이후]
app-backend (자립형 빌드)
app-frontend (자립형 빌드)

각 앱의 Dockerfile.no-common은 build-common에 의존하지 않고 자체적으로 의존성을 설치하고 빌드한다.

COPY 범위를 앱 단위로 좁히기

가장 중요한 변경은 COPY 범위를 앱과 관련된 파일만으로 제한한 것이다.

# Dockerfile.no-common (변경 후)
FROM node:20-slim AS builder

WORKDIR /app

# 패키지 파일만 먼저 복사 (캐시 최적화)
COPY package.json yarn.lock ./
COPY apps/backend/package.json apps/backend/
COPY packages/*/package.json packages/  # 의존하는 공통 패키지만

# 의존성 설치 (package.json이 바뀌지 않으면 캐시 HIT)
RUN yarn install --frozen-lockfile

# 소스코드 복사
COPY apps/backend/ apps/backend/
COPY packages/ packages/

# 빌드
RUN yarn workspace @app/backend build

package.json / yarn.lock 레이어를 소스코드와 분리했다. 의존성이 바뀌지 않는 한 yarn install 레이어는 캐시가 유지된다.

[변경 후 캐시 동작]

apps/frontend 파일 수정
        ↓
apps/backend Dockerfile: COPY 범위 밖 → 캐시 유지 ✅
apps/backend: yarn install → 캐시 HIT ✅
apps/backend: yarn build → 캐시 HIT ✅

ECR 외부 캐시로 yarn install 최적화

자립형 전환만으로도 캐시 미스가 줄었지만, yarn install은 여전히 병목이었다. GitHub Actions 러너는 매번 새 환경에서 시작해 로컬 캐시가 없다.

이를 해결하기 위해 ECR을 외부 캐시 저장소로 활용했다.

# GitHub Actions workflow
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    file: apps/backend/Dockerfile.no-common
    push: true
    tags: $/app-backend:$
    cache-from: type=registry,ref=$/app-backend:cache
    cache-to: type=registry,ref=$/app-backend:cache,mode=max

mode=max는 최종 이미지뿐 아니라 빌더 스테이지의 모든 레이어를 캐시에 저장한다. yarn install 레이어도 포함된다.

[ECR 캐시 동작]

1차 빌드: cache MISS → ECR에 레이어 저장 (cache-to)
2차 빌드 (소스만 변경): ECR에서 yarn install 레이어 HIT → 재설치 생략

빌더 스테이지와 실행 스테이지를 분리하는 멀티스테이지 빌드와 함께 사용하면 효과가 배가된다. 실행 이미지에는 node_modules와 빌드 산출물만 포함되므로 이미지 크기도 줄어든다.

# Run stage
FROM node:20-slim AS runner

WORKDIR /app

# 빌더 스테이지에서 필요한 것만 복사
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/backend/build ./build
# src/ 미포함 — 런타임에 불필요

결과

구분 이전 이후
빌드 방식 build-common 공유 이미지 앱별 자립형 Dockerfile
캐시 유효 조건 전체 소스 무변경 해당 앱/의존성 무변경
캐시 저장소 (없음 / 로컬만) ECR (mode=max)
빌드 시간 (캐시 미스) 약 14분 약 8분
빌드 시간 (캐시 HIT) 약 14분 (사실상 항상 미스) 약 3분 53초
앱 간 영향 한 앱 변경 → 전체 재빌드 변경된 앱만 재빌드

소스 수정 후 재빌드하는 일반적인 케이스(캐시 HIT 상황)에서 약 14분에서 약 3분 53초로 단축됐다.


정리

이번 작업의 핵심은 두 가지다.

1. COPY 범위를 최소화하라

Docker 캐시 무효화는 항상 COPY/ADD 레이어에서 시작된다. 모노레포에서 COPY ./ ./는 사실상 캐시 무효화 보장이다. package.json 먼저, 소스코드 나중 — 이 순서와 최소 범위 원칙을 지키면 캐시가 살아난다.

2. 공유 베이스 이미지는 의존성 고정에만 써라

“한 번 만들어두고 재사용”이라는 아이디어 자체는 틀리지 않지만, 가변적인 소스코드가 섞이는 순간 캐시 전략이 붕괴된다. 공유 이미지는 변경이 거의 없는 Node 버전, OS 설정, 글로벌 패키지 정도에만 쓰는 것이 맞다.

멀티스테이지 빌드 + 외부 캐시 레지스트리 조합은 CI 환경처럼 stateless한 곳에서 Docker 레이어 캐시를 유지하는 실용적인 방법이다. mode=max로 빌더 레이어까지 캐시에 올려두면, 소스 변경이 있어도 yarn install은 건너뛸 수 있다.

Categories:

Updated:

Leave a comment