CI/CD 파이프라인 설계: 아티팩트 캐시, 테스트 병렬화, 블루그린·카나리·롤링 배포
이 글의 핵심
초록불 CI가 품질을 보증한다고 믿기 쉽지만, continue-on-error로 실패를 뭉개거나 락파일과 맞지 않는 옛 캐시를 물고 오면 검증은 비어 있는 채로 배포가 나갑니다. 테스트를 배포 경로에 묶는 DAG 설계, PR 캐시 포이즈닝을 막는 읽기·쓰기 분리, 플레이크 재시도 범위, 배포 전략과 DB 마이그레이션 순서, 장애 시 좁혀 가는 점검 순서를 다룹니다.
워크플로 YAML을 잘 짜 두고 main에 머지될 때마다 체크가 초록불로 켜진다고 해서 품질이 보장되는 것은 아닙니다. 테스트가 배포 경로에 묶여 있지 않은 CI는 배포 자동화일 뿐입니다. 초록 배지가 “품질 보증”이 아니라 “빌드 끝” 신호로만 쓰이는 팀에서는, 머지는 순조로운데 장애가 그다음에 터집니다.
흔한 사고 패턴은 이렇습니다. 린트와 npm run build만 돌리고 통합 테스트는 “로컬에서 했다”는 이유로 넘어가는 파이프라인에서, 캐시 키가 락파일을 반영하지 않아 옛 node_modules 캐시가 복원됩니다. CI에서는 설치 단계가 조용히 통과한 것처럼 보이고, 프로덕션에서는 런타임에만 드러나는 모듈 버전 불일치가 발생합니다. 원인을 추적해 보면 DAG상 deploy 잡이 build에만 needs로 걸려 있어서, test 잡이 실패해도, 혹은 아예 없어도 배포가 그대로 진행되는 구조였습니다. 실패는 파이프라인이 문제를 알려 주는 정상적인 신호입니다. 아무것도 터지지 않는다면 자동화가 잘돼서가 아니라 검증이 비어 있어서인 경우가 많습니다.
이 글은 그런 구멍을 막는 순서대로 정리합니다. 잡 그래프(DAG), 캐시와 아티팩트, 테스트 병렬화와 플레이크, 배포 전략과 DB 마이그레이션, 그리고 장애가 났을 때 좁혀 가는 순서입니다. 예제는 GitHub Actions 문법이지만 GitLab CI 등 다른 도구에도 같은 개념이 있습니다.
잡 그래프: 어디서 멈출지를 먼저 설계하기
CI 엔진이 하는 일을 한마디로 줄이면, 작업을 DAG(방향 비순환 그래프)로 펼쳐서 러너에 태우는 것입니다. needs로 엮인 잡 하나가 실패하면 그 아래 잡은 기본적으로 건너뜁니다. 함정은 중요한 잡을 그래프 밖에 두거나, 실패해도 continue-on-error로 뭉개서 초록으로 보이게 만드는 패턴입니다. 스케줄러 입장에서는 “완료”지만 품질 게이트는 비어 있게 됩니다.
# 잘못된 예: deploy가 build에만 의존
jobs:
build: { runs-on: ubuntu-latest, steps: [...] }
test: { runs-on: ubuntu-latest, steps: [...] }
deploy:
needs: [build] # test가 실패해도 배포가 진행됨
runs-on: ubuntu-latest
# 테스트를 배포 경로에 묶기
jobs:
build:
runs-on: ubuntu-latest
steps: [...]
unit:
needs: [build]
runs-on: ubuntu-latest
steps: [...]
integration:
needs: [build]
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: [unit, integration] # 두 테스트 잡이 모두 성공해야 실행
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
그래프만으로는 충분하지 않습니다. 저장소 설정의 브랜치 보호 규칙에서 필수 상태 체크(required status checks)로 테스트 잡을 지정해야 PR 머지 자체가 막힙니다. 워크플로 파일에서 잡 이름을 바꾸면 필수 체크가 옛 이름을 계속 기다리거나, 반대로 아무것도 검사하지 않게 되므로 이름 변경 후에는 보호 규칙도 함께 확인해야 합니다. 경로 필터(paths)로 워크플로를 건너뛰게 한 경우에도, 필수 체크로 지정된 잡이 실행되지 않으면 PR이 “대기 중”으로 멈출 수 있습니다.
게이트를 무력화하는 또 하나의 흔한 실수는 deploy 잡에 if: always()를 붙이는 것입니다. 앞 잡이 건너뛰어지면(skipped) 뒤 잡도 건너뛰어지는 기본 동작이 불편해서, 예를 들어 “변경된 패키지만 테스트하는 잡이 skip돼도 배포는 되게” 하려고 always()를 넣는 경우입니다. 그런데 always()는 앞 잡이 실패하거나 취소돼도 참이므로, 테스트가 빨간불이어도 배포가 나갑니다. 의도가 “건너뛴 것은 허용하되 실패는 막기”라면 if: ${{ !failure() && !cancelled() }}처럼 실패와 취소를 명시적으로 배제해야 합니다. 저는 워크플로 리뷰에서 always()가 보이면 그 잡이 무엇을 우회하는지부터 묻는 편인데, 대부분은 이런 의도치 않은 게이트 우회였습니다.
continue-on-error가 필요한 경우도 있습니다. 새 린트 규칙을 도입하면서 당분간 결과만 보고 싶을 때처럼, 관측은 하되 게이트로는 쓰지 않겠다는 의도가 분명할 때입니다. 그런 잡은 이름에 (non-blocking)처럼 표시해 두고 기한을 정해 필수로 바꾸는 편이 좋습니다. 그렇지 않으면 “빨간불이지만 원래 그래”가 팀의 습관이 됩니다.
캐시와 아티팩트
캐시는 빌드를 빠르게 해 주지만, 키를 잘못 잡으면 옛 산출물을 가져와서 “로컬에선 됐는데”의 원인이 됩니다.
캐시 키에 들어가야 하는 것
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
- 락파일 해시: 의존성이 바뀌면 키가 바뀌어야 합니다.
- 툴체인 버전: Node·Python·컴파일러 버전이 다르면 네이티브 모듈이 호환되지 않습니다.
- OS·아키텍처: 러너 이미지가 바뀌면 바이너리가 달라집니다.
- 빌드 결과에 영향을 주는 환경 변수: 예를 들어 빌드 시 주입하는 API 주소.
restore-keys는 정확히 일치하는 캐시가 없을 때 가장 가까운 캐시를 복원합니다. 패키지 매니저의 다운로드 캐시(~/.npm)처럼 부분 일치여도 해가 없는 캐시에만 쓰는 것이 안전합니다. node_modules 자체를 부분 일치로 복원하면 락파일과 다른 의존성 트리가 그대로 쓰일 수 있습니다. 위 사고 패턴이 정확히 이 경우입니다. node_modules를 캐시하려면 락파일 해시가 정확히 일치할 때만 복원하고, 설치는 항상 npm ci처럼 락파일을 강제하는 명령으로 실행하세요.
PR 캐시 포이즈닝
공유 캐시는 공격면이기도 합니다. 포크나 PR 브랜치에서 실행된 워크플로가 기본 브랜치도 읽는 캐시 키에 악성 산출물을 써 넣으면, 이후 main 빌드가 그것을 복원할 수 있습니다. GitHub Actions는 브랜치별로 캐시 범위를 나누지만(PR은 자신의 캐시와 기본 브랜치 캐시를 읽을 수 있음), 캐시를 어디서 쓰는지는 직접 제한하는 편이 안전합니다.
# PR에서는 복원만, 기본 브랜치에서만 저장
- uses: actions/cache/restore@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
# ... 설치와 빌드 ...
- if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
같은 원리로 pull_request_target 트리거는 기본 브랜치 권한과 시크릿으로 실행되므로, 그 안에서 PR 코드를 체크아웃해 빌드하면 안 됩니다.
불변 아티팩트
CI가 만든 산출물(컨테이너 이미지, 번들)은 커밋 SHA나 콘텐츠 해시로 주소를 고정해야 합니다. latest나 staging 같은 움직이는 태그만 쓰면 “같은 레지스트리 URL인데 내용이 달라지는” 상황이 생기고, 장애가 났을 때 무엇이 배포돼 있었는지 알 수 없습니다. 테스트를 통과한 바로 그 이미지를 스테이징과 프로덕션에 순서대로 올리는 것(환경마다 다시 빌드하지 않는 것)이 기본 원칙입니다. 다시 빌드하면 그 사이에 바뀐 베이스 이미지나 의존성 때문에 테스트하지 않은 산출물이 나갈 수 있습니다.
테스트 병렬화와 플레이크
샤딩
테스트를 병렬로 돌리는 방법은 Jest·Playwright 샤딩, 매트릭스로 OS·런타임 나누기, E2E 워커 나누기 등이 있습니다.
test:
needs: [build]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4
주의할 점은 파이프라인 전체가 가장 느린 샤드 속도에 묶인다는 것입니다. 파일 개수 기준으로 나누면 느린 통합 테스트가 한 샤드에 몰릴 수 있습니다. 단위·통합·E2E처럼 유형별로 잡을 먼저 나누고, 이전 실행 시간을 기준으로 테스트를 고르게 분배하는 기능(도구별 타이밍 기반 분할)을 쓰면 편차가 줄어듭니다. fail-fast: false는 한 샤드가 실패해도 나머지를 끝까지 돌려서, 실패한 테스트 전체 목록을 한 번에 볼 수 있게 합니다.
플레이크 재시도
간헐적으로 실패하는 테스트가 생기면 재시도를 붙이고 싶어집니다. 재시도를 테스트 로직 안에 넣으면(jest.retryTimes로 전체 재시도 등) 진짜 경쟁 조건 버그까지 가려집니다. 재시도는 네트워크로 의존성을 받는 단계, 외부 서비스 기동 대기처럼 인프라 층에만 제한하고, 테스트 자체의 플레이크는 격리(quarantine) 목록으로 옮겨 추적하면서 원인을 고치는 편이 장기적으로 덜 꼬입니다. 재시도로 통과한 테스트가 있으면 그 사실을 리포트에 남겨야 플레이크가 늘어나는 추세를 볼 수 있습니다.
플레이크의 원인은 생각보다 몇 가지로 수렴합니다. 테스트끼리 공유하는 DB 행이나 전역 상태(한 테스트가 만든 데이터를 다른 테스트가 가정), 고정 sleep으로 비동기 작업을 기다리는 코드(러너가 느린 날에만 실패), 현재 시각이나 시간대에 의존하는 단언(자정 전후나 UTC 러너에서만 실패), 그리고 테스트 순서 의존입니다. 순서 의존은 샤딩을 처음 도입할 때 한꺼번에 드러나는 경우가 많습니다. 단일 프로세스에서 늘 같은 순서로 돌던 테스트가 샤드로 흩어지면서 “앞 테스트가 깔아 주던 상태”가 사라지기 때문입니다. 샤딩을 켰더니 갑자기 실패가 늘었다면 샤딩이 버그를 만든 것이 아니라 숨어 있던 결합을 드러낸 것으로 보는 편이 맞습니다.
배포 전략
배포는 결국 트래픽을 언제, 어떻게 새 버전으로 옮기느냐의 문제입니다.
| 전략 | 방식 | 장점 | 주의할 점 |
|---|---|---|---|
| 블루그린 | 환경 두 벌을 두고 라우팅을 한 번에 전환 | 전환과 롤백이 빠름 | 자원이 두 배 필요, 스키마 비호환 변경에 취약 |
| 카나리 | 일부 트래픽만 새 버전으로 보내며 점진 확대 | 문제의 영향 범위가 작음, 지표 기반 자동 롤백과 잘 맞음 | 좋은 지표와 SLO가 없으면 효과가 없음 |
| 롤링 | 인스턴스를 순서대로 교체 | 추가 자원이 적음 | 구·신 버전이 한동안 공존 |
롤링과 카나리에서는 구버전과 신버전이 동시에 요청을 처리하는 시간이 반드시 생깁니다. 새 버전이 캐시에 쓴 데이터 형식을 구버전이 읽지 못하거나, 세션 형식이 바뀌었거나, API 응답 필드를 없앴다면 “배포 중에만” 터지는 버그가 됩니다. 이런 버그는 배포가 끝나면 사라지기 때문에 재현이 어렵습니다. 한 번의 배포에서 바꾸는 계약(API, 캐시 형식, 메시지 스키마)은 구버전과 신버전이 모두 이해할 수 있게 만드는 것이 원칙입니다.
어떤 전략이든 공통으로 필요한 것이 두 가지입니다. 새 버전이 정말 요청을 처리할 수 있는지 확인하는 헬스체크(프로세스가 떠 있는지만 보는 것이 아니라 DB 연결 등 의존성까지 확인하는 readiness), 그리고 지금 어떤 아티팩트가 배포돼 있는지 추적할 수 있는 기록입니다. 둘 중 하나라도 없으면 자동 롤백은 판단 근거가 없고, 배포 자동화는 문제를 빠르게 퍼뜨리는 자동화가 됩니다.
동시에 두 배포가 달리지 않게
main에 PR 두 개가 몇 초 간격으로 머지되면 배포 워크플로도 두 개가 동시에 뜹니다. 나중 커밋의 배포가 먼저 끝나고, 먼저 커밋의 배포가 뒤늦게 끝나면서 더 오래된 버전으로 덮어쓰는 사고가 납니다. 로그만 보면 두 배포 모두 성공이라 원인을 찾기가 어렵습니다. GitHub Actions에서는 concurrency로 같은 환경에 대한 배포를 직렬화합니다.
deploy:
needs: [unit, integration]
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-production
cancel-in-progress: false # 진행 중인 배포는 끝까지, 대기 중인 것만 교체
cancel-in-progress: true는 PR의 테스트 워크플로처럼 “최신 커밋만 의미 있는” 잡에는 좋지만, 배포에 쓰면 마이그레이션이나 롤링 교체 도중에 잡이 끊겨 절반만 바뀐 상태가 남을 수 있습니다. 같은 그룹에서 대기 중인 실행은 하나만 유지되고 더 새 실행이 오면 대기 중이던 것이 취소되므로, false로 두어도 결국 가장 최신 커밋이 배포됩니다.
DB 마이그레이션과 배포 순서
애플리케이션은 롤백할 수 있어도 데이터베이스 스키마는 쉽게 되돌릴 수 없습니다. 그래서 스키마 변경은 확장 → 이전 → 축소(expand and contract) 단계로 나누어 배포합니다. 예를 들어 컬럼 이름을 name에서 full_name으로 바꾼다면 다음 순서를 따릅니다.
- 확장:
full_name컬럼을 추가합니다. 구버전 코드는 이 컬럼을 모르므로 영향이 없습니다. - 이중 쓰기: 새 버전 코드가 두 컬럼에 모두 쓰고, 읽기는
full_name을 우선합니다. 기존 데이터를 백필합니다. - 전환: 모든 인스턴스가 새 버전이 된 뒤, 읽기와 쓰기를
full_name으로만 합니다. - 축소: 충분히 안정화된 다음 배포에서
name컬럼을 삭제합니다.
각 단계는 별도의 배포이고, 어느 단계에서든 애플리케이션을 한 단계 전 버전으로 롤백해도 동작합니다. 마이그레이션을 배포 파이프라인의 별도 잡으로 두고 애플리케이션 배포보다 먼저 실행하되, 파괴적인 변경(컬럼 삭제, 타입 축소)은 코드가 더 이상 그 컬럼을 쓰지 않는 것을 확인한 뒤에만 실행하도록 순서를 강제하세요. 큰 테이블에 대한 인덱스 추가나 컬럼 변경은 락을 오래 잡을 수 있으므로, 온라인 DDL을 쓸 수 있는지도 미리 확인해야 합니다.
GitOps에서도 CI는 두꺼워야 합니다
GitOps(원하는 상태를 Git에 선언하고 클러스터의 에이전트가 그 상태로 맞추는 방식)를 쓰면 CD 쪽은 선언적으로 가벼워 보입니다. 하지만 배포 저장소의 매니페스트에 적힌 이미지 태그가 “무엇을 배포할지”를 결정하므로, 그 이미지가 믿을 만한지는 전적으로 CI의 빌드·테스트·스캔에 달려 있습니다. 이미지 서명과 SBOM(소프트웨어 구성 명세)은 “배포된 것이 CI가 빌드하고 테스트한 바로 그것인지”를 확인하는 연결 고리입니다.
DORA 지표(배포 빈도, 변경 리드 타임, 변경 실패율, 복구 시간)를 이야기할 때도 같은 관점이 적용됩니다. 파이프라인 자체를 하나의 서비스로 보고, 실행 시간과 실패율·플레이크 비율을 지표로 잡아 개선하라는 뜻에 가깝습니다.
반대로 이 글의 모든 장치가 모든 팀에 필요한 것은 아닙니다. 개발자 한두 명이 쓰는 내부 도구에 카나리 분석과 이미지 서명까지 붙이면 파이프라인을 유지하는 비용이 제품 개발보다 커집니다. 규모가 작을 때 먼저 챙길 것은 테스트를 배포 경로에 묶는 것, 캐시 키에 락파일을 넣는 것, 배포된 SHA를 기록하는 것 세 가지이고, 카나리·GitOps·SBOM은 트래픽과 팀이 커져서 “배포가 무섭다”는 말이 나오기 시작할 때 도입해도 늦지 않습니다.
장애가 났을 때 좁혀 가는 순서
운영 중에 무언가 이상할 때 확인하는 축은 다섯 가지입니다.
- 관측: 요청 단위 ID, 에러율과 지연, 의존성 타임아웃이 보이는가.
- 안전: 입력 검증, 권한, 시크릿 처리가 모든 경로에서 일관적인가.
- 신뢰성: 재시도가 멱등한 작업에만 적용되는가, 서킷 브레이커와 백오프가 있는가.
- 성능: 캐시, 배치, 백프레셔가 실제 데이터 규모에 맞는가.
- 배포: 롤백, 카나리, DB 마이그레이션 절차가 룬북으로 남아 있는가.
좁혀 가는 순서는 최소 재현 → 최근에 바뀐 것(코드, 설정, 의존성, 인프라) → 환경 차이(CI와 로컬의 컨테이너·런타임·의존성 버전) → 로그와 트레이스로 가설 검증입니다. 빌드만 깨진다면 CI 로그와 로컬 환경의 차이가 대개 답이고, 간헐적인 실패라면 경쟁 조건, 타임아웃, 외부 서비스부터 의심합니다. “최근에 바뀐 것”을 빠르게 찾으려면 배포 기록에 커밋 SHA와 아티팩트 해시가 남아 있어야 하므로, 앞에서 본 불변 아티팩트 원칙이 여기서 다시 효과를 냅니다.
이 글에서 정한 파이프라인 규칙
- 테스트 잡은
needs로 배포 경로에 묶고, 브랜치 보호의 필수 체크로도 지정합니다. continue-on-error는 관측용으로만, 기한을 정해 씁니다.- 캐시 키에는 락파일 해시, 툴체인 버전, OS를 넣고,
node_modules같은 결과물은 부분 일치로 복원하지 않습니다. - PR에서는 캐시를 읽기만 하고, 기본 브랜치에서만 저장합니다.
- 아티팩트는 SHA·해시로 고정하고, 테스트한 산출물을 그대로 환경별로 승격합니다.
- 샤딩은 가장 느린 샤드가 전체를 결정하므로 실행 시간 기준으로 나누고, 재시도는 인프라 층에만 둡니다.
- 롤링·카나리에서는 구·신 버전이 공존하므로 계약을 양쪽 호환으로 바꾸고, 스키마는 확장 → 이전 → 축소 순서로 배포합니다.