MMax's Develop Story

Spring Boot 3에서 Spring Boot 4로: JSpecify·API Versioning·Core Resilience와 함께 오는 마이그레이션 전략

Max··32분 읽기
Spring Boot 4Spring Framework 7MigrationSpring회고

Spring Boot 3.0이 나온 게 2022년 11월이었다. 3년 만에 4.0이 GA됐다. 이번 메이저의 가치는 "새 어노테이션 몇 개"가 아니라, Framework·Security·Jakarta EE·Jackson을 한 번에 올리는 통합 점프라는 점에 있다. 곧, 한 번 잘 올라가면 향후 3~4년의 의존성 빚을 한꺼번에 청산하는 자리다.

Spring Boot 4.0.0은 2025년 11월 20일에 GA됐다. 한 주 앞서 Spring Framework 7.0.0이 11월 13일에 나왔고, 같은 릴리스 사이클에 Spring Security 7, Spring Data 2025.1, Hibernate 7.1이 모두 함께 올라온다. 이 하나의 마이그레이션이 사실은 대여섯 개의 메이저 마이그레이션을 동시에 하는 일이라는 사실부터 정직하게 짚고 시작하자.

이 글은 "Spring Boot 4의 새 기능 소개"가 아니다. 이미 Boot 3.x로 운영 중인 팀이, 4로 올라가면서 어떤 코드 패턴이 무너지고, 어떤 코드 패턴이 정리되며, 어떤 순서로 손대야 할지를 시니어 백엔드 시선에서 정리한 전략 노트다. 특정 서비스를 지목하지 않고, 사내에서 흔히 마주치는 세 가지 유형의 시스템을 기준으로 적용 순서를 짠다.

Boot 3 → Boot 4 dependency stack

1. 상황: Boot 3.x에서 우리가 가진 코드 패턴들

먼저 출발선을 정확히 짚어 두자. Boot 3.x 서비스를 기술 특성으로 나누면 대체로 세 부류로 떨어진다.

  • 유형 A: 일반 동기 REST API 서비스. Spring Web MVC + JPA/Hibernate + RestTemplate/RestClient/OpenFeign로 외부 호출을 묶는, 가장 흔한 형태.
  • 유형 B: Spring Security/OAuth2가 비중이 큰 인증·API gateway 서비스. 커스텀 SecurityFilterChain, JwtDecoder, OAuth2 리소스 서버 구성이 짙게 박혀 있는 서비스.
  • 유형 C: WebFlux/R2DBC 기반 비동기 서비스 또는 Spring Batch 기반 데이터 파이프라인. 베이스라인이 유형 A와 다른 서비스.

이 세 유형은 Boot 3.x에서 공통의 자산각자의 부채를 들고 있다.

공통 자산은 명확하다. Jakarta EE namespace 전환(2.x → 3.x 때 끝낸 일), Java 17 baseline, observability 표준화(Micrometer), record/sealed 같은 모던 Java 채택. 이건 그대로 들고 간다.

각자의 부채는 다음과 같다.

  • 유형 A·C: @Nullable 어노테이션을 사내 표준으로 쓰지만, JSR 305(com.google.code.findbugs:jsr305)와 Spring 자체 어노테이션이 섞여 있어 IDE 경고가 일관되지 않는다.
  • 유형 A: API 버전 관리를 컨트롤러마다 자체 컨벤션으로 하고 있다. 어떤 팀은 헤더 매칭, 어떤 팀은 URL prefix, 어떤 팀은 둘 다.
  • 유형 A·C: 재시도가 필요한 외부 호출에 spring-retry 라이브러리를 박아 두고, 동시성 제한은 resilience4j를 따로 박아 둔다. 두 라이브러리의 우선순위·예외 변환을 매번 ADR로 정리해야 한다.
  • 유형 B: Spring Security 6 기준 설정이 한 화면 안에 들어가지 않을 만큼 크고, authorizeRequests()authorizeHttpRequests()가 코드 베이스에 섞여 있다.
  • 모든 유형: 자동설정이 spring-boot-autoconfigure 한 덩어리에 들어와 있어, 사용하지 않는 기술의 클래스까지 클래스로더에 매달려 있다.

이 다섯 가지 부채가 정확히 Boot 4에서 하나씩 정리된다. 그래서 4를 단순한 메이저 릴리스가 아니라 "Boot 3 시대의 임시 처방 코드를 청산하는 릴리스"로 본다.

2. 4년의 압축: Boot 4가 실제로 들고 오는 것

Boot 3.0(2022.11), 3.1, 3.2, 3.3, 3.4, 3.5를 거쳐 4.0이 나왔다. 그 사이 정식화된 주요 변경을 한 자리에 모으면 이렇다.

  • Spring Framework 7.0: JSpecify 표준 채택, API Versioning 일급화, @Retryable/@ConcurrencyLimit 코어 진입, Bean Background Initialization 강화.
  • Jakarta EE 11: Servlet 6.1, JPA 3.2, Bean Validation 3.1.
  • Spring Security 7: API 엔드포인트에도 CSRF 기본 활성, authorizeRequests() 제거, OAuth2 클라이언트/리소스 서버 API 정비.
  • Jackson 3: 패키지가 com.fasterxml.jacksontools.jackson으로 이동. JsonMapper 중심 API.
  • Spring Boot 4 코어: 자동설정 모듈화, RestTestClient 도입, @ImportHttpServices로 HTTP 인터페이스 빈 자동 등록, OpenTelemetry 우선 지원.
  • Spring Data 2025.1 / Hibernate 7.1: ORM 레이어의 메이저 이동.
  • Kotlin 2.2+ 필수.
  • Java: minimum은 17이지만, 4.0.x는 Java 21을 권장(JEP 491 synchronized + VThread 해소가 21+에서 효과)고, 메인스트림은 25.

시니어 입장에서 한 가지 강조하고 싶다. "Spring Boot 4 마이그레이션"이라는 한 줄에 사실은 다섯 개의 메이저 마이그레이션이 묶여 있다. Framework 6→7, Security 6→7, Jakarta EE 10→11, Jackson 2→3, Hibernate 6→7. 어느 하나라도 호환성 깨지는 라이브러리가 끼면 전체가 멈춘다. 이게 일정 산정의 출발점이다.

3. 첫 번째 큰 변화: JSpecify로 통일된 Null Safety

Spring Framework 7은 JSpecify를 공식 표준으로 채택했다. 그동안 Spring은 자체 org.springframework.lang.Nullable을 썼고, 외부 라이브러리들은 JSR 305, Checker Framework, IntelliJ 자체 어노테이션이 제각각이었다. JSpecify는 이 분열을 끝낸다.

// Before (Boot 3.x): 여러 출처가 섞여 있는 상태
import org.springframework.lang.Nullable;          // Spring 자체
import javax.annotation.Nullable;                  // JSR 305
import org.jetbrains.annotations.Nullable;          // IntelliJ
import edu.umd.cs.findbugs.annotations.Nullable;    // FindBugs
 
public @Nullable User findById(Long id) { ... }
 
// After (Boot 4 + Spring 7): JSpecify 하나로 통일
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
 
@NullMarked
package com.example.api.user;   // 패키지 수준에서 non-null이 default
 
// 그 패키지의 모든 클래스에서
public @Nullable User findById(Long id) { ... }

차이의 본질은 셋이다.

  • 표준 표준: JSpecify는 Google, Spring, JetBrains, Uber가 공동으로 만든 표준이다. IDE·정적 분석기·언어가 모두 같은 의미로 해석한다.
  • @NullMarked 기반 default: 패키지에 @NullMarked를 붙이면 그 패키지의 모든 타입은 non-null이 default가 된다. null을 허용할 때만 @Nullable을 명시. 이건 Kotlin의 default와 정확히 같은 사고방식이다.
  • 제네릭과 배열 원소까지 표현: List<@Nullable String>, String @Nullable [] arr 같은 표현이 의미를 갖는다. JSR 305로는 표현 불가능했던 영역.

운영 코드에 미치는 영향:

  • 유형 A·C: 사내 표준이 JSR 305 기반이라면 단순한 import 치환이 아니다. 각 모듈의 package-info.java에 @NullMarked를 도입하면서 NPE 후보 코드를 정리하는 작업이 같이 따라온다. 이게 가장 큰 일감이다.
  • 유형 B: Spring Security의 nullability annotation이 정리되면서 커스텀 Filter, AuthenticationProvider 시그니처에 잠재된 null 가정이 가시화된다. 마이그레이션 시 가장 많은 컴파일 경고가 나오는 영역.
  • Kotlin 코드 베이스가 있다면 상호운용성이 크게 개선된다. Kotlin의 ? 타입과 JSpecify의 @Nullable이 같은 의미로 해석되어 IDE 경고가 가지런해진다.

트레이드오프 하나: JSpecify 도입은 "한 번에 다 끝나는" 일이 아니다. NullAway 같은 정적 분석기를 CI에 끼우고 점진적으로 모듈을 마킹해 가는 방식이 현실적이다. 처음 한 모듈에 @NullMarked를 붙이면 컴파일 경고가 수십 개씩 쏟아질 수 있다. 그게 정상이다.

4. 두 번째 큰 변화: API Versioning 일급화

Spring Framework 7 + Spring Boot 4의 가장 실용적인 변화 중 하나가 이것이다. Spring MVC와 WebFlux가 API 버전을 첫 시민(first-class citizen) 으로 받는다.

API Versioning before/after

Boot 3까지는 어떻게 했을까. 헤더 매칭으로 분기하거나(headers = "X-API-Version=1"), URL prefix(/v1/users)로 분기하거나, 라이브러리(webmvc-api-versioning 같은)를 끼웠다. 컨트롤러마다 컨벤션이 미묘하게 달라서, "v1.5 deprecated 처리는 어떻게 하지?", "RestTemplate에서 클라이언트가 버전을 어떻게 전달하지?" 같은 결정이 매번 ADR이 됐다.

Boot 4에서는 이렇게 된다.

// 1) 구성: WebMvcConfigurer / WebFluxConfigurer에서 한 번 설정
@Configuration
class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureApiVersioning(ApiVersionConfigurer config) {
        config.useRequestHeader("X-API-Version")
              .setDefaultVersion("1.0")
              .setDeprecationHandler(new StandardDeprecationHandler());
    }
}
 
// 2) 컨트롤러: version 속성을 직접 받는다
@RestController
@RequestMapping("/api/users")
class UserController {
 
    @GetMapping(version = "1.0")
    UserV1 getV1() { ... }
 
    @GetMapping(version = "2.0+")  // 2.0 이상 매칭
    UserV2 getV2() { ... }
}
 
// 3) 클라이언트도 일관: RestClient / WebClient / HttpExchange
RestClient client = RestClient.builder()
    .baseUrl("https://api.example.com")
    .defaultApiVersion("1.0")
    .build();

본질은 두 가지다.

  • 버전 해석을 횡단 관심사로 분리: 헤더로 받든, 경로로 받든, 쿼리 파라미터로 받든 ApiVersionResolver 한 번 구성이면 모든 컨트롤러가 같은 방식으로 해석한다.
  • RestClient·WebClient·HttpExchange 일관: 클라이언트 측에서도 defaultApiVersion() 한 줄로 끝난다. 호출자가 매번 헤더를 손으로 박지 않는다.

유형별 적용 포인트:

  • 유형 A (공개 API 서비스): 가장 큰 수혜자다. 사내 ADR로 운영하던 버전 컨벤션을 표준으로 옮긴다. 다만 기존 컨트롤러의 일괄 치환이 아니라, 새 엔드포인트부터 새 방식으로 가는 게 안전하다.
  • 유형 B (gateway/auth): 외부 노출 면이 적으면 효과 작음. 다만 OAuth2 권한 서버 같은 표준 endpoint에는 큰 의미 없다.
  • 유형 C: WebFlux 쪽에도 동일하게 적용. Spring Batch는 무관.

5. 세 번째 큰 변화: Core Resilience (@Retryable, @ConcurrencyLimit)

Spring Framework 7부터 재시도와 동시성 제한이 spring-core/spring-context에 들어왔다. 그동안 외부 라이브러리(spring-retry, resilience4j)로 풀던 것이다.

Core Resilience before/after

// Before (Boot 3): 두 개의 외부 라이브러리
// implementation("org.springframework.retry:spring-retry")
// implementation("io.github.resilience4j:resilience4j-spring-boot3")
 
@Retryable(value = IOException.class, maxAttempts = 3)
@Bulkhead(name = "ext", type = Bulkhead.Type.SEMAPHORE)
public Result callExternal() { ... }
 
// After (Boot 4 + SF 7): 의존성 추가 없음
@Configuration
@EnableResilientMethods
class ResilienceConfig {}
 
@Service
class ExternalClient {
 
    @Retryable                         // 기본 3회, 1초 간격
    @ConcurrencyLimit(20)              // 최대 20개 동시 호출
    public Result callExternal() { ... }
}

핵심 차이는 셋이다.

  • 외부 라이브러리 0개: spring-retry, resilience4j-spring-boot3를 빼고 시작할 수 있다. 단, circuit breaker가 필요하면 resilience4j는 계속 쓴다. 코어가 들고 온 건 retry와 concurrency throttling 두 가지만.
  • Servlet과 Reactive에 같은 어노테이션: @Retryable이 반환 타입이 Mono/Flux이면 자동으로 Reactor의 .retry() 파이프라인에 합쳐진다.
  • AOP 우선순위가 표준 규약 안에 있다: @Transactional과의 순서, @Async와의 상호작용이 Spring 자체 규약 안에서 결정된다. 외부 라이브러리 두 개를 끼울 때마다 매번 따지던 우선순위 표가 사라진다.

유형별 적용 포인트:

  • 유형 A (외부 호출 다수): 가장 큰 수혜. 외부 API 호출 경로마다 박아둔 spring-retry·resilience4j 의존성을 점진적으로 걷어낼 수 있다.
  • 유형 B: 외부 호출이 적으면 효과 작음.
  • 유형 C (배치): Spring Batch의 job/step 레벨 retry는 별도 체계라 직접 영향 없음. 단, step 내에서 외부 호출을 하는 경우라면 동일하게 적용.

한 가지 강조: circuit breaker는 여전히 외부 라이브러리(resilience4j) 영역이다. Spring Framework 7은 retry와 concurrency limit만 코어에 넣었다. 우리가 사내 표준으로 circuit breaker를 써왔다면, Boot 4 도입 후에도 resilience4j는 남는다. 다만 retry/bulkhead는 코어로 옮길 수 있다.

6. 자동설정 모듈화와 Jackson 3

Boot 3까지 spring-boot-autoconfigure 한 덩어리에 모든 자동설정이 들어와 있었다. Boot 4부터는 기술별로 모듈이 분리된다. JPA 자동설정은 spring-boot-autoconfigure-jpa, Jackson은 spring-boot-autoconfigure-jackson 같은 식.

이게 운영에 미치는 실질적 효과는 둘이다.

  • 시작 시간이 살짝 빨라진다: 안 쓰는 자동설정 클래스를 클래스로더가 보지 않아도 된다.
  • starter 의존성 트리가 깨끗해진다: 어떤 starter가 어떤 자동설정을 끌고 오는지 더 명확해진다.

다만 마이그레이션 관점에서는 거의 자동이다. starter 의존성을 그대로 가져가면 트랜지티브로 새 모듈 구조가 들어온다. 직접 자동설정 클래스를 import하던 코드만 패키지가 바뀐다.

Jackson 3는 다르다. 패키지가 com.fasterxml.jacksontools.jackson으로 이동했다.

// Before
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;
 
// After
import tools.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.annotation.JsonProperty;  // 어노테이션 패키지는 유지
 
JsonMapper mapper = JsonMapper.builder().build();      // ObjectMapper 대신 JsonMapper

핵심 변화는 둘.

  • ObjectMapperJsonMapper: 빌더 기반의 더 명확한 API. ObjectMapper도 유지되지만 JsonMapper가 권장.
  • 어노테이션 모듈만 예외: @JsonProperty, @JsonCreator 같은 어노테이션은 호환성 때문에 com.fasterxml.jackson.annotation에 그대로 남아 있다. 모든 import를 일괄 치환하면 어노테이션도 깨진다. 주의.

유형별 영향:

  • 모든 유형 공통: ObjectMapper를 직접 노출하는 사내 라이브러리가 있다면 시그니처를 바꿔야 한다. Spring이 빈으로 주입하는 경우만 쓴다면 거의 무손실.
  • 사내 직렬화 모듈을 운영하는 팀: Jackson 3의 새 빌더 API로 다시 작성. 한 번에 끝나는 일은 아니다.

7. Spring Security 7: 깨질 가능성이 가장 큰 곳

Boot 4 마이그레이션에서 가장 많이 깨지는 모듈이 Spring Security다. 두 변화에 주의가 필요하다.

(1) API 엔드포인트에도 CSRF가 기본 활성화된다.

Spring Security 6까지 form-based 인증 앱에만 CSRF가 기본이었다. 7부터는 REST API에도 켜진다. 기존 API 클라이언트가 CSRF 토큰을 안 보내면 403으로 막힌다.

// Before (Boot 3 + Security 6)
http.csrf(csrf -> csrf.disable());  // REST API는 명시적으로 끄던 패턴
 
// After (Boot 4 + Security 7): 두 가지 선택
// 선택지 1: 그대로 끄기 (가장 단순한 마이그레이션)
http.csrf(csrf -> csrf.disable());
 
// 선택지 2: 토큰 기반(Bearer JWT)인 경우, CSRF를 명시적으로 ignore
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    .ignoringRequestMatchers("/api/**"));

바로 모든 곳에 .disable()을 박지 말 것. 우리 API가 정말 stateless JWT인지(이때만 비활성화가 안전), 세션 쿠키 기반인지(그러면 CSRF는 켜야 한다)를 먼저 점검한다. 마이그레이션을 빠르게 하기 위해 .disable()로 일괄 처리한 뒤, 보안 회귀를 만드는 경우가 가장 흔한 사고다.

(2) authorizeRequests()가 완전히 제거됐다.

Boot 3 시절부터 deprecated였던 게 7에서 삭제됐다. authorizeHttpRequests()로 전면 치환해야 한다.

// Before
http.authorizeRequests()
    .antMatchers("/api/**").authenticated();
 
// After
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/**").authenticated());

대부분 IDE의 quick fix로 한 번에 처리된다. 단, antMatchersrequestMatchers 치환에서 path matcher의 미묘한 동작 차이(예: /foo/**/foo를 포함하는지)가 있을 수 있어 마이그레이션 후 통합 테스트는 필수.

유형별 적용 포인트:

  • 유형 B (Security 비중 큰 서비스): 가장 일감이 많은 영역이다. SecurityFilterChain, JwtDecoder, OAuth2 리소스 서버 설정을 다 점검해야 한다. PoC 전용 별도 브랜치를 떼서 1~2주 잡고 마이그레이션 시나리오를 미리 돌려본다.
  • 유형 A·C: Security를 얹기만 한 서비스라면 변경 면적은 위 두 가지 정도. 통합 테스트로 충분히 잡힌다.

8. 그 외 챙길 것들: RestTestClient, HttpExchange, OpenTelemetry, Spring Batch

이 절은 짧게 정리한다.

  • RestTestClient (신규): WebFlux의 WebTestClient처럼 fluent API를 가지면서 동기 스택용. @AutoConfigureRestTestClient로 켠다. TestRestTemplate을 점진 대체하는 게 권장 방향. 다만 4.0에서 TestRestTemplate이 제거된 건 아니다.
  • @ImportHttpServices: HTTP 인터페이스(@HttpExchange)를 빈으로 자동 등록. 외부 API 클라이언트를 인터페이스로 선언해 두면 Boot가 구현체를 빈으로 만들어 준다. 사내 외부 API 클라이언트 표준이 OpenFeign이었다면 이 자리로 점진 이주 검토.
  • OpenTelemetry 우선 지원: Micrometer Observation과 OTel bridge가 1급으로. 사내 표준이 OTel이면 설정이 가벼워진다.
  • Spring Batch in-memory default: spring-boot-starter-batchDB 없이도 동작하도록 default 변경됐다. DB에 메타데이터를 저장하던 운영 배치는 명시적으로 JobRepository를 DB로 구성해야 한다. 모르고 업그레이드하면 배치 메타데이터가 사라진다. 유형 C 사용자는 가장 먼저 점검할 항목.
  • Mockito 변경: MockitoTestExecutionListener가 제거됐다. @Mock/@Captor가 동작하지 않으면 JUnit 5의 MockitoExtension을 명시적으로 추가해야 한다.
  • Kotlin 2.2+ 필수.

9. 적용 시나리오: 유형별 4단계 롤아웃

여기까지의 정리를 사내 적용 계획으로 압축한다. 빅뱅으로 모든 서비스를 한 번에 올리지 않는다. 유형별로 우선순위와 깊이가 다르다.

Phase 1: 사전 정비 (Boot 3.5.x로 정렬, 1~2주)

목표는 Boot 4 마이그레이션의 출발선을 통일하는 것. 운영에는 손대지 않는다.

  • 모든 서비스를 Boot 3.5.x 최신 패치로 먼저 정렬. Boot 4 공식 마이그레이션 가이드가 3.5.x를 권장 출발점으로 명시한다.
  • authorizeRequests()가 남아 있다면 authorizeHttpRequests()로 미리 치환. 이건 6.x에서도 가능.
  • spring-retry 사용처와 resilience4j 사용처를 표 하나로 정리. 코어로 이주 가능한 자리 후보 식별.
  • 사내 JSR 305 어노테이션 사용처 목록 작성. 모듈 단위로 JSpecify 도입 계획서.

이 단계의 산출물은 호환성 매트릭스 ADR 하나다. "어느 의존성이 Boot 4 호환 버전을 어디서부터 내놓는지"를 명시.

Phase 2: 비운영 환경에서 빌드만 통과시키기 (2~3주)

  • 새 브랜치에서 org.springframework.boot:spring-boot-dependencies:4.0.0으로 BOM 교체.
  • 컴파일 에러부터 잡는다. Jakarta EE namespace는 이미 끝났으니, 이번 라운드는 주로 다음:
    • tools.jackson 패키지 변경
    • authorizeRequests() 잔재
    • Mockito 리스너 누락
    • Spring Security 7의 OAuth2 구성 시그니처 변경
  • 모든 테스트가 통과하면 CI에 Boot 4 매트릭스 추가. 신규 PR은 3.5.x와 4.0.x 양쪽에서 통과를 강제.

Phase 3: 핵심 운영 서비스, 유형 기준 우선순위 (4~12주)

유형 A부터 시작한다. 이유는 둘이다: 마이그레이션 면적이 가장 좁고(Security 비중 작음), Boot 4의 수혜(API Versioning, Core Resilience, JSpecify)가 곧장 보인다.

유형 A 적용 순서:

  1. 의존성 BOM만 Boot 4로 올린 컨테이너 배포. 코드 변경 최소화.
  2. Jackson 3 마이그레이션. ObjectMapper 직접 사용처 정리.
  3. JSpecify 패키지 단위 마킹 시작. 한 번에 한 모듈만.
  4. 새 외부 API 호출 경로에 @Retryable + @ConcurrencyLimit 코어 적용. 기존 spring-retry는 천천히 걷어내기.
  5. 신규 컨트롤러부터 API Versioning을 사용. 기존 컨트롤러는 유지.

유형 C 적용 순서:

  1. Spring Batch starter의 in-memory default 변경을 먼저 점검. JobRepository를 명시적으로 DB로 설정.
  2. 의존성 BOM만 Boot 4로 올린 컨테이너 배포.
  3. Reactive 서비스라면 @Retryable이 Reactor 파이프라인과 어떻게 합쳐지는지 staging에서 측정.
  4. 나머지는 유형 A와 동일.

유형 B 적용 순서 (가장 신중하게):

  1. 별도 브랜치에서 Security 7 마이그레이션만 1~2주 PoC. CSRF, authorizeHttpRequests(), OAuth2 구성을 모두 점검.
  2. Security 통합 테스트가 100% 통과하면 staging 배포.
  3. 카나리 5% → 25% → 100% 단계로 production 롤아웃.
  4. JSpecify, API Versioning은 Security가 안정된 후 별 트랙으로.

Phase 4: 새 코드는 4 기본값 (지속)

  • 신규 마이크로서비스는 처음부터 Boot 4 위에서 출발.
  • 외부 API 클라이언트 신규 작성은 @HttpExchange + @ImportHttpServices 표준.
  • @Retryable/@ConcurrencyLimit 코어 사용을 사내 표준으로. spring-retry는 신규 도입 금지.
  • 모든 신규 패키지는 @NullMarked default.

한 가지 강조하고 싶은 건, "3.x의 마지막 패치를 거치지 않고 4.0으로 직행하지 않는다" 는 점이다. 3.5.x로 한 번 정렬한 뒤 4.0으로 가는 게 가장 안전하다. Boot 4 자체보다 Boot 4와 함께 오는 다섯 개의 메이저(Framework·Security·Jackson·Hibernate·Spring Data)가 진짜 변수다.

10. 시니어 시선 인사이트

① "Boot 4 마이그레이션"은 사실 다섯 개의 메이저 마이그레이션이다

이걸 처음부터 정직하게 짚지 않으면 일정이 무너진다. Framework 6→7, Security 6→7, Jakarta EE 10→11, Jackson 2→3, Hibernate 6→7이 동시에 일어난다. 어느 하나라도 호환성을 못 맞추는 외부 라이브러리가 있다면 전체가 멈춘다.

그래서 Phase 1의 호환성 매트릭스 ADR이 가장 중요한 산출물이다. "우리 의존성 중 무엇이 Boot 4 호환 버전을 언제 내놓는가"를 표 한 장으로 들고 있어야 한다.

② 새 기능 도입과 마이그레이션은 분리한다

JSpecify, API Versioning, @Retryable 코어는 모두 매력적이다. 하지만 Boot 4 마이그레이션과 동시에 도입하지 않는다. 마이그레이션 PR에는 "버전만 올린다"가 들어가야 하고, 새 기능 도입은 별 PR로 들어가야 한다.

이유는 단순하다. 장애가 났을 때 원인을 한 줄로 좁힐 수 있어야 한다. "Boot 4 + JSpecify + API Versioning을 한 번에 올렸는데 prod에서 NPE가 떴다"가 가장 디버깅하기 어려운 모양이다.

③ 깨질 만한 곳은 Security와 Batch다, 나머지는 거의 무손실

마이그레이션 PR이 가장 길게 멈추는 자리는 두 곳이다. Security 7의 CSRF 기본 활성화Spring Batch starter의 in-memory default. 둘 다 운영에 직결되는 변경이고, 둘 다 모르고 넘기면 사고가 난다.

반대로 말하면, Security 비중이 낮은 서비스(유형 A·C 중 배치가 없는 것)는 마이그레이션 면적이 의외로 작다. BOM 교체 + Jackson 3 import 정리 + Mockito 리스너 보정 정도로 끝나는 케이스가 대부분이다. 일정을 잡을 때 이 차이를 분명히 둔다.

④ Spring 코어로 들어온 기능을 외부 라이브러리로 계속 쓰는 건 부채다

@Retryable이 코어에 들어왔다는 건, 3년 뒤엔 spring-retry가 사실상 deprecated 라이브러리로 취급될 것이라는 뜻이다. 지금 당장 일괄 치환할 필요는 없지만, 신규 코드부터는 코어 어노테이션을 쓰는 컨벤션을 박아 두는 게 정상이다.

Spring의 진화는 늘 같은 패턴이다: 외부 라이브러리에서 검증된 패턴을 코어로 흡수한다. 그때 외부 라이브러리를 계속 들고 있는 건 의존성 부채로 남는다. "코어로 옮길 수 있는 자리"를 식별해 두는 것이 시니어가 마이그레이션 가이드에 적어야 할 한 줄이다.

⑤ 메이저 버전은 LTS 정렬의 기회로 쓴다

Boot 4는 Java 17이 minimum이지만 21을 권장하고, 메인스트림은 25다. Boot 4 마이그레이션은 Java LTS 정렬을 같이 하기에 가장 좋은 타이밍이다.

Boot 3에서 Java 21로 갔던 서비스는 Boot 4 마이그레이션과 함께 Java 25로 점프. Boot 3에서 Java 17에 머물러 있던 서비스는 두 단계(언어와 프레임워크)를 같이 정리. 각각의 메이저를 별개로 다루면 같은 회귀 검증을 두 번 한다. 한 번에 묶는 게 효율적이다.

물론, 묶는 만큼 위험도 합쳐진다. 그 위험을 Phase 분리와 카나리 단계로 분산한다. 묶지 않는 게 안전한 게 아니라, 묶되 단계를 명확히 끊는 것이 안전한 것이다.

마무리: 한 문장으로 줄이면

Spring Boot 4는 새 기능을 배우러 가는 릴리스가 아니다. Boot 3 시대에 외부 라이브러리·사내 컨벤션·중복 어노테이션으로 풀던 패턴들을 Spring 표준이 흡수해 가는 릴리스다.

다음 액션 아이템은 셋이다.

  1. 호환성 매트릭스 ADR: 사내 의존성이 Boot 4에서 어떻게 동작하는지 한 페이지로 정리. 외부 라이브러리, 사내 라이브러리, 빌드 도구 전체.
  2. 유형별 PoC 브랜치: 유형 A·B·C 대표 서비스 각 하나씩 Boot 4 PoC. 특히 유형 B(Security)는 별도 1~2주 전용.
  3. 신규 코드 컨벤션 가이드: Boot 4 도입 이후 새 코드가 따라야 할 것들. @NullMarked default, 코어 @Retryable, API Versioning 표준, @HttpExchange + @ImportHttpServices.

Boot 3.0이 Jakarta EE 전환이라는 한 번의 큰 깨짐을 우리에게 떠넘겼다면, Boot 4.0은 그동안 우리 손으로 메워온 자리에 표준의 답을 다시 채워 넣는 릴리스다. 빅뱅으로 가지 말되, 미루지도 말 것. 메이저 릴리스의 가치는 결국 그 안에서 누가 가장 빨리 표준에 다시 정렬하느냐로 갈린다.