
JPA를 활용해 복잡한 도메인 모델을 다루다 보면 성능 최적화를 위해 Fetch Join을 자주 사용하게 됩니다.
하지만 무분별한 Fetch Join은 데이터 중복과 누락이라는 치명적인 결과를 초래할 수 있습니다. IdolGlow 프로젝트에서 발생한 위치 정보 누락 문제를 해결하며 정립한 JPA 쿼리 최적화와 커밋 관리 원칙을 정리했습니다.
상품 목록 조회 시, 동일한 상품임에도 불구하고 어떤 요청에서는 위치 정보가 정상적으로 출력되지만, 다른 요청에서는 null로 반환되는 데이터 불일치 현상이 발생했습니다.
성능을 위해 하나의 쿼리에서 위치 정보와 옵션 목록을 한꺼번에 Fetch Join하려 했던 것이 화근이었습니다.
// 위험한 쿼리: 여러 컬렉션을 한 번에 Fetch Join
@Query("""
SELECT DISTINCT p
FROM Product p
LEFT JOIN FETCH p.productLocation
LEFT JOIN FETCH p.productOptions po
LEFT JOIN FETCH po.option o
WHERE p.id IN (:ids)
""")
fun findAllWithDetails(ids: List<Long>): List<Product>
DB 수준에서 위 쿼리를 실행하면 상품 1개당 옵션이 3개일 경우 총 3개의 로우가 생성됩니다.
ProductLocation은 1:1 관계지만, ProductOption은 1:N 관계 입니다.null을 할당하는 불안정한 상태에 빠지게 됩니다.불안정한 단일 쿼리를 버리고, 역할을 명확히 나눈 3단계 쿼리 분리 전략을 도입했습니다.
// 개선된 구조: 단계를 나누어 배치 조회
override fun hydrateForBrowse(orderedIds: List<Long>, ...): List<ProductPagingQueryResponse> {
// 1단계: 기본 상품 정보 조회 (ToOne 관계만 포함)
val products = productQueryRepository.findBasicByIds(orderedIds)
// 2단계: 위치 정보 별도 조회 (안정적인 1:1 매핑)
val locations = productQueryRepository.findLocationsByProductIds(orderedIds)
val locationMap = locations.associateBy { it.product.id }
// 3단계: 옵션 정보 별도 조회 (단일 컬렉션 Fetch Join)
val options = productQueryRepository.findOptionsByProductIds(orderedIds)
val optionsMap = options.groupBy { it.product.id }
// 4단계: 메모리에서 조립 (안정성 확보)
return products.map { product ->
ProductPagingQueryResponse(
location = locationMap[product.id],
options = optionsMap[product.id] ?: emptyList(),
...
)
}
}
목록 응답에서 누락되었던 '투어 선택 개수' 필드를 엔티티의 JSON 데이터를 활용해 노출하도록 개선했습니다.
product.tourAttractionPicksJson에 담긴 데이터를 파싱합니다.tourAttractionPickCount 필드에 주입합니다.코드의 질을 높였지만, 관리 측면에서는 아쉬움이 남습니다.
하나의 커밋에 버그 수정, 기능 추가, 설정 변경이 섞여 있었기 때문입니다.
fix: 쿼리 분리를 통한 데이터 안정화feat: 투어 선택 개수 필드 노출chore: 빌드 설정 및 환경 설정 변경IN 절로 가져온 데이터를 associateBy나 groupBy로 Map화하면 조립 시 속도를 보장합니다.위 항목 중 하나라도 해당한다면, 지금 바로 쿼리 분리 전략을 검토해야 합니다.
이번 문제를 해결하면서 JPA Fetch Join은 강력하지만, 도메인 구조를 이해하지 못한 채 남용하면 오히려 데이터 정합성을 깨뜨릴 수 있다는 점을 느꼈습니다.
특히 컬렉션 관계가 포함된 조회에서는 한 쿼리로 모든 데이터를 가져오는 방식이 항상 정답은 아니었습니다.
쿼리 수는 줄어들 수 있지만, 카르테시안 곱으로 인해 중복 데이터가 늘어나고 location 같은 필드가 불안정하게 반환될 수 있기 때문입니다.
이번 경험을 통해 복잡한 조회는 무리하게 합치기보다 역할별로 나누고, 배치 조회 후 Map으로 조립하는 방식이 더 안전하다는 것을 배웠습니다.
쿼리 수보다 중요한 것은 데이터가 정확하고 일관되게 반환되는 구조였습니다.
또한 하나의 커밋에 버그 수정, 기능 추가, 설정 변경을 함께 넣으면 나중에 추적이 어려워진다는 점도 체감했습니다.
앞으로는 변경 목적에 따라 커밋을 분리해, 코드뿐 아니라 이력 관리까지 신뢰할 수 있는 방식으로 가져가야겠다고 느꼈습니다.