Engineering Note

[SW Engineering] JPQL에서 FETCH JOIN과 DTO 생성자 패턴 충돌 해결하기 본문

SW Engineering

[SW Engineering] JPQL에서 FETCH JOIN과 DTO 생성자 패턴 충돌 해결하기

Software Engineer Kim 2025. 9. 23. 23:37

장바구니 조회 기능을 구현하던 중 JOIN FETCH와 DTO 생성자 패턴을 함께 사용했을 때 발생하는 Hibernate 에러를 해결한 과정을 공유하고자 합니다. 이 에러는 JPA를 사용하면서 성능 최적화를 시도할 때 자주 마주치는 문제입니다.

문제 상황

@Query("select new com.shop.dto.CartDetailDto(ci.id, i.itemNm, i.price, ci.count, im.imgUrl) "+
        "from CartItem ci " +
        "join fetch ci.item i " +  // 문제가 된 부분
        "join ItemImg im on im.item.id = i.id " +
        "where ci.cart.id = :cartId " +
        "and im.repImgYn = 'Y' " +
        "order by ci.regTime desc"
)
List<CartDetailDto> findCartDetailDtoList(Long cartId);

장바구니 조회 쿼리에서 N+1 문제를 해결하기 위해 JOIN FETCH를 사용했는데, 애플리케이션 시작 시 다음과 같은 에러가 발생했습니다.

에러 메시지 분석

핵심 에러 내용

org.springframework.beans.factory.UnsatisfiedDependencyException: 
Error creating bean with name 'cartController' defined

---중략---

Caused by: org.hibernate.query.SemanticException: 
Query specified join fetching, but the owner of the fetched association was not present 
in the select list 
[SqmSingularJoin(com.shop.entity.CartItem(ci).item(i) : item)]

 
 

  • "join fetching": JOIN FETCH를 사용했음
  • "owner of the fetched association was not present in the select list": FETCH된 연관관계의 소유자(CartItem)가 SELECT 절에 없음
  • 문제의 핵심: JOIN FETCH로 가져온 데이터를 SELECT 절에서 사용하지 않음

 

근본 원인 분석

FETCH JOIN의 동작 원리

// FETCH JOIN의 올바른 사용 예시
@Query("SELECT ci FROM CartItem ci JOIN FETCH ci.item WHERE ci.cart.id = :cartId")
List<CartItem> findCartItemsWithItem(Long cartId);

 
 
FETCH JOIN의 목적:

  • 연관된 엔티티를 한 번의 쿼리로 함께 조회
  • 지연 로딩 방지 및 N+1 문제 해결
  • 조회된 엔티티 객체 전체를 반환해야 함

DTO 생성자 패턴의 특징

// DTO 생성자 패턴
@Query("SELECT new com.shop.dto.CartDetailDto(ci.id, i.itemNm, i.price) " +
       "FROM CartItem ci JOIN ci.item i")
List<CartDetailDto> findCartDetailDtos();

 
DTO 생성자 패턴의 목적:

  • 필요한 컬럼만 선택적으로 조회
  • 메모리 사용량 최적화
  • 엔티티 객체가 아닌 DTO 객체 반환

왜 충돌하는가?

FETCH JOIN: "엔티티 전체를 가져와서 연관관계를 미리 초기화해줄게"
DTO 생성자: "엔티티는 필요 없고 특정 값들만 뽑아서 DTO로 만들어줘"
이 두 가지 접근 방식은 서로 상반된 목적을 가지고 있어 함께 사용할 수 없습니다.
 
 

해결 방법

방법 1: FETCH JOIN 제거 (채택한 방법)

@Query("select new com.shop.dto.CartDetailDto(ci.id, i.itemNm, i.price, ci.count, im.imgUrl) "+
        "from CartItem ci " +
        "join ci.item i " +  // FETCH 제거
        "join ItemImg im on im.item.id = i.id " +
        "where ci.cart.id = :cartId " +
        "and im.repImgYn = 'Y' " +
        "order by ci.regTime desc"
)
List<CartDetailDto> findCartDetailDtoList(Long cartId);

 
장점:

  • 필요한 컬럼만 조회하여 메모리 효율적
  • DTO로 바로 매핑되어 변환 과정 불필요
  • JOIN은 여전히 한 번의 쿼리로 처리되므로 N+1 문제는 발생하지 않음

단점:

  • DTO 생성자 의존성
    • DTO 구조 변경 시 쿼리도 함께 수정 필요
    • 생성자 파라미터 순서와 타입이 정확히 일치해야 함
  • 재사용성 부족
    • 특정 DTO에만 특화된 쿼리
    • 다른 용도로 활용하기 어려움

 

방법 2: Entity 조회 후 DTO 변환 (JOIN FETCH 사용)

@Query("SELECT ci FROM CartItem ci " +
       "JOIN FETCH ci.item i " +
       "JOIN FETCH ci.item.itemImgs im " +
       "WHERE ci.cart.id = :cartId " +
       "AND im.repImgYn = 'Y' " +
       "ORDER BY ci.regTime DESC")
List<CartItem> findCartItemsWithDetails(Long cartId);

 
서비스에서 DTO로 변환:

public List<CartDetailDto> getCartList(String email) {
    // ... 기존 로직
    List<CartItem> cartItems = cartItemRepository.findCartItemsWithDetails(cart.getId());
    
    return cartItems.stream()
        .map(ci -> new CartDetailDto(
            ci.getId(),
            ci.getItem().getItemNm(),
            ci.getItem().getPrice(),
            ci.getCount(),
            ci.getItem().getRepresentativeImage().getImgUrl()
        ))
        .collect(Collectors.toList());
}

 
장점:

  • FETCH JOIN으로 N+1 문제 완전 해결
  • 엔티티 객체 활용 가능

단점:

  • 메모리 사용량 증가 (엔티티 전체 로드)
  • 추가 변환 과정 필요

 

방법 3: 네이티브 쿼리 사용

@Query(value = "SELECT ci.cart_item_id, i.item_nm, i.price, ci.count, im.img_url " +
               "FROM cart_item ci " +
               "JOIN item i ON ci.item_id = i.item_id " +
               "JOIN item_img im ON im.item_id = i.item_id " +
               "WHERE ci.cart_id = :cartId AND im.rep_img_yn = 'Y' " +
               "ORDER BY ci.reg_time DESC", 
       nativeQuery = true)
List<Object[]> findCartDetailNative(@Param("cartId") Long cartId);

 
 

쿼리 실행 계획 확인

방법 1 (JPQL JOIN):

-- 생성되는 SQL
SELECT ci.cart_item_id, i.item_nm, i.price, ci.count, im.img_url 
FROM cart_item ci 
JOIN item i ON ci.item_id = i.item_id 
JOIN item_img im ON im.item_id = i.item_id 
WHERE ci.cart_id = ? AND im.rep_img_yn = 'Y'

 
방법 2 (FETCH JOIN):

-- 생성되는 SQL (더 많은 컬럼 조회)
SELECT ci.*, i.*, im.* 
FROM cart_item ci 
JOIN item i ON ci.item_id = i.item_id 
JOIN item_img im ON im.item_id = i.item_id 
WHERE ci.cart_id = ? AND im.rep_img_yn = 'Y'

 

해결 방법 비교

방법메모리 효율성개발 편의성성능권장 사항
JPQL JOIN⭐⭐⭐⭐⭐⭐⭐⭐단순 조회, 대용량 데이터
FETCH JOIN⭐⭐⭐⭐복잡한 비즈니스 로직, 엔티티 조작 필요
Native Query⭐⭐⭐⭐⭐⭐⭐⭐복잡한 쿼리, DB 최적화 기능 사용

 

배운 점

1. JPA 최적화 전략의 트레이드오프

  • FETCH JOIN: 객체 그래프 탐색 최적화에 특화
  • DTO 프로젝션: 메모리 사용량 최적화에 특화
  • 둘 다 N+1 문제를 해결하지만 접근 방식이 다름

2. 에러 메시지 읽기의 중요성

owner of the fetched association was not present in the select list

 
이 메시지만 제대로 이해했어도 문제를 빠르게 파악할 수 있었습니다.

3. DTO 패턴 사용 방법과 FETCH JOIN Entity 조회 최적화 차이

DTO 프로젝션을 선택할 때:

  • 단순 조회 or 불필요한 Entity 컬럼 제외하고 화면에 필요한 데이터만 조회
  • 특정 화면/API 전용 데이터
  • 대용량 조회 (메모리 효율성 중요)
  • 집계/통계 데이터

엔티티 조회를 선택할 때:

  • 비즈니스 로직 실행 필요
  • 엔티티 상태 변경 필요
  • 연관관계 탐색 필요
  • 여러 용도로 메서드 재사용

 
단순 조회용 DTO 패턴 예시

// 장바구니 목록 - 화면에 보여주기만 함
SELECT new CartDetailDto(ci.id, i.itemNm, i.price, ci.count)
FROM CartItem ci JOIN ci.item i

결론

FETCH JOIN과 DTO 생성자 패턴은 각각 다른 목적의 최적화 기법입니다. 함께 사용할 수 없다는 제약을 이해하고, 프로젝트 상황에 맞는 적절한 방법을 선택하는 것이 중요합니다.
이번 경험을 통해 JPA의 다양한 조회 전략과 그 특성을 더 깊이 이해할 수 있었습니다. 비슷한 문제를 겪고 계신 분들에게 도움이 되기를 바랍니다.

Comments