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 — 넓어진 트리거