[인턴] path-filter로 인한 빌드 스킵 버그와 Docker 캐시 중심 파이프라인으로 재설계

path-filter로 인한 빌드 스킵 버그와 Docker 캐시 중심 파이프라인으로 재설계

들어가며

이전 글에서 dorny/paths-filter를 활용해 변경된 서비스만 선택적으로 빌드하는 파이프라인을 구성했다.

그 다음 글에서는 build-common job에 ECR 레지스트리 기반 Docker 레이어 캐시를 적용해 빌드 시간을 11분에서 3~4분으로 줄였다.

두 가지를 조합하면 꽤 괜찮은 파이프라인이 완성된 것처럼 보였다.

그런데 실제로 운영하다 보니, 생각보다 심각한 문제가 있다는 걸 발견했다.

path-filter가 false를 반환해서 build-common이 스킵됐는데, 실제로는 반드시 재빌드했어야 하는 경우가 있었다.

이번 글은 그 문제의 원인을 분석하고, 파이프라인을 근본적으로 재설계한 과정을 정리한 기록이다.


1부: 무엇이 문제였나

path-filter의 동작 방식

dorny/paths-filter는 현재 push의 git diff를 분석해서, 지정한 경로에 변경이 있었는지 true/false로 반환한다.

이전 파이프라인에서 build_common 필터는 이렇게 설정되어 있었다.

filters: |
  build_common:
    - 'Dockerfile'
    - 'package.json'
    - 'yarn.lock'
    - 'packages/**'
    - 'apps/*/package.json'

build-common job은 이 필터가 true일 때만 실제 빌드를 수행하고, false이면 ECR에 있는 기존 이미지를 그대로 사용했다.

build-common:
  steps:
    - name: Check if rebuild is needed
      id: check
      run: |
        if [ "$CHANGED" == "true" ] || [ "$IMAGE_EXISTS" == "false" ]; then
          echo "needed=true" >> $GITHUB_OUTPUT
        else
          echo "needed=false" >> $GITHUB_OUTPUT
        fi

    - name: Build & push build-common
      if: steps.check.outputs.needed == 'true'
      run: docker build ...

직관적으로는 합리적인 설계처럼 보인다.

실제로 발생한 버그

문제는 필터가 커버하지 못하는 변경이 존재한다는 것이다.

대표적인 케이스 두 가지:

케이스 1: 워크플로우 파일 자체의 변경

.github/workflows/build.yml을 수정해서 빌드 인자나 환경변수를 바꿨다고 가정하자.

- name: Build & push build-common
  run: |
    docker buildx build \
      --build-arg NEW_ENV=value \  # ← 새로 추가
      ...

이 경우 Dockerfile, package.json, yarn.lock, packages/** 중 어느 것도 변경되지 않았다. path-filter는 build_common: false를 반환한다.

결과: build-common이 스킵되고, 새 빌드 인자가 반영되지 않은 구버전 이미지가 ECR에 남아있다.

케이스 2: 필터 경로에 누락된 의존성

프로젝트 구조가 커지면서 새로운 공통 설정 파일이 생겼다고 가정하자.

config/
  tsconfig.base.json   # ← 새로 추가된 공통 설정
  ...

이 파일은 build_common 필터의 어느 경로에도 포함되지 않는다. config/tsconfig.base.json을 수정해도 path-filter는 여전히 false를 반환한다.

Dockerfile에서 이 파일을 COPY해서 사용하고 있다면?

COPY config/tsconfig.base.json ./config/   # 변경됐지만 캐시는 미스

결과: 실제로는 재빌드가 필요한데 build-common이 스킵된다.

왜 이게 치명적인가

단순히 “최신 상태가 아닌 이미지를 사용한다”는 것이 왜 치명적인지 설명할 필요가 있다.

build-common:latest는 앱 빌드들의 베이스 이미지다.

build-common:latest  →  build-backend  (FROM build-common:latest)
                    →  build-frontend (FROM build-common:latest)
                    →  build-app-proxy, ...

build-common이 구버전 상태로 ECR에 남아있으면, 이후 앱 빌드들이 모두 구버전 베이스 이미지 위에 새 코드를 얹는다.

새 환경변수가 반영되지 않은 채로, 또는 수정된 공통 설정이 없는 채로 이미지가 만들어진다.

[기대하는 상태]
build-common v2 (새 설정 반영) → build-backend v5 (정상)

[실제 발생하는 상태]
build-common v1 (구버전) → build-backend v5 (버그 포함)

개발자는 코드를 push했고, 파이프라인도 성공으로 표시됐다. 하지만 실제로 배포된 이미지는 의도한 상태가 아니다.


2부: 근본 원인 분석

path-filter의 책임 범위

돌이켜보면, path-filter에 잘못된 책임을 맡겼던 것이 문제였다.

path-filter가 실제로 하는 일: git diff 기반으로 파일 변경 여부를 감지한다.

path-filter에 맡겼던 일: Docker 이미지를 재빌드해야 하는지 판단한다.

이 두 가지는 다르다. Docker 레이어 캐시 무효화는 파일 변경만으로 결정되지 않는다. 빌드 인자, 환경변수, Dockerfile의 변경, 베이스 이미지의 변경 등 다양한 요소가 영향을 준다.

그리고 가장 중요한 사실: Docker 자체가 이미 캐시 무효화를 처리하는 메커니즘을 내장하고 있다.

COPY package.json yarn.lock ./   # ← 이 파일이 바뀌면 아래 레이어는 자동으로 캐시 미스
RUN yarn install                 # ← Docker가 자동으로 재실행
COPY . .                         # ← Docker가 자동으로 재실행

path-filter로 “빌드할지 말지”를 결정하는 건 Docker의 캐시 메커니즘과 충돌하는 구조다.

Docker에게 “변경이 없으니 빌드하지 마”라고 하는 건 결국 Docker보다 덜 정확한 판단을 Docker 앞에 추가하는 셈이다.


3부: 재설계 — Docker 캐시를 단일 진실 공급원으로

핵심 아이디어

해결 방향은 단순하다.

항상 빌드하되, Docker 레이어 캐시가 빠르게 처리하도록 한다.

path-filter가 빌드 여부를 결정하는 게 아니라, Docker 자체의 캐시 메커니즘이 “무엇을 재실행할지”를 결정한다.

변경이 없으면? Docker 캐시 히트 → 빠르게 완료.
변경이 있으면? 해당 레이어부터 Docker가 재실행 → 정확하게 반영.

이 방식에서는 Docker Dockerfile이 캐시 무효화의 단일 진실 공급원 (Single Source of Truth) 이 된다.

build-common: 항상 실행

이전 파이프라인에서 build-commondetect-changes job의 결과에 따라 조건부로 빌드했다.

새 파이프라인에서는 detect-changes job 자체가 없다.

# 이전
build-common:
  needs: detect-changes
  steps:
    - name: Check if rebuild is needed
      # ... 복잡한 로직
    - name: Build & push
      if: steps.check.outputs.needed == 'true'
      run: docker buildx build ...

# 이후
build-common:
  runs-on: ubuntu-latest
  steps:
    - name: Build & push build-common
      run: |
        docker buildx build \
          --cache-from type=registry,ref=$ECR_REGISTRY/app-build-common:cache \
          --cache-to   type=registry,ref=$ECR_REGISTRY/app-build-common:cache,mode=max \
          -t $ECR_REGISTRY/app-build-common:latest \
          -t $ECR_REGISTRY/app-build-common:$IMAGE_TAG \
          --push \
          .

항상 빌드하지만, ECR에 저장된 캐시 덕분에 실질적인 변경이 없을 때는 빠르게 완료된다.

# build-common job 주석
# paths-filter 없이 항상 빌드 — Dockerfile이 캐시 무효화의 단일 진실 공급원
# yarn install 레이어: package.json/yarn.lock 변경 시에만 캐시 miss (가장 무거운 레이어)
# COPY ./ ./ 레이어: 소스 변경 시 캐시 miss (단순 복사라 빠름)

앱 빌드: 모든 이미지에 캐시 적용

이전 파이프라인에서는 build-common에만 ECR 캐시를 적용했다. (17번 글에서 작업한 내용)

앱별 빌드(backend, frontend 등)는 docker build만 사용했고 캐시가 없었다.

새 파이프라인에서는 모든 이미지에 ECR 레지스트리 캐시를 적용했다.

# 이전: 앱 빌드에 캐시 없음
- name: Build & push backend
  run: |
    docker build \
      -f apps/backend/Dockerfile \
      -t $ECR_REGISTRY/app-backend:latest \
      --push \
      .

# 이후: 앱 빌드에도 캐시 적용
- name: Build & push backend
  run: |
    docker buildx build \
      --build-context build-common=docker-image://$ECR_REGISTRY/app-build-common:latest \
      --cache-from type=registry,ref=$ECR_REGISTRY/app-backend:cache \
      --cache-to   type=registry,ref=$ECR_REGISTRY/app-backend:cache,mode=max \
      -f apps/backend/Dockerfile \
      -t $ECR_REGISTRY/app-backend:latest \
      -t $ECR_REGISTRY/app-backend:$IMAGE_TAG \
      --push \
      .

여기서 주목할 옵션이 하나 있다.

--build-context build-common=docker-image://...

이전에는 앱 빌드 전에 docker pull로 build-common 이미지를 받아서 로컬에 태그했다.

# 이전 방식
docker pull $ECR_REGISTRY/app-build-common:latest
docker tag $ECR_REGISTRY/app-build-common:latest build-common:latest

새 방식은 --build-context로 직접 참조한다. pull 없이 BuildKit이 필요한 레이어만 가져온다.

앱 빌드도 항상 실행

이전에는 각 앱 빌드 job에 if: 조건이 붙어 있었다.

# 이전
build-backend:
  needs: [detect-changes, build-common]
  if: |
    needs.build-common.result == 'success' &&
    (needs.detect-changes.outputs.backend == 'true' ||
     needs.detect-changes.outputs.build_common == 'true')

새 파이프라인에서는 if:가 없다.

# 이후
build-backend:
  needs: build-common
  runs-on: ubuntu-latest

build-common이 완료되면 모든 앱 빌드가 항상 실행된다. 하지만 각 앱의 Dockerfile 내용이 바뀌지 않았다면 캐시 히트로 빠르게 끝난다.


4부: 실행 흐름 비교

이전 파이프라인

push 발생
  │
  ├─ detect-changes
  │    backend: true / false
  │    build_common: true / false
  │
  ├─ build-common
  │    build_common=false + ECR에 이미지 있음 → 빌드 스킵
  │    → 구버전 이미지가 그대로 남아있을 수 있음 ⚠️
  │
  ├─ build-backend   ← backend=true일 때만 실행
  ├─ build-frontend  ← frontend=true일 때만 실행
  └─ ...

새 파이프라인

push 발생
  │
  ├─ build-common (항상 실행)
  │    ECR 캐시 참조
  │    변경 없음 → Docker 캐시 히트 → 빠르게 완료 ✅
  │    변경 있음 → 해당 레이어 재실행 → 정확하게 반영 ✅
  │
  ├─ build-backend   (항상 실행, 병렬)
  │    ECR 캐시 참조
  │    변경 없음 → 캐시 히트
  ├─ build-frontend  (항상 실행, 병렬)
  ├─ build-app-proxy (항상 실행, 병렬)
  └─ ...

“변경됐는데 스킵되는” 케이스가 구조적으로 불가능해진다.


5부: 부수적 개선 — YAML 앵커로 중복 제거

각 job마다 반복되는 공통 step들이 있었다.

# build-backend, build-frontend, build-app-proxy ... 모두 동일
- name: Configure AWS credentials (OIDC)
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::xxxxxxxxxxxx:role/github-actions-ecr
    aws-region: $

YAML 앵커(&)와 별칭(*)을 활용해서 중복을 제거했다.

jobs:
  build-common:
    steps:
      - &step-aws-auth          # ← 앵커 정의
        name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::xxxxxxxxxxxx:role/github-actions-ecr
          aws-region: $

      - &step-ecr-login         # ← 앵커 정의
        name: Login to Amazon ECR
        uses: aws-actions/amazon-ecr-login@v2

      - &step-setup-buildx      # ← 앵커 정의
        name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - &step-set-image-tag     # ← 앵커 정의
        name: Set image tag
        run: echo "IMAGE_TAG=$(date +%Y%m%d).$-${GITHUB_SHA:0:7}" >> $GITHUB_ENV

  build-backend:
    steps:
      - *step-aws-auth          # ← 별칭으로 재사용
      - *step-ecr-login
      - *step-setup-buildx
      - *step-set-image-tag
      - name: Build & push backend
        run: ...

앵커는 처음 정의되는 곳(build-common)에서 선언하고, 이후 job들에서 별칭으로 참조한다.

GitHub Actions가 YAML을 파싱할 때 별칭이 자동으로 원본으로 치환된다.

코드 중복은 줄었지만, 각 job이 실제로 어떤 step을 실행하는지는 달라지지 않는다.


6부: 이미지 태그 전략 변경

이전에는 이미지 태그를 $(date +%Y%m%d).$ 형태로 생성했다.

20260401.42

새 파이프라인에서는 Git SHA를 포함했다.

echo "IMAGE_TAG=$(date +%Y%m%d).$-${GITHUB_SHA:0:7}" >> $GITHUB_ENV
20260401.42-a3f9c1d

날짜와 실행 번호만으로는 “어느 커밋에서 만들어진 이미지인지”를 추적하려면 GitHub Actions 로그를 직접 봐야 했다.

SHA를 포함하면 이미지 태그만으로 커밋을 바로 특정할 수 있다.


결과

파이프라인이 재설계된 후 달라진 점을 정리하면:

항목 이전 이후
빌드 여부 결정 path-filter (git diff 기반) Docker 레이어 캐시 (Dockerfile 기반)
build-common 빌드 조건부 (필터 결과에 따라) 항상 실행
앱 빌드 변경된 서비스만 항상 실행 (병렬)
앱 이미지 캐시 없음 ECR 레지스트리 캐시
build-common 참조 docker pull + tag –build-context
이미지 태그 날짜.번호 날짜.번호-SHA
잘못된 빌드 스킵 발생 가능 구조적으로 불가능

마치며

“항상 빌드하면 느리지 않나?”라는 의문이 처음에 있었다.

그런데 실제로 실행해보니, 변경이 없는 이미지는 Docker 레이어 캐시로 1~2분 안에 끝난다.

이전에 path-filter로 빌드를 스킵했을 때와 실제 소요 시간 차이가 크지 않다. 스킵은 0초지만, 캐시 히트도 충분히 빠르다.

반면 얻는 것은 분명하다. “이 빌드에 내 변경이 반영됐나?”를 의심하지 않아도 된다.

path-filter 방식이 문제없이 동작하는 경우가 대부분이다. 하지만 잘못됐을 때는 조용히 잘못된다. 파이프라인은 성공으로 표시되고, 이미지도 ECR에 올라가지만, 실제 변경은 반영되지 않은 채로.

Docker 캐시를 단일 진실 공급원으로 삼는 방식은 더 단순하고, 더 정확하다.

복잡한 조건부 로직보다, 도구가 원래 잘하는 일을 맡기는 게 낫다는 걸 이번에 다시 확인했다.

Categories:

Updated:

Leave a comment