마이그레이션 작업 중 마주친 이슈들 정리해드릴게요. 그대로 블로그에 올리실 수 있도록 마크다운 형식으로 작성했습니다.
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.* 그대로 유지 (호환성).
// 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:
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 패턴을 강제한다.
// 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()
주요 메서드 변경:
| setVisibility(accessor, vis) | changeDefaultVisibility { it.withVisibility(accessor, vis) } |
| registerModule() | Builder의 addModule() |
| ObjectMapper.DefaultTyping | tools.jackson.databind.DefaultTyping (별도 클래스로 이동) |
Kotlin DSL이 더 간결한 케이스:
val mapper = jsonMapper {
addModule(kotlinModule())
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
1.3 Exception 변경
JsonProcessingException → JacksonException로 이름 변경. 그리고 IOException → RuntimeException 상속으로 unchecked exception이 됨.
// 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 접미사로 통일:
| 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 기반)로 변경.
// 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 동작 유지하려면:
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 단순 패턴 (필드 레벨)
// 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 클래스(테이블) 레벨
// 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 속성 지원:
@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 시그니처가 바뀌었다.
// 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 방식이 동작 안 하는 경우 발생.
// Before
registrationBean.filter = xssFilter
// After (getter return type 변경으로 property syntax 사용 불가)
registrationBean.setFilter(xssFilter)
6. 라이브러리 호환성 이슈 (실행 단계에서 발견)
빌드는 통과하는데 런타임에서 ClassNotFoundException이 줄줄이 나오는 단계. transitive dependency가 옛 패키지를 참조하는 경우.
6.1 commons-logging Exclusion → 제거
// 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 호환.
// 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 동일):
// 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를 찾는 방법:
# 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. 마이그레이션 단계 체크리스트
- 컴파일 통과: ./gradlew clean compileKotlin
- Deprecation 0: ./gradlew clean build 2>&1 | grep -c "is deprecated"
- 실행 검증: ./gradlew bootRun 또는 IntelliJ 실행
- LogFactory ClassNotFoundException → commons-logging exclusion 확인
- JpaProperties ClassNotFoundException → third-party 라이브러리의 옛 경로 참조
- BindableType ClassNotFoundException → Hibernate 6용 라이브러리 7용으로 교체
- 기타 ClassNotFoundException → jar 내부 옛 클래스 참조 검색
9. 회고
가장 시간이 많이 걸린 부분:
- @Comment 292개 정리 - 패턴별 Python 스크립트 작성
- transitive dependency 추적 - jar 안의 클래스 참조를 일일이 찾기
컴파일은 비교적 빠르게 끝났지만, 진짜 함정은 런타임 단계였다. Spring Boot 3 시대 라이브러리가 transitive로 끌고 들어오는 옛 패키지 참조들이 줄줄이 터지는 과정.
마이그레이션을 시작한다면 이 순서가 효율적이었다:
- Spring Boot 의존성 버전업 → 컴파일 에러 잡기
- Jackson 패키지 일괄 치환 (sed)
- Hibernate @Comment 일괄 치환 (Python 스크립트)
- 빌드 통과 확인
- 실행해서 런타임 에러 → 의존성 라이브러리 버전업
특히 transitive dependency 정리가 핵심. 멋대로 라이브러리 빼지 말고, 실제 코드 사용처 확인 후 결정하는 게 안전하다.
이 정도면 블로그 한 편 분량으로 충분할 것 같습니다. 필요하면 특정 섹션 더 자세히 풀어달라거나, 따로 분리해서 시리즈로 나누는 것도 가능해요.