Direct Answer & TL;DR
- **Spring Boot 2.7은 이미 EOL(지원 종료)**입니다. 프로덕션 환경에 방치 중이라면 지금 당장 마이그레이션 계획을 세워야 합니다.
보안 패치 없는 서버는 열린 창문과 같습니다 - 2.7 → 4.0 직접 전환은 하지 마세요.
javax→jakarta전환, Java 버전 도약, Spring 코어 변경이 한꺼번에 터지면 원인을 추적하기가 극도로 어려워집니다. - 정답은 2단계 Phased Migration:
2.7 → 3.5(생태계 전환) →3.5 → 4.0(구조적 현대화) 순서로 나눠서 진행해야 디버깅 범위를 통제할 수 있습니다.
“그냥 한 번에 올리면 안 되나요?”
Spring Boot 버전 마이그레이션 얘기가 나올 때마다 어김없이 등장하는 질문입니다. 충분히 이해합니다. 중간 버전 하나를 건너뛰면 시간이 절약될 것 같으니까요.
저도 한때 “메이저 버전 하나 정도는 건너뛸 수 있지 않을까?” 하고 안일하게 생각했습니다. 그러다가 실제로 두 버전 차이가 나는 마이그레이션 작업에 참여해보고 생각이 완전히 바뀌었습니다. 동시에 너무 많은 것이 바뀌면 아무것도 통제할 수 없어집니다.
이 글에서는 Spring Boot 2.7에서 4.0으로 올라가는 길을 버전별 스펙 비교와 단계별 마이그레이션 전략으로 정리해 보겠습니다. 실무에서 바로 적용할 수 있도록요.
버전별 핵심 스펙 비교: 어디서 어디로 가는 건가?
먼저 각 버전이 어떤 상태인지 냉정하게 살펴봐야 합니다. 마이그레이션의 복잡도는 결국 출발지와 목적지 사이의 거리에서 나옵니다.
| 비교 항목 | Spring Boot 2.7 (Legacy) | Spring Boot 3.5 (Midpoint) | Spring Boot 4.0 (Latest) |
|---|---|---|---|
| 출시 시기 | 2022년 5월 | 2025년 5월 | 2025년 11월 |
| 지원 상태 | ❌ EOL (2023년 11월 종료) | ✅ 지원 중 | ✅ 최신 GA |
| 최소 Java | Java 8 / 11 | Java 17 이상 | Java 17 / 21 권장 |
| Spring 기반 | Spring Framework 5.x | Spring Framework 6.x | Spring Framework 7.0 |
| EE 네임스페이스 | javax.* |
jakarta.* |
jakarta.* |
| 주요 특징 | 레거시 표준, 보안 패치 중단 | 구조화 로깅, Native Image 안정화 | 코드베이스 모듈화, HTTP Clients 자동 구성, API Versioning 지원 |
[!WARNING] Spring Boot 2.7은 2023년 11월에 OSS 지원이 공식 종료되었습니다. 보안 취약점이 발견되어도 패치가 제공되지 않습니다. 상업용 지원(VMware/Broadcom)도 결국 만료가 옵니다. 2.7을 프로덕션에 유지하는 것은 기술적 부채가 아니라 운영 리스크입니다.
표를 보면 2.7에서 4.0 사이에 바뀌는 것이 한두 가지가 아닙니다. Java 버전, Spring Framework 메이저 버전, EE 패키지 네임스페이스가 모두 한꺼번에 바뀝니다. 이 세 가지가 동시에 충돌하기 시작하면… 굉장히 재미있는 스택 트레이스를 보게 됩니다. 재미있다고 표현했지만 실제론 고통입니다
마이그레이션 전략: 왜 2단계인가?
결론부터 말하면, Spring Boot 팀(그리고 OpenRewrite 같은 자동 마이그레이션 도구들)도 메이저 버전을 건너뛰는 것을 권장하지 않습니다. 도구 자체가 순차적 레시피를 제공하도록 설계되어 있습니다.
[현재 상태] [1단계] [2단계]
Spring Boot 2.7 →→→ Spring Boot 3.5 →→→ Spring Boot 4.0
Java 8/11 Java 17+ Java 21+
javax.* jakarta.* jakarta.*
Framework 5.x Framework 6.x Framework 7.0
두 구간의 성격이 완전히 다르기 때문에 분리해서 접근해야 합니다.
1단계: 2.7 → 3.5 — 생태계 전환 (The Jakarta Chasm)
이 구간이 전체 마이그레이션에서 가장 위험하고 손이 많이 가는 구간입니다. 이름 그대로 **“Jakarta 심연”**을 건너는 작업입니다.
① Java 17+ 업그레이드
2.7은 Java 8에서도 동작하지만, 3.5는 Java 17 미만 지원을 완전히 끊었습니다. 단순히 JDK 버전을 올리는 것 외에도:
- 레거시 API 제거:
sun.*패키지 직접 참조, 구식DateAPI 사용 패턴 점검 - 모듈 시스템(JPMS): 강화된 캡슐화로 인해 내부 API 접근이 차단될 수 있습니다
- GC 동작 변화: Java 17부터 G1GC가 기본값이며 튜닝 파라미터가 바뀌었습니다
[!TIP]
jdeprscan도구로 deprecated API 사용 현황을 사전에 스캔할 수 있습니다. 마이그레이션 전에 꼭 먼저 돌려보세요. 그리고--release 17플래그로 컴파일 테스트를 해보면 충격을 미리 받고 마음의 준비를 할 수 있습니다.
② Jakarta EE 네임스페이스 전환
이게 2.7→3.5 마이그레이션의 핵심이자 가장 노가다스러운 부분입니다. javax.*를 jakarta.*로 전면 교체해야 합니다.
주요 대상:
// Before (Spring Boot 2.7 / javax)
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotNull;
// After (Spring Boot 3.5 / jakarta)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotNull;
단순 패키지 교체처럼 보이지만, 서드파티 라이브러리들의 Jakarta EE 지원 여부가 변수가 됩니다. QueryDSL, MapStruct, 각종 ORM 확장 라이브러리들이 Jakarta 호환 버전을 지원하는지 의존성 목록을 전수 조사해야 합니다.
[!CAUTION] QueryDSL은 특히 주의가 필요합니다. QueryDSL 5.x 공식 라이브러리는 Jakarta 지원이 늦었고, 커뮤니티 포크(querydsl-jakarta)를 사용해야 했던 시절이 있었습니다. 최신 버전 출시 현황을 꼭 확인하고 대안도 검토해두세요.
③ Hibernate 6.x 호환성 검증
Spring Boot 3.5는 Hibernate 6.x를 사용합니다. Hibernate 5.x → 6.x는 JPQL 파서 동작, 타입 매핑, 배치 처리 방식에서 미묘한 차이가 있습니다.
검증해야 할 것들:
| 포인트 | 확인 사항 |
|---|---|
@Formula 어노테이션 |
일부 표현식 파싱 동작이 변경됨 |
| Native Query | Tuple 매핑 방식 변화 |
| Criteria API | CriteriaBuilder 일부 메서드 시그니처 변경 |
| 배치 INSERT | hibernate.jdbc.batch_size 동작 검증 필요 |
[!NOTE] 1단계에서 테스트 코드가 진가를 발휘합니다. JPQL 쿼리 하나하나를 수동으로 검증하기는 현실적으로 불가능합니다. Repository 레이어를 커버하는 통합 테스트가 없다면, 마이그레이션 전에 테스트부터 작성하는 것을 강력히 권합니다.
2단계: 3.5 → 4.0 — 구조적 현대화 (Architectural Modernization)
1단계의 고통을 이겨내고 3.5에서 프로덕션을 충분히 안정화한 후에 진행합니다. 이 구간은 상대적으로 덜 고통스럽지만, 아키텍처 수준의 변화를 담고 있습니다.
① 의존성 관리 체계 자체 구축
Spring Boot 4.0에서는 spring-boot-parent BOM 퍼블리시 방식이 변경됩니다. 기존에 spring-boot-starter-parent에 기대어 버전 관리를 통째로 맡겼다면, 자체적인 Dependency Management 체계를 구축해야 할 수 있습니다.
<!-- 4.0 대응 의존성 관리 예시 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>4.0.x</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
② RestTemplate → @HttpExchange 리팩토링
RestTemplate은 Spring 5.x 시절부터 maintenance 모드였고, Spring Boot 4.0에서는 @HttpExchange 기반 인터페이스 클라이언트가 완전히 자리를 잡습니다.
// Before: RestTemplate (레거시)
public UserDto getUser(Long id) {
return restTemplate.getForObject(
"https://api.example.com/users/{id}",
UserDto.class,
id
);
}
// After: @HttpExchange (4.0 방식)
@HttpExchange("https://api.example.com")
public interface UserClient {
@GetExchange("/users/{id}")
UserDto getUser(@PathVariable Long id);
}
인터페이스 기반으로 전환하면 테스트 용이성과 결합도 감소라는 두 마리 토끼를 잡을 수 있습니다. 레거시 RestTemplate 코드가 서비스 레이어에 직접 침투해 있다면, 이번 기회에 구조를 정리하는 것을 추천합니다.
③ API 버저닝 표준화
산발적으로 관리하던 API 버전 관리 로직을 spring.mvc.apiversion.* 자동 구성으로 통합할 수 있습니다.
# application.yml
spring:
mvc:
apiversion:
default-version: "1.0"
supported-versions: ["1.0", "2.0"]
기존에 URL 파라미터, 헤더, URL 경로 등으로 파편화되어 있던 버전 관리 로직을 프레임워크 레벨로 위임할 수 있게 됩니다. 커스텀 유틸리티 클래스를 하나씩 지울 수 있는 기회입니다.
“그냥 한 번에 올리면 안 되나요?” 다시 한 번 검토
이쯤에서 처음 질문으로 돌아가 봅시다.
반대 가설: “공수 최소화를 위해 2.7에서 4.0으로 직접 올립니다.”
여기에는 세 가지 치명적인 함정이 있습니다.
-
디버깅 범위 통제 불가: Java 도약 + EE 패키지 변경 + Spring Core 변경이 동시에 발생합니다. 런타임 오류가 터졌을 때 어느 레이어에서 문제가 생긴 건지 추적 범위가 폭발적으로 넓어집니다.
-
자동화 도구도 순차를 강제: OpenRewrite의 Spring Boot 마이그레이션 레시피는 버전 건너뛰기를 지원하지 않습니다. “2.7 → 3.0” 레시피와 “3.x → 4.0” 레시피를 순차적으로 실행하도록 설계되어 있습니다.
-
롤백 단위가 너무 커짐: 1단계/2단계로 나누면 각 단계에서 배포하고 안정화하는 체크포인트가 생깁니다. 한 번에 올리면 문제 발생 시 롤백 범위가 너무 커집니다.
지름길처럼 보이는 게 결국 돌아가는 길이 되는 경우가 많습니다. Big-bang으로 시작했다가 중간에 결국 “단계 나눠서 다시 해야겠다"며 방향을 트는 팀을 실제로 봤습니다. 처음부터 그렇게 하면 될 걸.. 총 소요 시간은 처음부터 단계적으로 접근한 팀보다 더 길었습니다.
Insight: 프레임워크에 위임할수록 코드는 더 읽기 좋아진다
마이그레이션을 단순한 기술 부채 갚기로 바라보면 놓치는 것들이 있습니다. Spring Boot 4.0의 변화, 특히 모듈화와 향상된 자동 구성은 기존 레거시에 쌓인 커스텀 코드를 프레임워크 레벨로 위임할 기회입니다. 그리고 위임하고 나면 — 남은 코드가 오히려 더 명확해집니다. 인프라 관심사가 프레임워크로 빠져나가면, 내 코드에는 비즈니스 로직만 남기 때문입니다.
Spring Boot 4.0은 이 원칙을 실현할 절호의 기회입니다.
| 삭제 대상 (레거시 커스텀 코드) | 대체 (프레임워크 위임) | 효과 |
|---|---|---|
RestClient 래퍼 클래스 (수십 줄) |
@HttpExchange 인터페이스 (5줄) |
구현 숨김, 의도만 남음 |
| 커스텀 API 버전 관리 필터 | spring.mvc.apiversion.* 설정 (3줄) |
설정 선언형으로 전환 |
| 자체 구조화 로깅 유틸리티 | 3.x Structured Logging (기본 제공) | 코드 0줄 |
코드 양이 줄었는데 오히려 더 읽기 쉬워졌다는 점이 핵심입니다. 커스텀 RestClient 래퍼 클래스의 내부 구현 50줄을 읽는 것보다, @HttpExchange 인터페이스 한 줄을 읽는 게 훨씬 빠르게 의도를 파악할 수 있죠.
[!TIP] 마이그레이션 중 커스텀 유틸리티를 만날 때마다 이 질문을 던져보세요: “프레임워크가 이미 이걸 제공하는가?” 그렇다면 과감히 위임하세요. 비즈니스 로직이 아닌 코드가 빠져나갈수록, 남은 코드의 의도는 더 선명해집니다.
에디터의 한마디: 마이그레이션이 끝났을 때 커밋 로그에
feat:보다remove:가 더 많다면, 잘 한 겁니다. 여러분의 레거시javax코드가jakarta로 — 그리고 더 나아가 아예 사라지는 것으로 — 화려하게 부활하기를 응원합니다! ☕