Architecture · Tooling

아무도 눈치채지 못한
빌드 안 되는 이미지

이게 애초에 필요한 작업인지 확인하는 것으로 시작한 Spring Boot 4 마이그레이션 — 낡은 문서는 그렇다고 했고, git 히스토리는 이미 2주 전에 끝났다고 했다 — 은 하루 뒤, 모든 체크가 초록인데 실제 배포 가능한 컨테이너 이미지는 아예 빌드가 안 되는 상태로 끝났다. CI 안 그 무엇도, 바로 밑에서 의미가 바뀌어버린 그 파일 하나를 지켜보고 있지 않았기 때문이다.

루트 문서는 여전히 java 구현체가 Spring Boot 3.3을 쓰고 있다고 서술하고 있었다. 그 마이그레이션을 시작하기 전, 문서를 믿는 대신 빌드 파일에 git log -S를 돌려보니 문서가 따라잡지 못한 게 드러났다: java는 이미 2주 전에 그 도약을 마쳤고, 커밋 메시지 하나가 버전 업이라기보다 우회해야 했던 모든 것에 대한 작은 에세이처럼 읽혔다. 낡은 건 문서였지 코드가 아니었다. 이 확인 하나가 이미 배포된 작업을 다시 하는 하루를 아꼈다 — 그리고 이번 라운드 나머지의 패턴을 정했다: 무언가가 스스로에 대해 주장하는 걸 믿기 전에 실제 상태를 확인하라, 문서도 예외 없이.

kotlin 차례, 그리고 조용히 깨진 의존성 체인

kotlin은 아직 마이그레이션 전이었고, Boot 4의 Gradle 플러그인은 더 이상 io.spring.dependency-management와 연동하지 않았다 — 네이티브 platform() BOM으로의 전환은 단순했지만, 그 뒤 첫 빌드는 엉뚱한 이유로 깨끗해 보였다: build -x test는 테스트 소스 컴파일 자체를 건너뛰어서, 테스트 클래스패스의 진짜 의존성 해석 붕괴가 실제로 뭔가가 그걸 컴파일하려 시도할 때까지 눈에 띄지 않은 채 남아 있었다.

Testcontainers도 같은 모양의 놀라움을 자기만의 버전으로 갖고 있었다. 프로젝트는 이미 testcontainers-bom을 임포트하고 있었는데, 알고 보니 그게 내내 완전한 무동작이었다 — Boot 3 자신의 BOM이 옛 이름의 아티팩트에 조용히 버전을 공급해주고 있어서, 명시적인 임포트는 아무것도 한 적이 없었다. Testcontainers 2.x의 실제 아티팩트 이름 — testcontainers-junit-jupiter와 그 형제들 — 로 리네이밍하고 나서야 지금까지 보이지 않던 그 BOM이 비로소 의미를 갖기 시작했다.

잘못된 곳에서 보면 "존재하지 않는" 라이브러리

resilience4j의 Boot 3 스타터는 Boot 4에서 애플리케이션 부팅 시점에 크래시한다 — 라이브러리 자체에 내장된 검증기가 실행을 거부한다. Boot 4용 대체재를 검색해보니 패키지 검색 인덱스의 웹 UI에서는 아무것도 안 나왔고, 이건 "아직 아무것도 공개되지 않았다"로 읽혔다. rate limiter는 손으로 만들기엔 충분히 작으니, 그렇게 했다: 없는 스타터가 했을 것과 같은 설정 키를 읽는, 손으로 조립한 레지스트리. 동작했다. 테스트도 통과했다. 하네스도 통과했다.

검색 인덱스는 저장소가 아니다

나중에서야, java 자신의 의존성 목록이 이미 쓰고 있던 것과 대조해보다가, 진짜 답이 나왔다: resilience4j-spring-boot4는 실제 패키지 저장소에 내내 존재하고 있었다 — 저장소 자신의 파일 경로를 직접 조회하니 즉시 나왔다. 웹 검색 UI가 그냥 인덱싱을 안 해뒀을 뿐이었다. 손으로 조립한 우회책은 빠지고, java가 이미 2주째 쓰고 있던 진짜 스타터로 교체됐다. 패키지 인덱스의 검색창이 뭔가를 못 찾는다는 건 그 인덱스에 대한 주장이지 실제로 뭐가 공개돼 있는지에 대한 주장이 아니다 — 믿기 전에 직접 조회해볼 가치가 있다.

실제 애플리케이션 코드는 딱 한 줄

이 모든 의존성·설정 요동 밑에서, 실제로 바뀌어야 했던 진짜 비즈니스 로직은 딱 한 줄이었다. Spring Security 7의 JSpecify nullability 애노테이션이 PasswordEncoder.encode를 nullable로 표시하고, kotlin의 null 안전성이 컴파일 시점에 즉시 이걸 잡아냈다 — 실제로는 절대 null을 반환하지 않는 호출을 checkNotNull()로 감싼 것. 전체 마이그레이션의 나머지 전부는 인프라와 설정이었다 — 이게 프레임워크 업그레이드가 개발자가 실제로 작성한 로직을 건드린 유일한 지점이었다.

아무도 올바른 파일을 가리키고 있지 않던 체크

마이그레이션은 끝난 것처럼 보였다: 빌드 초록, 전체 테스트 스위트 초록, 하네스 초록, push 완료. 다음 날, 무관해 보이는 예약 작업 — 주간 컨테이너 이미지 보안 스캔 — 이 실패했다. kotlin 서비스의 Dockerfile은 여전히 gradle:8.10 베이스 이미지로 빌드하고 있었다, 저장소 자체의 8.14+ 래퍼가 아니라. 그리고 Spring Boot 4.1의 Gradle 플러그인은 Gradle 8.14 이상을 요구한다. 실제 배포 가능한 컨테이너 이미지는 하루 온전히 빌드가 안 되고 있었다, 다른 모든 체크가 초록을 보고하는 동안. 이미지 스캔 워크플로의 트리거가 오직 Dockerfile 자체의 변경만 지켜보고 있었기 때문이다 — 그리고 이번 마이그레이션은 빌드 파일을 건드렸지, Dockerfile은 한 번도 건드리지 않았다.

베이스 이미지 버전은 고치기 쉬운 부분이었다. 제대로 고칠 가치가 있던 건 트리거였다: 이제는 각 언어의 Dockerfile뿐 아니라 의존성 매니페스트도 함께 지켜본다. 그래야 다음번에 베이스 이미지를 조용히 뛰어넘는 툴체인 업그레이드가, 다음에 우연히 도는 예약 작업이 아니라 그걸 일으킨 바로 그 push에서 잡힌다.

같은 마이그레이션 안의 서로 다른 세 지점에서, 각각 실제로 중요한 것에 대해 틀린 것으로 드러난 출처를 믿었다: 이미 배포된 작업을 아직 남았다고 주장하는 문서, 저장소에 멀쩡히 있는 패키지를 존재하지 않는다고 주장하는 검색 인덱스, 그리고 자신이 겉으로 보호하고 있다던 바로 그 산출물이 아예 빌드조차 안 되는 동안 마이그레이션이 끝났다고 주장하는 초록색 CI 보드.

저장소 내 추가 자료

build.gradle.kts — 네이티브 platform() BOM, resilience4j-spring-boot4, 그 외 이번 마이그레이션이 건드린 전부 · docker-image-scan.yml — 넓어진 트리거