채널톡 메인 백엔드 서버의 CI를 약 36.6분에서 보통 15분 안팎, 캐시가 잘 맞으면 10분대 초반까지 줄인 기록이다. 출발점은 메인 서버가 아니라 더 작은 Go 서비스였고, 거기서 확인한 패턴을 큰 시스템에 옮겼다. AI 에이전트가 코드를 빠르게 만들면서 병목이 구현 속도에서 피드백 속도로 옮겨갔다는 문제의식에서 시작하며, 각 단계마다 AI가 잘한 일과 사람이 해야 했던 일을 함께 짚는다.
핵심 포인트- 작은 서비스에서 배운 패턴 셋은 공유 상태를 없애야 병렬화가 열린다, 생성물은 매 작업마다 다시 만들지 않는다, 직렬처럼 보이지 않는 직렬 구간이 숨어 있다는 것이었다.
- "병렬 옵션을 켠다"와 "병렬 실행이 가능한 시스템을 만든다" 사이에는 큰 차이가 있었다.
- 러너를 50개까지 늘리자 병목이 CPU와 테스트 코드에서 네트워크와 이미지 레지스트리로 옮겨갔다. 미러가 차가우면 75~78분, 워밍되면 18~25분으로 극단적으로 흔들렸다.
- 그래서 평균적으로 가장 빠른 설정 대신 변동성이 작고 운영 가능한 설정인 20 러너로 되돌리고 이미지 미러 워밍도 제거했다.
- 고정 샤딩은 느린 노드 하나가 전체를 끌고 간다. 동적 큐로 바꾼 뒤 노드 종료 편차가 약 6분에서 약 1분 36초로 줄었다.
상세 정리- 순서 전략: 테스트 수가 많고 의존 서비스도 많은 메인 서버부터 시작하면 무엇이 진짜 병목인지 놓치기 쉬워, 규모가 작고 실험하기 쉬운 Go 기반 앱 연동 서비스에서 먼저 답을 찾았다. 이 순서를 정한 것은 사람이었고 AI가 먼저 제안한 접근이 아니었다고 밝힌다.
- 작은 서비스의 원인 셋: 테스트가 공유 데이터베이스를 써서 병렬 실행을 못 했고, 여러 CI 작업이 같은 생성 작업을 반복했으며, 독립적인 작업 사이에 불필요한 직렬 의존이 숨어 있었다.
- 저자가 먼저 한 일은 "CI가 느리다"는 한 문장을 세 개의 하위 문제로 바꾸는 것이었다. 그 뒤에야 AI에게 각 문제의 수정안을 여러 버전 만들게 할 수 있었다.
- DB 격리: 하나의 PostgreSQL을 공유하고 테스트마다 전체 테이블을 비우던 구조를, migration이 적용된 template DB에서 각 테스트가 자기 전용 DB를 몇 ms 단위로 복제해 쓰도록 바꿨다. 이 단계만으로 test 작업이 579초에서 261초로 내려갔다.
- 생성 코드 정리: 여러 작업이 매번 PostgreSQL을 띄우고 migration을 적용하고 도구를 설치해 코드를 다시 만들고 있었다. 자동 생성 코드를 git에 커밋하고 생성 결과가 최신인지 검증하는 일만 별도 diff 작업으로 분리했으며, mockery도 v2에서 v3로 올렸다. PostgreSQL이 필요한 작업이 4개에서 2개로 줄었다.
- 숨은 직렬 발견: 프런트엔드 산출물이 Go 바이너리에 직접 포함되는 게 아니라 Docker build 시점에 복사되는 구조여서 백엔드 빌드가 기다릴 이유가 없었다. 분리하자 5개 작업이 완전히 병렬로 돌아 전체 경로가 약 10분에서 약 3분 20초로 줄었다.
- 메인 서버 규모: 메시징·유저 관리·워크플로우 같은 핵심 로직이 든 Java 서비스로 약 1,300개 테스트 클래스와 약 9,800개 테스트를 검증하며, PostgreSQL·Redis·Kafka 호환 브로커·OpenSearch·LocalStack·DynamoDB Local을 함께 준비해야 한다.
- 초기 상태: 15개 테스트 노드가 각자 준비 작업을 반복하는 구조였고 공통 prepare가 없어 같은 준비 비용을 노드 수만큼 되풀이하고 있었다.
- 1단계 prepare 분리: compile과 migration을 한 번만 수행하고 결과물을 모든 노드가 재사용하게 바꿨다. 다만 그보다 먼저 어떤 테스트를 병렬로 올릴 수 있는지 구분하는 기반이 필요했다.
- 테스트 초기화 모드 전환: 느린 통합 테스트 다수가 각 테스트 메서드마다 public 테이블 전체를 비우고 sequence를 초기화한 뒤 공용 기본 데이터를 다시 넣는 모드에 묶여 있었다. 안전하지만 무거웠고 하드코딩된 ID에 기대는 테스트가 많아 병렬로 올리기 어려웠다.
- 새 모드는 TRUNCATE 없이 테스트마다 고유 Channel·Account·Manager를 갖게 하는 방식인데, annotation만 바꾸면 되는 작업이 아니었다. seed data가 깔린다고 가정하던 테스트, 공용 관리자 계정을 써야 하는 endpoint, 전역 unique key나 userId 충돌로 되돌린 테스트가 반복해서 나와 여러 라운드에 걸쳐 조금씩 옮겼다.
- 격리 범위: PostgreSQL만 격리하면 끝이 아니었다. Redis·OpenSearch·DynamoDB·인메모리 저장소는 테스트 클래스별 고유 접두어로 격리했고, HTTP 요청과 비동기 executor에 같은 식별자가 끝까지 전파되도록 맞췄으며, 공유한 Kafka 호환 브로커도 cleanup 비동기 작업에서 식별자가 끊기지 않게 손봤다.
- 병렬 실행을 켜면서 공유 상태 때문에 깨지는 테스트를 실제로 찾아 고쳤다. 상대 순위 계산을 바꾸거나 비결정적 assertion을 수정하거나 무거운 테스트를 덜 비싼 setup 경로로 옮겼다.
- 2단계 전환 시간: prepare가 끝나고 테스트가 실제로 시작되기 전까지의 빈 대기가 길었다. 질문을 "prepare를 더 줄일까"에서 "기다리는 시간을 없앨 수 없을까"로 바꿨다.
- 조치: S3 업로드·다운로드와 압축 해제·DB 복원을 병렬화하고, 각 노드가 Java 소스 전체 대신 필요한 파일만 sparse checkout 하게 하고, 작업 의존으로 기다리는 대신 S3 폴링으로 준비 결과물을 기다리게 했다. 노드가 자기 초기화를 먼저 시작하고 결과물이 준비되는 즉시 이어가는 구조다.
- 실측에서 전환 시간이 중앙값 기준 약 7분 줄었다.
- 3단계 러너 수 실험: 15, 25, 30, 40, 50까지 바꿔 봤고 50노드 설정도 한번 들어갔다. 많은 노드가 한꺼번에 여러 서비스 이미지를 pull하면서 테스트 자체보다 init container와 image pull이 더 큰 병목이 됐다.
- 4단계 캐시: "같은 입력인데 왜 또 compile을 해야 하지"라는 질문에서 나온 것이 입력 해시 기반 캐시다. Java 소스·테스트·submodule·build 설정·런타임 환경 정보를 묶어 해시를 만들고, 같으면 prepare 결과를 통째로 재사용해 compile과 DB snapshot 생성을 생략한다.
- 저자는 여기서 중요한 것이 캐시를 붙이는 게 아니라 어떤 입력이 같으면 같은 계산으로 봐도 되는지를 먼저 정의하는 일이었다고 짚는다.
- 5단계 동적 큐: 최근 5회 JUnit 실행 시간을 바탕으로 60개 배치를 만들고 20개 노드가 S3 큐에서 하나씩 가져가 처리한다. 빨리 끝난 노드가 더 많이 가져가는 구조다.
- 구현 포인트 둘: 단일 JVM 안에서 여러 배치를 연속 실행하게 해 Dropwizard 서버를 매 배치마다 다시 띄우지 않게 했고, 메서드 병렬 실행이 가능한 테스트는 실행 시간을 그대로 쓰지 않고 실제 완료 시간에 가까운 값으로 계산해 배치했다.
- 결과로 평균 테스트 노드 소요 시간이 약 15분 30초에서 약 8분 35초로 내려갔다.
- 병렬도 튜닝의 교훈: 특정 클래스 병렬도가 가장 좋아 보였던 때도 있었지만 동적 큐와 mixed workload가 합쳐진 구조에서는 PostgreSQL·OpenSearch·CPU·메모리 경합으로 종료 편차가 커졌다. 메서드 병렬성도 넓게 시도했지만 숨은 공유 상태 때문에 검증된 일부만 남기는 쪽이 안정적이었다.
- 마지막 단계: prepare 결과를 한 덩어리가 아니라 레이어로 나눠 재사용했다. SQL migration이 안 바뀌면 DB snapshot을, 코드 생성 결과가 안 바뀌면 그것을, 소스와 테스트까지 모두 같으면 컴파일 자체를 건너뛴다.
- 사전 구성 러너로 옮겨 의존 서비스들을 미리 준비된 상태로 시작하게 했고, 이미지를 미리 받아 두는 수준이 아니라 서비스 기동과 대기 구간이 겹치도록 실행 환경을 재정리했다. OpenSearch의 불안정한 health check 보완과 prepare 실패 시 노드가 빨리 종료되는 안전장치도 넣었다.
- 실패한 실험 1: Go 빌드 캐시는 이미 컴파일이 충분히 빨라진 뒤라 복원·저장 오버헤드가 절약 시간보다 커서 작업당 8~12초 순손실이 나 롤백했다.
- 실패한 실험 2: 50노드와 이미지 미러 워밍은 평균보다 변동성을 키웠고, 더 많은 병렬화가 항상 더 나은 CI를 만들지는 않는다는 것을 보여줬다.
- AI와 사람의 분담: AI는 테스트 헬퍼나 Makefile 수정안, 폴링 스크립트, 워크플로우 조합, 해시 계산 코드, queue claim 스크립트처럼 지표가 명확하고 반복 실험이 가능한 문제에서 빠르게 여러 시도를 만들어냈다.
- 반면 병목을 발견하고 문제를 쪼개고 이번 단계에서 무엇을 성공으로 볼지 정하는 일, 어떤 테스트가 공유 상태에 기대는지 분류하는 일, 캐시의 안전한 재사용 경계를 정하는 일은 사람이 해야 했다. AI는 "지금 가장 먼저 고쳐야 할 것은 CI다"라고 먼저 말해 주지 않았다.
왜 읽나CI 시간이 팀의 피드백 속도를 잡아먹고 있는 백엔드 팀에게, prepare 중복·전환 시간·러너 수·캐시 경계·노드 불균형을 별개 문제로 쪼개 순서대로 푸는 구체적 경로와 실패한 실험까지 함께 준다.