부제 : spring boot 2.7에서 3.x 로 올리며 당신도 당할 수 있는 이야기
0. 왜 이 글을 쓰게 되었는가?
spring boot 2.7에서 spring boot 3.2로 대 격변을 일으키게 되며 MSA로 구성중이던 서비스들 중 일부를 업그레이드하게 되었습니다. 근데 아니 세상에 결과가 이전과 다르게 나왔는데 제가 시원하게 기존 코드를 제거하면서 발생한 일이었습니다.
문제의 라이브러리는
Criteria.DISTINCT_ROOT_ENTITY였고 저는 변명을 하자면 이게 다 마이그레이션 가이드를 탓할 겁니다.
분명 다 해준다고 했거든요? 근데 이건 예외더라고요...
그래서 문서를 좀 봤어요. Criteria는 Deprecated 이런 것도 없고 setResultTransformer에는 이렇게 되어있네요
요약 : 없앨 거임ㅋㅋ 네가 좋은 놈으로 하나 잘 개발해 봐
진짜 온갖 삽질을 다 해봤는데 결국 하나 자체 구현하는 게 제일 좋겠더라고요... 그래서 했습니다.
1. Hibernate 5의 DISTINCT_ROOT_ENTITY는 어떻게 생겼었나?
일단 Hibernate 5에서의 원본 구현부터 살펴보죠. 기존의 구조는 아주 심플했습니다.
public Object transformTuple(Object[] tuple, String[] aliases) {
return tuple[tuple.length - 1];
}
여기서 중요한 건, 튜플의 마지막 요소를 루트 엔티티로 간주하는 방식이었습니다. 그리고 중복 엔티티 제거 로직은 단순히 Identity를 기준으로 동작했죠.
2. Hibernate 6에서의 변화: TupleTransformer와 ResultListTransformer
Hibernate 6는 결과 변환을 두 개의 인터페이스로 분리해 놓았습니다.
- TupleTransformer: 각 튜플을 변환하는 역할
- ResultListTransformer: 전체 결과 리스트에서 중복을 제거하고 데이터를 병합하는 역할
쉽게 말하면, 이전보다 구현해야 할 일이 콩.. 아니 2배로 늘어났습니다. 이 또한 홍진호 님의 은총이겠지요.
그리고
이전 Hibernate 구현에서는 단순히 Object[] 배열 자체를 다루며 transformTuple에서 필요한 부분만 추출했지만,
새로운 방식에서는 각 튜플에 대한 추가 정보(예: 원본 tuple 전체와 alias 정보)를 보존해서
후속의 transformList 단계에서 연관 엔티티 병합 작업을 수행하기 위해 더 많은 콘텍스트가 필요합니다.
따라서,
기존 방식:
단순히 Object[] tuple을 반환하며, 마지막 요소만 루트 엔티티로 간주하는 단순 변환을 수행했습니다.
현재 방식:
튜플을 RootEntityTuple이라는 객체에 담아서, 원본 tuple 배열과 alias 배열, 그리고 추출한 루트 엔티티를 함께 보존합니다.이렇게 하면 transformList 단계에서 동일 루트 엔티티의 여러 튜플이 가지고 있는 추가 정보를 활용해, 연관된 값들을 병합할 수 있는 여지를 제공하게 됩니다.
더 많이 정보를 줘야 한다고 채찍피티가 그랬습니다.
3. 예 아무튼 뭐 직접 구현합니다.
일단 기본적인 flow는 이렇게 됩니다.
[NativeQuery 실행]
│
▼
[데이터베이스에서 각 row(튜플: Array<Any?>) 반환]
│
▼
────────────────────────────────────────
각 튜플에 대해:
TupleTransformer.transformTuple(tuple, aliases)
│
▼
변환된 객체 (예: RootEntityTuple)
────────────────────────────────────────
│
▼
[중간 결과 리스트: List<RootEntityTuple> 생성]
│
▼
ResultListTransformer.transformList(resultList)
│
▼
[후처리 (중복 제거, 연관 병합 등) 수행]
│
▼
최종 결과 리스트 (List<Any?>) 반환
채찍피티와 저와 손을 잡고 삽질을 시작했습니다.
TupleTransformer의 구현
override fun transformTuple(tuple: Array<Any?>, aliases: Array<String>): RootEntityTuple {
val root = tuple.last() ?: throw IllegalArgumentException("Root entity in tuple cannot be null")
return RootEntityTuple(root, tuple, aliases)
}
마지막 요소를 루트로 보는 건 변하지 않았습니다. 다만 저는 코-틀린 유저니까 코틀린스러운 last를 사용합니다.
ResultListTransformer의 구현 (중복 제거 및 병합)
@Suppress("UNCHECKED_CAST")
override fun transformList(resultList: List<Any?>): List<Any?> {
val tupleList = resultList.map { it as RootEntityTuple }
val entityMap = mutableMapOf<Int, Any>()
for (tupleWrapper in tupleList) {
val root = tupleWrapper.root
val key = System.identityHashCode(root)
if (entityMap.containsKey(key)) {
mergeAssociations(entityMap[key]!!, tupleWrapper)
} else {
mergeAssociations(root, tupleWrapper)
entityMap[key] = root
}
}
entityMap.values.forEach { initializeEntityGraph(it) }
return entityMap.values.toList()
}
여기서는 리플렉션과 Hibernate의 lazy initialization 기능을 적극 활용했습니다. 특히, 이 과정에서 많은 시행착오가 있었습니다.
리플렉션과 친한 사람 아닙니다. 근데 왜 사용하냐고요?
필드가 미친 듯이 많고 복잡하면 미칠 것 같았거든요
4. 예 아무튼 뭐 최종 코드는요
import org.hibernate.Hibernate
import org.hibernate.query.ResultListTransformer
import org.hibernate.query.TupleTransformer
import java.lang.reflect.Field
/**
* 각 튜플의 정보를 보관하는 래퍼 클래스.
* - root: 튜플의 마지막 요소(루트 엔티티)
* - tuple: 원본 튜플 배열 (연관 엔티티 값 포함)
* - aliases: 각 컬럼에 해당하는 alias 배열
*/
data class RootEntityTuple(
val root: Any,
val tuple: Array<Any?>,
val aliases: Array<String>
)
/**
* Hibernate 6 환경에서 Criteria.DISTINCT_ROOT_ENTITY와 유사한 동작을 구현하는 트랜스포머.
*
* TupleTransformer의 R 타입은 RootEntityTuple로 지정하고,
* ResultListTransformer는 최종 merge된 루트 엔티티 리스트를 반환합니다.
*/
object DistinctRootEntityResultTransformer : TupleTransformer<RootEntityTuple>,
ResultListTransformer<Any?> {
override fun transformTuple(tuple: Array<Any?>, aliases: Array<String>): RootEntityTuple {
// 튜플의 마지막 요소를 루트 엔티티로 간주합니다.
val root = tuple.last() ?: throw IllegalArgumentException("Root entity in tuple cannot be null")
return RootEntityTuple(root, tuple, aliases)
}
@Suppress("UNCHECKED_CAST")
override fun transformList(resultList: List<Any?>): List<Any?> {
// resultList의 각 요소는 RootEntityTuple임을 전제로 합니다.
val tupleList = resultList.map { it as RootEntityTuple }
// System.identityHashCode를 사용해 동일 루트 엔티티를 그룹화
val entityMap = mutableMapOf<Int, Any>()
for (tupleWrapper in tupleList) {
val root = tupleWrapper.root
val key = System.identityHashCode(root)
if (entityMap.containsKey(key)) {
mergeAssociations(entityMap[key]!!, tupleWrapper)
} else {
mergeAssociations(root, tupleWrapper)
entityMap[key] = root
}
}
// 병합된 각 루트 엔티티에 대해 강제로 lazy 로딩을 초기화
entityMap.values.forEach { initializeEntityGraph(it) }
return entityMap.values.toList()
}
/**
* 현재 튜플의 나머지 컬럼(루트 엔티티 외)의 값을,
* 루트 엔티티의 동일한 이름의 연관 필드에 병합합니다.
*
* - 필드가 Collection 타입이면 기존 컬렉션에 추가합니다.
* - 단일 값이면 값이 없을 때만 설정합니다.
*/
private fun mergeAssociations(root: Any, tupleWrapper: RootEntityTuple) {
val aliases = tupleWrapper.aliases
val tupleValues = tupleWrapper.tuple
// 마지막 컬럼은 루트 엔티티이므로, 0 until tupleValues.size - 1 반복
for (i in 0 until tupleValues.size - 1) {
val alias = aliases.getOrNull(i) ?: continue
val value = tupleValues.getOrNull(i) ?: continue
try {
val field = resolveField(root, alias) ?: continue
field.isAccessible = true
if (Collection::class.java.isAssignableFrom(field.type)) {
var collection = field.get(root) as? MutableCollection<Any?>
if (collection == null) {
collection = mutableListOf()
field.set(root, collection)
}
if (!Hibernate.isInitialized(collection)) {
Hibernate.initialize(collection)
}
if (!collection.contains(value)) {
collection.add(value)
}
} else {
if (field.get(root) == null) {
field.set(root, value)
}
}
} catch (ex: Exception) {
// 필요에 따라 로깅 처리 (예: ex.printStackTrace())
}
}
}
/**
* 주어진 alias에 해당하는 필드를 루트 엔티티에서 찾습니다.
* 기본적으로 정확한 이름을 찾되, 없으면 대소문자 무시 비교를 수행합니다.
*/
private fun resolveField(root: Any, alias: String): Field? {
return try {
root::class.java.getDeclaredField(alias)
} catch (e: NoSuchFieldException) {
root::class.java.declaredFields.firstOrNull { it.name.equals(alias, ignoreCase = true) }
}
}
/**
* 루트 엔티티 및 그 연관 필드를 Hibernate.initialize()를 통해 강제로 초기화합니다.
*/
private fun initializeEntityGraph(entity: Any) {
try {
Hibernate.initialize(entity)
} catch (ex: Exception) {
// 예외 발생 시 로깅 처리 가능
}
entity::class.java.declaredFields.forEach { field ->
try {
field.isAccessible = true
val value = field.get(entity)
if (value != null && !Hibernate.isInitialized(value)) {
Hibernate.initialize(value)
}
} catch (ex: Exception) {
// 예외 발생 시 로깅 처리 가능
}
}
}
}
지피티에게 주석도 달아달라고 하면 아주 이쁘게 잘해줍니다.
5. 결론
많이 배웠습니다! 최대한 다들 이런 일 없기를 바랍니다..