BE-commerce 프로젝트
약 6분 분량 테스트·검증

PR마다 18~35분 걸리던 통합 테스트를 전부 돌아도 3분으로 줄였다

목차

PR마다 18~35분 걸리던 실 DB 통합 테스트 잡이 전부 돌아도 3.2분, 바뀐 모듈만 골라 돌면 1.7분이 됐습니다. 줄인 순서는 로그가 알려준 순서를 따랐습니다. 측정용 테스트 넷을 매일 한 번 도는 워크플로로 옮겼습니다. 클래스마다 새로 띄우던 MySQL은 한 번만 띄웠습니다. 그리고 Spring Modulith로 PR이 바꾼 모듈의 테스트만 고르게 했습니다. 그 과정에서 제가 넣은 종료 훅이 60초를 쓰는 것과 테스트 리포트에 잡히지 않던 데이터 적재 2.5분을 찾았습니다. 효과가 편차보다 작았던 개선 하나는 닫았습니다.

결론부터

  • 통합 테스트는 모두 실행해도 3.2분, 변경된 모듈만 선택하면 1.7분으로 줄었습니다. 핵심은 테스트를 무작정 병렬화한 것이 아니라 로그에서 대기 구간을 분리한 것입니다.
  • MySQL 컨테이너를 클래스마다 띄우던 구조를 JVM당 한 번으로 바꾸고, 측정용 테스트와 30만 행 데이터 적재를 PR 경로에서 분리했습니다. 남아 있던 60초 종료 훅도 제거했습니다.
  • 이 개선은 현재 저장소의 모듈 경계와 GitHub Actions 실행 환경에 맞춘 결과입니다. 모듈 의존성이 바뀌거나 러너가 달라지면 선택 실행의 안전성과 시간을 다시 확인해야 합니다.

느린 CI를 어떻게 쪼개 봤나

이 저장소의 CI에는 잡이 다섯 개 있고 기다리는 시간은 가장 긴 잡이 정합니다. 네 개는 2분 안에 끝나고 남는 것이 Testcontainers로 실제 MySQL·Redis를 띄워 도는 통합 테스트 잡입니다. 보통 18~19분, 느릴 때 35분이 걸렸습니다. ADR 두 줄을 고친 PR도 이 잡을 18분 기다렸습니다.

통합 테스트 잡의 시간이 어디서 나갔고 무엇을 해서 줄였나

35분은 어디서 나갔나

35분 걸린 실행의 로그를 쪼갰습니다.

항목시간무엇
MySQL 컨테이너 기동약 8.7분테스트 클래스마다 새로 띄웠다. 25번 × 약 21초
스프링 컨텍스트 기동약 6.3분16번. 번마다 새 DB에 마이그레이션 68개
테스트 실행17.2분그중 측정용 네 클래스가 약 14분

측정용 네 클래스는 인덱스의 쓰기 비용, FDS 판정과 승인을 합친 지연, 원장 잔액 조회 비용, 같은 행 경합을 재는 테스트입니다. 넷 다 결함을 막는 검사보다 수치를 남기는 실험에 가깝습니다.

무엇을 했나

단계통합 테스트 잡한 일
이전18~19분(느리면 35분)
19.9분측정 테스트 넷을 매일 새벽 3시 워크플로로 옮겼다. 바뀐 파일이 전부 문서면 통합 테스트 잡을 건너뛴다
27.0분MySQL·Redis를 JVM에서 한 번만 띄우고 클래스마다 새 DB와 빈 Redis를 준다
3전부 돌 때 4.9분PR이 바꾼 모듈과 거기 기대는 모듈의 테스트만 돈다. main 푸시는 늘 전부 돈다
4종료 단계 61초 → 1초2단계에서 넣은 종료 훅을 걷어냈다(아래)
5전부 3.2분 · 골라 1.7분30만 행을 채우는 인덱스 실측 둘을 측정 워크플로로 옮겼다(아래)

2단계는 격리를 그대로 두는 것이 조건이었습니다. 예전에는 클래스마다 빈 컨테이너였으니, 컨테이너를 나눠 쓰되 클래스마다 새 데이터베이스를 만들고 Redis를 비웠습니다. 한 JVM에서 MySQL 컨테이너 생성은 25번에서 1번이 됐습니다.

Spring Modulith로 고르기

이 저장소는 Spring Modulith로 모듈 경계를 검사합니다. 1.3부터는 git 변경분을 보고, 바뀐 모듈과 거기에 기대는 모듈의 테스트만 돌리고 나머지는 “not affected by changes”로 건너뛰는 확장이 있습니다. 붙이면서 세 군데서 걸렸습니다.

1.3.1에서는 판단 단계가 끝나지 않았습니다. 모든 모듈의 전이 의존을 재귀로 푸는 곳에서 CPU를 10분 넘게 쓰며 멈췄습니다(스레드 덤프로 확인). 1.3.12로 올리자 2초에 끝났습니다.

건너뛴 이유가 결과 파일에 남지 않았습니다. 이 CI에는 “건너뛴 테스트는 통과가 아니다” 검사가 있습니다. 환경변수가 없어 조용히 건너뛴 테스트가 초록불 아래로 나가는 것을 막으려는 검사입니다. 그런데 JUnit XML에는 Modulith가 건너뛴 이유가 남지 않아서 “변경 영향 없음”과 “조용히 빠진 것”을 가를 수 없었습니다. 테스트 실행 중에 이유를 파일로 적는 리스너를 두고 검사가 그 파일로 둘을 가르게 했습니다. 이유 없이 빠진 테스트는 지금처럼 실패합니다.

모듈 밖 도우미가 바뀌면 전부 건너뛸 수 있었습니다. 공용 컨테이너 코드는 어느 모듈에도 속하지 않습니다. 그 파일만 고친 PR은 Modulith가 변경을 어느 모듈에도 대지 못해, 통합 테스트를 거의 다 건너뛴 채 초록이 될 수 있었습니다. 그런 파일이 바뀌면 최적화를 끄게 했습니다. 설정 파일이나 마이그레이션이 바뀌어도 전부 돕니다.

실제 PR에서 고른 결과

Modulith가 처음 골라 돈 PR은 order 모듈 코드만 바꿨습니다. 78개 중 47개(결제·정산·개인화 등 15개 클래스)를 “변경 영향 없음”으로 건너뛰었습니다. 그런데 잡 시간은 전부 돌 때와 비슷했습니다.

로그의 긴 공백 둘을 따라가 보니 order 모듈의 인덱스 실측 두 개였습니다. 주문 30만 행과 정산 항목 30만 행을 채우는 데 78초, 72초를 썼습니다. 적재가 @BeforeAll에서 돌아 테스트 리포트의 클래스 시간에는 잡히지 않았습니다. 그래서 처음 측정 테스트를 골라낼 때 놓쳤습니다. 둘 다 인덱스 효과를 재는 실험이라 측정 워크플로로 옮겼습니다. 인덱스가 실제 스키마에 있는지는 다른 테스트가 PR마다 지킵니다. 옮긴 뒤 order 테스트만 바꾼 PR은 1.7분이었습니다.

그때 하나를 더 확인했습니다. Modulith는 테스트 클래스 자신이 바뀌면 그 테스트를 돌리지만 테스트 코드 변경을 모듈 변경으로 치지는 않습니다. 모듈 안의 테스트 도우미(이름이 Test로 끝나지 않는 픽스처)만 바뀐 PR은 그 도우미를 쓰는 테스트를 안 돌릴 수 있습니다. 그래서 모듈 안팎을 가리지 않고 테스트 도우미가 바뀌면 전부 돌게 넓혔습니다.

제가 넣은 종료 훅이 60초를 썼다

2단계에서 공용 컨테이너를 JVM 종료 때 직접 멈추는 훅을 넣었습니다. 이 노트북의 Docker Desktop에서는 Testcontainers의 정리 컨테이너(Ryuk)가 소켓을 마운트하지 못해 떠 있지 않았고 실행마다 MySQL이 남았기 때문입니다.

그런데 JVM 종료 훅은 순서가 없습니다. 스프링 테스트 컨텍스트가 닫히기 전에 MySQL이 먼저 멈췄고 컨텍스트마다 커넥션 풀이 죽은 DB를 검증하며 기다렸습니다. 전부 도는 실행마다 종료 단계가 61초, 커넥션 검증 실패가 240번이었습니다. 잡 시간이 러너마다 흔들려 이 60초는 잡 시간으로는 보이지 않았습니다. 로그의 공백을 쪼개다 찾았습니다.

CI(리눅스)에서는 Ryuk이 JVM이 끝난 뒤 컨테이너를 치웁니다. 그래서 훅은 Ryuk을 끈 경우에만 걸게 했습니다. 로컬에서는 Docker Desktop 소켓 경로를 따로 알려 주면 Ryuk이 뜹니다. 고친 뒤 종료 단계는 1초 안, 검증 실패는 0이었습니다.

닫은 개선: 템플릿 DB 복사

다음 후보는 클래스마다 새 DB에서 도는 마이그레이션이었습니다. 한 번만 마이그레이션한 템플릿 DB를 복사하면 번마다 약 10초를 줄일 것으로 봤습니다.

만들어서 CI에 올린 뒤 로그로 다시 셌더니, 10초는 컨테이너를 매번 새로 띄우던 때의 값이었습니다. 컨테이너를 나눠 쓴 뒤에는 마이그레이션이 번마다 1.3~1.6초(17번 합계 22~28초)였습니다. 줄일 몫이 20초 남짓인데, 같은 조건의 잡 시간이 러너마다 4.9~7.0분으로 흔들렸습니다. 이득이 편차에 묻혀 닫았습니다.

만들면서 함정 둘을 밟았습니다. 템플릿 이름을 static final String 상수로 뒀더니 컴파일러가 값을 박아 넣어, 템플릿을 만드는 클래스 초기화가 아예 돌지 않았습니다. 그리고 없는 DB를 덤프한 실패를 파이프 뒤쪽 mysql의 성공이 삼켜, 빈 DB가 조용히 복사됐습니다. 앱의 마이그레이션이 68개를 처음부터 다시 도는 로그를 보고 알았습니다.

정리

  • 기다리는 시간을 정하는 잡은 하나였습니다. 그 잡의 시간을 로그로 쪼개자 어디를 줄일지 보였습니다
  • 측정 테스트는 매일 한 번 돕니다. 문서만 바뀐 PR은 통합 테스트를 건너뛰고 나머지는 바뀐 모듈만 돕니다. 설정·마이그레이션·빌드 파일이나 테스트 도우미가 바뀌면 전부 돌고 main 푸시도 늘 전부 돕니다
  • 같은 조건의 잡 시간이 1~2분 흔들립니다. 개선의 효과는 잡 시간 한 번보다 로그의 구성 요소(컨테이너 생성 횟수, 컨텍스트 기동 합, 마이그레이션 시간 합, 긴 공백)로 가릅니다. 종료 훅의 60초와 적재 2.5분은 둘 다 테스트 리포트에 잡히지 않았습니다
  • 도커 이미지 빌드는 이 잡과 동시에 돌아 대기 시간과 상관없었습니다. 다만 Ubuntu 미러가 동기화 중일 때 실패한 적이 있어 레이어 캐시를 붙였습니다. 같은 커밋을 다시 돌리면 32초에 끝납니다
프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.