[의존성의 방향을 따라 1/5] 버전업이 고통인 이유

50개 레포, 3500개 모듈, 하나의 버전업
Spring Boot 버전을 올린다고 합시다. 3.5.5에서 3.5.10으로. 패치 버전이니까 간단할 것 같습니다.
백엔드 생태계의 규모를 먼저 짚겠습니다.
| 지표 | 수치 |
|---|---|
| 레포 수 | 50+ |
| 전체 모듈 수 | 3,500+ |
| 공유 라이브러리 레포 | gradle-plugins, commons-core, backend-commons |
| 서비스 레포 | service-a, service-b, service-c 등 40+ |
| 의존성 체인 깊이 | 최대 4단계 (plugins → commons → backend-commons → 서비스) |
단순한 패치 버전업조차, 이 생태계 전체를 관통해야 합니다. backend-commons의 build.gradle.kts에서 Spring Boot 버전을 올리면, 그 라이브러리를 사용하는 40개 이상의 서비스 레포가 영향을 받습니다.
여기서 "패치 버전이니까 간단하다"는 직관이 처음 어긋납니다. SemVer의 약속대로라면 패치는 하위 호환을 깨지 않아야 합니다. 하지만 그 약속은 직접 의존하는 라이브러리 하나에 대한 것일 뿐입니다. Spring Boot 패치 한 번에는 수십 개의 전이 의존성(transitive dependency) 버전이 함께 딸려 올라가고, 그중 어느 하나가 자신의 패치 규약을 느슨하게 지켰다면 그 영향이 우리 코드까지 흘러듭니다. 우리가 올린 건 버전 숫자 하나지만, 실제로 바뀐 건 의존성 트리 전체입니다. 규모가 크지 않다면 이 정도는 한 사람이 반나절이면 수습할 수 있는 일입니다. 문제는 그 반나절이 50번 반복된다는 데 있습니다.
실제 사례: Spring Boot 3.5.5 버전업에서 일어난 일들
패치 버전업이 "단순"하지 않았던 실제 사례들을 살펴보겠습니다.
사례 1: MySQL 커넥션 릭
Spring Boot 3.5.5로 올리면서 함께 올라간 MySQL Connector/J 버전이 커넥션 풀 반환 로직을 변경했습니다. 기존에는 정상적으로 반환되던 커넥션이, 특정 예외 경로에서 릭이 발생했습니다.
// 증상: 배포 후 30분 뒤 커넥션 풀 고갈
HikariPool-1 - Connection is not available, request timed out after 30000ms.눈여겨볼 점은, 이 증상이 빌드 시점에는 전혀 드러나지 않는다는 것입니다. 컴파일도 통과하고, 테스트도 초록불이고, 배포까지 멀쩡합니다. 그러다 운영 트래픽이 30분쯤 쌓여 커넥션 풀이 고갈되는 순간 처음 모습을 드러냅니다. 버전업의 위험이 컴파일러가 잡아주는 영역에만 있는 게 아니라, 런타임 동작의 미묘한 변화에까지 걸쳐 있다는 신호입니다. 그래서 이런 문제는 누군가가 먼저 부딪혀보기 전에는 가이드에 적을 수조차 없습니다.
이 문제는 파이오니어 레포에서 먼저 발견되었습니다. 원인을 추적하고, MySQL Connector/J 버전을 고정하는 것으로 해결했습니다. backend-commons의 의존성 버전 선언에서 MySQL Connector/J를 9.1.0에서 9.2.0으로 고정했습니다. Spring Boot가 끌고 온 기본 버전을 명시적으로 덮어써서, 커넥션 릭이 발생하는 버전을 회피한 것입니다.
문제는, 이 수정이 반영되기 전에 이미 다른 3개 레포에서 같은 버전업을 시작했다는 것입니다. 파이오니어가 며칠에 걸쳐 알아낸 교훈이 채 공유되기도 전에, 같은 함정이 세 곳에서 동시에 재생산되고 있었습니다. 한 명이 발견한 해결책과 나머지가 그것을 받기까지의 시간 차 — 이 간극이 뒤에서 이야기할 모든 비효율의 뿌리입니다.
사례 2: Kotlin 스마트 캐스팅 변경
Kotlin 2.x에서 스마트 캐스팅 규칙이 강화되면서, 기존에 컴파일되던 코드가 타입 에러를 발생시켰습니다.
// 기존에는 컴파일됨
fun process(value: Any) {
if (value is String) {
// nullable receiver에서의 스마트 캐스트가 K2에서 더 엄격해짐
value.someExtensionFunction() // ← 컴파일 에러
}
}앞의 커넥션 릭과 정반대 성격의 문제라는 점이 중요합니다. 이건 런타임이 아니라 컴파일 단계에서 즉시 터지는 에러라, 발견은 쉽습니다. 대신 위치를 특정할 수 없는 게 함정입니다. 이런 코드가 3,500개 모듈 전체에 흩어져 있고, K2 컴파일러가 스마트 캐스트를 더 엄격하게 따지기 시작하면 그동안 "우연히" 통과하던 곳들이 곳곳에서 한꺼번에 드러납니다. 레포 하나에서 발견하고 수정해도, 같은 패턴이 다른 레포에 그대로 남아 있어 49번을 더 마주쳐야 합니다. 패턴은 같은데 손은 50번 가야 하는 — 자동화가 가장 빛을 발할 종류의 반복입니다.
사례 3: FixtureMonkey 호환성
테스트 프레임워크인 FixtureMonkey가 Spring Boot 3.5.x의 Jackson 설정 변경과 충돌했습니다. ObjectMapper의 기본 동작이 바뀌면서, FixtureMonkey가 생성하는 테스트 객체의 직렬화가 깨졌습니다.
FixtureMonkey도 1.1.7에서 호환성 이슈가 있어 1.1.11로 버전 조정이 필요했습니다.
세 사례는 발현 시점도(런타임 vs 컴파일 vs 테스트), 원인도(전이 의존성 vs 컴파일러 vs 프레임워크 충돌) 제각각입니다. 하지만 공통점은 명확합니다. 파이오니어가 문제를 발견하고 해결책을 찾기까지의 시간은 그 자체로 가치 있지만, 그 해결책을 50개 레포에 전파하는 과정이 병목이라는 것입니다. 첫 번째 발견에는 전문성이 필요하지만, 그 뒤 49번의 적용은 사실상 복사·붙여넣기에 가깝습니다. 그런데 현실에서는 이 "복사·붙여넣기"가 가장 많은 시간과 사고를 잡아먹습니다. 왜 그런지는 다음 절에서 드러납니다.
슬랙 커뮤니케이션 오버헤드
수동 버전업 과정에서 발생하는 커뮤니케이션 패턴을 재현해 보겠습니다.
[월요일 10:00] 파이오니어: Spring Boot 3.5.5로 올려봤는데,
MySQL 커넥션 릭 이슈가 있습니다. MySQL Connector 9.2.0으로 같이 올려주세요.
[월요일 14:00] 담당자 A: 저는 이미 금요일에 3.5.5로 올렸는데요...
MySQL 버전은 안 올렸습니다.
[월요일 14:05] 파이오니어: 죄송합니다, 다시 수정해주세요.
[화요일 09:00] 담당자 B: 저도 시작했는데, FixtureMonkey 테스트가 깨집니다.
[화요일 11:00] 파이오니어: 아, FixtureMonkey도 1.1.11로 올려야 합니다.
가이드에 추가하겠습니다.
[화요일 15:00] 담당자 A: FixtureMonkey 올렸더니 이번엔 다른 테스트가 깨집니다.
[수요일 10:00] 파이오니어: Kotlin 스마트캐스팅 이슈도 발견했습니다.
가이드 다시 업데이트합니다. 이미 작업하신 분들 확인 부탁드립니다.
[수요일 10:05] 담당자 C: 저는 아직 시작도 못 했는데, 가이드가 세 번째 바뀌었네요.
최종 버전이 뭔가요?이 패턴의 비용을 정량화하면 이렇습니다.
| 비용 항목 | 수동 프로세스 |
|---|---|
| 가이드 작성 및 갱신 | 파이오니어 2-3시간 × 여러 번 |
| 레포당 작업 시간 | 담당자 1-4시간 × 40+ 레포 |
| 재작업 (가이드 변경) | 담당자 0.5-2시간 × 이미 작업한 레포 수 |
| 싱크 커뮤니케이션 | 전원 참여, 1-2주간 비동기 |
| 진행 상황 추적 | 스프레드시트 수동 관리 |
| 총 소요 기간 | 2-4주 |
이 대화에서 진짜 비용은 어느 한 메시지가 아니라 상태가 한 곳에 모이지 않는다는 구조에 있습니다. 누가 어디까지 했는지, 최신 가이드가 무엇인지가 사람들의 머릿속과 스크롤되는 메시지 사이에 흩어져 있습니다. 가이드가 세 번 바뀌는 동안 누구는 첫 번째 버전으로, 누구는 두 번째 버전으로 작업을 끝내버립니다. 정보가 도착하는 순서와 작업이 진행되는 순서가 어긋나기 때문에, 부지런한 사람일수록 먼저 작업하고 먼저 재작업하는 역설이 생깁니다.
한 번의 패치 버전업에 조직 전체가 2-4주를 소비합니다. 이걸 분기에 한 번 하면, 연간 8-16주. 그런데 이 숫자에는 더 비싼 항목이 빠져 있습니다. 바로 이 고통을 기억한 사람들이 다음 버전업을 미루기 시작한다는 것입니다. 보안 패치가 긴급하게 필요한 상황에서 이 속도는 조직 수준의 리스크이고, 미루는 습관은 그 리스크를 조용히 키웁니다.
미루면 쌓이고, 쌓이면 터진다
버전업이 고통스러우면 자연스럽게 미루게 됩니다. "지금 당장 문제없으니 다음에 하자." 이 판단은 개별적으로는 합리적이지만, 조직 전체로 보면 기술 부채가 복리로 쌓입니다.
시간 →
[정기적 업데이트] [미루기]
v3.4.0 → v3.4.1 v3.4.0 ─────────────────────┐
→ v3.4.2 │
→ v3.5.0 │
→ v3.5.5 ↓
→ v3.5.10 ← 여기서 한꺼번에 v3.4.0 → v3.5.10
패치 5개분의 변경 사항이 한꺼번에
변경만 적용 6개 버전의 브레이킹 체인지패치 1개씩 적용하면 각각의 변경이 작고, 문제가 생겨도 원인을 특정하기 쉽습니다. 변경 하나에 의심할 후보 하나, 디버깅은 거의 이진 탐색에 가깝습니다. 하지만 6개 버전을 한꺼번에 올리면, 어떤 버전에서 문제가 생겼는지 추적하기 어렵고, 수정해야 할 코드의 양도 비선형적으로 증가합니다. 버전 간 변경이 서로 얽혀, A를 고치면 B가 깨지고 B를 고치면 C가 깨지는 상황이 벌어집니다. 미루기가 위험한 건 단순히 할 일이 쌓여서가 아니라, 쌓이는 동안 문제들이 서로 엉켜 난이도 자체가 곱으로 올라가기 때문입니다. 그래서 "나중에 한꺼번에"는 거의 항상 "나중에 훨씬 더 비싸게"가 됩니다.
보안 취약점의 경우는 더 심각합니다. Log4Shell(CVE-2021-44228) 같은 긴급 패치가 필요할 때, "버전업이 고통스러워서 미루고 있었다"는 말은 통하지 않습니다. 전 레포에 걸쳐 빠르게 패치를 적용할 수 있는 구조가 필요합니다.
그렇다고 반대편 극단도 답은 아닙니다. "사람이 일일이 하니 느리다면, AI에게 통째로 맡기면 되지 않나?" 하지만 검증 없이 일괄 변환을 풀어놓으면, 한 레포에서의 잘못된 수정이 50개 레포에 그대로 복제됩니다. 사람에게만 의존하면 느려서 미루게 되고, AI에게 전부 맡기면 같은 실수가 빠르게 번집니다. 필요한 것은 둘 중 하나를 고르는 게 아니라, 속도는 자동화에 맡기되 변경의 정확성은 빌드가 검증하는 구조입니다. Evergreen이 그 구조입니다.
Before/After: 수동 vs 자동화
flex가 구축한 Evergreen 파이프라인이 이 과정을 어떻게 바꾸는지, 동일한 Spring Boot 버전업 시나리오로 비교합니다.
Before: 수동 프로세스

After: Evergreen 파이프라인

| 항목 | Before (수동) | After (Evergreen) |
|---|---|---|
| 가이드 작성 | 파이오니어가 수동 작성, 반복 갱신 | recipe가 곧 가이드 |
| 이슈 전파 | 슬랙에서 비동기 공유 | recipe에 수정 사항 포함 |
| 레포당 작업 | 담당자가 수동 적용 | Updater가 자동 적용 |
| 재작업 | 가이드 변경 시 재작업 필요 | recipe 수정 후 재실행 |
| 진행 추적 | 스프레드시트 | Distributer가 자동 추적 |
| 사람 개입 | 전 과정 | 리뷰와 예외 처리만 |
| 소요 기간 | 2-4주 | 패치 1일 / 마이너 1주 / 메이저 2주 |
핵심 차이는, 파이오니어의 경험이 "슬랙 메시지"가 아니라 "실행 가능한 recipe"로 인코딩된다는 것입니다. recipe는 반복 실행 가능하고, 버전 관리되고, 테스트 가능합니다. 슬랙 메시지는 그 어느 것도 아닙니다.
다음 화 — 의존 그래프를 읽는 Planner. 레포 간 의존 관계가 어떤 그래프를 형성하고, 변경의 성격에 따라 전파 방향이 어떻게 달라지는지 살펴봅니다.
🚀플렉스팀 채용페이지 바로가기☕flex Private Talk 신청하기
