컬렉션 중복 문제와 쿼리 분리 전략 JPA Fetch Join의 함정

JPA를 활용해 복잡한 도메인 모델을 다루다 보면 성능 최적화를 위해 Fetch Join을 자주 사용하게 됩니다.
하지만 무분별한 Fetch Join은 데이터 중복과 누락이라는 치명적인 결과를 초래할 수 있습니다. IdolGlow 프로젝트에서 발생한 위치 정보 누락 문제를 해결하며 정립한 JPA 쿼리 최적화와 커밋 관리 원칙을 정리했습니다.


1. 목록 조회 시 location 필드 누락

일관성 없는 위치 정보

상품 목록 조회 시, 동일한 상품임에도 불구하고 어떤 요청에서는 위치 정보가 정상적으로 출력되지만, 다른 요청에서는 null로 반환되는 데이터 불일치 현상이 발생했습니다.

  • 영향 범위:
    • 지리 기반 필터링 오류: 거리 정렬 시 좌표 부재로 인해 정렬 기준이 무너짐.
    • 지도 표시 실패: 프론트엔드 지도 UI에서 핀(Pin)을 렌더링할 수 없음.
    • 사용자 신뢰도 하락: 반경 검색 결과에 위치 정보가 없는 모순 발생.

2. Fetch Join과 컬렉션의 카르테시안 곱

문제가 발생한 쿼리 구조

성능을 위해 하나의 쿼리에서 위치 정보와 옵션 목록을 한꺼번에 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개의 로우가 생성됩니다.

  • JPA의 혼란: ProductLocation은 1:1 관계지만, ProductOption은 1:N 관계 입니다.
    여러 컬렉션을 동시에 조인하면 결과 행이 기하급수적으로 늘어나는 카르테시안 곱이 발생합니다.
  • 객체 변환 오류: JPA가 중복된 행들을 객체로 변환하는 과정에서 옵션 데이터를 리스트에 담는 작업은 성공하지만, 반복되는 위치 정보 데이터를 처리하다가 특정 상황에서 필드를 유실하거나 null을 할당하는 불안정한 상태에 빠지게 됩니다.

3. 컬렉션 Fetch Join 분리

"한 쿼리에서는 최대 하나의 컬렉션만 Fetch Join한다"

불안정한 단일 쿼리를 버리고, 역할을 명확히 나눈 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(),
            ...
        )
    }
}
  • 장점: 쿼리 수는 1개에서 3개로 늘었지만, 각 쿼리가 단순하고 예측 가능해졌으며 데이터 누락 위험이 완벽히 제거되었습니다.

4. Tour 연계 필드 노출

목록 응답에서 누락되었던 '투어 선택 개수' 필드를 엔티티의 JSON 데이터를 활용해 노출하도록 개선했습니다.

  1. 도메인 데이터 활용: product.tourAttractionPicksJson에 담긴 데이터를 파싱합니다.
  2. 계산 로직 추가: 리포지토리 레이어에서 JSON 배열의 크기를 계산하여 tourAttractionPickCount 필드에 주입합니다.
  3. 스키마 반영: GraphQL 타입에 해당 필드를 추가하여 클라이언트가 정보를 사용할 수 있게 했습니다.

5. 커밋 단위와 변경 분리의 중요성

코드의 질을 높였지만, 관리 측면에서는 아쉬움이 남습니다.
하나의 커밋에 버그 수정, 기능 추가, 설정 변경이 섞여 있었기 때문입니다.

  • 나쁜 커밋의 결과: 나중에 특정 기능만 되돌리거나, 문제 발생 시 변경 이력을 추적하기가 매우 까다로워집니다.
  • 권장하는 분리 방식:
    • fix: 쿼리 분리를 통한 데이터 안정화
    • feat: 투어 선택 개수 필드 노출
    • chore: 빌드 설정 및 환경 설정 변경

6. JPA 쿼리 최적화의 실전 원칙

  1. 컬렉션 Fetch Join은 하나만: 둘 이상의 ToMany 관계를 Fetch Join하는 것은 금물입니다.
  2. 데이터 정합성이 최우선: 쿼리 수를 줄이는 것보다 데이터가 정확하게 나오는 것이 훨씬 중요합니다.
  3. 배치 조회는 Map으로 활용: IN 절로 가져온 데이터를 associateBygroupBy로 Map화하면 조립 시 속도를 보장합니다.
  4. 복잡하면 나누어라: 한 번에 모든 것을 해결하려는 쿼리는 유지보수의 지옥을 만듭니다. 명확한 여러 개의 쿼리가 복잡한 하나의 쿼리보다 낫습니다.

위 항목 중 하나라도 해당한다면, 지금 바로 쿼리 분리 전략을 검토해야 합니다.

느낀 점

이번 문제를 해결하면서 JPA Fetch Join은 강력하지만, 도메인 구조를 이해하지 못한 채 남용하면 오히려 데이터 정합성을 깨뜨릴 수 있다는 점을 느꼈습니다.

특히 컬렉션 관계가 포함된 조회에서는 한 쿼리로 모든 데이터를 가져오는 방식이 항상 정답은 아니었습니다.
쿼리 수는 줄어들 수 있지만, 카르테시안 곱으로 인해 중복 데이터가 늘어나고 location 같은 필드가 불안정하게 반환될 수 있기 때문입니다.

이번 경험을 통해 복잡한 조회는 무리하게 합치기보다 역할별로 나누고, 배치 조회 후 Map으로 조립하는 방식이 더 안전하다는 것을 배웠습니다.
쿼리 수보다 중요한 것은 데이터가 정확하고 일관되게 반환되는 구조였습니다.

또한 하나의 커밋에 버그 수정, 기능 추가, 설정 변경을 함께 넣으면 나중에 추적이 어려워진다는 점도 체감했습니다.
앞으로는 변경 목적에 따라 커밋을 분리해, 코드뿐 아니라 이력 관리까지 신뢰할 수 있는 방식으로 가져가야겠다고 느꼈습니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글