채널톡 메인 백엔드 서버 CI 개선기
AI 네이티브 시대에도 엔지니어링이 중요한 이유
Perry • Software Enginner
- 엔지니어링
안녕하세요, 채널톡 소프트웨어 엔지니어 페리입니다.
AI 에이전트가 코드를 빠르게 만들 수 있게 되면서 병목은 달라졌습니다. 예전에는 구현 속도가 문제였다면, 이제는 피드백 속도가 더 자주 발목을 잡습니다. 에이전트가 코드를 만들고, 사람이 검토하고, CI가 결과를 돌려주는 왕복 시간이 길수록 실험 속도는 급격히 떨어집니다. 사람만 일할 때도 마찬가지지만, 여러 작업을 병렬로 돌리는 AI 네이티브 워크플로우에서는 이 병목이 더 크게 느껴집니다.
이 글은 채널톡 메인 백엔드 서버의 CI를 어떻게 줄였는지에 대한 이야기입니다. 다만 출발점은 메인 서버가 아니었습니다. 저는 먼저 더 작은 서비스에서 패턴을 확인했고, 그다음 그 패턴을 채널톡 메인 백엔드 서버에 옮겨 갔습니다. 그 흐름이 이번 작업을 이해하는 데 중요해서, 시간 순서대로 정리해보려 합니다.
특히 이 과정을 돌아보면, "AI가 무엇을 잘했고 사람은 무엇을 해야 했는가"도 꽤 선명하게 드러납니다. 병목을 발견하고, 문제를 쪼개고, 이번 단계에서 무엇을 성공으로 볼지 정한 것은 사람이었습니다. 반대로 하위 문제가 정리된 뒤에는 AI가 반복 구현과 비교 실험 속도를 크게 올려줬습니다.
이 글에서 자주 나오는 용어도 먼저 짧게 맞춰두겠습니다. 여기서 말하는 CI는 PR이 올라왔을 때 자동으로 도는 검증 파이프라인이고, 테스트 노드는 테스트를 병렬로 나눠 실행하는 작업 단위입니다. 또 prepare는 compile과 DB migration처럼 모든 테스트 노드가 공통으로 필요로 하는 준비 단계입니다.
이번 여정 한눈에 보기
단계 | 대상 | 핵심 질문 | 대표 변경 | 대표 결과 |
|---|---|---|---|---|
1단계 | 채널톡 앱 연동 서비스 | 공유 상태를 어떻게 없앨까 | pgtestdb, | 테스트 작업 579초 -> 261초 |
2단계 | 채널톡 앱 연동 서비스 | 반복 생성과 숨은 직렬 의존을 어떻게 없앨까 | 자동 생성 코드 커밋, diff 분리, 백엔드 빌드와 프런트엔드 산출물 검증 분리 | 약 10분 -> 약 3분 20초 |
3단계 | 채널톡 메인 백엔드 서버 | 각 테스트 노드의 중복 prepare를 어떻게 없앨까 | 병렬 phase 분리, | 36.6분 -> 20.9분 |
4단계 | 채널톡 메인 백엔드 서버 | 준비 단계와 테스트 초기화를 어떻게 겹칠까 | S3 병렬화, sparse checkout, | 전환 시간 약 7분 단축 |
5단계 | 채널톡 메인 백엔드 서버 | 러너를 늘리면 정말 빨라질까 | 50노드 실험, 이미지 미러 워밍, 러너 수 재조정 | 병렬화만으로는 해결되지 않음을 확인 |
6단계 | 채널톡 메인 백엔드 서버 | 입력이 같을 때 prepare를 어떻게 생략할까 | Gradle 빌드 캐시, 입력 해시 캐시, 단계별 S3 캐시 | 캐시 적중 시 10분대 초반 가능 |
7단계 | 채널톡 메인 백엔드 서버 | 마지막 꼬리 시간을 어떻게 줄일까 | 동적 큐, 단일 JVM 연속 실행, 안정 병렬도 | 최근 성공 실행 기준 중앙값 15분 38초 |
작은 서비스부터 시작한 이유
처음부터 채널톡 메인 백엔드 서버를 건드리지는 않았습니다. 더 작은 시스템에서 병목의 패턴을 먼저 확인하고 싶었습니다. 채널톡 앱 연동 서비스는 Go 기반 서비스라 규모가 더 작고, CI 구조를 실험하기도 쉬웠습니다. 반대로 채널톡 메인 백엔드 서버는 테스트 수가 훨씬 많고 의존 서비스도 많아서, 처음부터 여기서 시작하면 "무엇이 진짜 병목인지"를 놓치기 쉬웠습니다.
작은 서비스에서 먼저 답을 찾고, 큰 시스템에는 그 답이 어디까지 통하는지 확인하는 순서가 더 낫다고 판단했습니다. 이 순서 자체도 사람이 정한 전략이었습니다. AI가 먼저 제안한 접근은 아니었습니다. 나중에 돌아보면 이 판단이 중요했습니다. 작은 서비스에서는 병목의 패턴을 배우고, 큰 서비스에서는 그 패턴을 더 잘게 쪼개 적용할 수 있었기 때문입니다.
앱 연동 서비스에서 확인한 패턴
채널톡 앱 연동 서비스의 당시 CI는 약 10분이 걸렸습니다. 테스트 수가 아주 많은 서비스는 아니었는데도 느렸습니다. 분석해보니 원인은 세 가지였습니다.
첫째, 테스트가 공유 데이터베이스를 써서 병렬 실행을 거의 못 하고 있었습니다. 둘째, 여러 CI 작업이 같은 생성 작업을 반복하고 있었습니다. 셋째, 실제로는 독립적인 작업 사이에 불필요한 직렬 의존이 숨어 있었습니다.
여기서 제가 먼저 한 일은 "CI가 느리다"는 한 문장짜리 불만을 세 개의 하위 문제로 바꾸는 것이었습니다. 그 뒤에는 AI에게도 각 문제에 대한 수정안을 여러 버전씩 만들어 보게 할 수 있었습니다. 하지만 어떤 조각부터 풀어야 효과가 큰지는 사람이 판단해야 했습니다.
1단계. 공유 상태 제거
가장 먼저 손본 것은 테스트 구조였습니다.
기존 통합 테스트는 하나의 PostgreSQL 데이터베이스를 공유하고, 테스트가 끝날 때마다 전체 테이블을 비우는 방식이었습니다. 이런 구조에서는 테스트가 서로 간섭하기 때문에 사실상 직렬 실행만 가능합니다.
그래서 pgtestdb를 도입해 테스트마다 독립된 데이터베이스를 clone해서 쓰도록 바꿨습니다. migration이 적용된 template DB를 만들어 두고, 각 테스트는 여기서 자기 전용 DB를 몇 ms 단위로 복제해 사용합니다. 그 결과 테스트를 병렬로 돌릴 수 있게 됐고, Run test 스텝은 324초에서 50초로 줄었습니다.
여기에 make test가 불필요하게 프로덕션 빌드까지 포함하고 있던 문제도 함께 정리했습니다. CI용 ci-test 타겟을 따로 만들고, PostgreSQL 컨테이너에 tmpfs, pg_isready, shm-size를 적용했습니다. 이 단계만으로 test 작업은 579초에서 261초까지 내려갔습니다.
이 단계에서 AI에 맡기기 좋았던 일은 테스트 헬퍼 코드나 Makefile 수정안처럼 반복적인 구현 작업이었습니다. 반대로 "공유 상태를 없애야 병렬화가 열린다"는 핵심 판단은 사람이 먼저 내려야 했습니다. 이 차이가 뒤 단계들에서도 계속 반복됩니다.
2단계. 반복 생성 제거와 의존 관계 정리
다음으로 본 문제는 반복 작업이었습니다.
이 서비스는 자동 생성 코드가 꽤 많았습니다. 그런데 test, lint, build, diff 같은 여러 작업이 매번 같은 코드 생성을 반복하고 있었습니다. PostgreSQL을 띄우고, migration을 적용하고, 생성 도구를 설치하고, 자동 생성 코드를 다시 만드는 과정이 작업마다 되풀이되고 있었던 것입니다.
그래서 자동 생성 코드를 git에 커밋하고, "생성 결과가 최신인지 검증하는 일"만 별도 diff 작업으로 분리했습니다. 그리고 mockery도 v2에서 v3로 올려 생성 시간 자체를 줄였습니다. 이 변화로 PostgreSQL이 필요한 작업 수는 4개에서 2개로 줄었습니다.
마지막으로 백엔드 빌드와 WAM(Web Application Module, 당시 내부에서 부르던 프런트엔드 산출물) 작업 사이의 의존을 다시 봤습니다. 분석해보니 WAM 결과물은 Go 바이너리에 직접 포함되는 구조가 아니라 Docker build 시점에 복사되는 구조였습니다. 즉 백엔드 빌드가 WAM을 기다릴 이유가 없었습니다. 둘을 분리하자 5개 작업이 완전히 병렬로 돌 수 있게 됐고, 전체 소요 시간을 결정하던 경로는 약 10분에서 약 3분 20초로 줄었습니다.
여기서도 AI는 workflow 수정안을 빠르게 제안하는 데는 유용했습니다. 하지만 "정말 이 의존이 필요했는가"를 시스템 구조까지 따라가며 확인하는 일은 사람이 해야 했습니다. 결국 숨은 직렬 구간을 발견하는 감각은 여전히 엔지니어의 몫이었습니다.
작은 서비스에서 얻은 패턴
이 작은 서비스에서 확인한 패턴은 아주 단순했습니다.
공유 상태는 병렬화를 막는다.
생성물은 가능한 한 매 작업마다 다시 만들지 않는다.
워크플로우의 의존 관계를 의심해야 한다. 직렬처럼 보이지 않는 직렬 구간이 숨어 있다.
이 세 가지는 나중에 채널톡 메인 백엔드 서버의 CI를 볼 때 그대로 다시 등장했습니다. 그래서 메인 서버로 넘어갈 때도 먼저 문제를 나누고, AI는 이미 정의된 하위 문제의 구현 속도를 높이는 쪽으로 쓰자는 원칙을 세웠습니다.
메인 백엔드 서버로 확장
채널톡 메인 백엔드 서버는 내부 레포 이름으로는 ch-dropwizard입니다. 메시징, 유저 관리, 워크플로우 같은 핵심 비즈니스 로직이 들어 있는 Java 서비스이고, 약 1,300개 테스트 클래스와 약 9,800개 테스트를 CI에서 검증합니다. PostgreSQL, Redis, Kafka 호환 브로커, OpenSearch, LocalStack, DynamoDB Local 같은 의존성도 함께 준비해야 합니다.
앱 연동 서비스와 비교하면 규모가 전혀 다릅니다. 그래서 여기서는 "한 번에 다 고치기"가 아니라, 병목을 하위 문제로 쪼개고 하나씩 푸는 접근이 필요했습니다. 저는 이 시점에 순서를 분명히 정했습니다. 먼저 모든 노드가 반복하는 고정 비용을 줄이고, 다음으로 비어 있는 대기 시간을 줄이고, 그다음 분배 불균형과 인프라 비용을 보자는 식이었습니다.
기준점은 초기 CI였습니다. 구조는 단순하지만 비효율적이었습니다. 15개 테스트 노드가 각자 compile + migrate + test를 반복하던 구조였고, 실제 소요 시간은 약 36.6분이었습니다. 다시 말해 공통 준비 단계인 prepare가 따로 없어서, 같은 준비 비용을 노드 수만큼 계속 되풀이하고 있었던 셈입니다.
초기에는 모든 노드가 같은 준비를 반복했고, 현재는 병렬 phase 분리, prepare 재사용, 동적 큐가 결합된 구조로 바뀌었습니다.
3단계. 중복 prepare 제거와 테스트 격리
첫 번째 단계는 가장 명확했습니다. 15개 노드가 모두 같은 준비 작업을 반복하고 있었습니다. 그래서 저는 동적 큐처럼 더 큰 구조 변경으로 바로 가지 않고, 먼저 "모두가 같이 낭비하는 고정 비용"부터 없애기로 했습니다.
다만 이 단계는 prepare를 분리하는 일만은 아니었습니다. 그보다 앞서 일부 테스트를 순차 phase와 병렬 phase로 나누는 기반이 먼저 필요했습니다. SEED_EACH나 초기화 의존이 큰 테스트는 순차로, ISOLATED나 상태 공유가 없는 테스트는 병렬로 분리해 "무엇을 병렬로 올릴 수 있는가"부터 구분해야 했습니다. 뒤에 나오는 prepare 분리와 SEED_EACH -> ISOLATED 전환은 모두 이 바닥 위에서 효과를 냈습니다.
각 테스트 노드는 다음을 자기 혼자 다시 하고 있었습니다.
Java compile
Flyway migration
테스트 실행
이 구조에서는 노드를 늘려도 준비 비용이 같이 늘어납니다. 그래서 prepare 작업을 따로 만들어 compile과 migration을 한 번만 수행하고, 결과물을 모든 테스트 노드가 재사용하도록 바꿨습니다.
compile 결과는 압축 아티팩트로 저장
migration이 끝난 데이터베이스는
pg_dump로 snapshot 생성각 테스트 노드는 이를 받아
pg_restore로 복원
이 단계에서 테스트 JVM도 손봤습니다. PostgreSQL을 tmpfs에 올리고, 테스트 JVM에 맞는 옵션을 적용하고, JUnit 5 클래스 병렬 실행을 켰습니다. 하지만 여기서 중요한 점은 "병렬 실행 스위치를 켰다"가 아니었습니다. 공유 상태 때문에 깨지는 테스트를 실제로 찾아 고쳐야 했습니다. 일부 테스트는 상대 순위 계산을 바꿨고, 일부는 비결정적인 assertion을 수정했고, 특정 무거운 테스트는 훨씬 덜 비싼 setup 경로로 바꿨습니다.
여기서 실제로는 prepare 분리와 함께 테스트 초기화 모드도 같이 손봐야 했습니다. 당시 느린 통합 테스트 다수는 SEED_EACH 모드에 묶여 있었는데, 이 모드는 각 테스트 메서드마다 initDatabase()와 seedDatabase()를 호출해 public 테이블 전체를 비우고 sequence를 초기화한 뒤, 공용 기본 데이터를 다시 넣는 구조였습니다. 안전하지만 무거웠고, 미리 깔린 공용 기본 데이터와 하드코딩된 ID에 기대는 테스트가 많아서 병렬 phase로 올리기 어려웠습니다.
그래서 별도 작업으로 SEED_EACH 테스트를 ISOLATED로 옮기는 전환도 병행했습니다. ISOLATED는 TRUNCATE를 하지 않고 테스트마다 고유 Channel, Account, Manager를 가진 IsolatedContext를 만들고, 필요한 경우에만 withBilling(), withEnterprisePlan(), createManager(...) 같은 helper로 상태를 조립합니다.
같은 병렬화 이야기처럼 보여도, SEED_EACH와 ISOLATED는 테스트 초기화 모델 자체가 다릅니다.
이 전환이 중요했던 이유는, annotation만 바꾸면 끝나는 작업이 아니었기 때문입니다. 기존 seed data가 자동으로 깔린다고 가정하던 테스트, 공용 관리자 계정을 계속 써야 하던 endpoint, 전역 unique key나 DynamoDB userId 충돌 때문에 다시 SEED_EACH로 되돌린 테스트가 반복해서 나왔습니다. 여러 라운드에 걸쳐 조금씩 옮기고, 깨지는 것은 되돌리고, 필요한 helper를 늘려가며 병렬 phase에 올릴 수 있는 영역을 넓혀 갔습니다.
앱 연동 서비스와 달리 메인 백엔드 서버는 PostgreSQL 하나만 격리하면 끝나지 않았습니다. PostgreSQL은 클래스별 clone DB를 쓰고, Redis, OpenSearch, DynamoDB, 인메모리 저장소는 테스트 클래스별 고유 접두어를 붙여 격리했고, HTTP 요청과 비동기 executor에는 같은 식별자가 끝까지 전파되도록 맞춰야 했습니다. Kafka 호환 브로커는 공유했지만 cleanup 비동기 작업에서도 이 식별자가 끊기지 않도록 따로 손봐야 했습니다.
즉, 이 단계는 YAML만 바꾼 작업이 아니었습니다. 병렬 실행이 가능한 테스트 환경을 실제로 만들어내는 엔지니어링 작업이었습니다.
이 단계에서 AI에 맡기기 좋았던 일은 스냅샷 복원 흐름이나 workflow 골격처럼 반복 구현이 많은 부분이었습니다. 하지만 어떤 테스트가 공유 상태에 기대고 있는지 분류하고, 그걸 병렬 실행 가능하게 고치는 일은 사람이 해야 했습니다. "병렬 옵션을 켠다"와 "병렬 실행이 가능한 시스템을 만든다" 사이에는 큰 차이가 있었습니다.
이 결과 첫 번째 큰 점프가 나왔습니다.
지표 | 변경 전 | 3단계 이후 |
|---|---|---|
실제 소요 시간 | 36.6분 | 20.9분 |
러너 사용 시간 | 약 425분 | 약 202분 |
가장 느린 테스트 노드 | 1303초 | 565초 |
4단계. 대기 시간 제거
첫 번째 개선 이후에도 CI를 자세히 보면 여전히 이상한 시간이 보였습니다. 테스트 자체보다, prepare가 끝나고 테스트가 실제로 시작되기 전까지의 전환 시간이 길었습니다. 이 시점에는 "prepare를 더 줄일까"보다 "기다리는 시간을 없앨 수 없을까"라는 질문으로 문제를 다시 정의했습니다.
앱 연동 서비스에서 build와 wam의 관계를 다시 본 것처럼, 여기서도 전체 소요 시간을 결정하는 경로를 다시 뜯어봤습니다. 그러자 prepare와 test init 사이에 꽤 긴 빈 대기 시간이 숨어 있었습니다.
그래서 이 단계에서는 세 가지를 했습니다.
S3 업로드와 다운로드, 압축 해제와 DB 복원을 병렬화했습니다.
각 테스트 노드는 Java 소스 전체를 checkout하지 않고, 필요한 파일만 sparse checkout 하도록 바꿨습니다.
가장 중요하게는 test 작업이 prepare를
needs로 기다리지 않도록 바꾸고, S3 폴링으로 준비 결과물을 기다리게 했습니다.
이렇게 하면 테스트 노드는 자기 초기화 작업을 먼저 시작하고, prepare 결과물이 준비되는 즉시 이어서 테스트로 넘어갈 수 있습니다. 요약하면 "prepare가 끝날 때까지 아무 일도 못 하고 기다리는 시간"을 전체 소요 시간을 결정하는 경로 밖으로 밀어낸 것입니다.
이 단계의 실측에서 전환 시간은 중앙값 기준 약 7분 줄었습니다. 앱 연동 서비스에서 배운 "숨은 직렬 구간을 없애면 체감 속도가 확 달라진다"는 패턴이 여기서 다시 한 번 확인됐습니다.
AI에는 폴링 스크립트, 워크플로우 조합, sparse checkout 같은 구현 실험을 여러 형태로 시켜볼 수 있었습니다. 하지만 prepare와 test init 사이의 빈 시간을 병목으로 읽어낸 것은 사람이었습니다. AI가 먼저 "여기가 숨어 있는 직렬 구간"이라고 찾아주지는 않았습니다.
5단계. 러너 수 실험과 인프라 병목
이 단계는 성능 개선 이야기이면서 동시에 실패한 실험 이야기이기도 합니다.
동료 엔지니어 페퍼는 러너 수를 15, 25, 30, 40, 50까지 바꿔가며 실험했습니다. 직관적으로는 테스트 노드 수를 늘리면 더 빨라질 것 같았습니다. 실제로 50노드 설정도 한번 들어갔습니다.
그런데 여기서 다른 병목이 터졌습니다. 많은 테스트 노드가 한꺼번에 여러 서비스 이미지를 pull하면서, 테스트 그 자체보다 init containers와 image pull이 더 큰 병목이 되기 시작한 것입니다. 이미지 미리받기용 미러를 차갑게 시작한 상태에서는 전체 CI가 75~78분까지 튀고, 워밍된 상태에서는 18~25분대로 내려오는 극단적인 변동이 생겼습니다.
이 경험이 중요했습니다. 병렬화는 만능이 아니고, 노드를 늘리는 순간 병목이 CPU나 테스트 코드에서 네트워크와 이미지 레지스트리로 이동할 수 있다는 것을 보여줬기 때문입니다.
결국 우리는 20개 테스트 러너로 다시 줄이고, 이미지 미러 워밍도 제거했습니다. 이 결정은 "평균적으로 가장 빠른 설정"보다 "변동성이 작고 운영 가능한 설정"을 고른 사례였습니다.
이 시기에 성태는 Gradle 빌드 캐시를 CI 실행 간에 유지하는 작업을 넣었습니다. 이건 화려한 변화는 아니었지만, prepare 단계의 비용을 다루는 데 꼭 필요한 기반이 됐습니다.
이 단계에서 AI가 잘하는 것은 노드 수별 설정 차이나 init 흐름의 변형을 빠르게 만들어 비교하는 일입니다. 하지만 어떤 지표를 보고 의사결정할지는 사람이 정해야 합니다. 우리는 평균 최저값보다 변동성이 작은 구성을 택했고, 그 판단은 운영 경험과 서비스 이해를 바탕으로 한 것이었습니다.
6단계. 입력 해시 캐시 도입
앞 단계들로 기본 구조를 정리한 뒤에는 "같은 입력인데 왜 또 compile을 해야 하지?"라는 질문을 하게 됐습니다. 여기서 중요한 건 "캐시를 붙여보자"가 아니라, "어떤 입력이 같으면 같은 계산으로 봐도 되는가"를 먼저 정의하는 일이었습니다.
이 질문에서 나온 것이 입력 해시 기반 캐시입니다. Java 소스, 테스트, submodule, build 설정, 런타임 환경 정보를 묶어서 해시를 만들고, 그 해시가 같으면 prepare 결과를 통째로 재사용하는 방식입니다.
이 변화의 의미는 단순한 캐시 복원이 아닙니다. 입력이 같을 때는 prepare 단계의 핵심 작업 자체를 생략하는 것입니다. compile과 DB snapshot 생성을 다시 하지 않고, 이미 만들어진 결과를 현재 SHA 경로로 복사해 테스트 노드가 그대로 쓰게 했습니다.
이 시점부터 CI는 조금씩 "매번 새로 빌드하는 시스템"에서 "같은 입력이면 이전 계산을 재사용하는 시스템"으로 바뀌기 시작했습니다.
AI에는 해시 계산 코드, S3 경로 처리, 복원 조건 분기 같은 반복 구현을 맡기기 좋았습니다. 하지만 어떤 파일 집합과 환경 정보를 입력으로 묶을지, 어디까지 같아야 안전하게 재사용할 수 있는지는 사람이 정해야 했습니다. 캐시는 구현보다 경계 설정이 더 중요했습니다.
7단계. 동적 큐와 실행 환경 정리
구조와 캐시가 어느 정도 정리되자, 이번에는 테스트 노드 사이의 불균형이 더 눈에 띄기 시작했습니다. 고정 샤딩은 결국 느린 노드 하나가 전체 CI를 끌고 갑니다. 그래서 이 단계에서는 문제를 "테스트를 더 많이 병렬화하자"가 아니라, "마지막 하나가 오래 남는 꼬리 시간을 줄이자"로 다시 정의했습니다.
그래서 이 단계에서는 테스트 분배 방식 자체를 바꿨습니다.
기존에는 각 테스트 노드가 자기 class list를 고정으로 받아 실행했습니다. 바뀐 구조에서는 split-tests.py가 최근 5회 JUnit 실행 시간을 바탕으로 60개의 배치를 만들고, 20개 테스트 노드가 S3 큐에서 그 배치를 하나씩 가져가 처리합니다. 빨리 끝난 노드는 배치를 더 많이 가져가고, 느리게 시작한 노드는 적게 가져갑니다.
동적 큐의 목표는 테스트를 더 많이 쪼개는 것이 아니라, 마지막 느린 노드가 전체 CI를 끌고 가는 문제를 줄이는 것이었습니다.
여기서 중요한 구현 포인트는 두 가지였습니다.
첫째, DynamicQueueTestRunner가 단일 JVM 안에서 여러 배치를 연속 실행하도록 만들었습니다. 그래서 Dropwizard 서버를 매 배치마다 다시 띄우지 않아도 됩니다.
둘째, 메서드 병렬 실행이 가능한 테스트는 분배기에서 그 특성을 반영하도록 했습니다. 최근 5회 실행 시간을 그대로 쓰는 게 아니라, 실제 완료 시간에 가까운 값을 계산해 배치했습니다.
이 단계 이후에는 "유독 하나만 끝까지 남는 테스트 노드"가 크게 줄었습니다. 내부 측정에서는 테스트 노드 종료 편차가 약 6분에서 약 1분 36초로 줄었고, 평균 테스트 노드 소요 시간도 약 15분 30초에서 약 8분 35초로 내려갔습니다.
이 시기에는 JUnit 클래스 병렬도도 여러 번 바꿔 봤습니다. 4에서 시작해 6으로 올렸다가 다시 4로 내리고, 리소스 튜닝을 붙인 뒤 다시 6, 그다음 8까지 실험했습니다. 일부 구간에서는 6이 가장 좋아 보였던 때도 있었습니다. 하지만 dynamic queue와 mixed workload 전체를 합친 현재 구조에서는 PostgreSQL, OpenSearch, CPU, 메모리 경합 때문에 종료 편차가 커졌고 8은 더 나빴습니다. 현재 stable 기본값은 다시 4입니다. 메서드 병렬성도 넓게 시도해봤지만, 숨은 공유 상태와 무거운 @BeforeEach 때문에 결국 proven-safe한 일부 테스트만 남기는 쪽이 더 안정적이었습니다. 겉으로 드러나는 parallelism 수치를 올리는 것보다, 어떤 테스트를 ISOLATED로 옮기고 어떤 자원 격리를 먼저 풀 것인지가 훨씬 중요하다는 걸 여기서 배웠습니다.
이건 단순히 테스트를 여러 개로 쪼갠 것이 아닙니다. 테스트 분배 자체를 실행 특성을 이해하는 방식으로 바꾼 것입니다.
이 단계에서 AI는 queue claim 스크립트나 runner 보일러플레이트를 빠르게 바꾸는 데 도움이 됩니다. 하지만 "지금 진짜 문제는 고정 샤딩 자체"라고 정의하고, 최근 실행 시간과 메서드 병렬성까지 반영한 분배 전략을 세우는 일은 사람이 해야 했습니다. 복잡한 문제는 여전히 문제 정의에서 승부가 갈립니다.
여기에 더해 현재 구조를 운영 가능한 형태로 굳히는 작업도 이어졌습니다.
제가 넣은 단계별 S3 캐시는 prepare 결과를 한 덩어리로만 저장하지 않고 레이어로 나눠 재사용하게 했습니다.
DB 레이어: SQL migration이 안 바뀌면 DB snapshot 재사용
코드 생성 레이어: JOOQ, protobuf, ANTLR 결과가 안 바뀌면 재사용
전체 결과 레이어: Java 소스와 테스트까지 모두 같으면 컴파일 자체를 건너뜀
prepare 결과를 한 덩어리로만 저장하지 않고, 바뀌는 범위에 따라 레이어별로 재사용했습니다.
또 이 시기에는 사전 구성 러너로 옮기면서, PostgreSQL, Redis, OpenSearch, Kafka 호환 브로커, DynamoDB Local 같은 일부 서비스를 러너에 미리 준비된 상태로 더 빠르게 시작할 수 있게 했습니다. 단순히 이미지를 미리 받아 두는 수준이 아니라, 서비스 기동과 대기 구간을 겹치도록 실행 환경을 다시 정리한 것입니다. OpenSearch의 불안정한 health check를 다루는 보완과 prepare 실패 시 테스트 노드가 빨리 종료되도록 하는 안전장치도 함께 들어갔습니다.
이 시점부터 현재 CI의 모습이 거의 완성됐습니다. 지금의 테스트 노드는 서비스를 비동기적으로 먼저 띄우고, queue READY를 기다리고, prepare 결과물을 다운로드하고, 단일 JVM 동적 큐 러너로 테스트를 계속 이어서 실행합니다.
즉 마지막 단계는 "한 방에 끝나는 큰 아이디어"라기보다, 앞선 단계들에서 만든 구조를 운영 가능한 형태로 굳히는 작업이었습니다. 여기서도 사람의 역할은 전체 시스템의 경계를 정하고, AI는 그 안에서 반복 구현 속도를 올리는 쪽에 가까웠습니다.
현재 성과
최신 수치는 문서가 아니라 실제 성공 실행으로 보는 게 맞습니다.
최근 Continuous Integration이 성공한 실행 21건을 기준으로 보면:
지표 | 수치 |
|---|---|
초기 기준선 | 36.6분 |
최근 성공 실행 중앙값 | 15분 38초 |
최근 성공 실행 사분위 범위 | 15분 02초 ~ 16분 27초 |
가장 빠른 성공 실행 | 10분 40초 |
15분 이내 성공 실행 | 21건 중 5건 |
20분 이내 성공 실행 | 21건 중 19건 |
즉, 현재 CI는 "운 좋으면 한번 빠른" 수준이 아닙니다. 보통은 15분 안팎에 끝나고, 전체 캐시가 잘 맞으면 10분대 초반까지 내려옵니다.
이 숫자는 왜 중요할까요. 지금의 결과는 한 번의 요령이 아니라, 시간 순서대로 쌓인 여러 단계의 구조 개선이 합쳐진 결과이기 때문입니다. 그리고 각 단계마다 먼저 사람이 질문을 바꾸고, 그다음 AI와 구현을 밀어붙였다는 점도 함께 봐야 합니다.
롤백한 시도
기존 글에서도 이 부분이 중요했는데, 이번에도 그대로 남기고 싶었습니다. CI 최적화는 성공 사례만 모아놓으면 과장되기 쉽기 때문입니다.
앱 연동 서비스: Go 빌드 캐시는 오히려 손해였다
앱 연동 서비스에서는 Go 빌드 캐시도 시도했습니다. 하지만 이미 컴파일 자체가 충분히 빨라진 뒤라, 캐시를 복원하고 저장하는 오버헤드가 실제 절약 시간보다 컸습니다. 결과적으로 작업당 8~12초 정도 순손실이 나서 롤백했습니다.
메인 백엔드 서버: 50노드와 이미지 미러 워밍은 평균보다 변동성을 키웠다
메인 백엔드 서버에서는 러너를 50개까지 늘리고 이미지 미러 워밍도 시도했습니다. 하지만 미러가 비워진 상태에서 대량 pull이 겹치면 오히려 훨씬 느려졌고, 워밍된 상태와의 차이도 너무 커졌습니다. 이 실험은 "더 많은 병렬화가 항상 더 나은 CI를 만들지는 않는다"는 사실을 분명하게 보여줬습니다.
이런 실패는 중요합니다. 무엇이 안 되는지 알아야, 현재 구조가 왜 그렇게 생겼는지도 설명할 수 있기 때문입니다.
AI와 사람이 한 일
이 과정을 단계별로 다시 보면 AI가 잘한 일과 사람이 해야 했던 일이 꽤 선명하게 나뉩니다.
앱 연동 서비스에서는 먼저 문제를 공유 상태, 반복 생성, 숨은 직렬 의존으로 나눈 뒤에야 손을 댈 수 있었습니다. 메인 백엔드 서버에서는 prepare 중복, 전환 시간, 러너 수, 캐시 경계, 테스트 노드 불균형을 서로 다른 문제로 분리하고 순서를 정해야 했습니다. AI는 스스로 "지금 가장 먼저 고쳐야 할 것은 CI다"라고 말해주지 않았고, ch-app-store에서 먼저 패턴을 검증한 뒤 ch-dropwizard로 옮기자는 전략도 제안하지 않았습니다. 그 부분은 사람이 해야 했습니다.
반면 일단 하위 문제가 정의되고 나면 AI는 굉장히 유용했습니다. 예를 들어 "prepare 단계 1분 더 줄이기", "특정 테스트 노드 편차 줄이기", "캐시 적중 조건 정리하기"처럼 지표가 명확하고 반복 실험이 가능한 문제에서는 빠르게 여러 시도를 만들어낼 수 있었습니다. 구현 뼈대를 만들고, 스크립트 변형을 비교하고, 비슷한 패치를 여러 버전으로 내놓는 일에서는 확실히 속도가 납니다.
그렇지만 이번 작업 전체를 놓고 보면 더 중요한 것은 기술 그 자체보다 문제를 구조화하는 능력이었습니다. 저는 작은 서비스에서 먼저 패턴을 확인하고 메인 서버의 문제 순서를 정했고, 페퍼는 전환 시간과 러너 전략 같은 병목을 집요하게 실험했고, 성태는 빌드 캐시 같은 기반을 다졌습니다. 이건 도구가 아니라 엔지니어링의 영역이었습니다.
정리
이번 작업을 통해 더 확신하게 된 점은 세 가지입니다.
AI 시대에도 의지는 사람의 영역입니다. 이 개선은 AI가 먼저 시작한 일이 아니라,
ch-app-store에서 먼저 패턴을 검증하고ch-dropwizard로 확장하자고 사람이 결정하면서 시작됐습니다. 또prepare분리로 20.9분까지 줄인 뒤에도 거기서 멈추지 않고 전환 시간, 러너 수, 캐시, 테스트 노드 불균형까지 계속 병목으로 읽어낸 것도 사람이었습니다.AI 시대에도 엔지니어링은 여전히 중요합니다.
pgtestdb로 공유 상태를 없애고, 자동 생성 코드를 커밋해 반복 계산을 줄이고,prepare를 분리하고, 동적 큐와 단계별 캐시를 넣고, 서비스 기동 전략을 다시 짠 성과는 모두 기본적인 소프트웨어 엔지니어링 역량에서 나왔습니다. 화려한 비법이 아니라 공유 상태 제거, 의존 관계 정리, 측정 기반 분배 같은 기본기가 결과를 만들었습니다.복잡한 문제는 여전히 사람을 필요로 합니다.
50노드 실험에서 병목이 인프라로 이동하는 것을 보고 다시20노드로 줄인 일, content-hash cache에서 "어디까지 같아야 같은 입력인가"를 정의한 일, 동적 큐에서 "더 병렬화"가 아니라 "마지막 하나가 오래 남는 꼬리 시간을 줄이자"로 문제를 다시 정의한 일은 모두 사람이 해야 했습니다. 하위 문제가 정의된 뒤의 반복 실험은 LLM이 잘하지만, 문제를 정의하고 쪼개고 순서를 정하는 일은 여전히 사람의 역할입니다.
AI 네이티브 시대에 필요한 역량이 완전히 달라졌다고 생각하지는 않습니다. 오히려 더 중요해졌다고 느낍니다. 좋은 엔지니어는 여전히 병목을 발견하고, 문제를 구조화하고, 시스템 전체를 더 나은 방향으로 밀어가는 사람입니다. 이번 CI 개선도 그렇게 진행됐습니다.
