본문 바로가기

Programming!

Spring Boot 3.5 → 4.0, Jackson 2 → 3 마이그레이션 기록

마이그레이션 작업 중 마주친 이슈들 정리해드릴게요. 그대로 블로그에 올리실 수 있도록 마크다운 형식으로 작성했습니다.


Spring Boot 3.5 → 4.0, Jackson 2 → 3 마이그레이션 기록

작업 환경

  • Spring Boot 3.5.14 → 4.0.6
  • Kotlin 2.3.21, Java 21
  • Hibernate 7.2 (Spring Boot 4 transitive)
  • 멀티 모듈 프로젝트 (core, client-api, admin-web)

1. Jackson 2 → Jackson 3 마이그레이션

가장 큰 변화. 단순 버전업이 아닌 groupId/패키지명 변경API 구조 자체 변경이 동시에 일어났다.

1.1 패키지 변경

com.fasterxml.jackson → tools.jackson로 이동. 단, jackson-annotations만은 com.fasterxml.jackson.* 그대로 유지 (호환성).

 
 
kotlin
// Before
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper

// After
import tools.jackson.databind.ObjectMapper
import tools.jackson.module.kotlin.jacksonObjectMapper

build.gradle.kts:

 
 
kotlin
implementation("tools.jackson.core:jackson-databind:3.0.3")
implementation("tools.jackson.module:jackson-module-kotlin:3.0.3")

1.2 ObjectMapper → JsonMapper (immutable + Builder)

Jackson 3에서 ObjectMapper가 immutable로 바뀌었다. mutator 메서드(setVisibility, registerModule 등) 대신 Builder 패턴을 강제한다.

 
 
kotlin
// Before (Jackson 2)
val mapper = ObjectMapper().apply {
    registerKotlinModule()
    registerModule(JavaTimeModule())
    setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY)
    activateDefaultTyping(polymorphicTypeValidator, ObjectMapper.DefaultTyping.NON_FINAL)
}

// After (Jackson 3)
val mapper = JsonMapper.builder()
    .addModule(KotlinModule.Builder().build())
    // JavaTimeModule은 Jackson 3 default에 내장 - 별도 등록 불필요
    .changeDefaultVisibility { it.withVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY) }
    .activateDefaultTyping(typeValidator, DefaultTyping.NON_FINAL)
    .build()

주요 메서드 변경:

Jackson 2Jackson 3
setVisibility(accessor, vis) changeDefaultVisibility { it.withVisibility(accessor, vis) }
registerModule() Builder의 addModule()
ObjectMapper.DefaultTyping tools.jackson.databind.DefaultTyping (별도 클래스로 이동)

Kotlin DSL이 더 간결한 케이스:

 
 
kotlin
val mapper = jsonMapper {
    addModule(kotlinModule())
    configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}

1.3 Exception 변경

JsonProcessingException → JacksonException로 이름 변경. 그리고 IOException → RuntimeException 상속으로 unchecked exception이 됨.

 
 
kotlin
// Before
import com.fasterxml.jackson.core.JsonProcessingException
try { ... } catch (e: JsonProcessingException) { ... }

// After
import tools.jackson.core.JacksonException
try { ... } catch (e: JacksonException) { ... }

추가로:

  • JsonParseException → StreamReadException
  • JsonMappingException → DatabindException

1.4 JsonNode API 메서드명 변경 (JSTEP-3)

Text 접미사 → String 접미사로 통일:

Jackson 2Jackson 3
asText() asString()
isTextual() isString()
textValue() stringValue()
findValuesAsText() findValuesAsString()

OpenRewrite 레시피로 자동 변환 가능: org.openrewrite.java.jackson.UpgradeJackson_2_3_JsonNodeMethodRenames

1.5 Spring Data Redis Serializer

GenericJackson2JsonRedisSerializer가 deprecated 처리됨. GenericJacksonJsonRedisSerializer(Jackson 3 기반)로 변경.

 
 
kotlin
// Before
GenericJackson2JsonRedisSerializer(objectMapper)

// After
GenericJacksonJsonRedisSerializer.create { builder ->
    builder
        .typePropertyName("@class")
        .enableDefaultTyping(typeValidator)
        .enableSpringCacheNullValueSupport("@class")
}

1.6 ⚠️ Default 값 변경 주의

Jackson 3에서 일부 기본값이 바뀌었다. API 응답 형식 달라질 수 있어 주의:

  • SerializationFeature.WRITE_DATES_AS_TIMESTAMPS: false (ISO-8601 문자열로 직렬화)
  • MapperFeature.SORT_PROPERTIES_ALPHABETICALLY: true (속성 알파벳 순)

Jackson 2 동작 유지하려면:

 
 
properties
spring.jackson.use-jackson2-defaults=true

1.7 jsr310 모듈 불필요

Jackson 3는 java.time 타입 지원이 default ObjectMapper에 내장. jackson-datatype-jsr310 의존성 제거 가능.


2. Hibernate 7: @Comment 마이그레이션

org.hibernate.annotations.Comment가 deprecated. JPA 3.2의 @Column(comment=...), @Table(comment=...)로 통일.

프로젝트에서 292개 위치 발견. 패턴별로 정리.

2.1 단순 패턴 (필드 레벨)

 
 
kotlin
// Before
@Comment("티커")
@Column(name = "ticker", nullable = false, length = 20)
var ticker: String

// After
@Column(name = "ticker", nullable = false, length = 20, comment = "티커")
var ticker: String

2.2 클래스(테이블) 레벨

 
 
kotlin
// Before
@Entity
@Comment(value = "랭킹")
@Table(name = "ranking", indexes = [...])
class RankingEntity(...)

// After
@Entity
@Table(name = "ranking", indexes = [...], comment = "랭킹")
class RankingEntity(...)

2.3 @JoinColumn 케이스

JPA 3.2부터 @JoinColumn도 comment 속성 지원:

 
 
kotlin
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false, comment = "알림")
var notification: NotificationEntity

2.4 자동화 팁

수동으로 292개 처리는 비현실적. Python 스크립트로 패턴별 처리:

  • @Comment + @Column 인접 패턴 → regex + 괄호 매칭으로 통합
  • @Comment + 중간 어노테이션(@Enumerated, @JoinColumn) + @Column → 같은 방식
  • 클래스 레벨 → @Table(comment=...)로 이동
  • @Column 없는 케이스 → 수동 처리

3. Spring Boot 4: 패키지 이동 (Modularization)

Spring Boot 4가 modularization을 진행하면서 많은 클래스의 패키지가 바뀌었다. 가장 흔하게 마주친 변경:

옛 위치새 위치
autoconfigure.cache.RedisCacheManagerBuilderCustomizer cache.autoconfigure.RedisCacheManagerBuilderCustomizer
autoconfigure.domain.EntityScan persistence.autoconfigure.EntityScan
autoconfigure.orm.jpa.EntityManagerFactoryBuilder jpa.EntityManagerFactoryBuilder
autoconfigure.orm.jpa.JpaProperties jpa.autoconfigure.JpaProperties
autoconfigure.orm.jpa.HibernateJpaAutoConfiguration hibernate.autoconfigure.HibernateJpaAutoConfiguration
autoconfigure.security.SecurityAutoConfiguration security.autoconfigure.SecurityAutoConfiguration
web.client.RestTemplateBuilder restclient.RestTemplateBuilder

(모두 org.springframework.boot.* prefix)


4. JSpecify Nullability 변경

Spring Framework 7이 JSpecify로 마이그레이션되면서 일부 API의 nullable/non-null 시그니처가 바뀌었다.

 
 
kotlin
// PasswordEncoder - 파라미터가 nullable로 변경
override fun encode(rawPassword: CharSequence?): String

// UserDetails - Collection 요소가 non-null로 변경
override fun getAuthorities(): Collection<GrantedAuthority> = ...  // 이전엔 GrantedAuthority?

// Page<T> - T가 non-null type parameter 강제
fun <T : Any> someMethod(page: Page<T>) = ...

5. Kotlin Property Syntax 호환성 깨짐

Spring Boot 4에서 일부 getter 시그니처가 바뀌면서, Kotlin의 property setter 방식이 동작 안 하는 경우 발생.

 
 
kotlin
// Before
registrationBean.filter = xssFilter

// After (getter return type 변경으로 property syntax 사용 불가)
registrationBean.setFilter(xssFilter)

6. 라이브러리 호환성 이슈 (실행 단계에서 발견)

빌드는 통과하는데 런타임에서 ClassNotFoundException이 줄줄이 나오는 단계. transitive dependency가 옛 패키지를 참조하는 경우.

6.1 commons-logging Exclusion → 제거

 
 
kotlin
// Spring 5/6 시절 흔히 추가하던 설정
configurations.forEach {
    it.exclude(group = "commons-logging", module = "commons-logging")
}

Spring 5에서 spring-jcl이 commons-logging의 LogFactory를 자체적으로 제공했기 때문에 충돌 회피용으로 흔히 쓰던 설정. 하지만 Spring Framework 7에서 spring-jcl이 제거되고 정식 commons-logging:1.3.0이 의존성으로 들어옴. 이 exclusion이 남아있으면:

 
 
java.lang.NoClassDefFoundError: org/apache/commons/logging/LogFactory

→ exclusion 제거하면 해결.

6.2 JaVers 7.8.0 → 7.11.0

증상:

 
 
ClassNotFoundException: org.springframework.boot.autoconfigure.orm.jpa.JpaProperties

원인: javers-spring-boot-starter-sql:7.8.0이 Spring Boot 3 시절의 옛 JpaProperties 경로 참조. JaVers 7.10+가 Spring Boot 4 호환.

 
 
kotlin
// Versions.kt
const val javers = "7.11.0"  // 7.8.0 → 7.11.0

6.3 spring-cloud-starter-openfeign 제거

같은 JpaProperties 에러였는데, 추적해보니 spring-cloud-context:4.3.0이 또 다른 범인이었다. spring-cloud-starter-openfeign:4.3.0이 transitive로 가져오는 모듈.

확인해보니 코드 어디에서도 @FeignClient, @EnableFeignClients를 안 쓰고 있어서 그냥 의존성 제거가 가장 깔끔.

참고: Spring Boot 4부터는 @HttpExchange + @ImportHttpServices 기반 declarative HTTP clients가 표준화되어 OpenFeign 자체가 deprecated 상태.

6.4 hypersistence-utils-hibernate-63 → 71

증상:

 
 
NoClassDefFoundError: org/hibernate/query/BindableType

원인: Hibernate 7에서 org.hibernate.query.BindableType 클래스가 제거됨. hypersistence-utils-hibernate-63는 Hibernate 6.3 전용이라 이 옛 클래스를 참조한다.

→ hypersistence-utils-hibernate-71로 변경 (버전 3.15.2 동일):

 
 
kotlin
// Before
implementation("io.hypersistence:hypersistence-utils-hibernate-63:3.15.2")

// After
implementation("io.hypersistence:hypersistence-utils-hibernate-71:3.15.2")

7. 트랜지티브 의존성 탐정질 노하우

런타임 ClassNotFoundException의 원인 jar를 찾는 방법:

 
 
bash
# classpath에 있는 모든 jar에서 옛 클래스 참조 검색
./gradlew :admin-web:dependencies --configuration runtimeClasspath -q | \
  grep -oE "[a-zA-Z0-9._-]+:[a-zA-Z0-9._-]+:[a-zA-Z0-9.+_-]+" | sort -u | \
  while read dep; do
    group=$(echo "$dep" | cut -d: -f1)
    name=$(echo "$dep" | cut -d: -f2)
    ver=$(echo "$dep" | cut -d: -f3)
    jar=$(ls ~/.gradle/caches/modules-2/files-2.1/$group/$name/$ver/*/*.jar 2>/dev/null | head -1)
    if [ -n "$jar" ]; then
      count=$(unzip -p "$jar" '*.class' 2>/dev/null | strings | grep -c "옛/클래스/경로")
      [ "${count:-0}" -gt 0 ] && echo "$count refs: $name:$ver"
    fi
  done

이 방법으로 JaVers 7.8.0과 spring-cloud-context 4.3.0을 찾아냈다.


8. 마이그레이션 단계 체크리스트

  1. 컴파일 통과: ./gradlew clean compileKotlin
  2. Deprecation 0: ./gradlew clean build 2>&1 | grep -c "is deprecated"
  3. 실행 검증: ./gradlew bootRun 또는 IntelliJ 실행
    • LogFactory ClassNotFoundException → commons-logging exclusion 확인
    • JpaProperties ClassNotFoundException → third-party 라이브러리의 옛 경로 참조
    • BindableType ClassNotFoundException → Hibernate 6용 라이브러리 7용으로 교체
    • 기타 ClassNotFoundException → jar 내부 옛 클래스 참조 검색

9. 회고

가장 시간이 많이 걸린 부분:

  1. @Comment 292개 정리 - 패턴별 Python 스크립트 작성
  2. transitive dependency 추적 - jar 안의 클래스 참조를 일일이 찾기

컴파일은 비교적 빠르게 끝났지만, 진짜 함정은 런타임 단계였다. Spring Boot 3 시대 라이브러리가 transitive로 끌고 들어오는 옛 패키지 참조들이 줄줄이 터지는 과정.

마이그레이션을 시작한다면 이 순서가 효율적이었다:

  1. Spring Boot 의존성 버전업 → 컴파일 에러 잡기
  2. Jackson 패키지 일괄 치환 (sed)
  3. Hibernate @Comment 일괄 치환 (Python 스크립트)
  4. 빌드 통과 확인
  5. 실행해서 런타임 에러 → 의존성 라이브러리 버전업

특히 transitive dependency 정리가 핵심. 멋대로 라이브러리 빼지 말고, 실제 코드 사용처 확인 후 결정하는 게 안전하다.


이 정도면 블로그 한 편 분량으로 충분할 것 같습니다. 필요하면 특정 섹션 더 자세히 풀어달라거나, 따로 분리해서 시리즈로 나누는 것도 가능해요.