Spring BootArchitectureRefactoring

Spring Boot 3에서 4로 마이그레이션하며 만난 Jackson 3, Jakarta EE 11, properties 변경 정리

Spring Boot 4 업그레이드 과정에서 Jackson 3 직렬화 깨짐, Jakarta EE 11 전환으로 인한 컴파일 오류, application.properties 키 변경까지 실제로 부딪힌 문제와 해결 과정을 공유합니다.

Srue2026년 4월 24일
Spring Boot 3에서 4로 마이그레이션하며 만난 Jackson 3, Jakarta EE 11, properties 변경 정리

"메이저 버전 업그레이드는 나중에 하자."

팀에서 Spring Boot 3.x를 안정적으로 운영하고 있었기 때문에, 4.0 릴리스 소식을 듣고도 한동안 이 말만 반복했습니다. 하지만 Virtual Threads 기본 활성화API Versioning 공식 지원 같은 기능들이 쏟아지면서, "나중에"가 점점 "지금"으로 바뀌기 시작했습니다.

결국 마이그레이션을 시작했고, 처음에는 build.gradle에서 버전 번호만 올리면 절반은 끝날 줄 알았습니다. 현실은 빌드 실패 로그 수백 줄과의 전쟁이었습니다. 이 글에서는 실제로 부딪힌 세 가지 큰 벽 — Jackson 3 직렬화 깨짐, Jakarta EE 11 전환, application.properties 키 변경 — 을 정리합니다.

가장 먼저 해야 했던 일: 현황 파악

버전을 올리기 전에 ./gradlew dependencies 결과를 훑어봤습니다. 직접 선언한 것보다 간접 의존(Transitive Dependency)으로 끌려오는 라이브러리가 훨씬 많았고, 특히 Jackson 2.x, jakarta.servlet-api 6.0, 자동 구성에 숨겨진 설정들이 눈에 들어왔습니다.

Spring Boot 4.0의 릴리스 노트를 꼼꼼히 읽으면서, 깨질 수 있는 지점을 미리 표시해 뒀습니다. 이 과정이 나중에 디버깅 시간을 크게 줄여줬습니다.

첫 번째 벽: Jackson 3 직렬화 깨짐

빌드는 성공했는데 API 응답이 달라졌다

Spring Boot 4.0은 Jackson 3.x를 기본으로 채택했습니다. 빌드 자체는 큰 문제 없이 통과했지만, 통합 테스트를 돌리는 순간 문제가 터졌습니다. API 응답 JSON의 필드명이 바뀌어 있었습니다.

OrderResponse.java
public class OrderResponse {
    private Long id;
    private String userName;
    private LocalDateTime createdAt;
    // getter, setter 생략
}

Jackson 2에서는 userName이 그대로 JSON에 내려갔는데, Jackson 3에서는 PropertyNamingStrategies의 기본 동작이 미묘하게 달라진 부분이 있었습니다. 더 큰 문제는 LocalDateTime 직렬화였습니다.

Jackson 2 응답 (before)
{
  "id": 1,
  "userName": "홍길동",
  "createdAt": "2026-04-24T09:00:00"
}
Jackson 3 응답 (after — 의도하지 않은 변경)
{
  "id": 1,
  "userName": "홍길동",
  "createdAt": [2026, 4, 24, 9, 0, 0]
}

LocalDateTime이 ISO 문자열이 아니라 배열로 직렬화된 것입니다. Jackson 3에서 JavaTimeModule 등록 방식이 바뀌었고, Spring Boot의 자동 구성이 이를 완전히 커버하지 못하는 시점이 있었습니다.

해결: ObjectMapper 설정 재점검

결국 ObjectMapper 설정을 명시적으로 잡아야 했습니다.

JacksonConfig.java
@Configuration
public class JacksonConfig {
 
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
        return builder -> builder
            .modules(new JavaTimeModule())
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE);
    }
}

추가로, Jackson 3에서 달라진 점들을 정리하면 이렇습니다.

항목Jackson 2.xJackson 3.x
모듈 패키지com.fasterxml.jacksontools.jackson
JavaTimeModule별도 등록 필요기본 포함되나 동작 확인 필수
@JsonProperty동일동일 (패키지명만 변경)
날짜 기본 직렬화TimestampTimestamp (명시 설정 권장)
PropertyNamingStrategyDeprecated 클래스 존재PropertyNamingStrategies로 통합

가장 위험했던 건 패키지명 변경이었습니다. com.fasterxml.jacksontools.jackson으로 바뀌면서, 직접 Jackson 어노테이션을 import하고 있던 DTO 클래스들의 import문을 전부 수정해야 했습니다.

import 변경 (before)
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonIgnore;
import 변경 (after)
import tools.jackson.annotation.JsonProperty;
import tools.jackson.annotation.JsonIgnore;

IDE의 전체 검색-치환(Replace in Files)으로 일괄 변경했는데, 문제는 서드파티 라이브러리가 내부적으로 Jackson 2를 쓰고 있는 경우였습니다. 이런 라이브러리들은 Jackson 2와 3이 동시에 클래스패스에 올라가면서 NoSuchMethodError가 터졌고, 해당 라이브러리가 Jackson 3을 지원하는 버전으로 올라올 때까지 기다려야 하는 상황도 있었습니다.

두 번째 벽: Jakarta EE 11 전환

javax에서 jakarta로는 이미 끝난 줄 알았는데

Spring Boot 3에서 javax.*jakarta.* 전환을 이미 겪었기 때문에, Jakarta EE 11 전환은 별거 아닐 거라고 생각했습니다. 하지만 Jakarta EE 11은 단순히 패키지명 변경이 아니라, API 자체가 변경되거나 제거된 부분이 있었습니다.

Servlet API 변경 (before — Jakarta EE 10)
import jakarta.servlet.http.HttpServletRequest;
 
// HttpServletRequest의 일부 deprecated 메서드 사용
String sessionId = request.getRequestedSessionId();

Jakarta EE 11에서는 Servlet 6.1이 적용되면서, 몇 가지 메서드의 시그니처가 바뀌거나 deprecated 메서드가 실제로 제거되었습니다.

컴파일 에러가 쏟아진 지점들

가장 많이 부딪힌 곳은 세 군데였습니다.

1. Servlet API

Filter 구현체 변경
// before: GenericFilter 상속
public class CustomFilter extends GenericFilter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
                         FilterChain chain) throws IOException, ServletException {
        chain.doFilter(req, res);
    }
}
 
// after: HttpFilter 상속 권장 (Jakarta Servlet 6.1)
public class CustomFilter extends HttpFilter {
    @Override
    protected void doFilter(HttpServletRequest req, HttpServletResponse res,
                            FilterChain chain) throws IOException, ServletException {
        chain.doFilter(req, res);
    }
}

2. Bean Validation

jakarta.validation 3.1에서 제약 조건(Constraint) 관련 일부 API가 바뀌었습니다. 커스텀 Validator를 만들어 쓰고 있었다면 ConstraintValidatorContext의 메서드 시그니처를 확인해야 했습니다.

3. JPA (Jakarta Persistence 3.2)

이건 특히 아팠습니다. JPA 관련 코드를 많이 다루고 있었기 때문에, EntityManagerFactory 생성 관련 API 변경이 여러 테스트를 깨뜨렸습니다.

EntityManager 관련 변경
// before: Hibernate 6.x + Jakarta Persistence 3.1
@PersistenceContext
private EntityManager entityManager;
 
// after: 동작은 동일하나 Hibernate 7.0이 기본 적용되면서
// Hibernate 전용 API(Session 등)의 일부 메서드 시그니처 변경
Session session = entityManager.unwrap(Session.class);
// session.createQuery() → session.createSelectionQuery()로 일부 변경

해결 전략: 단계적 접근

한 번에 모든 걸 고치려다 포기할 뻔했습니다. 결국 아래 순서로 나눠서 진행했습니다.

  1. 컴파일 에러부터 전부 수집 — 빌드 로그에서 에러를 파일별로 분류
  2. Servlet 관련 수정 — Filter, Interceptor, 커스텀 에러 핸들러 순서로 수정
  3. JPA/Hibernate 관련 수정 — Hibernate 7.0 마이그레이션 가이드 참고
  4. Validation 관련 수정 — 커스텀 Validator 위주로 확인

예외 처리 구조에서 HttpServletRequest를 직접 참조하는 코드가 꽤 있었는데, 이 부분을 정리하면서 오히려 불필요한 Servlet 의존을 줄이는 계기가 됐습니다.

세 번째 벽: application.properties 키 변경

갑자기 동작하지 않는 설정들

Spring Boot 4.0에서는 오랫동안 deprecated 상태였던 설정 키들이 실제로 제거되었고, 일부는 이름이 바뀌었습니다. 이 부분이 가장 은밀하게 문제를 일으켰습니다. 컴파일 에러도 없고 런타임 에러도 없는데, 기능이 의도대로 동작하지 않는 상황이었기 때문입니다.

application.yml — 문제가 된 Redis 설정 (before)
spring:
  redis:                   # Spring Boot 3.x에서 deprecated 경고만 냄
    host: localhost
    port: 6379
application.yml — 수정 후 (after)
spring:
  data:
    redis:                 # Spring Boot 4.x에서는 이 키만 인식
      host: localhost
      port: 6379

주요 변경 사항 비교

항목Spring Boot 3.xSpring Boot 4.x
Redis 설정spring.redis.* (deprecated) 허용spring.data.redis.*만 인식
Actuator 노출기본 health만 노출기본 health, info 노출
HikariCP 설정spring.datasource.hikari.*동일 (일부 기본값 변경)
로깅 패턴logging.pattern.console동일 (구조화 로깅 옵션 추가)
Graceful shutdownserver.shutdown=graceful기본 graceful (명시 불필요)
Virtual Threadsspring.threads.virtual.enabled=true기본 true (명시 불필요)

가장 흔하게 걸리는 것이 Redis 설정이었습니다. Spring Boot 3.x에서 spring.redis.*를 deprecated 경고만 내고 동작시켜줬는데, 4.x에서는 아예 무시합니다. Redis 캐시를 붙여 놓은 서비스에서 캐시가 조용히 작동을 멈춰버려서, 한참을 헤맸습니다.

해결: spring-boot-properties-migrator

Spring Boot 팀이 제공하는 spring-boot-properties-migrator 의존성이 큰 도움이 됐습니다.

build.gradle
// 마이그레이션 중에만 임시로 추가
runtimeOnly 'org.springframework.boot:spring-boot-properties-migrator'

이 라이브러리를 추가하면 애플리케이션 시작 시 변경된 설정 키를 로그로 알려줍니다. 런타임에 구 키를 새 키로 매핑해주기도 하지만, 이건 어디까지나 마이그레이션 기간에만 쓸 임시 장치입니다. 마이그레이션이 끝나면 반드시 제거해야 합니다.

마이그레이션 체크리스트

여러 차례 삽질 끝에 정리한 체크리스트입니다. 배포 전 체크리스트처럼 이것도 문서로 남겨두니 팀원들이 각자 맡은 모듈을 점검할 때 유용했습니다.

순서항목확인
1build.gradle Spring Boot 버전 4.x로 변경
2Java 17 이상 확인 (권장 21)
3Jackson import: com.fasterxml.jacksontools.jackson
4ObjectMapper 설정에 JavaTimeModule 등록 확인
5spring.redis.*spring.data.redis.* 전환
6deprecated properties 제거 또는 변경
7Servlet/Filter 코드 Jakarta EE 11 호환 확인
8Hibernate 7 API 변경 사항 반영
9커스텀 Validator jakarta.validation 3.1 호환 확인
10서드파티 라이브러리 Jackson 3 호환 버전 확인
11통합 테스트 전체 수행
12spring-boot-properties-migrator 제거

마무리

처음에는 "버전 번호 하나 올리는 일"이라고 가볍게 생각했습니다. 하지만 실제로 겪어보니, 메이저 버전 업그레이드는 프레임워크가 그동안 참아온 deprecated 경고들을 한꺼번에 청구하는 시간이었습니다.

Jackson 3의 패키지명 변경은 단순 치환이라 금방 끝날 줄 알았지만, 서드파티 라이브러리와의 호환 문제가 발목을 잡았습니다. Jakarta EE 11은 "이미 javax에서 jakarta로 바꿨으니 괜찮겠지"라는 안일함을 깨뜨렸습니다. properties 변경은 조용히 기능을 멈추게 만든다는 점에서 셋 중에 가장 무서웠습니다.

결국 이번 경험에서 배운 건 두 가지입니다. 첫째, deprecated 경고는 미래의 컴파일 에러 예고라는 것. 평소에 경고를 무시하지 않고 꾸준히 대응했더라면 마이그레이션이 훨씬 수월했을 겁니다. 둘째, 통합 테스트가 마이그레이션의 안전망이라는 것. 컴파일만 통과하고 테스트 없이 배포했다면, 프로덕션에서 JSON 응답이 달라진 걸 사용자가 먼저 발견했을 겁니다.

다음에 Spring Boot 5가 나오면, 이번처럼 허둥대지 않을 자신이 있습니다. 아마도요.