# 모노레포에서 바뀐 이미지만 빌드하기 - 경로 필터가 만드는 함정
이미지 여러 개를 한 레포에서 관리하면 곧 같은 문제에 도달합니다. 파일 하나 고쳤을 뿐인데 이미지 열 개가 전부 다시 빌드됩니다. 해법은 명확해 보입니다. 워크플로에 경로 필터를 걸어 바뀐 것만 빌드하면 됩니다.
그런데 이 방식은 브랜치 보호 규칙과 정면으로 충돌합니다. 경로 필터로 건너뛴 워크플로의 체크는 성공이 아니라 대기 상태로 남고, PR은 영원히 병합되지 않습니다. 이 글에서는 그 구조와 우회 방법, 그리고 경로 필터 대신 쓸 수 있는 접근을 정리합니다.
# 1. 가장 단순한 형태와 그 한계
# .github/workflows/build-api.yml
on:
push:
paths:
- 'services/api/**'
- '.github/workflows/build-api.yml'
이미지가 서너 개면 이 방식으로 충분합니다. 워크플로 파일을 서비스마다 하나씩 두고 각자 자기 경로만 감시합니다. 워크플로 자신의 경로를 필터에 넣는 것이 요령입니다. 빌드 정의를 고쳤는데 빌드가 안 도는 상황을 막아 줍니다.
한계는 두 방향에서 옵니다. 첫째, 이미지가 열 개를 넘어가면 거의 같은 파일 열 개를 관리하게 됩니다. 둘째, 공통 코드 변경을 표현하기 어렵습니다. libs/common이 바뀌면 그것을 쓰는 이미지 전부를 빌드해야 하는데, 의존 관계를 각 워크플로의 paths 목록에 손으로 복제해야 합니다. 이 목록은 반드시 실제 의존성과 어긋납니다.
# 2. 필수 체크와 충돌하는 지점
여기가 진짜 함정입니다. 문서가 상황을 그대로 설명합니다.
If a workflow is skipped due to path filtering, branch filtering or a commit message, then checks associated with that workflow will remain in a "Pending" state. A pull request that requires those checks to be successful will be blocked from merging.
건너뛴 워크플로는 실패도 성공도 아닌 대기로 남습니다. 브랜치 보호에서 그 체크를 필수로 걸어 뒀다면 PR은 병합 불가 상태로 멈춥니다. README만 고친 PR이 API 빌드 체크를 기다리며 영원히 서 있는 상황이 이렇게 만들어집니다.
문서의 권고는 단호합니다.
You should not use path or branch filtering to skip workflow runs if the workflow is required.
우회 방법도 함께 제시됩니다. 같은 이름의 워크플로를 하나 더 만들어, 첫 워크플로가 돌지 않는 경우에 항상 성공을 반환하게 하는 방식입니다.
# .github/workflows/build-api.yml
name: build-api
on:
pull_request:
paths: ['services/api/**']
jobs:
build: # ← 필수 체크로 등록할 job 이름
runs-on: ubuntu-latest
steps: [ ... 실제 빌드 ... ]
# .github/workflows/build-api-skip.yml
name: build-api
on:
pull_request:
paths-ignore: ['services/api/**'] # 위와 정확히 반대
jobs:
build: # ← 같은 이름
runs-on: ubuntu-latest
steps:
- run: echo "변경 없음 - 건너뜀"
동작은 하지만 정직한 평가가 필요합니다. 워크플로 파일이 두 배가 되고, 두 파일의 경로 목록이 서로의 여집합이어야 한다는 제약이 생깁니다. 한쪽만 고치면 조용히 무너집니다. 이미지가 열 개면 파일이 스무 개입니다.
# 3. 대안 - 하나의 워크플로에서 판단하기
경로 필터를 워크플로 진입 조건이 아니라 잡 내부의 판단으로 옮기면 위 문제가 사라집니다. 워크플로는 항상 돌고, 무엇을 빌드할지는 안에서 정합니다.
name: build-images
on: [pull_request]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.detect.outputs.targets }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # 비교하려면 히스토리가 필요하다
- id: detect
run: |
BASE=${{ github.event.pull_request.base.sha }}
CHANGED=$(git diff --name-only "$BASE" HEAD)
# 공통 모듈이 바뀌면 전체, 아니면 해당 서비스만
if echo "$CHANGED" | grep -q '^libs/'; then
TARGETS='["api","worker","scheduler"]'
else
TARGETS=$(echo "$CHANGED" | awk -F/ '/^services\//{print $2}' \
| sort -u | jq -R . | jq -sc .)
fi
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
build:
needs: changes
if: needs.changes.outputs.targets != '[]'
strategy:
matrix:
target: ${{ fromJSON(needs.changes.outputs.targets) }}
runs-on: ubuntu-latest
steps:
- run: echo "빌드 대상 ${{ matrix.target }}"
ci-result: # 필수 체크로 등록할 잡
needs: [changes, build]
if: always()
runs-on: ubuntu-latest
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
이 구조의 이점은 분명합니다. 워크플로가 항상 실행되므로 필수 체크가 대기 상태로 남지 않습니다. 다만 changes만 필수 체크로 등록하면 build가 실패해도 머지가 막히지 않습니다. 그래서 needs의 결과를 always()로 모으는 ci-result 잡 하나를 필수 체크로 등록합니다. 조건으로 건너뛴 build는 성공으로 보고되므로 변경이 없는 PR도 통과합니다. 의존 관계 판단도 셸 스크립트 한 곳에 모입니다.
주의할 점이 둘 있습니다. fetch-depth: 0이 없으면 비교 기준 커밋이 로컬에 없어 git diff가 실패합니다. 그리고 대상이 비었을 때 매트릭스가 빈 배열이 되면 잡이 조용히 생략되는 것이 아니라 Matrix vector 'target' does not contain any values 오류로 실패하므로, if 조건이 반드시 필요합니다.
# 4. 빌드를 건너뛰는 것과 캐시로 빨라지는 것은 다르다
여기서 한 걸음 물러날 필요가 있습니다. "안 바뀐 이미지를 빌드하지 않는다"는 목표에는 두 가지 접근이 있습니다.
| 접근 | 방식 | 얻는 것 | 잃는 것 |
|---|---|---|---|
| 실행 자체를 건너뛴다 | 경로 필터·매트릭스 판단 | 러너 시간 절약 | 의존성 판단을 사람이 유지 |
| 실행하되 캐시로 빨리 끝낸다 | 레이어·레지스트리 캐시 | 판단 로직 불필요 | 러너 시작 비용은 발생 |
두 번째가 과소평가되는 경우가 많습니다. 변경이 없는 이미지의 빌드는 캐시가 잘 맞으면 수십 초에 끝납니다. 이미지가 서너 개라면 판단 로직을 유지하는 비용보다 그냥 다 도는 비용이 쌀 수 있습니다.
판단 기준은 단순합니다. 전체 빌드 시간이 개발 흐름을 방해하는 수준인지, 그리고 의존 관계가 스크립트로 정확히 표현될 만큼 단순한지입니다. 후자가 아니라면 선택적 빌드는 틀린 이미지를 배포하지 않는 안전성을 내주는 선택이 됩니다. 공통 모듈 변경을 놓쳐 낡은 이미지가 그대로 배포되는 사고는 이 방식에서 나옵니다.
# 5. 태그 전략이 함께 정해져야 한다
선택적 빌드를 도입하면 곧바로 따라오는 문제가 있습니다. 이번에 빌드하지 않은 이미지는 어떤 태그를 배포해야 하는가.
latest 하나로 운영하고 있었다면 문제가 없어 보이지만, 이 태그는 배포 시점마다 내용이 달라져서 롤백도 재현도 불가능합니다. 선택적 빌드와 함께 쓰면 "무엇이 배포됐는지 아무도 모르는" 상태가 됩니다.
커밋 SHA를 태그로 쓰는 방식이 단순합니다. 빌드된 이미지는 새 SHA 태그를 얻고, 빌드되지 않은 이미지는 이전 SHA 태그를 그대로 참조합니다. 배포 매니페스트에 이미지별 태그를 명시해 두면, 어떤 조합이 나갔는지가 커밋 하나로 남습니다.
# 배포 매니페스트에 이미지별로 명시
images:
api: registry.example.com/api:9f3c1a2
worker: registry.example.com/worker:4b81de0 # 이번에 안 바뀜
scheduler: registry.example.com/scheduler:4b81de0
# 6. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| PR이 대기 체크 때문에 병합 불가 | 경로 필터로 건너뛴 필수 워크플로 | 항상 실행되는 잡을 필수 체크로 지정 |
| 공통 모듈을 고쳤는데 일부만 빌드됨 | 의존 관계 목록이 실제와 어긋남 | 판단 로직을 한 곳으로 모으고 검증 |
git diff가 실패 | 얕은 클론 | fetch-depth: 0 |
| 매트릭스 잡이 생성되지 않음 | 대상 배열이 비어 있음 | if 조건 명시 |
| 배포된 이미지 조합을 알 수 없음 | 가변 태그 사용 | 커밋 SHA 태그 + 매니페스트 명시 |
| 워크플로 자신을 고쳤는데 안 돎 | paths에 워크플로 경로 누락 | 필터에 자기 경로 추가 |
# 7. 마무리
- 경로 필터는 간단하지만 필수 체크와 충돌합니다. 건너뛴 워크플로는 실패가 아니라 대기로 남아 PR을 막습니다.
- 같은 이름의 더미 워크플로로 우회할 수 있지만, 파일이 두 배가 되고 경로 목록이 서로의 여집합이어야 합니다.
- 워크플로는 항상 실행하고 빌드 대상을 잡 안에서 판단하면 두 문제가 함께 풀립니다.
- 선택적 빌드를 넣기 전에 캐시로 충분한지 먼저 확인하는 편이 낫습니다. 판단 로직은 틀릴 수 있고, 틀리면 낡은 이미지가 배포됩니다.
- 선택적 빌드를 쓴다면 불변 태그가 전제 조건입니다.