728x90
반응형

앞 글에서 던진 떡밥, 오늘 회수합니다.
"Virtual Threads가 게임 체인저"라고 했는데, 그래서 어떻게 써야 잘 쓰는 건지 코드와 함께 정리해드립니다.

Virtual Threads(가상 스레드)는 Java 21에서 정식 출시된 후 Java 24의 JEP 491로 가장 큰 약점이었던 pinning 문제까지 해결되면서, 2026년 현재 운영 환경 도입의 모든 장벽이 사라진 상태입니다.

이 글은 "개념 설명"이 아니라 "실전 코드 + 함정 회피" 중심입니다. Spring Boot 통합, HikariCP 이슈, ThreadLocal 트랩까지 모두 다룹니다.


✅ TL;DR — 5줄 요약

  • Spring Boot 3.2+에서는 spring.threads.virtual.enabled=true 한 줄이면 끝
  • Virtual Thread는 풀링 금지 — 매 작업마다 새로 만드는 게 정석
  • 동시성 제한은 풀 대신 Semaphore
  • Java 21~23에서 synchronized는 pinning 발생 → Java 24/25부터 해결됨
  • CPU-bound 작업은 여전히 Platform Thread가 유리

🎯 왜 가상 스레드인가? (1분 복습)

기존 방식의 한계

전통적인 Java 스레드는 OS 스레드와 1:1 매핑됩니다. 스레드 하나당 약 1MB의 스택 메모리를 잡아먹어요. 그래서:

  • 동시 요청 1만 개? → 메모리 폭발 💥
  • 해결책으로 나온 게 WebFlux 같은 리액티브 프로그래밍
  • 하지만 코드 스타일이 완전히 달라서 학습 비용 ↑

가상 스레드의 해법

Platform Thread (기존):  1 스레드 = 1 OS 스레드 = 1MB 메모리
Virtual Thread (신규):   수백만 개 가능, 메모리 거의 안 씀

JVM이 자체 스케줄러로 가상 스레드를 carrier(플랫폼) 스레드에 mount/unmount 하면서 관리합니다. "동기 코드처럼 짜되, 리액티브처럼 확장된다"가 핵심이에요.


🛠️ 활용법 1 — 순수 Java로 사용하기

가장 기본적인 방법

// 방법 1: Thread.ofVirtual() 빌더
Thread vt = Thread.ofVirtual()
    .name("my-task")
    .start(() -> {
        System.out.println("가상 스레드 실행 중!");
    });
vt.join();

// 방법 2: 한 줄로 시작
Thread.startVirtualThread(() -> {
    System.out.println("간단 버전");
});

실무에서 쓰는 건 이 패턴 ⭐

// ExecutorService를 가상 스레드로 — 가장 많이 쓰는 패턴
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = IntStream.range(0, 10_000)
        .mapToObj(i -> executor.submit(() -> {
            // 각 작업이 별도의 가상 스레드에서 실행됨
            return callExternalApi(i);
        }))
        .toList();

    for (var future : futures) {
        System.out.println(future.get());
    }
}
// try-with-resources 종료 시 모든 작업 완료 대기

⚠️ 중요: newVirtualThreadPerTaskExecutor()는 작업마다 새 가상 스레드를 생성합니다. 이게 정상이에요. 풀링하지 마세요!


🌱 활용법 2 — Spring Boot 3.2+에서 사용하기

단 한 줄로 활성화

# application.properties
spring.threads.virtual.enabled=true
# application.yml
spring:
  threads:
    virtual:
      enabled: true

이 한 줄이면 Spring Boot가 알아서 다음 영역에 가상 스레드를 적용합니다.

  • Tomcat/Jetty 웹 요청 처리
  • @Async 메서드 실행
  • @Scheduled 작업
  • WebClient 비동기 요청
  • 기타 내부 TaskExecutor

적용 확인하기

@RestController
@RequestMapping("/thread")
public class ThreadCheckController {

    @GetMapping("/info")
    public String getThreadInfo() {
        Thread current = Thread.currentThread();
        return String.format(
            "Thread: %s, Virtual: %s",
            current.getName(),
            current.isVirtual()
        );
    }
}

호출 결과:

Thread: VirtualThread[#171]/runnable@ForkJoinPool-1-worker-4, Virtual: true

isVirtual()true로 나오면 성공!

환경 요구사항

항목 최소 사양 권장
Java 21 24 이상 (JEP 491 적용)
Spring Boot 3.2 3.3+
Spring Framework 6.1 6.2+

⚙️ 활용법 3 — 세밀한 제어가 필요할 때

전체 적용이 아니라 특정 영역에만 가상 스레드를 쓰고 싶다면:

@Configuration
public class VirtualThreadConfig {

    // Tomcat 요청 처리만 가상 스레드로
    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() {
        return protocolHandler -> protocolHandler.setExecutor(
            Executors.newVirtualThreadPerTaskExecutor()
        );
    }

    // @Async 메서드만 가상 스레드로
    @Bean
    public AsyncTaskExecutor asyncTaskExecutor() {
        return new TaskExecutorAdapter(
            Executors.newVirtualThreadPerTaskExecutor()
        );
    }

    // 특정 비즈니스 로직 전용
    @Bean
    public ExecutorService emailSenderExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

조건부 활성화도 가능합니다.

@Bean
@ConditionalOnProperty(value = "app.thread-mode", havingValue = "virtual")
public AsyncTaskExecutor virtualThreadExecutor() {
    return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}

⚠️ 함정 1 — Virtual Thread는 절대 풀링하지 마세요

가장 많이 하는 실수입니다.

// ❌ 잘못된 예 — 가상 스레드의 의미를 완전히 무시
ExecutorService bad = Executors.newFixedThreadPool(20,
    Thread.ofVirtual().factory());

// ✅ 올바른 예 — 매 작업마다 새 가상 스레드 생성
ExecutorService good = Executors.newVirtualThreadPerTaskExecutor();

가상 스레드는 생성/소멸이 거의 무료입니다. 풀링은 플랫폼 스레드의 비싼 비용을 아끼려는 패턴이지, 가상 스레드에 적용하면 오히려 손해예요.

그럼 동시성 제한은 어떻게?

다운스트림 서비스 보호를 위해 동시 호출을 제한하고 싶다면 Semaphore를 쓰세요.

public class RateLimitedService {
    // 외부 API 동시 호출을 20개로 제한
    private final Semaphore semaphore = new Semaphore(20);

    public String callExternalApi(String url) throws InterruptedException {
        semaphore.acquire();
        try {
            return httpClient.send(url);
        } finally {
            semaphore.release();
        }
    }
}

// 사용 시 — 가상 스레드는 마음껏 만들어도 됨
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> service.callExternalApi("..."));
    }
}

⚠️ 함정 2 — Pinning 이슈 (Java 21~23에서 발생)

Pinning이 뭔가요?

가상 스레드가 carrier 스레드에 달라붙어서 떨어지지 않는 현상입니다. 이렇게 되면 carrier가 다른 가상 스레드를 처리하지 못해 사실상 풀링 모델로 퇴화해요.

Java 21~23에서의 원인

// ❌ Java 21~23에서 pinning 발생
public synchronized void cachedFetch(String key) {  // synchronized
    var data = database.query(key);  // 블로킹 I/O
    cache.put(key, data);
}

synchronized 블록 안에서 블로킹 I/O가 일어나면 가상 스레드가 carrier에 pinning됩니다.

Java 24/25에서 해결됨 (JEP 491) 🎉

Java 24부터 JEP 491: Synchronize Virtual Threads without Pinning이 적용되어 synchronized로 인한 pinning이 거의 사라졌습니다. JVM이 모니터를 virtual thread 단위로 추적하도록 변경됐어요.

그래서 2026년 현재의 권장사항은:

  • Java 24+ 사용 가능synchronized 그대로 사용해도 OK
  • Java 21~23 사용 → 핫 패스의 synchronizedReentrantLock으로 교체 권장
// Java 21~23용 — pinning 회피 패턴
public class SafeCache {
    private final ReentrantLock lock = new ReentrantLock();

    public Data fetch(String key) {
        lock.lock();
        try {
            return database.query(key);  // pinning 안 일어남
        } finally {
            lock.unlock();
        }
    }
}

아직 pinning이 발생하는 케이스

JEP 491 이후에도 다음 경우엔 여전히 pinning이 발생합니다.

  • 네이티브 메서드 호출 중 블로킹
  • Foreign Function & Memory API 사용 중 블로킹
  • 클래스 초기화 중 블로킹

JFR 이벤트 jdk.VirtualThreadPinned로 모니터링 가능합니다.


⚠️ 함정 3 — HikariCP & ThreadLocal 트랩

Spring Boot에서 가상 스레드를 켰는데 DB 연결이 자꾸 타임아웃난다면 의심할 곳이 여기입니다.

문제 상황

HikariCP는 ThreadLocal로 connection을 캐싱하는데, 가상 스레드는 매번 새로 만들어지므로 캐시 히트율이 0에 수렴합니다. 결과적으로:

HikariPool-1 - Connection is not available, request timed out after 30000ms

해결 방법

1. 커넥션 풀 사이즈 증설

spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.connection-timeout=30000

2. 동시 DB 호출에 Semaphore 적용

@Service
public class UserService {
    private final Semaphore dbSemaphore = new Semaphore(50);  // pool size에 맞춤
    private final UserRepository repository;

    public User findById(Long id) throws InterruptedException {
        dbSemaphore.acquire();
        try {
            return repository.findById(id).orElseThrow();
        } finally {
            dbSemaphore.release();
        }
    }
}

3. JDBC 드라이버 최신 버전 사용

MySQL Connector/J 9.0+, PostgreSQL JDBC 최신 버전은 내부 synchronizedReentrantLock으로 교체해 가상 스레드 친화적으로 개선됐습니다.

<!-- pom.xml -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.1.0</version> <!-- 9.0+ 권장 -->
</dependency>

⚠️ 함정 4 — ThreadLocal 의존 코드 주의

ThreadLocal을 캐시처럼 쓰는 코드는 가상 스레드에서 무용지물입니다. 매 요청마다 새 스레드라 캐시 히트가 안 일어나요.

// ❌ 가상 스레드에서 의미 없음
private static final ThreadLocal<DateFormat> dateFormat =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

대안: ScopedValue (Java 21 Preview, Java 25 정식)

Java 25에서 정식화된 ScopedValue를 쓰면 가상 스레드에서도 효율적으로 컨텍스트를 전달할 수 있습니다.

// Java 25+
public class RequestContext {
    public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

    public void handleRequest(String userId, Runnable task) {
        ScopedValue.where(USER_ID, userId).run(task);
    }

    public void someMethod() {
        String currentUser = USER_ID.get();  // 어디서든 접근
        // ...
    }
}

🚫 가상 스레드를 쓰면 안 되는 경우

만능이 아닙니다. 다음 상황에서는 여전히 Platform Thread가 유리해요.

1. CPU-bound 작업

// ❌ 이런 건 가상 스레드 의미 없음
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        // 행렬 곱셈, 이미지 처리, 암호화 등
        return computePrime(1_000_000);  // 순수 CPU 연산
    });
}

// ✅ CPU-bound는 ForkJoinPool 또는 코어 수만큼의 플랫폼 스레드
ExecutorService cpuPool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);

가상 스레드의 강점은 블로킹 I/O 처리입니다. CPU 작업은 어차피 코어 수만큼만 동시 실행되니 이득이 없어요.

2. 짧고 동기적인 작업만 하는 경우

레이턴시가 1ms 이하의 매우 짧은 작업이라면 컨텍스트 스위칭 오버헤드가 더 클 수 있습니다.

3. 기존 시스템이 잘 돌아가는 경우

리액티브 코드(WebFlux)가 이미 잘 동작 중이고 성능 이슈가 없다면, 굳이 다 갈아엎을 필요는 없습니다.


📊 모니터링 & 디버깅

Pinning 감지

# Java 21~23: 시스템 프로퍼티
-Djdk.tracePinnedThreads=full

# Java 24+: JFR 이벤트로
java -XX:StartFlightRecording=filename=app.jfr,settings=profile MyApp

가상 스레드 사용 여부 확인

@Component
public class ThreadMonitor {

    @EventListener(ApplicationReadyEvent.class)
    public void logThreadInfo() {
        log.info("Available processors: {}",
            Runtime.getRuntime().availableProcessors());
        log.info("Default Virtual Thread Scheduler parallelism: {}",
            System.getProperty("jdk.virtualThreadScheduler.parallelism"));
    }
}

Spring Boot Actuator 활용

management.endpoints.web.exposure.include=health,metrics,threaddump

/actuator/threaddump에서 가상 스레드와 플랫폼 스레드를 모두 확인할 수 있습니다.


🚀 실전 마이그레이션 체크리스트

기존 Spring Boot 프로젝트에 가상 스레드를 적용하려면 이 순서로 진행하세요.

  • Java 24 이상으로 업그레이드 (또는 최소 Java 21)
  • Spring Boot 3.2 이상으로 업그레이드
  • JDBC 드라이버를 가상 스레드 친화 버전으로 업데이트 (MySQL 9.0+, pgjdbc 최신)
  • HikariCP maximum-pool-size 재검토 (필요 시 증설)
  • ThreadLocal 사용 코드 점검 — 캐시 용도라면 ScopedValue 또는 다른 방식으로 교체
  • 핫 패스의 synchronized 블록에서 블로킹 호출 여부 점검 (Java 21~23인 경우)
  • 스테이징 환경에서 부하 테스트 후 프로덕션 적용
  • application.propertiesspring.threads.virtual.enabled=true 추가
  • 프로덕션 적용 후 JFR/Actuator로 pinning 모니터링

✍️ 결론

Virtual Threads는 더 이상 "신기술 한번 써보자" 단계가 아닙니다. JEP 491로 마지막 장벽까지 사라진 2026년 현재, 다음과 같은 워크로드라면 도입을 진지하게 고민할 시점이에요.

  • 외부 API를 많이 호출하는 백엔드
  • 데이터베이스 I/O가 주요 병목인 애플리케이션
  • 동시 접속자가 많은 웹 서비스
  • 마이크로서비스 간 통신이 빈번한 시스템

리액티브 프로그래밍의 학습 비용 없이도 수만 동시 요청을 처리하는 백엔드를 만들 수 있다는 것, 이게 가상 스레드의 진짜 가치입니다.


📌 핵심 요약 (TL;DR)

🔹 활성화: Spring Boot 3.2+에서 spring.threads.virtual.enabled=true 한 줄
🔹 풀링 금지: newVirtualThreadPerTaskExecutor() 사용, 동시성 제한은 Semaphore
🔹 Pinning: Java 24+에서 JEP 491로 거의 해결됨, Java 21~23은 ReentrantLock 권장
🔹 HikariCP: pool size 재조정 + 최신 JDBC 드라이버 사용
🔹 CPU-bound 작업: 여전히 플랫폼 스레드가 정답


👉 다음 글 예고

다음 편에서는 Java 25에서 정식 출시된 Structured Concurrency 활용법을 다룰게요. 가상 스레드와 함께 쓰면 동시 작업의 생명주기를 깔끔하게 관리할 수 있습니다.

도움이 되셨다면 공감 + 댓글 부탁드려요 😊

 

728x90
반응형
728x90
반응형

Spring Boot 4.0 핵심 변경점 정리 — Java 25 지원부터 API 버저닝까지

2025년 11월, Spring Boot 4.0이 정식 출시됐다. 단순한 마이너 업데이트가 아니라 Spring Boot의 다음 세대를 여는 메이저 릴리즈다. Spring Framework 7과 Jakarta EE 11 위에 새로 올라섰고, 코드베이스 자체가 재구성됐다. 3.x를 잘 쓰고 있다면 굳이 지금 올려야 하나 고민될 수도 있는데, 결론부터 말하면 지금 당장 옮길 필요는 없지만, 신규 프로젝트라면 4.x로 시작하는 게 합리적이다.

이 글에서는 실무 관점에서 가장 중요한 변경점만 추려서 정리한다.


한눈에 보는 핵심 변경점

  • Spring Framework 7 / Jakarta EE 11 기반으로 전환
  • Java 25에 대한 1급 지원, 단 Java 17 호환은 유지
  • 거대한 auto-configure JAR을 작은 단위 모듈로 분해
  • JSpecify 기반 null 안전성을 표준으로 채택
  • API 버저닝 내장 지원 (@GetMapping(version = "1.0"))
  • 인터페이스로 HTTP 클라이언트를 만드는 HTTP Service Clients
  • Jackson 3 기본 채택 (패키지 경로 변경)
  • Gradle 9 빌드 지원
  • GraalVM 25+ 네이티브 이미지 요구사항 상향

출시 배경 — 왜 4.0인가

Spring Boot 3.x는 2022년 말 출시된 이후 약 3년 동안 안정적인 LTS 라인으로 쓰였다. 그동안 Java는 17 → 21 → 25로 두 번의 LTS를 거쳤고, Jakarta EE도 10에서 11로 올라갔다. 무엇보다 Spring Framework 7이 등장하면서, Boot 쪽도 그에 맞게 베이스라인을 끌어올릴 시점이 됐다.

Spring 팀이 이번 릴리즈에서 강조하는 키워드는 두 개다. 모듈화null 안전성. 둘 다 *"이미 동작하는 앱"* 보다는 *"앞으로 만들어질 앱"* 을 위한 변화에 가깝다. 즉, 4.0은 화려한 신기능이라기보다 앞으로 5년치 토대를 다시 까는 작업이라고 보는 편이 정확하다.


1. 코드베이스 모듈화 — 거대한 가방을 여러 개의 작은 가방으로

3.x 시절에는 spring-boot-autoconfigure라는 거대한 JAR 하나가 거의 모든 자동 설정을 들고 있었다. WebMVC만 쓰는 프로젝트에서도 LDAP, Neo4j, Quartz 같은 자동 설정 클래스가 클래스패스에 함께 끌려왔다.

Spring Boot 4.0은 이 구조를 깨고, 각 기술별로 작고 집중된 JAR로 쪼갰다. 예를 들어 spring-boot-starter-webmvc를 추가하면 WebMVC 관련 자동 설정만 따라온다. 그 결과 다음과 같은 효과가 있다.

  • 시작 시간 단축: 클래스패스 스캔 대상이 줄어든다
  • 메모리 사용 감소: 불필요한 자동 설정이 안 올라온다
  • 디버깅 편의성: 어디서 어떤 자동 설정이 들어왔는지 추적이 쉬워진다

다만 모듈이 잘게 쪼개진 만큼, starter POM을 쓰지 않고 직접 의존성을 관리하던 프로젝트는 의존성 누락 이슈가 발생할 수 있다. 마이그레이션 가이드에서도 이 부분을 가장 먼저 강조한다.


2. Java 25 1급 지원, Java 17 호환 유지

Spring Boot 4.0의 최소 요구 Java 버전은 여전히 Java 17이다. 다만 권장은 Java 25 LTS다.

Spring Boot 4.0 requires Java 17 or newer, with first-class support for Java 25.

여기서 "1급 지원(first-class support)"이 의미하는 바는, 단순히 Java 25에서 동작한다는 게 아니라 Virtual Threads, Pattern Matching, Sequenced Collections, 향상된 GC 같은 최신 JVM 기능을 프레임워크 차원에서 적극 활용한다는 뜻이다.

네이티브 이미지를 쓰려면 이야기가 좀 다르다. GraalVM 25 이상이 필요하다. 이전 GraalVM 23/24에서 빌드되던 네이티브 이미지가 그대로 빌드되지 않을 수 있으니, 네이티브를 쓰는 팀은 빌드 파이프라인을 먼저 점검해야 한다.


3. JSpecify로 표준화된 null 안전성

이번 릴리즈에서 가장 조용하지만 가장 영향력이 큰 변화다.

기존 Spring 코드에서 null 처리는 사실상 컨벤션에 의존했다. @Nullable 같은 애노테이션이 있긴 했지만, 어디 패키지의 어떤 애노테이션을 쓰느냐가 라이브러리마다 달랐다(Spring, JetBrains, Checker Framework, FindBugs…). IDE는 이걸 어찌어찌 통합해서 표시해줬지만, 일관성은 부족했다.

Spring Boot 4.0은 JSpecify를 표준 null 안전성 라이브러리로 채택했다. JSpecify는 Google, JetBrains, Spring 팀이 함께 합의한 일종의 표준 신호 체계다. 핵심 애노테이션은 다음과 같다.

  • @Nullable — 이 값은 null일 수 있다
  • @NonNull — 이 값은 절대 null이 아니다
  • @NullMarked — 이 패키지/클래스의 모든 참조는 기본 non-null이다

package-info.java@NullMarked를 한 번만 붙이면 패키지 안의 모든 참조 타입이 기본 non-null로 간주되고, null이 들어올 수 있는 곳만 @Nullable로 표시하면 된다. 코드 가독성이 명확해지고, IntelliJ나 NullAway 같은 정적 분석 도구가 빌드 단계에서 더 잘 잡아낸다.

// package-info.java
@NullMarked
package com.kraft.member;

import org.jspecify.annotations.NullMarked;
public Member findById(Long id) { ... }       // 절대 null 아님
public @Nullable Member findByEmail(String email) { ... }  // null 가능

기존 코드에 즉시 영향을 주진 않지만, 새로 작성하는 코드에는 처음부터 적용하는 게 좋다.


4. API 버저닝 — 이제 컨트롤러 한 줄로 처리

API 버저닝은 그동안 모든 팀이 각자의 방식으로 고생하던 영역이었다. URL 경로(/v1/...)에 박아 넣거나, 커스텀 헤더로 분기하거나, 미디어 타입으로 분기하거나. 어느 방식을 택하든 컨트롤러를 직접 갈라야 했고, 전략을 바꾸면 모든 컨트롤러를 다시 손대야 했다.

Spring Boot 4.0에서는 @RequestMapping 계열 애노테이션에 version 속성이 추가됐다.

@RestController
@RequestMapping("/api/posts")
public class PostController {

    @GetMapping(version = "1.0")
    public PostV1Response getPostsV1() { ... }

    @GetMapping(version = "2.0")
    public PostV2Response getPostsV2() { ... }
}

버전 분기 전략(URL, 헤더, 미디어 타입)은 설정에서 한 번만 정의하면 되고, 컨트롤러는 버전 번호만 신경 쓰면 된다. 전략을 나중에 바꿔도 컨트롤러 코드는 그대로 둘 수 있다는 점이 핵심이다.


5. HTTP Service Clients — 인터페이스만 선언하면 끝

외부 API를 호출할 때 RestTemplate이나 WebClient로 매번 코드를 짜는 대신, 인터페이스만 선언하고 구현체를 자동 생성하는 방식이 정식 자동 설정으로 들어왔다.

@HttpExchange(url = "https://api.example.com")
public interface WeatherClient {

    @GetExchange("/weather/{city}")
    WeatherResponse getWeather(@PathVariable String city);

    @PostExchange("/alerts")
    AlertResponse createAlert(@RequestBody AlertRequest request);
}

Feign이나 Retrofit을 써 본 사람이라면 친숙할 것이다. 차이는 Spring 자체에 통합됐다는 점이다. 별도 라이브러리 없이 @HttpExchange만 붙이면 Spring Boot가 알아서 구현체를 만들어 빈으로 등록한다. 4.x의 Spring Cloud Commons 5.0에서는 여기에 Circuit Breaker, 로드밸런싱(lb:// 스킴) 같은 기능까지 선언적으로 얹을 수 있다.

마이크로서비스 환경이 아니더라도, 외부 API를 호출하는 곳이 많은 프로젝트라면 코드량이 눈에 띄게 줄어든다.


6. Jackson 3 — 패키지 경로가 바뀐다

Spring Boot 4.0은 Jackson 3을 기본 JSON 라이브러리로 채택했다. 가장 눈에 띄는 변경은 패키지/그룹 ID다.

  • com.fasterxml.jackson.*tools.jackson.* 로 이동
  • 단, jackson-annotations 모듈은 호환성 때문에 com.fasterxml.jackson.core 그룹 ID와 com.fasterxml.jackson.annotation 패키지를 유지

직접 ObjectMapper를 import해서 쓰는 코드가 있다면 패키지 경로를 바꿔야 한다. 다만 Jackson 2를 요구하는 외부 라이브러리도 여전히 많기 때문에, Jackson 2와 3을 한 프로젝트에서 공존시킬 수 있도록 의존성 관리는 계속 제공된다.


그 외 주목할 변화

위에 적은 6가지 외에도 실무에서 한 번씩 만나게 되는 변경이 많다.

  • OpenTelemetry Starter 신설: spring-boot-starter-opentelemetry 추가만으로 OTLP 메트릭/트레이스를 내보낼 수 있다
  • Gradle 9 지원: Gradle 8.14 이상이면 둘 다 지원
  • Kotlin 2.2+ 요구: Kotlin 프로젝트는 빌드 환경부터 점검 필요
  • 클래식 uber-jar 로더 제거: 빌드 스크립트에서 관련 설정을 떼야 한다
  • Spring Retry 의존성 제거: 재시도 로직은 spring-core의 새 retry API로 이동
  • Testcontainers 모듈 prefix 정리: 모든 모듈이 testcontainers- 접두어로 통일
  • SSL 인증서 만료 모니터링 강화: expiringChains 항목 추가로 만료 임박 인증서를 health 응답에서 분리해 보여줌

3.x → 4.x 업그레이드, 무엇을 점검해야 하나

기존 프로젝트를 올리려면 다음 순서로 접근하는 게 안전하다.

  1. 3.5로 먼저 올린다: 3.x 안에서 deprecated 된 API를 모두 정리한 뒤 4.x로 점프
  2. Java 17 이상인지 확인: 11이나 8을 쓰고 있다면 자바 마이그레이션이 선행
  3. starter POM 사용 여부 점검: 직접 의존성 관리를 하고 있다면 누락 모듈 식별이 가장 먼저
  4. Jackson 사용처 점검: com.fasterxml.jackson.* 직접 import 여부, 커스텀 직렬화 코드 확인
  5. 네이티브 이미지 쓰는 경우 GraalVM 25로 업그레이드
  6. JSpecify 기반 정적 분석 도입 검토: 즉시 적용은 부담스러우면 신규 패키지부터

운영 중인 서비스를 무조건 빨리 올릴 이유는 없다. 3.x도 한동안 유지보수가 계속될 예정이고, 4.0의 진가는 신규 코드를 쓸 때 더 잘 드러난다.


마무리 — 4.0을 어떻게 받아들여야 할까

Spring Boot 4.0은 눈에 확 띄는 신기능 보다는 앞으로 5년의 토대를 다시 까는 릴리즈에 가깝다. 모듈화로 슬림해진 런타임, JSpecify로 표준화된 null 안전성, 인터페이스 기반 HTTP 클라이언트, 내장 API 버저닝 — 이 네 가지만으로도 신규 프로젝트라면 4.x로 시작할 명분이 충분하다.

반면 운영 중인 3.x 서비스는 서두를 필요가 없다. 3.5에서 deprecated 정리부터 차근차근 하면서, 분기점이 잡힐 때 옮기면 된다. 진짜로 챙겨야 할 건 새로 짜는 코드부터 4.x 스타일로 쓰는 것이다. JSpecify 애노테이션, 인터페이스 기반 HTTP 클라이언트, API 버저닝 attribute 같은 건 지금 손에 익혀두면 마이그레이션 시점에 부담이 훨씬 줄어든다.

Spring Boot 4.0은 이미 동작하는 앱을 깨러 온 게 아니라, 앞으로 만들 앱을 더 잘 만들게 하러 온 릴리즈다. 그 관점으로 보면 어디서부터 손을 댈지 답이 보인다.


참고 자료

  • Spring Boot 4.0 Release Notes (spring-projects/spring-boot Wiki)
  • Spring Boot 4.0 Migration Guide
  • Spring 공식 블로그 — Spring Boot 4.0.0 available now (2025.11.20)
  • JetBrains Blog — Spring Boot 4: Leaner, Safer Apps and a New Kotlin Baseline

#SpringBoot4 #SpringBoot #Java25 #JSpecify #APIVersioning #HTTPInterface #Jackson3 #SpringFramework7 #JakartaEE11 #백엔드 #Spring업그레이드 #자바개발


728x90
반응형

+ Recent posts