Spring Boot

Spring Boot 4 마이그레이션 가이드 - Virtual Threads와 Spring AI 통합

백엔드 개발자 김승원 2026. 4. 2. 16:42

들어가며

Spring Boot 4.0이 2025년 11월에 정식 출시되었습니다. Spring Framework 7 기반으로 구축된 이번 메이저 버전은 Virtual Threads 기본 실행 모델, Spring AI 통합, JSpecify 기반 null safety 등 패러다임 수준의 변화를 포함합니다. 이 글에서는 Spring Boot 3.x에서 4.0으로 마이그레이션하는 과정을 단계별로 안내합니다.

1. Spring Boot 4.0 핵심 변경사항

항목 Spring Boot 3.x Spring Boot 4.0
Spring Framework 6.x 7.x
최소 Java 버전 Java 17 Java 21
스레드 모델 Platform Threads (기본) Virtual Threads (기본)
Null Safety Spring @Nullable JSpecify @Nullable
AI 통합 별도 프로젝트 spring-boot-starter-ai 내장
JAR 구조 fat jar 모듈화된 jar (CDS 최적화)
GraalVM 실험적 1등급 지원
Observability Micrometer 1.x Micrometer 2.x

2. 사전 준비 - 3.x 최신 버전 업데이트

마이그레이션의 첫 단계는 Spring Boot 3.x 최신 버전(3.4.x)으로 먼저 업데이트하는 것입니다.

// build.gradle (3.x 최신 버전으로 먼저 업데이트)
plugins {
    id 'org.springframework.boot' version '3.4.1'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'java'
}

java {
    sourceCompatibility = JavaVersion.VERSION_21  // Java 21로 전환
}

// 3.x에서 Deprecation 경고를 모두 해결한 후 4.0으로 올리세요
tasks.withType(JavaCompile).configureEach {
    options.compilerArgs.add('-Xlint:deprecation')
}

체크리스트:

  • Java 21 이상으로 전환 완료
  • Deprecated API 사용 제거
  • jakarta.* 네임스페이스 전환 확인 (3.0에서 이미 완료되었어야 함)
  • 서드파티 라이브러리 호환성 확인

3. Virtual Threads 기본 실행 모델

Spring Boot 4.0의 가장 큰 변화는 Virtual Threads가 기본 실행 모델이 된 것입니다. 별도 설정 없이 Tomcat, Jetty, 그리고 @Async, @Scheduled 등 모든 스레드 관련 기능에서 Virtual Threads를 사용합니다.

Spring Boot 3.x에서의 Virtual Threads (명시적 활성화 필요)

# application.yml (Spring Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true  # 3.x에서는 명시적으로 활성화 필요

Spring Boot 4.0 (기본 활성화)

# application.yml (Spring Boot 4.0)
# Virtual Threads가 기본! 별도 설정 불필요
# Platform Threads로 되돌리려면:
spring:
  threads:
    virtual:
      enabled: false  # 명시적으로 비활성화할 때만

Virtual Threads 활용 예제

@RestController
@RequestMapping("/api/reports")
@RequiredArgsConstructor
public class ReportController {

    private final ReportService reportService;
    private final ExternalApiClient apiClient;

    @GetMapping("/{id}")
    public ResponseEntity<ReportResponse> getReport(@PathVariable Long id) {
        // Virtual Thread에서 실행 - 블로킹 I/O 시에도 OS 스레드 점유하지 않음
        Report report = reportService.findById(id);
        ExternalData data = apiClient.fetchData(report.getExternalId());

        return ResponseEntity.ok(
            ReportResponse.of(report, data)
        );
    }
}

@Service
public class ReportService {

    // StructuredTaskScope로 병렬 작업 관리 (Java 21+)
    public AggregatedReport generateReport(Long userId) {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Subtask<UserProfile> profileTask =
                scope.fork(() -> userClient.getProfile(userId));
            Subtask<List<Order>> ordersTask =
                scope.fork(() -> orderClient.getOrders(userId));
            Subtask<CreditScore> creditTask =
                scope.fork(() -> creditClient.getScore(userId));

            scope.join();
            scope.throwIfFailed();

            return new AggregatedReport(
                profileTask.get(),
                ordersTask.get(),
                creditTask.get()
            );
        } catch (Exception e) {
            throw new ReportGenerationException("리포트 생성 실패", e);
        }
    }
}

Virtual Threads 마이그레이션 시 주의사항

  • synchronized 블록 주의: Virtual Thread가 synchronized 블록 안에서 블로킹되면 캐리어 스레드(OS 스레드)를 고정(pin)합니다. ReentrantLock으로 교체하세요.
  • ThreadLocal 사용 재검토: Virtual Threads에서 ThreadLocal은 메모리 오버헤드가 큽니다. ScopedValue 사용을 고려하세요.
  • 커넥션 풀 크기 조정: Virtual Threads는 수만 개가 생성될 수 있어 DB 커넥션 풀이 병목됩니다. HikariCP maximumPoolSize를 적절히 설정하세요.
// Before: synchronized (Virtual Thread pinning 발생)
private synchronized void updateCache(String key, Object value) {
    cache.put(key, value);
}

// After: ReentrantLock (pinning 방지)
private final ReentrantLock lock = new ReentrantLock();

private void updateCache(String key, Object value) {
    lock.lock();
    try {
        cache.put(key, value);
    } finally {
        lock.unlock();
    }
}

4. JSpecify Null Safety

Spring Framework 7은 기존 Spring의 @Nullable 어노테이션 대신 JSpecify 표준을 채택했습니다.

// Before: Spring @Nullable
import org.springframework.lang.Nullable;
import org.springframework.lang.NonNull;

public class UserService {
    @Nullable
    public User findByEmail(@NonNull String email) {
        // ...
    }
}

// After: JSpecify (Spring Boot 4.0)
import org.jspecify.annotations.Nullable;
import org.jspecify.annotations.NonNull;

// 패키지 수준에서 기본 NonNull 선언
// package-info.java
@NullMarked
package com.example.service;
import org.jspecify.annotations.NullMarked;

// 개별 클래스
public class UserService {
    public @Nullable User findByEmail(String email) {
        // @NullMarked 패키지이므로 email은 기본 NonNull
        // ...
    }
}

5. Spring AI 통합 (@AiClient)

Spring Boot 4.0은 AI 기능을 1등급으로 지원합니다. spring-boot-starter-ai를 추가하면 @AiClient를 통해 LLM을 선언적으로 호출할 수 있습니다.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-ai'
}
# application.yml
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o
          temperature: 0.7
// @AiClient 선언적 AI 서비스
@AiClient
public interface ContentAssistant {

    @UserMessage("다음 글의 요약을 3줄로 작성해줘: {{content}}")
    String summarize(@Param("content") String content);

    @UserMessage("다음 코드를 리뷰하고 개선 사항을 알려줘: {{code}}")
    CodeReview reviewCode(@Param("code") String code);
}

public record CodeReview(
    String summary,
    List<String> improvements,
    String refactoredCode
) {}

// 컨트롤러에서 사용
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
public class AiController {

    private final ContentAssistant contentAssistant;

    @PostMapping("/summarize")
    public ResponseEntity<Map<String, String>> summarize(
            @RequestBody Map<String, String> request) {
        String summary = contentAssistant.summarize(request.get("content"));
        return ResponseEntity.ok(Map.of("summary", summary));
    }

    @PostMapping("/review")
    public ResponseEntity<CodeReview> review(
            @RequestBody Map<String, String> request) {
        CodeReview review = contentAssistant.reviewCode(request.get("code"));
        return ResponseEntity.ok(review);
    }
}

6. 모듈화된 JAR 구조 및 CDS 최적화

Spring Boot 4.0은 빌드 결과물의 구조가 변경되어 Class Data Sharing(CDS)을 활용한 빠른 기동이 가능합니다.

# CDS 아카이브 생성 (최초 1회)
java -XX:ArchiveClassesAtExit=app-cds.jsa -Dspring.context.exit=onRefresh -jar myapp.jar

# CDS 아카이브를 활용한 빠른 기동
java -XX:SharedArchiveFile=app-cds.jsa -jar myapp.jar

# Docker 환경에서 활용
# Dockerfile
FROM eclipse-temurin:21-jre
COPY build/libs/myapp.jar /app/myapp.jar

# CDS 생성 레이어
RUN java -XX:ArchiveClassesAtExit=/app/app-cds.jsa \
    -Dspring.context.exit=onRefresh -jar /app/myapp.jar

ENTRYPOINT ["java", "-XX:SharedArchiveFile=/app/app-cds.jsa", "-jar", "/app/myapp.jar"]

7. 단계별 마이그레이션 가이드

실제 프로젝트를 마이그레이션하는 순서입니다.

Step 1: build.gradle 업데이트

plugins {
    id 'org.springframework.boot' version '4.0.0'
    id 'io.spring.dependency-management' version '1.2.0'
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.jspecify:jspecify:1.0.0'
    // Spring AI (필요시)
    implementation 'org.springframework.boot:spring-boot-starter-ai'
}

Step 2: Deprecation 및 제거된 API 수정

// 제거된 API 예시

// Before (3.x에서 deprecated)
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return (web) -> web.ignoring().requestMatchers("/static/**");
}

// After (4.0 권장 방식)
@Bean
@Order(0)
public SecurityFilterChain staticResourceChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/static/**")
        .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
        .securityContext(ctx -> ctx.disable())
        .sessionManagement(session -> session.disable());
    return http.build();
}

Step 3: synchronized -> ReentrantLock 교체

Virtual Threads의 pinning 이슈를 방지하기 위해 프로젝트 내 synchronized 키워드를 검색하여 교체합니다.

Step 4: Null Safety 어노테이션 마이그레이션

# IntelliJ IDEA에서 일괄 변환
# Edit > Find and Replace (Ctrl+Shift+R)
# org.springframework.lang.Nullable -> org.jspecify.annotations.Nullable
# org.springframework.lang.NonNull -> org.jspecify.annotations.NonNull

Step 5: 테스트 실행 및 검증

# 전체 테스트 실행
./gradlew test

# Virtual Threads pinning 감지 (JDK 옵션)
java -Djdk.tracePinnedThreads=short -jar myapp.jar

마치며

Spring Boot 4.0은 Java 21의 Virtual Threads를 기본값으로 채택하면서 동시성 처리의 패러다임을 바꿨습니다. 핵심 마이그레이션 포인트를 정리합니다.

  • Java 21이 최소 요구 버전입니다. 먼저 JDK 업그레이드를 완료하세요.
  • Virtual Threads가 기본이므로 synchronized, ThreadLocal 사용을 점검하세요.
  • JSpecify 기반 null safety로 전환하면 컴파일 타임에 NPE를 방지할 수 있습니다.
  • @AiClient로 LLM 통합이 선언적으로 가능해져, AI 기능 도입 장벽이 낮아졌습니다.
  • CDS를 활용하면 애플리케이션 기동 시간을 크게 단축할 수 있습니다.

마이그레이션은 반드시 3.x 최신 버전에서 deprecation 경고를 모두 해결한 뒤 진행하세요. 급하게 버전을 올리기보다는 충분한 테스트와 함께 단계적으로 진행하는 것을 추천합니다.