CI

로컬에서 빨라진 테스트가 CI에서는 느려졌다

  • #AI
  • #Claude Code
  • #CI
  • #Gradle
  • #테스트

문제: PR마다 CI가 8분

  • 구성 — 빌드 2분 51초 + 테스트 5분 08초
  • 체감 — PR을 올리고 커피 한 잔
  • 시작점 — 추측하지 않고 실제 run 로그를 열어 구간별로 쟀다

원인은 네 가지였고, 전부 로그에 있었다.

  • Gradle 캐시가 전혀 없다 — 매 실행이 의존성 해석부터. 초기 30초 + APT 컴파일 2분
  • clean이 무의미하다 — 러너는 매번 새로 뜨므로 지울 게 없다. 로그상 9개 모듈 전부 UP-TO-DATE. 오히려 빌드 캐시를 무효화한다
  • Gradle을 두 번 호출bootJar, test를 따로 불러 기동·설정 단계를 중복 지불(약 12초)
  • 중복 실행 방치 — 같은 PR에 새 커밋을 밀어도 이전 CI가 끝까지 돈다. 최근 100회 중 32회가 이런 중복 실행이었고, 한 PR은 10회였다

마지막 항목이 특히 컸다. 러너 시간의 3분의 1을 이미 의미 없어진 커밋을 검증하는 데 쓰고 있었다.

1차 조치: 캐시·중복 제거

  • concurrency + cancel-in-progress — 같은 PR의 이전 실행 취소
  • gradle/actions/setup-gradle@v4 — 의존성·빌드 캐시 사용
  • clean 제거 + bootJar·test한 번의 Gradle 호출로 병합
  • gradle.properties 신설 — build cache, parallel, 힙 상향

캐시 쓰기 범위는 나눴다.

  • 캐시 쓰기 — dev 브랜치에서만
  • PR 브랜치 — read-only

PR 브랜치가 캐시를 쓰게 두면 아직 검증되지 않은 빌드 산출물이 공용 캐시에 올라간다. 읽기만 허용하면 그 오염을 막으면서 이득은 그대로 가져간다.

검증도 캐시가 실제로 도는지까지 확인했다.

  • 빌드 디렉터리 전삭제 후 캐시로 22개 태스크 복원, BUILD SUCCESSFUL
  • 격리된 GRADLE_USER_HOME에서 콜드 빌드 2회 — 56초 → 32초
  • libs 삭제 후 bootJar 재실행 시 배포 JAR 정상 생성 (stale 아티팩트 위험 없음)

마지막 항목이 중요하다. 캐시를 켜면 “빌드는 성공했는데 산출물이 옛날 것” 인 사고가 가능해진다. 배포 JAR이 제대로 다시 만들어지는지 따로 확인해야 한다.

남은 5분: 테스트

빌드는 줄였지만 테스트 5분은 그대로였다. 이건 캐시로 안 줄어든다.

  • 대부분 — MySQL Testcontainers 기동과 스키마 로딩
  • 줄이려면 — 테스트 코드 구조 변경
  • 판단 — 범위를 분리하고 커밋 메시지에 명시

여기서 한 번 더 재보니 구조가 보였다. CI 테스트 234초의 대부분이 모듈별 순차 실행이었다.

feature_ghg 66s → platform 38s → feature_lca 29s

CPU는 한가한데 모듈이 줄을 서 있었다.

2차 조치: 모듈 내부를 여러 JVM으로 분할

maxParallelForks로 모듈 안에서 테스트를 여러 JVM에 나눠 돌렸다.

가장 신경 쓴 건 속도가 아니라 격리였다. 테스트를 병렬로 돌리면 DB를 공유해 서로를 깨뜨리는 게 보통이다. 그런데 이 경우는 반대였다.

  • maxParallelForksJVM을 분리한다
  • 각 포크가 자기 인메모리 H2(h2:mem:test)를 갖는다
  • 이름이 고정이어도 JVM이 다르면 서로 다른 DB
  • 따라서 @DataJpaTest들이 오히려 격리된다

반대로 같은 JVM 안에서 도는 JUnit 병렬 실행은 이 DB를 실제로 공유하므로 켜지 않았다. 같은 “병렬”이라도 프로세스 경계가 어디냐에 따라 안전성이 정반대다.

한 모듈은 제외했다.

  • 대상 — LCA 기능 모듈
  • 이유 — MySQLTestEnvironment의 Testcontainers 싱글턴이 JVM 단위
  • 결과 — 포크를 늘리면 MySQL 컨테이너가 포크 수만큼 뜬다. 기동 비용이 이득을 상쇄

로컬에서 재봤다. 잘 나왔다.

대상 이전 이후
테스트 합계 179s 104.6s
feature_ghg 66.2s 46.6s
platform 38.1s 22.0s
feature_lca 28.9s 10.4s

테스트 2139개 전부 통과, 실패·에러 0. 75초를 줄였다.

그런데 CI에서는 느려졌다

머지하고 CI 실측치를 확인했다.

대상 이전 이후
feature_ghg 66.2s 81.4s
feature_lca 28.9s 31.9s
테스트 합계 179s 195.8s

17초 악화. 로컬에서 75초를 줄인 그 변경이다.

원인은 러너 사양이었다.

  • 포크마다 드는 비용 — JVM 기동 + Spring 컨텍스트 로딩
  • 로컬 — 10코어. 병렬 이득이 그 비용을 크게 넘는다
  • GitHub 무료 러너 — 2코어. 이득이 비용을 못 넘는다

측정 자체는 했었다. 문제는 측정한 환경이 적용할 환경이 아니었다는 것이다. 커밋 메시지에 이렇게 적었다.

로컬 10코어에서는 179s → 102.6s 로 크게 줄어 이득처럼 보였으나 그 수치는 CI 에 적용되지 않았다. 로컬 결과만으로 판단한 것이 잘못이었다.

되돌렸다. 다만 전부는 아니다.

  • maxParallelForks — 제거
  • 같은 PR에서 함께 넣었던 BCrypt 라운드 조정 — 유지

한 커밋에 두 변경이 있었는데 한쪽만 역효과였다. 실측 대상을 나눠 봤기에 나눠서 되돌릴 수 있었다.

실패를 코드에 남기기

되돌리면서 그냥 지우지 않고, 같은 자리에 경고를 남겼다.

// ⚠️ maxParallelForks 는 넣지 말 것 — CI 에서 실측상 역효과다.
// 시도 결과(run 31473990652, GitHub 무료 러너 2코어):
//   feature_ghg  66.2s → 81.4s   feature_lca  28.9s → 31.9s
//   테스트 합계   179s  → 195.8s  (17초 악화)
//
// 포크마다 JVM 기동과 Spring 컨텍스트 로딩 비용이 따로 드는데, 러너가 2코어라
// 병렬로 얻는 이득이 그 비용을 넘지 못한다. (테스트 격리 자체는 문제없었다
// — 2139개 전부 통과. 순수하게 비용 문제다.)
//
// 다시 시도한다면 러너를 4코어 이상으로 올린 뒤 CI 에서 직접 측정할 것.

이 주석에 넣은 것들:

  • 실측치와 run 번호 — 나중에 원본 로그를 다시 볼 수 있게
  • 실패한 이유 — “안 된다”가 아니라 “2코어라서 안 된다”
  • 격리는 문제없었다는 사실 — 다음 사람이 엉뚱한 걱정을 하지 않게
  • 다시 시도할 조건 — 러너 4코어 이상. 영구 금지가 아니라 조건부 보류

maxParallelForks는 검색하면 좋다고 나오는 옵션이다. 주석이 없으면 반년 뒤 누군가(나 포함) 같은 걸 다시 넣고 같은 17초를 잃는다.

안 켠 것

한 가지 더 남겨뒀다.

  • 대상 — Gradle configuration-cache
  • 이유 — 당시 sonarqube 플러그인 버전이 비호환
  • 판단 — 플러그인 상향 후 별도 검증 필요. 이번 범위에서 제외

이것도 커밋에 적었다. 안 한 이유를 적어두지 않으면 다음 사람은 “왜 이건 안 켰지”부터 다시 조사한다.

남는 교훈

이번 작업의 순서는 이랬다.

단계 한 일 결과
1 run 로그로 구간별 측정 원인 4개 특정
2 캐시·중복 제거 빌드 구간 단축
3 로컬에서 포크 병렬 측정 179s → 104.6s
4 CI 실측 179s → 195.8s, 되돌림

1단계는 잘했다. 추측 대신 로그를 열었고, “캐시가 없다”·”중복 실행 32회” 같은 구체적 원인이 나왔다.

3단계에서 무너졌다. 측정은 했는데 측정 환경과 적용 환경이 달랐다. 로컬 10코어와 러너 2코어는 5배 차이인데, 포크 병렬화는 정확히 코어 수에 민감한 기법이다. 하필 가장 환경 의존적인 최적화를 다른 환경에서 재고 판단했다.

지금은 이렇게 나눠 생각한다.

  • 환경 무관한 개선 — 중복 호출 제거, 불필요한 clean 제거. 어디서 재도 방향이 같다
  • 환경 의존적 개선 — 병렬도, 힙 크기, 캐시 히트율. 반드시 적용할 환경에서 재야 한다

두 번째에 속하는 걸 첫 번째처럼 다뤘던 게 이번의 실수였다.