"어차피 새로 만드는 건데 최신으로 가면 되지 않나?" — 그렇게 단순하지 않다. 고객사 환경, 팀 역량, 라이브러리 생태계까지 따져야 한다.


들어가며: 신규 프로젝트의 기술 스택 선택

기존 시스템 마이그레이션이라면 "지금 올릴까 말까"가 고민이겠지만, 신규 프로젝트라면 질문이 다르다. "처음부터 어떤 조합으로 시작할 것인가?"

2026년 4월 현재 선택지는 크게 두 가지다.

  • 안정 노선: Java 21 (LTS, 2023.09) + Spring Boot 3.5.x
  • 최신 노선: Java 25 (LTS, 2025.09) + Spring Boot 4.0.x

둘 다 LTS 기반이고, 둘 다 프로덕션 레디다. 하지만 프로젝트 성격에 따라 정답이 달라진다. SI 환경에서 신규 프로젝트를 설계하는 실무 관점으로 비교한다.


1. 기반 요구사항부터 확인

프로젝트 기술 스택을 고르기 전에 환경 제약을 먼저 본다.

                                                                              항목Java 21 + Spring Boot 3.5       Java 25 + Spring Boot 4.0
최소 Java 17 17 (Java 25 1급 지원)
Spring Framework 6.x 7.x
Jakarta EE 9+ 11
Gradle 7.5+ 8.14+ / 9
Kotlin 1.7+ 2.0+
OSS 지원 종료 2026년 6월 수년간 지원

첫 번째 판단 기준은 Spring Boot 3.5.x의 OSS 지원이 2026년 6월에 끝난다는 사실이다. 지금 신규 프로젝트를 Spring Boot 3으로 시작하면, 개발 완료 시점에 이미 지원이 종료됐거나 종료 직전일 수 있다. 6개월 이상 걸리는 프로젝트라면 이 점만으로도 Spring Boot 4가 합리적이다.


2. Java 21 vs Java 25: 신규 코드에서 체감되는 차이

동시성: 가장 큰 실무 차이

Java 21의 Virtual Threads는 혁신적이었지만, 실전에서 쓰다 보면 두 가지 문제에 부딪힌다. 컨텍스트 전달과 병렬 작업 관리다. Java 25는 이 두 문제를 정식 API로 해결한다.

Java 21로 신규 프로젝트를 시작하면:

 
 
java
// 요청 컨텍스트를 Virtual Thread에 전달하려면 ThreadLocal 사용
private static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>();

public OrderResult processOrder(OrderRequest request) {
    CTX.set(new RequestContext(request.userId(), request.traceId()));
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        Future<User> userFuture = executor.submit(() -> userService.find(CTX.get().userId()));
        Future<Inventory> invFuture = executor.submit(() -> inventoryService.check(request.itemId()));
        
        // 문제 1: userFuture가 실패해도 invFuture는 계속 돌아감
        // 문제 2: CTX.get()이 자식 스레드에서 null일 수 있음
        // 문제 3: ThreadLocal 정리를 까먹으면 메모리 누수
        
        return new OrderResult(userFuture.get(), invFuture.get());
    } finally {
        CTX.remove(); // 반드시 수동 정리
    }
}

Java 25로 신규 프로젝트를 시작하면:

 
 
java
// ScopedValue: 불변, 자식 스레드에 자동 전파, 스코프 종료 시 자동 정리
private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();

public OrderResult processOrder(OrderRequest request) {
    return ScopedValue.where(CTX, new RequestContext(request.userId(), request.traceId()))
        .call(() -> {
            try (var scope = StructuredTaskScope.open()) {
                var userTask = scope.fork(() -> userService.find(CTX.get().userId()));
                var invTask = scope.fork(() -> inventoryService.check(request.itemId()));
                scope.join(); // 하나 실패 → 나머지 자동 취소
                return new OrderResult(userTask.get(), invTask.get());
            }
            // 정리 코드 불필요 — 스코프가 끝나면 자동 해제
        });
}

차이를 정리하면:

문제                                                   Java 21 (ThreadLocal)                    Java 25 (ScopedValue + Structured Concurrency)
자식 스레드 컨텍스트 전달 수동 전달 또는 InheritableThreadLocal 자동 전파
병렬 작업 중 일부 실패 시 나머지 작업 계속 실행 (자원 낭비) 자동 취소
메모리 정리 수동 remove() 필수 스코프 종료 시 자동
불변성 보장 X (set으로 언제든 변경 가능) O (불변값)

신규 프로젝트에서 Virtual Thread 기반 동시성을 본격적으로 설계한다면, Java 25의 ScopedValue + Structured Concurrency가 코드량도 적고, 버그 가능성도 낮고, 구조도 깔끔하다.

패턴 매칭: 도메인 모델 표현력

 
java
// Java 21: Record Pattern + Switch Pattern Matching
sealed interface PaymentResult permits Success, Failure, Pending {}
record Success(String txId, BigDecimal amount) implements PaymentResult {}
record Failure(String errorCode, String message) implements PaymentResult {}
record Pending(String txId, Duration estimatedWait) implements PaymentResult {}

// Java 21에서도 이미 강력함
String describe(PaymentResult result) {
    return switch (result) {
        case Success(var txId, var amount) -> "결제 완료: " + txId + " (" + amount + "원)";
        case Failure(var code, var msg) -> "결제 실패: [" + code + "] " + msg;
        case Pending(var txId, var wait) -> "처리 중: " + txId + " (예상 " + wait.toMinutes() + "분)";
    };
}

// Java 25 추가: Flexible Constructors — super() 전에 검증 로직
public class PremiumUser extends User {
    public PremiumUser(String name, int grade) {
        if (grade < 1 || grade > 5) throw new IllegalArgumentException("등급은 1~5");
        // Java 21에서는 이 위치에 코드를 쓸 수 없었음 — super()가 반드시 첫 줄
        super(name);
        this.grade = grade;
    }
}

패턴 매칭 자체는 Java 21에서도 충분히 강력하다. Java 25의 추가분(원시 타입 패턴, Flexible Constructors)은 편의성 개선이지 패러다임 변화는 아니다.

성능: Compact Object Headers

Java 25의 Compact Object Headers(JEP 519)는 객체 헤더를 128비트에서 64비트로 줄인다. 이건 코드 변경 없이 JVM 옵션 하나로 적용되며, 객체를 많이 생성하는 애플리케이션에서 메모리 10~15% 절감 효과가 보고되고 있다.

신규 프로젝트에서 마이크로서비스 다수를 운영하거나, 컨테이너 메모리 제한이 빡빡한 환경이라면 이것만으로도 Java 25를 선택할 이유가 된다.


3. Spring Boot 3 vs Spring Boot 4: 신규 프로젝트 설계에 미치는 영향

모듈 구조: 프로젝트 초기 설계가 달라짐

Spring Boot 3에서 spring-boot-starter-web을 추가하면 spring-boot-autoconfigure라는 거대한 JAR이 통째로 딸려온다. 사용하지 않는 기술의 자동 설정 코드까지 전부 포함된다.

Spring Boot 4는 기술별로 모듈이 분리됐다. 필요한 것만 정확히 가져온다.

 
xml
<!-- Spring Boot 3: 하나의 거대한 autoconfigure -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- → spring-boot-autoconfigure (모든 기술의 자동 설정 포함) -->

<!-- Spring Boot 4: 기술별 분리 모듈 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- → 웹 관련 모듈만 포함, 나머지는 각자의 스타터로 -->

실질적 효과는 세 가지다.

  1. 빌드 속도: 불필요한 자동 설정 스캔이 줄어 컴파일·시작 시간 단축
  2. GraalVM 네이티브 이미지: 분석 대상이 줄어 이미지 크기 감소 + 빌드 시간 단축
  3. 의존성 명확성: 프로젝트가 실제로 무엇에 의존하는지 명확해짐

헥사고날 아키텍처처럼 의존성 방향을 엄격히 관리하는 설계에서는 이 모듈화가 특히 의미 있다.

API 설계: 내장 버전 관리

신규 프로젝트에서 REST API를 설계할 때, "나중에 API 버전 관리가 필요해지면 어떡하지?"는 항상 따라오는 고민이다.

Spring Boot 3: URL 경로(/v1/users, /v2/users)나 커스텀 헤더로 직접 구현. 매번 프로젝트마다 컨벤션이 달라지고, 라우팅 로직이 분산된다.

Spring Boot 4: 프레임워크 수준에서 지원.

 
java
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping(version = "1")
    public List<UserV1Response> listV1() {
        return userService.findAll().stream()
            .map(UserV1Response::from)
            .toList();
    }
    
    @GetMapping(version = "2")
    public List<UserV2Response> listV2() {
        // V2: 프로필 이미지 URL, 마지막 접속 시간 등 추가 필드
        return userService.findAllWithDetails().stream()
            .map(UserV2Response::from)
            .toList();
    }
}

프로젝트 초기에 이 구조를 잡아놓으면, 이후 API 진화가 훨씬 깔끔하다.

HTTP 클라이언트: MSA 환경의 서비스 간 통신

마이크로서비스 신규 프로젝트에서 서비스 간 통신 코드는 상당한 비중을 차지한다.

Spring Boot 3의 선택지:

 
 
java
// 1. RestTemplate (레거시, 동기)
RestTemplate restTemplate = new RestTemplate();
User user = restTemplate.getForObject("/api/users/{id}", User.class, userId);

// 2. WebClient (리액티브, Webflux 의존)
User user = webClient.get().uri("/api/users/{id}", userId)
    .retrieve().bodyToMono(User.class).block();

// 3. HTTP Interface (선언적, 하지만 설정이 장황)
@HttpExchange("/api/users")
public interface UserClient {
    @GetExchange("/{id}")
    User getUser(@PathVariable Long id);
}

Spring Boot 4의 Interface Client:

 
 
java
// 인터페이스 선언만으로 완전한 HTTP 클라이언트
@HttpExchange("/api/users")
public interface UserClient {
    
    @GetExchange("/{id}")
    User getUser(@PathVariable Long id);
    
    @GetExchange
    List<User> listUsers(@RequestParam(required = false) String role);
    
    @PostExchange
    User createUser(@RequestBody CreateUserRequest request);
    
    @DeleteExchange("/{id}")
    void deleteUser(@PathVariable Long id);
}

// Spring Boot 4에서는 설정이 더 간결해짐
@Configuration
public class ClientConfig {
    @Bean
    UserClient userClient(RestClient.Builder builder) {
        var client = builder.baseUrl("http://user-service:8080").build();
        return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(client))
            .build()
            .createClient(UserClient.class);
    }
}

MSA 프로젝트에서 서비스가 5개, 10개로 늘어날 때, 이 선언적 방식의 생산성 차이는 누적적으로 커진다. Feign Client를 별도로 가져올 필요 없이 Spring 네이티브로 해결된다.

Null Safety: 신규 코드부터 적용

기존 프로젝트에 JSpecify를 도입하면 경고가 수백 개 터진다. 하지만 신규 프로젝트라면 처음부터 null safety를 전제로 설계할 수 있다.

 
 
java
// Spring Boot 4 + JSpecify + NullAway: 컴파일 타임 NPE 방지
import org.jspecify.annotations.Nullable;
import org.jspecify.annotations.NullMarked;

@NullMarked // 이 패키지의 모든 타입은 기본적으로 non-null
package com.example.order.domain;

// 도메인 모델
public record Order(
    Long id,
    String orderNumber,
    @Nullable String memo,       // null 허용을 명시적으로 선언
    OrderStatus status,
    LocalDateTime createdAt
) {}

// 서비스 레이어
@Service
public class OrderService {
    
    public Order getOrder(Long id) {  // 반환값 non-null 보장
        return orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }
    
    public @Nullable Order findOrder(Long id) {  // null 가능성 명시
        return orderRepository.findById(id).orElse(null);
    }
}

NullAway를 빌드에 걸어두면, getOrder()의 반환값을 null 체크 없이 사용해도 안전하고, findOrder()의 반환값을 null 체크 없이 쓰면 컴파일 에러가 난다. 런타임 NPE를 컴파일 타임 에러로 끌어올리는 것이 핵심이다.

 

Spring Boot 3에서도 NullAway를 쓸 수 있지만, 프레임워크 자체의 null 어노테이션이 통일되지 않아 경계 지점에서 불일치가 생긴다. Spring Boot 4는 프레임워크 전체가 JSpecify로 통일됐기 때문에 일관성이 보장된다.

보안: Spring Security 7

신규 프로젝트의 보안 설정도 차이가 있다.

Spring Boot 3 (Spring Security 6):

  • OAuth 2.0 / OIDC 지원
  • Spring Authorization Server는 별도 프로젝트
  • Kerberos, SAML은 별도 확장 모듈
  • MFA는 커스텀 구현

Spring Boot 4 (Spring Security 7):

  • OAuth 2.0 / OIDC 지원 (강화)
  • Spring Authorization Server가 Security에 통합 — 별도 의존성 추가 불필요
  • Kerberos, SAML 지원 내장
  • MFA 내장 지원

엔터프라이즈 인증이 복잡한 프로젝트(SSO, MFA, SAML 연동 등)라면, Spring Boot 4에서 보안 설정이 확실히 간결해진다.

관측성: OpenTelemetry 스타터

Spring Boot 3: Micrometer + Micrometer Tracing 조합. Prometheus, Zipkin 등 백엔드별 설정이 필요.

Spring Boot 4: OpenTelemetry 스타터 추가. 의존성 하나로 메트릭, 트레이싱, 로깅을 통합 설정.

 
 
yaml
# Spring Boot 4: application.yml
management:
  otlp:
    tracing:
      endpoint: http://otel-collector:4318/v1/traces
    metrics:
      endpoint: http://otel-collector:4318/v1/metrics

신규 MSA 프로젝트에서 관측성 인프라를 처음부터 설계한다면, Spring Boot 4의 OpenTelemetry 네이티브 지원이 설정량을 크게 줄여준다.


4. 현실적 제약: 그래도 Java 21을 선택해야 하는 경우

최신이 항상 정답은 아니다. 다음 상황에서는 Java 21 + Spring Boot 3이 오히려 합리적이다.

고객사 환경 제약:

  • 고객사 인프라팀이 Java 21까지만 검증/승인한 경우
  • 운영 환경의 WAS(Tomcat, Undertow 등)가 Jakarta EE 11을 미지원하는 경우
  • 보안 심사에서 "프로덕션 검증 6개월 이상"을 요구하는 경우 (Spring Boot 4는 출시 5개월차)

팀 역량과 생태계:

  • 팀원 대부분이 Java 21까지만 경험한 경우 (Structured Concurrency 학습 곡선)
  • 핵심 서드파티 라이브러리가 아직 Spring Boot 4를 공식 지원하지 않는 경우
  • Kotlin 1.x 기반 코드가 많은 팀에서 Kotlin 2.0 전환이 부담인 경우

프로젝트 특성:

  • 3~4개월 내 빠르게 완료해야 하는 단기 프로젝트
  • 팀이 이미 Spring Boot 3 기반 보일러플레이트/템플릿을 보유한 경우
  • 유지보수를 인수받을 팀이 Java 21 환경을 기준으로 하는 경우

이런 경우라면 Java 21 + Spring Boot 3.5로 시작하되, 프로젝트 중반에 Spring Boot 3.5 → 4.0 전환 가능성을 염두에 두는 것이 현실적이다.


5. 의사결정 플로우차트

 
 
신규 프로젝트 시작
    │
    ├── 고객사가 Java 버전을 지정했나?
    │   ├── Yes → 고객사 기준 따름
    │   └── No ↓
    │
    ├── 프로젝트 기간이 6개월 이상인가?
    │   ├── Yes → Spring Boot 4 (3.5 지원 종료 리스크)
    │   └── No ↓
    │
    ├── Virtual Thread 기반 동시성이 핵심인가?
    │   ├── Yes → Java 25 (ScopedValue + Structured Concurrency)
    │   └── No ↓
    │
    ├── MSA로 서비스 3개 이상인가?
    │   ├── Yes → Spring Boot 4 (Interface Client + API Versioning + OTel)
    │   └── No ↓
    │
    ├── 팀이 Java 25 경험이 있나?
    │   ├── Yes → Java 25 + Spring Boot 4
    │   └── No → Java 21 + Spring Boot 3.5 (안정 노선)
    │
    └── 내부/학습/PoC 프로젝트인가?
        ├── Yes → 무조건 Java 25 + Spring Boot 4 (경험 축적)
        └── No → 위 기준으로 판단

6. 조합별 추천 프로젝트 프로필

Java 21 + Spring Boot 3.5 — "검증된 안정"

  • SI 수주 프로젝트 (고객사 Java 21 표준)
  • 단기 프로젝트 (3~6개월)
  • 팀에 Java 25 경험자가 없는 경우
  • 서드파티 호환성이 확인되지 않은 경우

Java 25 + Spring Boot 4 — "미래 지향 설계"

  • 자체 서비스/SaaS 개발
  • MSA 기반 중대형 프로젝트 (6개월+)
  • 높은 동시성 요구 (Virtual Thread + Structured Concurrency)
  • 컨테이너/클라우드 네이티브 환경 (메모리 최적화, 네이티브 이미지)
  • 학습/PoC/내부 도구 프로젝트

Java 25 + Spring Boot 4 + 헥사고날 아키텍처 — "풀 스펙"

  • 도메인 복잡도가 높은 프로젝트
  • JSpecify + NullAway로 컴파일 타임 null safety
  • 포트/어댑터 패턴으로 의존성 방향 엄격 관리
  • Spring Boot 4의 모듈화와 헥사고날의 모듈 경계가 자연스럽게 매칭

7. Spring Initializr 설정 비교

실제로 프로젝트를 시작할 때 start.spring.io에서의 선택:

Java 21 + Spring Boot 3.5

 
 
Project: Gradle - Kotlin DSL
Language: Java
Spring Boot: 3.5.x
Java: 21
Dependencies:
  - Spring Web
  - Spring Data JPA
  - Spring Security
  - Spring Boot Actuator
  - Micrometer Tracing (Zipkin/OTLP)
  - PostgreSQL Driver
  - Validation
  - Lombok (선택)

Java 25 + Spring Boot 4.0

 
 
Project: Gradle - Kotlin DSL
Language: Java
Spring Boot: 4.0.x
Java: 25
Dependencies:
  - Spring Web
  - Spring Data JPA
  - Spring Security
  - Spring Boot Actuator
  - OpenTelemetry Starter (신규)
  - PostgreSQL Driver
  - Validation
  (Lombok: JSpecify + Record 조합으로 대체 가능)

Spring Boot 4에서는 Lombok 없이 Record + JSpecify 조합으로 보일러플레이트를 줄이는 방식이 더 자연스럽다. 물론 기존 습관대로 Lombok을 쓰는 것도 가능하다.


마무리: 신규 프로젝트의 특권

신규 프로젝트는 레거시 부채 없이 최적의 기술을 선택할 수 있는 드문 기회다. 마이그레이션 비용이 0이기 때문에, 순수하게 "이 프로젝트에 어떤 조합이 가장 적합한가?"만 따지면 된다.

정리하면:

  • 고객사 제약이 없고, 6개월 이상 프로젝트라면 → Java 25 + Spring Boot 4
  • 고객사가 Java 21을 지정했거나, 단기 프로젝트라면 → Java 21 + Spring Boot 3.5
  • 어떤 경우든 내부 PoC는 → Java 25 + Spring Boot 4로 경험을 쌓아라

2→3이 javax에서 jakarta로의 고통스러운 전환이었다면, 3→4는 이미 정돈된 기반 위에서의 우아한 진화다. 신규 프로젝트에서 4를 선택하는 것은 "최신을 쫓는 것"이 아니라 **"더 나은 기본값으로 시작하는 것"**이다.


이 글은 2026년 4월 기준 정보를 바탕으로 작성되었습니다. Java 25 (LTS, 2025.09), Spring Boot 4.0.5 (2026.03) 기준입니다.

참고 링크:

LIST

+ Recent posts