테스트 잡이 13분 줄었는데, CI 는 1분 46초 늘었습니다

통합 테스트가 테스트 클래스마다 컨테이너를 다시 띄우던 구조를 모듈에 하나만 띄우도록 바꿨습니다. 이 전환 PR 의 CI 결과를 직전 PR 의 실행과 나란히 놓았습니다.

잡직전 PR 실행전환 PR 실행
주문 서비스 테스트18.6분5.2분
이미지 빌드(가장 오래 걸린 것)142초221초
e2e15.6분16.1분
CI 전체19분 03초20분 49초

(두 PR 의 CI 실행 하나씩을 잡 시각으로 비교. 직전 PR 은 문서만 바꾼 PR 이었다)

가장 오래 걸리던 테스트 잡에서 13분 넘게 덜어 냈는데 전체는 1분 46초 늘었습니다. 전환을 결정할 때는 “19분에서 약 16분” 으로 예측했습니다. 테스트가 줄면 전체 시간은 이미지 빌드와 e2e 쪽 경로가 정하고 그 경로가 약 16분이라고 추정한 숫자였습니다.

두 실행은 커밋도 러너도 다른 단일 실행입니다. 직전 PR 은 코드를 바꾸지 않아 이미지 빌드가 거의 캐시로 끝났다는 차이도 있습니다. 그래도 테스트를 13분 줄인 효과가 전체 시간에 나타나지 않은 이유는 이 차이로 설명되지 않습니다. 아래 장면에서 직전 실행을 전환 실행으로 바꿔 보면 어느 막대가 움직이는지 보입니다.

주문 테스트를 줄인 실행에서는 무엇이 움직였을까요?

직전 PR 실행의 잡 막대입니다. 전환 PR 실행으로 바꾸면 같은 막대들이 그 실행의 값으로 옮겨 갑니다. 주문 테스트 막대와 빨간 전체 종료선이 각각 어느 쪽으로 움직이는지 보세요.

CI 실행

막대는 PR 실행 하나씩의 jobs API 잡 시작, 종료 시각을 그 실행의 생성 시각 기준 초로 옮긴 것입니다. 두 실행은 커밋도 러너도 다르고, 직전 PR 은 문서만 바꿔 이미지 빌드가 거의 캐시로 끝났습니다. 테스트 줄에는 가장 늦게 끝난 샤드와 주문 서비스 샤드(점선 띠)만, 이미지 줄에는 e2e 직전에 끝난 서비스 하나만 그렸습니다(전환 실행에서 가장 오래 걸린 이미지는 221초). 상품 샤드 799초는 전환 실행의 값이고, 직전 실행의 다른 샤드는 그리지 않았습니다. guards 와 나머지 샤드는 생략했습니다.

이 결과는 CI 전체 시간이 무엇으로 정해지는지 알면 풀립니다. 출발점은 PR 하나에 36분이 걸리던 실행입니다.

CI 의 전체 시간을 정하는 경로

PR 하나에 36분

PR 을 올리면 GitHub Actions 워크플로 하나가 돌았습니다. 잡은 일곱 종류입니다.

잡하는 일
lint정책 검사 스크립트(셸과 Python). Java 가 필요 없다
testGradle 테스트. 서비스별 6개 샤드(matrix)
guards루트 Gradle 프로젝트에 걸린 빌드 검사. 서비스 테스트와 별개
gate테스트 결과를 모아 판정하는 집계 잡
images서비스별 Docker 이미지 빌드와 기동 확인(health smoke). 6개 matrix
e2e빌드한 이미지로 전체 스택을 띄워 시나리오와 음성 대조군 실행
publishmain push 에서만 이미지를 레지스트리(GHCR)에 게시

음성 대조군은 일부러 결함을 넣은 스택에서 e2e 가 실제로 실패하는지 보는 검사입니다. 서비스를 멈췄다 다시 띄우고 “일어나지 않아야 할 일” 이 일어나지 않는지 기다리는 구간이 많아 오래 걸립니다.

기준으로 삼은 PR 실행은 36분 51초가 걸렸습니다(실행 생성부터 종료까지. 마지막 잡 종료 기준으로는 36분 49초).

잡소요시작(UTC)
test (order-service)1051초13:12:23
test (product-service)808초13:12:23
test (payment-service)581초13:12:23
test (notification-service)433초13:12:23
test (user-service)201초13:12:24
test (platform)111초13:12:24
guards48초13:12:23
lint44초13:12:24
gate15초13:29:58
images (가장 늦게 끝난 것)173초13:30:43
e2e856초13:33:41

(CI 실행 하나의 jobs API 값으로 재구성)

시작 시각을 보면 순서가 드러납니다. gate 는 테스트가 모두 끝난 뒤, images 는 gate 가 끝난 뒤, e2e 는 images 가 끝난 뒤에 시작합니다. 워크플로 파일의 needs 도 이 순서 그대로 걸려 있었습니다.

gate:
  needs: [test, guards]
images:
  needs: [lint, gate]
e2e:
  needs: images
publish:
  needs: images
  if: github.event_name == 'push'

(당시 .github/workflows/ci.yml 에서 needs 와 if 만 발췌)

임계경로와 여유

needs 는 “이 잡들이 성공해야 시작한다” 는 선언입니다. 그래서 잡 그래프에서 시작부터 끝까지 이어지는 경로마다 걸리는 시간을 더할 수 있고 가장 긴 경로가 전체 시간이 됩니다. 이 경로를 임계경로(critical path)라고 부릅니다.

이 실행의 임계경로는 test(order) 1051초, gate 15초, images 173초, e2e 856초 네 잡이었고 더하면 2095초(34분 55초)입니다. 전체 36분 51초의 대부분이 이 경로 위에 있습니다. 나머지 1분 56초는 첫 잡이 러너를 받기까지의 75초와 잡 사이의 대기입니다. 나머지 다섯 테스트 샤드와 lint, guards 는 이 경로가 도는 동안 옆에서 먼저 끝났습니다. 이 잡들을 아무리 줄여도 전체 시간은 줄지 않습니다.

경로 밖의 잡이 마지막 잡보다 얼마나 먼저 끝났는지를 여유(slack)라고 하겠습니다. 여유가 큰 잡을 줄이면 러너 사용 시간만 줄고 여유가 0인 경로 위의 잡을 줄여야 전체가 줄어듭니다.

임계경로는 눈으로도 찾을 수 있지만 실행이 쌓이면 계산하는 것이 더 정확합니다. 작업을 마친 뒤 그 기간의 PR 과 main push 실행 30개를 다시 계산할 때는 스크립트를 썼습니다. GitHub API 로 잡 시각을 받고 그 실행의 head 커밋에 있던 워크플로 파일에서 needs 를 읽어 경로를 거슬러 올라갑니다. 가장 늦게 끝난 잡에서 출발해 그 잡의 needs 가운데 가장 늦게 끝난 잡으로 한 단계씩 올라갑니다.

cur = max(ran, key=lambda j: j["completed"])
chain = [cur]
while needs[cur["key"]]:
    preds = [j for j in ran if j["key"] in needs[cur["key"]]]
    if not preds:
        break
    cur = max(preds, key=lambda j: j["completed"])
    chain.append(cur)
chain.reverse()

(계산 스크립트에서 발췌해 변수 이름을 정리. 잡 시각은 GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{n}/jobs, needs 는 그 실행의 head 커밋에 있던 ci.yml. matrix 잡은 이름의 괄호 앞부분으로 같은 잡에 묶는다)

이렇게 계산하면 실행마다 어느 경로가 전체를 정했는지와 경로 밖 잡의 여유가 함께 나옵니다. 첫머리의 결과도 이 두 값으로 풀립니다.

첫 번째 수: 이미지 빌드가 테스트를 기다리지 않게

줄일 후보는 임계경로 위의 네 잡이었습니다.

  • 가장 긴 테스트 샤드(1051초)를 줄인다. 이 샤드의 JUnit 리포트에 잡힌 테스트 시간은 131초뿐이고 나머지는 테스트 메서드 밖에서 쓴 시간이었습니다. 첫머리의 컨테이너 구조 전환이 이 시간을 줄이려는 작업이었고 테스트 클래스가 컨테이너를 띄우는 방식을 바꿔야 하는 큰 작업입니다.
  • e2e(856초)를 줄인다. 856초 가운데 셋업과 이미지 로드가 50초, 시나리오 4종이 197초, 음성 대조군이 605초(71%)였습니다. 대조군을 main push 에서만 돌리면 크게 줄지만 PR 에서 “e2e 가 실제로 실패할 수 있는가” 를 확인하지 못하게 됩니다.
  • images 가 gate 를 기다리지 않게 한다. 이미지 빌드에는 Dockerfile 과 소스만 있으면 되고 테스트 결과는 필요 없습니다. 그런데도 needs: [lint, gate] 때문에 모든 테스트 샤드가 끝날 때까지 기다리고 있었습니다.

세 번째는 워크플로 파일 한 줄만 고치면 되고 되돌릴 때도 한 줄입니다. 이 간선을 끊으면 경로가 test, gate 와 lint, images, e2e 두 갈래로 나뉘고 전체 시간은 두 갈래 가운데 긴 쪽이 됩니다. 이것을 먼저 하고 테스트 인프라 변경은 그다음에 하기로 했습니다. 기대값은 “36분에서 약 19분” 이었습니다.

needs 간선 하나가 게시 게이트이기도 했습니다

images 의 needs 를 고치기 전에 이 잡을 기다리는 쪽을 살폈습니다. publish 가 needs: images 하나만 걸고 있었습니다.

변경 전   publish  <- images <- gate <- test, guards
변경안    publish  <- images <- lint

(당시 ci.yml 의 needs 에서 발췌해 그림으로 재구성)

publish 는 테스트를 직접 기다리지 않았습니다. images 가 gate 를, gate 가 테스트를 기다렸습니다. 그래서 테스트가 실패하면 gate 가 실패하고 그 뒤의 images 가 skip 되어 publish 도 skip 됐습니다. 게시를 막도록 의도한 설정은 없었고 사슬이 이어져 있어서 우연히 막혔을 뿐입니다. images 를 gate 에서 떼면 테스트가 실패한 main push 에서도 이미지가 빌드되고 latest 태그로 게시됩니다.

그래서 publish 가 gate 를 직접 기다리게 했습니다(needs: [images, gate]). 그리고 이 연결이 다시 끊기지 않도록 검사 스크립트를 만들어 lint 잡에 넣었습니다. 워크플로 파일을 읽어 publish 에서 거슬러 올라가며 gate 와 테스트까지 이어지는지 보는 스크립트입니다.

집계 잡은 선행이 실패해도 초록으로 끝날 수 있습니다

이 검사 스크립트는 경로가 이어지는지와 함께 gate 안의 스텝 하나가 있는지도 살폈습니다. gate 는 이렇게 생겼습니다.

gate:
  needs: [test, guards]
  if: ${{ !cancelled() }}
  steps:
    # ... 테스트 결과 수집과 진단 ...
    - name: Propagate upstream failure
      if: ${{ !cancelled() && (needs.test.result != 'success' || needs.guards.result != 'success') }}
      run: exit 1

(당시 ci.yml 의 gate 에서 구조만 발췌. 실제 스텝에는 진단 메시지 출력이 있다)

GitHub Actions 에서 needs 로 기다리던 잡이 실패하거나 skip 되면 그 잡을 기다리던 잡도 기본으로 skip 됩니다. 이 기본을 바꾸려면 if: 에 always() 나 !cancelled() 같은 상태 함수를 넣습니다(GitHub Docs: jobs.<job_id>.needs). 공식 문서는 always() 가 취소된 실행에서도 돌기 때문에 성공이든 실패든 실행하려는 경우에는 !cancelled() 를 권합니다(GitHub Docs: status check functions).

테스트가 실패해도 결과를 모으고 진단을 남기려고 gate 에 !cancelled() 를 붙였습니다. 그 대가로 gate 는 선행이 실패해도 끝까지 돌고 자기 스텝이 모두 성공하면 초록으로 끝납니다. 선행 실패는 마지막 Propagate upstream failure 스텝 하나에서만 결과에 반영됩니다. 이 스텝이 빠지면 테스트는 빨간데 gate 는 초록이고 gate 를 기다리는 publish 는 그대로 게시합니다.

그렇다면 if: 를 아예 빼면 될까요? 아래 장면에서 선행 잡을 실패시키고 집계 잡 설정을 바꿔 가며 필수 체크와 main 게시가 어떻게 되는지 확인해 보세요.

test 가 실패했는데 병합 버튼이 열릴까요?

글 뒤에서 다룰 최종 구성처럼 gate 가 다섯 잡(lint, test, guards, images, e2e)을 기다리는 집계 잡이고, 브랜치 보호의 필수 체크라고 놓았습니다. 실패시킬 잡과 gate 설정을 골라 보세요. 결과가 gate 로 모이고, 그 판정이 PR 병합과 main 게시로 갑니다.

실패시킬 잡
gate 설정
 
gate 결과미측정 필수 체크미측정 main 게시미측정

잡 그래프는 현재 구성(images 는 lint 를, e2e 는 images 를 기다림)으로 고정했고, 앞 잡이 실패하면 뒤 잡은 실행되지 않고 skip 으로 표시됩니다. 판정은 GitHub 문서 규칙(needs 가 성공하지 않으면 if 없는 잡은 skip, 필수 체크는 success, skipped, neutral 을 통과로 봄, 스텝 수준 continue-on-error 는 잡을 통과시킴)으로 계산했습니다. 실제 CI 실행으로 확인한 것은 3번 설정에서 images 가 실패한 main 실행 하나와 test 가 실패한 main 실행 둘(gate 실패, publish skip)뿐이고, 1, 2, 4, 5번은 문서 규칙과 로컬 검사 스크립트 실험으로만 확인했습니다. 4, 5번은 지금의 게시 게이트 검사 스크립트를 통과합니다. 병합 버튼은 관리자 우회가 꺼진 상태를 가정합니다.

if: 가 없으면 선행이 실패할 때 gate 가 skip 됩니다. 그런데 브랜치 보호의 필수 체크(required status check)는 success, skipped, neutral 을 모두 통과로 봅니다(GitHub Docs: About protected branches). 공식 트러블슈팅 문서도 실패한 잡에 의존하는 잡은 skip 되어 병합을 막지 않을 수 있다고 설명하고 필수 체크가 다른 잡에 의존하면 always() 와 needs 를 함께 쓰라고 권합니다(GitHub Docs: Troubleshooting required status checks).

집계 잡을 필수 체크로 쓰면 “선행이 실패해서 skip” 도, “선행이 실패했지만 끝까지 돌아 초록” 도 통과입니다. 실패와 skip 을 모두 실패로 바꾸는 마지막 스텝이 있어야 집계 잡이 판정을 합니다. 조건식을 || 대신 && 로 묶거나 그 스텝에 continue-on-error 를 붙여도 같은 문제가 생깁니다. 이 두 경우는 뒤에서 검사 스크립트 이야기로 다시 나옵니다.

이미지 레이어 캐시: scope, load, ref 격리

간선을 끊으면서 이미지 빌드에 레이어 캐시도 붙였습니다. 기존에는 매 실행 새 러너에서 docker build 를 처음부터 돌렸습니다. Docker 의 buildx 는 빌드 캐시를 GitHub Actions 캐시 저장소로 내보내고 다시 가져오는 백엔드(type=gha)를 제공합니다.

- name: Set up Buildx
  uses: docker/setup-buildx-action@v3
- name: Build image
  uses: docker/build-push-action@v6
  with:
    context: .
    build-args: SERVICE=${{ matrix.service }}
    tags: ${{ matrix.service }}:ci
    load: true
    cache-from: type=gha,scope=image-${{ matrix.service }}
    cache-to: type=gha,mode=max,scope=image-${{ matrix.service }}

(현재 ci.yml 의 images 잡에서 두 스텝을 주석을 빼고 발췌. 들여쓰기는 잡 밖으로 당김)

설정 두 가지가 필요했습니다.

  • scope 를 서비스별로 나눕니다. scope 는 캐시를 구분하는 키이고 기본값은 buildkit 하나입니다. 여러 이미지를 같은 scope 로 빌드하면 각 빌드가 앞의 캐시를 덮어써 마지막 하나만 남습니다(Docker Docs: GitHub Actions cache). matrix 로 이미지 6개를 빌드할 때 scope 를 나누지 않으면 각 이미지가 읽는 캐시는 대부분 다른 이미지가 남긴 것이 됩니다.
  • load: true 를 붙입니다. type=gha 캐시를 쓰면 buildx 는 docker-container 드라이버로 빌드하는데 이 드라이버는 결과를 로컬 Docker 엔진에 남기지 않습니다. 그러면 뒤의 health smoke(docker run)와 docker save 가 이미지를 찾지 못합니다. 로컬에서 --load 를 빼면 docker save 가 실패하고 붙이면 성공했습니다.

변경을 머지한 뒤 첫 main push 실행에서는 이미지 빌드가 오히려 느려졌습니다. 기준 PR 실행에서 104173초이던 이미지 빌드가 200257초였고 로그에서 캐시를 재사용한 스텝(CACHED)을 세어 보니 0개였습니다.

원인 후보는 둘이었습니다. 하나는 Dockerfile 구조상 재사용할 레이어가 원래 없다는 것이고, 다른 하나는 GitHub Actions 캐시의 접근 제한이었습니다. GitHub Actions 캐시는 ref 단위로 격리됩니다. 실행은 자기 브랜치와 기본 브랜치의 캐시를 읽을 수 있고, PR 실행은 base 브랜치의 캐시도 읽을 수 있습니다. 반대로 PR 실행이 만든 캐시는 그 PR 의 merge ref(refs/pull/N/merge)에 묶여 그 PR 의 재실행만 읽을 수 있고 base 브랜치는 읽지 못합니다(GitHub Docs: Restrictions for accessing a cache). buildx 의 type=gha 도 이 제한을 그대로 따릅니다(Docker Docs). 캐시를 처음 붙인 PR 의 실행이 채운 캐시는 main push 가 읽을 수 없으니, main 의 첫 실행은 빈 캐시에서 시작합니다.

두 후보는 다음 main push 한 번으로 가를 수 있었습니다. 다음 실행에서 CACHED 가 30스텝, 이미지 빌드가 86~130초로 원래보다도 빨라졌습니다. 캐시를 유지했습니다.

이 30을 얻기 전에 숫자를 한 번 잘못 읽었습니다. 다음 main push 를 처음 셌을 때도 CACHED 가 0으로 나왔는데 이 0은 로그에서 센 값이 아니었습니다. gh run view --log 는 실행이 끝나기 전에는 로그 대신 “아직 진행 중” 이라는 안내문 한 줄을 돌려줍니다. 기다리는 루프는 잡의 conclusion 이 아직 없을 때 null 이 아닌 빈 문자열이 온다는 점을 놓쳐 끝나지 않은 잡을 끝난 것으로 셌습니다. 그 안내문 한 줄에 grep 을 돌려 0이 나온 것입니다. 잡 단위 로그 API(/actions/jobs/{job_id}/logs)로 다시 받으니 30이었습니다. 재사용 0건과 측정 실패가 같은 숫자로 보였습니다.

결과: 36분 51초에서 21분 51초

간선을 끊은 PR 의 실행에서 images 는 15:04:50 에 시작했고 가장 긴 테스트 샤드는 15:20:29 에 끝났습니다. 이미지 빌드가 테스트 종료보다 15분 39초 먼저 시작했으니 직렬 사슬은 실제로 끊겼습니다.

변경 전변경 후
PR 실행36분 51초21분 51초
main push 실행39분 18초21분 20초

(PR 실행과 main push 실행 각각 하나씩의 비교)

기대한 약 19분보다 2분 51초 길었습니다. 임계경로를 다시 계산하면 이유가 보입니다.

변경 전   test(order) 1051s  ->  gate 15s  ->  images 173s  ->  e2e 856s
변경 후   lint 51s  ->  images(notification) 242s  ->  e2e 949s

(두 PR 실행의 잡 시각으로 계산한 결과에서 임계경로만 발췌)

이제 테스트는 경로 밖이고 e2e 가 전체의 72%를 차지했습니다. 가장 긴 테스트 샤드의 여유는 4분 16초였습니다.

테스트를 13분 줄였을 때 전체가 늘어난 이유

두 경로가 6초 차이로 붙어 있었습니다

첫머리의 두 실행을 같은 방법으로 계산하면 이렇습니다.

직전 PR 실행(19분 03초)전환 PR 실행(20분 49초)
임계경로test(order) 1116s, gate 17slint 52s, images(user) 219s, e2e 964s
경로 밖에서 가장 늦게 끝난 잡e2e, 여유 6초가장 긴 테스트 샤드, 여유 7분 25초
이미지 빌드(가장 오래 걸린 것)142초221초
e2e933초964초

(두 PR 실행의 jobs API 시각으로 계산. 경로 위의 이미지는 e2e 직전에 끝난 user 서비스 219초이고, 가장 오래 걸린 이미지는 product 서비스 221초)

직전 실행에서는 테스트 경로가 전체를 정했습니다. 하지만 e2e 경로가 6초 차이로 바로 뒤에 붙어 있었습니다. 두 경로의 길이가 거의 같으면 한쪽을 아무리 줄여도 전체는 다른 쪽 길이까지만 줄어듭니다. 이 실행에서 테스트를 0초로 만들었어도 전체는 6초 남짓밖에 줄지 않았을 것입니다. 테스트를 13분 줄인 효과는 그 6초를 넘지 못했고, 그사이 e2e 경로가 길어졌습니다. 테스트 쪽도 13분이 그대로 줄지는 않았습니다. 주문 샤드는 313초가 됐지만 테스트 쪽의 끝은 다음으로 긴 상품 샤드(799초)로 넘어갔습니다. 그래서 테스트 경로가 실제로 줄어든 폭은 약 5분입니다.

단일 실행의 6초는 러너 변동만으로도 뒤집힐 수 있는 차이입니다. 그래서 이 상태는 “테스트가 경로였다” 보다 “두 경로가 동률이었다” 로 읽는 쪽이 정확합니다.

e2e 경로를 늘린 것은 Dockerfile 레이어 무효화였습니다

e2e 경로가 길어진 원인은 이미지 빌드였습니다. 처음 해석은 “새 브랜치라 캐시가 비어 있었다” 였습니다. 나중에 그 실행의 이미지 빌드 로그를 다시 열어 보니 캐시는 정상으로 가져왔고(importing cache manifest) 11개 스텝이 CACHED 였습니다. PR 실행은 base 브랜치(main)의 캐시를 읽을 수 있다는 앞의 접근 제한 규칙과도 맞습니다. 재사용은 그 뒤에서 끊겼습니다.

COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
COPY common/build.gradle common/build.gradle
# ... 모든 모듈의 build.gradle ...
COPY order-service/build.gradle order-service/build.gradle
COPY payment-service/build.gradle payment-service/build.gradle
COPY gateway/build.gradle gateway/build.gradle
RUN chmod +x gradlew && ./gradlew :${SERVICE}:dependencies --no-daemon >/dev/null 2>&1 || true
COPY common/ common/
# ... 모든 모듈의 소스 ...
RUN ./gradlew :${SERVICE}:bootJar -x test --no-daemon

(현재 Dockerfile 빌드 단계에서 발췌, 일부 줄 생략)

이 Dockerfile 은 의존성 다운로드 레이어를 소스 복사 앞에 두어 소스만 바뀌면 의존성 레이어를 재사용하도록 되어 있습니다. 그런데 어느 서비스의 이미지든 모든 모듈의 build.gradle 을 먼저 복사합니다. 전환 PR 은 주문 서비스의 build.gradle 에 테스트 실행 설정 27줄을 더했고 그 COPY 줄부터 뒤의 레이어가 모든 이미지에서 무효가 됐습니다. 주문 서비스 이미지의 로그에서 의존성 받기가 28.8초, bootJar 가 34.2초 걸렸습니다. 테스트 설정을 고친 변경 하나 때문에 이미지 6개가 의존성 레이어를 다시 만들었습니다.

머지 직후 main push 에서도 이미지 빌드가 길었습니다(가장 오래 걸린 것 235초). 로그를 보면 이쪽도 캐시를 가져왔고 11개 스텝이 CACHED 였으며 같은 COPY 줄에서 재사용이 끊겼습니다. 전환 PR 이 새로 만든 레이어는 PR 의 merge ref 에 묶여 main 이 읽지 못하므로 main 에서도 같은 레이어를 다시 만든 것으로 봅니다.

1분 46초를 항목별로 정확히 나누지는 않았습니다. 경로가 시작하는 시각과 잡 사이의 대기 간격이 실행마다 달라 단순 합이 맞지 않습니다. 테스트 경로가 줄어드는 동안 이미지 빌드와 e2e 가 함께 길어졌다는 것까지만 확인했습니다.

테스트를 줄여도 CI 전체는 크게 줄지 않으리라고 전환 계획에서도 봤습니다. 이 전환의 값은 로컬에서 테스트를 반복하는 비용에 있었습니다. 빗나간 것은 “19분에서 약 16분” 이라는 숫자였습니다. 계획을 세울 때는 테스트가 경로에서 빠지면 이미지 빌드와 e2e 경로가 전체를 정한다고 보고 그 경로를 약 16분으로 추정했습니다. 직전 실행에서 그 경로는 이미 테스트 경로보다 6초 짧을 뿐인 약 19분이었고, 전환 PR 에서는 이미지 빌드와 e2e 가 더 길어졌습니다.

병목은 다시 옮겨 갑니다

e2e 를 두 잡으로 나누자 다시 테스트가 경로가 됐습니다

이제 e2e 16.1분이 혼자 남은 병목이었습니다. e2e 잡 하나 안에서 시나리오와 음성 대조군이 차례로 돌고 있었습니다. 선택지는 두 개였습니다.

  • 음성 대조군을 main push 에서만 돌린다. e2e 가 약 4분이 되지만 PR 에서 “검사가 실제로 실패할 수 있는가” 를 확인하지 못합니다. e2e 전체를 PR 에서 빼는 안도 같은 이유로 이미 기각했습니다.
  • 시나리오와 음성 대조군을 matrix 두 칸으로 나눠 별도 러너에서 동시에 돌린다. 검사는 그대로 두고 대기만 없앱니다. 대신 각 러너가 이미지를 로드하고 스택을 띄우는 일을 따로 하므로 러너 사용 시간은 늘어납니다.

두 번째를 골랐습니다. 두 모드가 각자 실행 ID 와 결과 파일 이름을 갖게 했습니다. 검사 스크립트도 함께 넣었는데 matrix 에서 한 모드가 빠지거나 한 러너에서 둘 다 돌게 바뀌면 실패합니다. 이 스크립트는 일부러 망가뜨린 워크플로 7종을 모두 실패로 판정했습니다.

PR 실행에서 두 e2e 잡은 같은 시각에 시작해 시나리오가 5분 13초, 음성 대조군이 10분 54초에 끝났습니다. 전체는 14분 10초였습니다. 다시 계산한 임계경로는 test(product) 824s, gate 20s 였고 음성 대조군의 여유는 14초였습니다. 경로가 다시 테스트 쪽으로 넘어갔고, 이번에도 두 경로가 십몇 초 차이로 붙어 있었습니다. 나머지 서비스의 테스트 인프라 전환이 이어지면서 테스트 샤드가 4~5분대로 내려갔고, 그 뒤로는 음성 대조군 쪽이 계속 경로를 차지했습니다.

필수 체크가 존재하지 않는 잡을 요구하고 있었습니다

publish 를 gate 에 묶은 변경은 main push 쪽에만 해당합니다. PR 병합 쪽에서 gate 가 병합을 실제로 막는지는 브랜치 보호 설정이 정합니다. 테스트 인프라 전환 PR 에서 16개 잡이 성공하고 publish 만 skip 됐는데도 병합 상태가 계속 BLOCKED 였습니다. 설정을 API 로 읽어 보니 필수 체크가 build 였습니다.

{"strict": true, "contexts": ["build"], "app_id": 15368, "enforce_admins": false}

(브랜치 보호 조회 결과를 필요한 키만 모아 재구성. 실제 API 응답은 required_status_checks 와 enforce_admins 로 나뉘어 있다)

워크플로에는 build 잡이 없었습니다. 처음 CI 를 만들 때는 잡이 build 하나였고 그 잡을 lint, test 샤드, guards, gate 로 나누면서 이름이 사라졌습니다. 그 뒤 24일 동안 41개 PR 이 이 상태에서 병합됐습니다.

존재하지 않는 잡의 체크는 보고될 일이 없으니 모든 PR 이 체크를 기다리는 상태로 남습니다. GitHub 문서는 경로 필터 때문에 워크플로가 돌지 않을 때 필수 체크가 “Waiting for status to be reported” 로 남는다고 설명하는데, 이름이 맞지 않는 경우도 같은 상태가 된다고 봅니다. 그런데도 enforce_admins: false 때문에 병합할 수 있었습니다. 브랜치 보호 규칙은 기본으로 저장소 관리자에게 적용되지 않습니다(GitHub Docs: About protected branches). 혼자 운영하는 저장소라 병합하는 사람이 곧 관리자였고 보호 설정은 있었지만 어떤 검사도 강제하지 않았습니다. gate 가 빨간 PR 도 같은 방법으로 들어갈 수 있었습니다. 41개 PR 이 각각 어떤 화면에서 어떻게 병합됐는지는 따로 확인하지 않았습니다.

필수 체크를 gate 로 바꿨습니다. 체크 이름과 함께 그 체크를 보고하는 GitHub App 도 지정할 수 있어서 실제 PR 에서 gate 를 보고한 GitHub Actions 앱 ID 를 확인한 뒤 같은 앱으로 고정했습니다. 다른 앱이나 사람이 같은 이름의 상태를 올려도 병합 조건으로 인정되지 않게 하기 위해서입니다(GitHub Docs). 바꾼 뒤 설정을 다시 읽어 이름, 앱, strict 를 대조했습니다. 다만 이때의 gate 는 아직 테스트와 가드만 기다렸습니다. lint, 이미지, e2e 가 실패해도 이 필수 체크 하나로는 병합이 막히지 않았습니다.

게시가 e2e 를 기다리지 않았습니다

main 쪽에도 같은 차이가 있었습니다. publish 를 gate 에 직접 묶은 뒤에도 e2e 는 images 만, gate 는 테스트만 기다렸으니 publish 는 e2e 를 기다리지 않았습니다. main push 실행 세 개에서 게시 시작과 e2e 종료 시각을 비교했습니다.

main push 실행게시 시작e2e 종료차이
처음 구성(게시가 이미지 빌드만 기다림)14:21:2114:37:23e2e 종료 16분 02초 전
게시를 gate 에 직접 묶은 뒤15:49:4215:51:52e2e 종료 2분 10초 전
최종 게이트로 바꾼 뒤08:17:4808:17:28e2e 종료 20초 뒤

(main push 실행 세 개의 jobs API 시각. 마지막 행의 e2e 는 음성 대조군 잡)

처음 구성에서는 e2e 가 아직 16분을 더 돌아야 하는 시점에 latest 가 이미 갱신됐습니다. 사슬을 끊기 전에도 끊은 뒤에도, 게시가 기다리는 검사와 PR 병합이 기다리는 검사가 서로 달랐습니다.

최종 게이트: 병합과 게시가 같은 검사를 기다리게

필수 체크 gate 의 초록은 lint, 이미지, e2e 의 성공을 뜻하지 않았고 main 의 게시는 e2e 가 끝나기 전에 시작할 수 있었습니다. 선택지는 세 가지였습니다.

안얻는 것결정
publish 만 e2e 를 직접 기다리게 한다한 줄 수정기각. 필수 체크 gate 는 여전히 e2e, lint, 이미지 실패를 집계하지 않아 병합 조건과 게시 조건이 계속 다르다
새 이름의 최종 집계 잡을 만들고 필수 체크를 그 이름으로 바꾼다기존 gate 의 시작 시점 보존기각. 이름을 바꾸는 동안 새 체크가 아직 보고되지 않아 PR 이 막히거나, 옛 체크만 필수인 기간이 생긴다
기존 gate 가 다섯 잡을 모두 기다리고 실패와 skip 을 실패로 센다필수 체크 이름이 바뀌지 않는다채택. 대가는 gate 결과가 e2e 뒤로 늦게 나오는 것
gate:
  needs: [lint, test, guards, images, e2e]
  if: ${{ !cancelled() }}
  steps:
    - name: Propagate upstream failure
      if: ${{ !cancelled() && (needs.lint.result != 'success' || needs.test.result != 'success' || needs.guards.result != 'success' || needs.images.result != 'success' || needs.e2e.result != 'success') }}
images:
  needs: [lint]
e2e:
  needs: images
publish:
  needs: [gate]
  if: github.event_name == 'push'

(현재 ci.yml 에서 needs 와 if 를 중심으로 발췌)

앞의 필수 체크 규칙 때문에 skip 을 실패로 셉니다. GitHub 는 skip 을 통과로 보는데 이 그래프에서 skip 은 대개 앞 단계가 실패해서 검사를 하지 못했다는 뜻입니다. 최종 게이트를 넣은 뒤의 한 main push 에서 주문 서비스 이미지 빌드가 실패했고 e2e 는 skip, gate 는 실패, publish 는 skip 됐습니다. 전파 조건이 failure 만 셌다면 skip 된 e2e 는 통과로 집계됐을 것입니다. 테스트 샤드가 실패한 다른 main push 두 번에서도 gate 실패, publish skip 이 나왔습니다.

PR 실행에서는 publish 가 skip 되므로 게시 경계는 main 에서만 확인할 수 있습니다. 머지 후 main 실행에서 음성 대조군이 08:17:28 에 끝나고, gate 가 08:17:31 에 시작해 08:17:45 에 성공했으며, publish 6개는 모두 그 뒤에 시작했습니다. 관리자도 이 체크를 우회하지 못하도록 브랜치 보호의 enforce_admins 를 켜고 다시 조회했습니다. 이 설정은 긴급할 때 관리자가 직접 병합하는 경로도 막으므로 예외 배포는 보호 설정을 바꾸는 별도 운영 작업이 됩니다.

같은 작업에서 검증한 이미지와 게시한 이미지가 같은지도 검사하게 했습니다. 이미지 빌드 잡은 docker save 파일과 함께 이미지 ID(config digest)와 파일 체크섬을 artifact 로 남기고, e2e 와 게시 잡은 불러온 이미지의 ID 를 그 값과 대조합니다. 게시는 커밋 SHA 태그를 먼저 push 합니다. 그리고 레지스트리의 manifest 가 가리키는 config digest 가 검증한 이미지 ID 와 같은지 확인한 뒤 그 manifest digest 를 그대로 latest 로 다시 태깅합니다(docker buildx imagetools create). 두 번째 docker push 나 재빌드로 latest 를 만들면 e2e 가 검증한 것과 다른 이미지가 될 수 있기 때문입니다. 다만 저장소 6개의 latest 를 한 번에 바꾸는 원자적 방법은 없어서, 게시 matrix 의 일부만 실패하면 일부 서비스만 새 이미지를 가리키는 상태가 생길 수 있습니다. 알고 남긴 한계였고 나중에 실제로 한 번 일어났습니다(정리하며 표).

e2e 잡 안의 셋업도 직렬이었습니다

e2e 두 모드가 각자 스택을 띄우게 되자 셋업 시간이 두 번씩 들었습니다. main push 실행 세 번에서 두 모드 모두 인프라 기동 26초, 토픽 사전 생성 6568초, 앱 기동 1825초씩 4회, readiness 2초였습니다.

토픽 사전 생성은 토픽 20종마다 docker compose exec 로 Kafka CLI(kafka-topics.sh)를 띄워 만들고 다시 토픽마다 같은 방식으로 파티션 수를 대조했습니다. docker exec 40회에 CLI JVM 기동 40회입니다. 이것을 컨테이너 안 셸 루프 한 번(생성 20회)과 전체 describe 한 번으로 묶었습니다. 생성 수와 파티션 수를 대조하는 검사는 그대로 두고 파티션 불일치, 생성 거부, describe 파서 고장을 하나씩 넣어 모두 실패로 끝나는 것을 로컬에서 확인했습니다. CLI JVM 은 여전히 21번 뜨지만 docker exec 와 셸 왕복은 2번이 됩니다.

컨테이너 안에서 생성 CLI 를 병렬로 띄우는 방법은 쓰지 않았습니다. 브로커가 기본 힙 1GB 로 메모리 상한 1GB 컨테이너를 이미 채우고 있어 CLI JVM 을 겹쳐 띄우면 브로커가 메모리 부족으로 죽는 일이 간헐 실패로 나타날 수 있다고 봤습니다. 로컬(8 CPU Docker Desktop, 같은 Kafka 이미지, 메모리 1GB 제한)에서 토픽 20개로 세 방식을 두 번씩 돌려 보면 토픽마다 exec 하는 방식이 7383초, 루프 한 번이 2728초, 병렬 생성이 13~15초였고 병렬 생성 두 번 모두 OOM 은 없었습니다. 두 번 무사했다는 것으로 메모리 위험이 없다고 말할 수는 없어 결정은 바꾸지 않았습니다.

앱 4개는 서로 의존이 없는데도 하나씩 띄우고 있었습니다. 코드 주석을 보면 이유는 예전에 동시에 띄웠을 때 CPU 경합으로 한 서비스가 기동 대기 시간을 넘겼다는 실측 하나였습니다. 앱 하나의 기동이 그때보다 짧아졌으므로 docker compose up 한 번으로 동시에 띄우되 PR CI 두 번 이상에서 한 번이라도 대기 시간을 넘기면 순차로 되돌리기로 했습니다.

구간(시나리오 / 음성 대조군, 초)순차 기동변경 PR다음 PR
토픽 사전 생성51 / 6633 / 3225 / 29
앱 기동(합계)72 / 8674 / 7460 / 60
기동 합계(인프라 포함)156 / 178133 / 132131 / 121

(PR 실행 세 개의 e2e 잡 로그에 찍힌 구간별 소요)

토픽 생성은 확실히 줄었습니다. 앱 동시 기동은 시나리오 쪽에서 72초가 74초로 오히려 늘기도 해서 실행 간 편차와 구별되지 않을 만큼 작았습니다. 4코어 러너에서 JVM 4개를 동시에 띄우면 서로 CPU 를 나눠 쓰니 합계가 크게 줄지 않았으리라고 보지만 러너의 CPU 사용률은 재지 않았습니다. 두 번 모두 대기 시간을 넘기지 않아 동시 기동을 유지했습니다. 이 단계 앞뒤 PR 실행의 전체 시간은 13분 01초에서 11분 46초였습니다(단일 실행끼리의 비교). 앞의 실행은 Java 를 바꾼 PR 이라 이미지 안에서 컴파일이 일어나 이미지 빌드가 더 길었으므로 이 차이를 셋업 변경의 효과로만 볼 수는 없습니다.

최종 형태

lint ---> images ---> e2e (scenarios)
               \----> e2e (negative-control)
test (6 샤드) ----------------------------------\
guards -------------------------------------------> gate ---> publish (main push 만)
lint, images, e2e 2개 ------------------------/

(현재 ci.yml 의 needs 에서 발췌해 그림으로 재구성)

  • 이미지 빌드는 lint 만 기다리고 테스트와 병렬로 돕니다.
  • gate 는 다섯 잡을 모두 기다리고 실패와 skip 을 실패로 셉니다. 브랜치 보호의 유일한 필수 체크이고 관리자에게도 적용됩니다.
  • publish 는 gate 만 기다리고 main push 에서만 돕니다. 검증한 이미지의 digest 를 그대로 latest 로 승격합니다.

PR 실행 하나의 전체 시간은 36분 51초에서 11분 46초가 됐습니다. 그 실행의 임계경로는 lint, images, e2e(음성 대조군), gate 이고 가장 긴 테스트 샤드의 여유는 5분 58초였습니다. 아래 타임라인은 단계마다 PR 실행 하나의 임계경로와 여유를 같은 축에 놓은 것입니다.

경로는 테스트 쪽과 e2e 쪽을 오갔습니다

단계마다 PR 실행 하나의 잡 막대를 같은 축에 그렸습니다. 진한 막대가 그 실행의 임계경로이고, 주황 점선이 경로 밖 잡의 여유(마지막 잡 종료까지)입니다. 단계를 누르면 그 단계에서 멈춥니다.

단계마다 다른 커밋의 PR 실행 하나씩이고 러너 변동이 섞여 있습니다. 막대는 jobs API 의 잡 시작, 종료 시각을 각 실행의 생성 시각 기준 초로 옮긴 것입니다. 여유는 마지막 잡 종료 기준이라, 실행 생성부터 갱신까지로 잰 전체 종료선과 1~2초 다릅니다. 테스트 줄은 가장 늦게 끝난 샤드, 이미지 줄은 e2e 직전에 끝난 서비스 하나만 그렸고 guards 와 publish(PR 이라 skip)는 생략했습니다. 문서 PR 단계는 코드 변경이 없어 이미지 빌드가 거의 캐시로 끝난 실행이고, 마지막 단계는 게이트 변경 뒤 e2e 셋업까지 정리한 실행입니다. 기준 단계의 36분 51초는 실행 생성부터 종료까지이고, 마지막 잡 종료 기준으로는 36분 49초입니다.

측정해 보니 하지 않기로 한 것: 테스트 잡의 Gradle 캐시

테스트 잡은 경로 밖에 있었습니다

테스트 잡의 Gradle 을 빠르게 하는 일(gradle.properties 신설, setup-gradle 로 Gradle 캐시 전환)은 위험이 없으니 언제든 하면 된다며 미뤄 둔 항목이었습니다. 처음 분석은 줄일 수 있는 컴파일 구간을 60초로 추정했는데 테스트 인프라를 바꾸기 전의 추정이라 착수 전에 다시 쟀습니다.

PR 실행성격테스트 잡 종료음성 대조군 종료음성 대조군 종료까지
셸 스크립트만 바꾼 PR이미지의 Gradle 레이어 전부 캐시 재사용12:35:4912:41:305분 41초
Java 를 바꾼 PR이미지 안에서 컴파일 발생07:57:0908:04:227분 13초

(PR 실행 두 개의 jobs API 시각. 앞 행은 11분 46초, 뒤 행은 13분 01초 실행. 여기서는 음성 대조군 종료를 기준으로 쟀고, 마지막 잡 종료 기준으로는 5분 58초와 7분 31초)

setup-gradle 의 캐시는 테스트 잡에 적용됩니다. 테스트 잡이 임계경로보다 5~7분 먼저 끝나니 여기서 몇십 초를 줄여도 PR 시간은 줄지 않고 러너 사용 시간만 줄어듭니다. 임계경로 위에서는 이미지 빌드 안, Dockerfile 의 bootJar 에서 Gradle 이 돕니다. 이 Gradle 은 docker build 안에서 돌기 때문에 러너의 Gradle 캐시가 적용되지 않고, 현재 Dockerfile 은 gradle.properties 를 복사하지도 않습니다. 그래서 하지 않기로 했습니다. 다시 볼 조건은 테스트 잡이 임계경로에 올라오거나 러너 사용 시간이 문제가 될 때입니다.

bootJar 35.6초를 다시 나눠 보니

하지 않기로 할 때 이미지 빌드 안의 bootJar 35.6초(Java 를 바꾼 PR 의 알림 서비스 이미지)도 나눠 봤습니다. 첫 태스크 헤더가 찍힌 27.96초 앞부분을 대부분 Gradle 기동과 설정으로 읽고 컴파일은 그 뒤 몇 초뿐이라고 본 것입니다. 나중에 같은 로그를 다시 보니 이 읽기의 근거가 약했습니다.

#35 [build 27/27] RUN ./gradlew :notification-service:bootJar -x test --no-daemon
#35 0.863 To honour the JVM settings for this build a single-use Daemon process will be forked.
#35 2.060 Daemon will be stopped at the end of the build
#35 27.96 > Task :common:compileJava
#35 27.96 > Task :common:processResources
#35 27.96 > Task :common:classes
#35 28.06 > Task :common:jar
...
#35 32.26 > Task :notification-service:compileJava
...
#35 35.46 > Task :notification-service:bootJar
#35 35.46 BUILD SUCCESSFUL in 35s

(Java 를 바꾼 PR 실행의 이미지 빌드 로그에서 발췌, 일부 줄 생략)

27.96초에 compileJava, processResources, classes 헤더 세 개가 같은 시각에 찍혔습니다. 서로 다른 태스크가 같은 순간에 시작했다고 보기는 어렵습니다. 이 로그만 보면 헤더는 태스크가 끝났거나 출력이 생길 때 찍힌 것으로 보입니다. Gradle 문서에서 plain 콘솔의 헤더 출력 시점 규칙은 찾지 못해 이 해석은 로그에 근거한 것입니다. 이렇게 읽으면 0초에서 28초 사이에는 기동과 설정에 더해 가장 먼저 도는 common 모듈의 컴파일도 들어 있습니다.

로컬에서도 다시 쟀습니다. 같은 Dockerfile 을 bootJar 직전까지 빌드한 이미지를 CPU 4개로 제한해 돌리고 Gradle --profile 을 붙였습니다. 세 번 돌린 결과 기동, 설정 로딩, 프로젝트 구성을 합쳐 약 79초, :common:compileJava 하나가 2547초였습니다. 줄마다 경과 시간을 붙여 따로 한 번 더 돌린 실행(이때 기동, 설정, 구성은 약 14초)에서는 :common:compileJava 헤더가 컴파일이 시작됐을 것으로 보이는 시점보다 약 40초 늦게 찍혔습니다. 로컬은 CPU 아키텍처(arm64)가 다르고 측정하는 동안 호스트 부하가 높아 절대값을 CI 와 비교할 수는 없습니다. 그래도 27.96초 앞이 거의 기동과 설정이라는 읽기는 이 결과와 맞지 않습니다. --no-daemon 인데도 Gradle 이 일회용 데몬 JVM 을 하나 더 띄운다는 것(로그 첫 줄)은 사실이지만 로컬 profile 에서 그 비용은 수 초 수준이었습니다.

하지 않기로 한 결정은 그대로입니다. 결정의 근거는 테스트 잡의 여유이고 이 분해와는 관계가 없습니다. 컴파일이 몇 초뿐이라는 설명만 고치면 됩니다. 다시 나눠 보면서 같은 이미지 빌드 잡의 다른 구간도 봤는데 type=gha 캐시를 내보내는 데 44.1초가 걸렸습니다. 이 구간은 처음 분석에 들어 있지 않았습니다.

검사기가 있어도 지키지 못하는 경우

같은 시기에 CI 에 한 번도 연결되지 않은 검사 스크립트 2개를 찾았습니다. 하나는 모든 @Scheduled 메서드에 분산 락(@SchedulerLock)이 붙어 있는지 확인하는 lint 였고 만들어진 뒤 11일 동안 어떤 CI 에서도 실행되지 않았습니다. 로컬에서 돌리면 통과하니 지키고 있는 것처럼 보였습니다. lint 잡에 넣은 뒤 @SchedulerLock 하나를 지우자 실패했습니다. 다만 이 lint 에는 --self-test 옵션이 없습니다. 일부러 망가뜨린 입력으로 검사 로직 자체를 확인하는 옵션입니다. lint 첫 줄에 echo "[SCHED-LOCK] OK"; exit 0 을 넣어 보니 락을 지운 상태에서도 0으로 끝났습니다. 누군가 검사 분기를 실수로 무력화해도 CI 는 알 수 없고, 지금 CI 에서 도는 lint 가운데 이런 것이 4개 있습니다.

같은 질문을 앞에서 만든 게시 게이트 검사 스크립트에도 던졌습니다. 이 스크립트에는 --self-test 가 있어 망가뜨린 입력 24가지를 모두 걸러 냅니다. 실제 워크플로 파일을 바꿔 가며 돌려 보니 gate 의 needs 에서 e2e 를 빼거나, gate 의 if 를 지우거나, publish 가 images 를 기다리게 하거나, 전파 조건에서 e2e 를 빼면 모두 실패로 판정했습니다. 그런데 전파 조건의 || 를 && 로 바꾸거나(모든 선행이 실패해야만 전파), 전파 스텝에 스텝 수준 continue-on-error: true 를 붙이면 통과했습니다. 스크립트가 조건식 안에 needs.<job>.result != 'success' 다섯 개가 있는지만 문자열로 확인하기 때문입니다. 스텝 수준 continue-on-error 는 스텝이 실패해도 잡을 통과시키는 설정이라(GitHub Docs) 두 변경 모두 선행이 실패해도 gate 를 초록으로 만듭니다. 위의 집계 잡 장면에서 4번과 5번 설정이 이 두 경우입니다.

다르게 할 수 있었던 것

아래는 작업을 마친 뒤 실행 기록과 재측정을 놓고 정리한 사후 분석입니다. 두 번째 항목의 시뮬레이션만 작업할 때 검증 계획으로 세워 두었고 나머지는 그때 검토하지 않은 대안입니다. 모두 효과를 재 보지 않은 해석입니다.

예측 전에 두 경로를 실제 실행으로 재기. “19분에서 약 16분” 은 두 경로를 비교한 숫자였지만 이미지 빌드와 e2e 경로의 길이는 실제 실행에서 재지 않고 추정했습니다. 직전 실행에서 그 경로가 테스트 경로와 6초 차이였다는 것은 잡 시각만으로 알 수 있었습니다. 임계경로와 각 잡의 여유를 매 CI 실행의 job summary 나 artifact 로 남기면 이 잡을 줄였을 때 전체가 줄어드는지를 예측 전에 볼 수 있습니다. 앞의 계산 스크립트 정도면 충분합니다. 단일 실행의 여유는 러너 변동에 흔들리므로 여유가 수십 초 안쪽이면 동률로 보고 두 경로를 함께 줄여야 합니다.

게이트 검사를 문자열 대신 실행 결과로 하기. 최종 게이트는 “다섯 선행 각각의 실패와 skip 에 대해 gate 결과가 실패가 되는지 시뮬레이션한다” 는 계획으로 검증하려 했지만, CI 에 남은 검사는 조건식 문자열을 확인하는 방식이었습니다. 실행 결과를 계산하는 검사가 CI 에 들어갔다면 && 나 스텝 수준 continue-on-error 같은 변경도 결과로 걸러졌을 것입니다. 조건식을 파싱해 선행 결과 조합(성공, 실패, skip)마다 전파 스텝이 실행되는지 계산하고 필수 잡과 전파 스텝에는 continue-on-error 를 금지하는 방식입니다. GitHub 표현식 문법 전부를 해석하기는 부담스러우니 허용하는 조건식 형태를 하나로 고정하고 그 형태만 해석하면 감당할 만합니다.

필수 체크 이름을 워크플로와 대조하기. 존재하지 않는 build 를 요구하던 상태는 24일 동안 드러나지 않았습니다. 관리자에게도 보호를 적용하는 것(최종적으로 그렇게 했습니다)과 함께, 브랜치 보호 API 의 필수 체크 이름이 현재 워크플로의 잡 이름(또는 name:)에 있는지 정기적으로 대조하는 방법이 있습니다. 잡 이름을 바꾸는 PR 은 흔하고 브랜치 보호는 git 이 추적하지 않는 저장소 설정이라 코드 리뷰에서 보이지 않습니다. 대조하려면 저장소 설정을 읽을 권한이 필요합니다.

검사기의 연결과 자기 검사를 검사하기. 연결되지 않은 lint 는 전수조사로 찾았고 다음에 생길 미연결을 막는 장치는 없습니다. 검사 스크립트 각각이 워크플로에서 호출되는지(또는 사유가 있는 예외 목록에 있는지) 확인하는 메타 검사와, 자기 검사가 없는 lint 목록을 따로 둘 수 있습니다. 메타 검사도 같은 질문을 받으므로 메타 검사에는 자기 검사를 반드시 붙여야 합니다.

Dockerfile 이 필요한 빌드 파일만 복사하게 하기. 어느 이미지든 모든 모듈의 build.gradle 을 복사하므로 한 모듈의 테스트 설정만 바꿔도 이미지 6개의 의존성 레이어가 다시 만들어집니다. 멀티모듈 저장소에서 흔한 구조입니다. 다만 지금 구조에도 이유가 있습니다. Gradle 설정 단계가 settings.gradle 에 포함된 모든 모듈을 평가하므로 어느 서비스를 빌드하든 모든 모듈의 빌드 파일과 소스를 함께 복사합니다. 대안은 서비스별로 필요한 모듈만 포함하는 설정과 빌드 파일을 복사하거나, BuildKit 의 RUN --mount=type=cache 로 Gradle 의존성 디렉터리를 레이어 밖 캐시에 두는 것입니다. 앞의 방법은 모듈 의존 목록을 Dockerfile 이 알아야 해서 목록 동기화 검사가 필요하고, 뒤의 방법은 type=gha 캐시와 별도로 동작하는 캐시 마운트를 CI 에서 어떻게 유지할지 따로 정해야 합니다.

정리하며

  • CI 전체 시간은 잡 그래프의 임계경로 하나가 정합니다. 기준 실행의 임계경로는 test(order), gate, images, e2e 네 잡으로 2095초였고, images 가 gate 를 기다리지 않게 바꾸자 PR 실행이 36분 51초에서 21분 51초가 됐습니다.
  • 두 경로가 몇 초 차이로 붙어 있으면 한쪽을 줄여도 전체는 다른 쪽까지만 줄어듭니다. 이 저장소에서는 테스트 경로와 e2e 경로가 6초, 14초 차이로 번갈아 경로가 됐고, 테스트 잡을 13분 넘게 줄인 실행은 Dockerfile 레이어 무효화로 이미지 빌드가 길어진 것과 겹쳐 오히려 1분 46초 늘었습니다. 모두 단일 실행끼리의 비교입니다.
  • needs 간선은 순서이면서 게이트였습니다. 이미지 빌드 앞의 대기를 끊자 테스트를 거쳐서만 막히던 게시 경로가 사라졌고, 이어서 !cancelled() 집계 잡, skip 을 통과로 보는 필수 체크, 존재하지 않는 체크 이름을 요구하던 브랜치 보호가 차례로 드러났습니다. 지금은 gate 가 다섯 잡의 실패와 skip 을 실패로 세고, 병합과 게시가 같은 gate 를 기다립니다.
  • 테스트 잡의 Gradle 캐시는 테스트 잡의 여유가 5~7분이라 PR 시간을 줄이지 못해 하지 않았습니다. 그 결정에 붙은 bootJar 구간 분해는 다시 재 보니 근거가 약했지만 결정의 근거인 여유는 그대로입니다.

남은 항목은 아래와 같습니다.

항목현재 상태
게시 게이트 검사 스크립트가 전파 조건의 && 와 스텝 수준 continue-on-error 를 걸러 내지 못한다할 일로 등록, 미처리
CI 시간을 정리한 내부 기록 일부(동률이던 경로, 이미지 빌드가 길어진 원인 등)가 원본 실행과 다르다할 일로 등록, 미처리
Gradle 캐시를 하지 않기로 한 결정에 붙은 구간 분해를 CI 안에서 다시 재야 한다할 일로 등록, 미처리
검사기의 CI 연결과 자기 검사 여부를 강제하는 장치가 없다(자기 검사 없는 lint 4개)할 일로 등록, 미처리
6개 latest 승격이 원자적이지 않다. 이후 한 main 실행에서 결제 서비스 게시만 레지스트리 오류로 실패해 5개만 새 이미지를 가리킨 적이 있다다음 main 실행으로 해소. 근본 원인과 대응 절차는 할 일로 등록, 미처리
e2e 두 모드가 각자 이미지를 로드하고 스택을 띄워 러너 사용 시간이 늘었다수용. 합치면 PR 시간이 다시 는다
앱 동시 기동의 이득이 실행 간 편차와 구별되지 않는다수용. 대기 시간 초과가 나면 순차로 되돌린다
이미지 빌드 잡의 type=gha 캐시 내보내기(약 44초)가 임계경로 위에 있다미추적. 측정만 했다
이후 시나리오가 늘면서 현재 main 실행의 임계경로는 e2e 시나리오 잡(714초)이다관찰. 병목은 다시 옮겨 갔다

참고