leo.dev
infra

배포 순서를 정하는 조건식

2026.04.248 min read ci-cdgithub-actionsdeployment

packages/**를 건드리면 FE와 BE가 동시에 트리거되고, 빠른 FE가 먼저 배포돼 구 BE를 호출한다. 1편에서 위험도가 가장 높았던 문제다. 원인은 워크플로우 3개가 서로를 모른다는 데 있어서, 세 워크플로우를 하나로 합쳐 서로를 알게 했다.

합치기 전

.github/workflows/에 독립 워크플로우 3개가 있었다.

  • deploy-backend.yml: apps/backend/** 또는 packages/**
  • deploy-frontend.yml: apps/frontend/** 또는 packages/**
  • deploy-batch.yml: apps/backend/** 또는 packages/**

packages/**가 바뀌면 셋 다 동시에 떴다. 조율할 방법이 없었다.

합친 후

3개 파일을 deploy.yml 하나로 통합하고, job 사이에 의존성을 걸었다.

detect-changes
├──▶ deploy-backend (backend 또는 packages 변경 시)
│ ├──▶ deploy-batch (백엔드 성공 시에만)
│ └──▶ deploy-frontend (프론트 변경 + 백엔드 성공 or skip 시)
└──▶ deploy-frontend (프론트만 변경된 경우 백엔드 skip → 즉시 실행)

통합 워크플로 — BE→FE 순서를 job 의존성으로 강제

이건 통합 직후의 모양이고, 순서를 job 의존성으로 강제한다는 골격은 그 뒤로도 그대로다.

변경 감지

detect-changes job이 커밋의 변경 파일을 분석해 boolean 두 개를 만든다.

deploy.yml
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}

packages/**는 backend·frontend 필터 양쪽에 들어 있다. 공유 패키지가 바뀌면 둘 다 true가 된다. (dorny/paths-filter 사용)

프론트엔드 실행 조건

deploy.yml
deploy-frontend:
needs: [detect-changes, deploy-backend]
if: |
always() &&
needs.detect-changes.outputs.frontend == 'true' &&
(needs.deploy-backend.result == 'success' || needs.deploy-backend.result == 'skipped')

세 시나리오를 한 조건으로 처리한다.

시나리오backend 결과frontend 실행이유
packages/** 변경success백엔드 완료 후 순차 실행
packages/** 변경failure실패한 백엔드에 새 FE를 안 올린다
apps/frontend/**skipped백엔드 변경 없으니 즉시 실행

always()가 없으면 needs의 job이 skipped일 때 이 job 자체가 실행되지 않는다. GitHub 문서가 규정하는 기본 동작이다. 선행 job이 실패하거나 건너뛰면, 그것을 needs로 잡은 job도 전부 건너뛴다. 이걸 뒤집으려면 조건식으로 계속 진행하게 만들어야 하고, 그 조건식이 always()다.

always()는 조건을 대신 판단해주지 않는다. 평가를 강제할 뿐이라 “무엇을 통과시킬지”는 직접 써야 한다. 그래서 뒤에 success 또는 skipped인지를 명시적으로 검사한다.

이 함수에는 쓰지 말라는 경고도 붙어 있다. 소스를 가져오는 단계처럼 치명적으로 실패할 수 있는 곳에 always()를 걸면 워크플로우가 타임아웃까지 멈춰 있을 수 있다는 내용이다.

Avoid using always for any task that could suffer from a critical failure, for example: getting sources, otherwise the workflow may hang until it times out. If you want to run a job or step regardless of its success or failure, use the recommended alternative: if: ${{ !cancelled() }}

Evaluate expressions in workflows and actions, GitHub Docs

이렇게 되는 이유는 always()가 취소된 경우에도 true를 돌려주기 때문이다. 워크플로우를 취소했는데 이 조건이 붙은 잡이 계속 도는 상황이 생긴다. 그래서 문서는 대안으로 if: ${{ !cancelled() }}를 권한다. 우리 조건은 뒤에 result를 화이트리스트로 검사하므로 취소되면 어차피 통과하지 않지만, 그건 always()가 안전해서가 아니다. 뒤의 조건이 막아줄 뿐이지

직접 쓴 조건에도 구멍이 있었다

위 조건에는 구멍이 있었다. 나중에 고쳤고, 고치면서 이 절에 쓴 경고에 내가 걸렸다는 걸 알았다.

skipped를 무조건 통과시킨 게 문제다. deploy-backend가 skip되는 경로는 하나가 아니다.

skip된 이유프론트를 내보내도 되나
애초에 백엔드 변경이 없었다된다. 백엔드는 그대로다
백엔드 변경은 있는데 선행 잡이 실패해 skip됐다안 된다. 구 백엔드에 새 프론트가 올라간다

두 경우 모두 resultskipped다. 그래서 원래 조건은 아래쪽을 걸러내지 못했다. 이 워크플로우를 합친 이유가 정확히 그 상황(구 BE + 신 FE 공존)을 막는 건데, 다른 경로로 같은 사고가 돌아올 수 있었다.

skipped가 “안전한 skip”인지를 함께 봐야 한다. detect-changes의 출력이 그걸 알고 있다.

deploy.yml
if: |
always() &&
needs.detect-changes.outputs.frontend == 'true' &&
(needs.deploy-backend.result == 'success' ||
(needs.detect-changes.outputs.backend == 'false' &&
needs.deploy-backend.result == 'skipped'))

backend == 'false'가 붙어야 “백엔드 변경이 없어서 건너뛴 것”만 통과한다.

always()는 판단을 대신해주지 않는다. 이 포인트를 놓쳤다.

상태값 하나로 두 상황을 구분하려 한 게 원인이다. 조건식에 result만 쓰면 “왜 그 결과가 됐는지”는 안 보인다. 그걸 아는 건 detect-changes뿐이라, 두 출처를 같이 봐야 한다.

result가 가질 수 있는 값

값이 하나 더 있다는 것도 실전에서 알았다. 문서가 이 필드의 가능한 값을 success·failure·cancelled·skipped 넷으로 규정하는데, 내 조건식은 successskipped만 통과시키고 있었다.

백엔드 잡이 ECS 안정화 대기로 타임아웃되면 cancelled이 된다. 위 조건은 화이트리스트라 그때 프론트가 조용히 skip되고, 배포는 성공했는데 프론트만 안 나가는 상태가 된다.

화이트리스트로 쓴 덕에 이 경우엔 안전한 쪽으로 떨어져 사고는 아니었다. 조건을 열거식(!= 'failure')으로 짰으면 백엔드가 어떤 상태든 프론트가 나갔을 테니, 모르는 값이 생겼을 때 막히는 쪽으로 기운 게 결과적으로 맞았다.

다만 원인이 조건식이 아니라 타임아웃이었으므로, 조건을 늘리는 대신 백엔드 잡 타임아웃을 30분으로 올려 cancelled 자체가 안 나게 했다. 지금 잡별 타임아웃은 verify 15분, 빌드·마이그레이션 25분, 배포 잡들 30분이다.

실행 두 개가 겹칠 때의 순서

job 의존성은 한 워크플로우 실행 안에서만 순서를 만든다. 커밋 두 개가 연달아 푸시되면 두 번 실행된다. 둘 사이에는 아무 의존성도 없다. 첫 실행의 백엔드 배포가 도는 동안 두 번째 실행이 마이그레이션을 시작할 수 있는데, 그러면 잡 순서를 아무리 정확히 그려도 소용이 없다.

이 포인트를 간과했고, 한 달 반 뒤 품질 게이트를 붙이는 단계에서 실행 단위의 직렬화를 같이 걸었다.

deploy.yml
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false

같은 브랜치의 배포는 한 번에 하나만 돈다. 뒤에 온 실행이 앞의 것을 취소하게 할 수도 있었는데, 취소하지 않고 기다리게 뒀다. 진행 중인 배포를 취소하면 마이그레이션이 도는 중간에 프로세스가 끊길 수 있고, 그러면 DB가 어중간한 상태로 남는다. 실행 시간을 아끼는 것보다 중단되지 않는 것이 중요한 구간이라 판단했다.

같은 규칙이 브랜치별로는 따로 적용된다. groupgithub.ref가 들어 있어 prod 배포와 dev 배포는 서로를 막지 않는다.

cancel-in-progress 값은 나중에 ${{ github.event_name == 'pull_request' }}가 됐다. 같은 워크플로우를 PR에서도 돌려 게이트만 태우게 되면서, 배포는 그대로 큐잉하고 PR 검증만 새 커밋이 오면 이전 실행을 버리도록 나눈 것이다. 그룹 키는 그대로 뒀는데, PR에서 github.refrefs/pull/N/merge라 push 실행과 애초에 안 섞인다.

배치도 같은 논리

deploy.yml
deploy-batch:
needs: [detect-changes, deploy-backend]
if: |
always() &&
needs.detect-changes.outputs.backend == 'true' &&
needs.deploy-backend.result == 'success'

배치는 백엔드가 성공했을 때만 돈다. 마이그레이션이 끝난 스키마를 전제로 동작해야 하기 때문이다. 백엔드 실패는 곧 마이그레이션 실패 가능성이므로, 그럴 땐 배치도 구버전을 유지한다. (1편의 문제 5가 이걸로 없어진다.)

시리즈를 다 진행한 뒤의 DAG

시리즈를 다 진행한 뒤의 DAG는 이렇다. 이 글을 쓰는 시점에 잡이 일곱 개이고 골격은 그대로다.

verify (타입체크·린트·테스트)
int-test (실 MySQL 통합테스트)
└─ detect-changes 둘 다 통과해야 시작
└─ build-migrate backend == 'true'일 때만
├─ deploy-backend build-migrate 성공 시
├─ deploy-worker build-migrate 성공 시 (백엔드와 병렬)
└─ deploy-frontend frontend == 'true' + 백엔드 성공 or 안전한 skip

바뀐 건 두 가지 정도다. 앞에 품질 게이트가 붙었고, 5편에서 이미지 빌드와 마이그레이션이 build-migrate로 묶여 배포 잡들 앞에 서면서 워커가 백엔드와 병렬이 됐다. 처음 통합할 때 배치가 백엔드 뒤에 있던 건 마이그레이션이 백엔드 잡 안에 있었기 때문이고, 그걸 앞으로 빼내니 두 잡이 나란히 돌 수 있게 됐다.

조건식이 지금 모양이 되기까지

워크플로우를 합친 것 자체는 하루짜리 작업이었다. 오래 걸린 건 if 한 줄인데, 그 줄이 result 하나로는 부족하다는 걸 한참 뒤에야 알았다.

순서를 강제해도 안 닫히는 구멍이 하나 남는다. 마이그레이션은 성공했는데 그 뒤 ECS 배포가 실패하면 DB만 새 버전이 되는데, 그건 조건식으로 막을 수 있는 게 아니라 실패한 뒤에 되돌리는 장치가 있어야 한다. 다음 편이 바로 그걸 붙이는 과정이다.

↑↓ 이동 열기esc 닫기