BlueNyang
[Spring Boot | JPA] 바쁜 일정 속 복잡한 조회 쿼리 해결기
Spring

[Spring Boot | JPA] 바쁜 일정 속 복잡한 조회 쿼리 해결기

BlueNyangBlueNyang
·
·
약 5분
·
# jpa# jpql# n-plus-1-problem# join-fetch# querydsl

멀티캠퍼스에서 진행했던 프로젝트의 커머스 기능을 구현하면서 가장 많은 고민을 했던 부분 중 하나는 바로 '데이터베이스 조회'였습니다.

"사용자의 이메일(userEmail)로 장바구니에 담긴 상품을 가져오고, 판매자의 대시보드 통계를 출력하라"

와 같은 비즈니스 요구사항이 늘어날수록, 다중 테이블을 넘나드는 조회가 필수적이었습니다.

이 글에서는 프로젝트 초기의 단순했던 엔티티 매핑을 객체 지향적으로 개선하여 얻은 이점과, 복잡한 쿼리 요구사항 앞에서 QueryDSL 도입을 시도하다 현실적인 일정 문제로 JPQL이라는 타협점을 찾기까지의 과정을 공유하고자 합니다.

1. JPA 연관관계 매핑의 도입과 활용

가장 먼저 선행된 작업은 관계형 데이터베이스의 패러다임을 객체 지향의 패러다임으로 맞추는 엔티티 리팩토링이었습니다.

1.1. 기존 외래 키(FK) 필드 직접 매핑의 한계

초기 엔티티 설계에서는 CartItemsOrderItems 엔티티 내부에 userId, cartId, productId와 같이 RDB의 외래 키(FK) 컬럼을 원시 타입(UUID, String) 필드로 1:1 매칭하여 사용했습니다.

이 방식은 직관적이지만 결정적인 단점이 있었습니다. 객체 간의 참조(그래프 탐색)가 불가능했기 때문에, 연관된 테이블의 데이터를 가져오려면 서비스 계층에서 Repository를 여러 번 호출하여 직접 조립해야만 했습니다.

  • 당시 예시:
java
@Entity
@Table(name = "cart_items")
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CartItems {
    @Id
    @UuidV7 // 커스텀 생성자
    @Column(columnDefinition = "RAW(16)")
    private UUID cartItemId;

    @Column(columnDefinition = "RAW(16)")
    private UUID cartId;

    @Column(columnDefinition = "RAW(16)")
    private UUID productId;

    private int quantity;
}

1.2. 객체 지향적 연관관계 매핑으로의 진화

이러한 한계를 극복하고자 RDB의 외래 키 대신 @ManyToOne, @OneToOne 어노테이션을 사용하여 실제 엔티티 객체를 참조하도록 연관관계 매핑을 구성했습니다.

사진1:객체참조기반-엔티티-연관관계-매핑구조

이 변경은 엄청난 생산성 향상을 가져왔습니다. 연관관계가 없던 시절에는 불가능했던 JPA 쿼리 메서드(Derived Query Method)를 통한 객체 그래프 탐색이 가능해졌기 때문입니다. 실제로 프로젝트 곳곳에서 다음과 같이 메서드명만으로 깊은 계층의 조인(Join)을 깔끔하게 처리할 수 있었습니다.

  • 장바구니 조회: findByCart_User_UserEmail(...)
  • 주문 상품 조회: findAllByProduct_Seller_User_UserEmail_AndOrderItemStatus_OrderByOrder_CreatedAtDesc(...)

1.3. 복잡한 Join과 조건에서의 JPQL 활용

하지만 만능일 것 같았던 쿼리 메서드도 비즈니스 로직이 복잡해지면서 한계를 드러냈습니다. GROUP BY를 통한 통계 추출이나, N+1 문제를 방지하기 위한 JOIN FETCH가 필요한 경우에는 메서드 이름만으로는 너무 길고 복잡했습니다.

따라서 우리 팀은 명확한 기준을 세웠습니다.

"기본적인 객체 탐색은 쿼리 메서드를 적극 활용하되, 복잡한 Join, 통계, 다중 조건이 들어가는 경우에만 JPQL(@Query)을 사용한다."

실제 프로젝트의 OrderItemsRepository에 이 원칙이 잘 녹아있습니다.

java
@Repository
public interface OrderItemsRepository extends JpaRepository<OrderItems, UUID> {
    // 기본적인 객체 그래프 탐색 (쿼리 메서드 적극 활용)
    Page<OrderItems> findAllByProduct_Seller_User_UserEmail_OrderByOrder_CreatedAtDesc(String userEmail, Pageable pageable);

    // N+1 문제 해결 및 성능 최적화를 위한 복잡한 Join (JPQL 활용)
    @Query(value = """
           SELECT oi
           FROM OrderItems oi
           JOIN FETCH oi.product p
           JOIN FETCH oi.order o
           WHERE p.seller.sellerId = :sellerId
           ORDER BY o.createdAt DESC
          """)
    Page<OrderItems> findBySellerIdWithDetails(UUID sellerId, Pageable pageable);

    // 복잡한 통계 및 그룹핑 (JPQL 활용)
    @Query("""
             SELECT  oi.orderItemStatus, COUNT(oi)
             FROM OrderItems oi
             JOIN oi.product p
             JOIN p.seller s
             JOIN s.user u
             WHERE u.userEmail = :userEmail
             GROUP BY oi.orderItemStatus
            """)
    List<Object[]> countOrderItemsByStatusGroupedBySellerUserEmail(@Param("userEmail") String userEmail);
}

2. 더 복잡한 요구사항과 QueryDSL 도입 실패기

연관관계 매핑과 JPQL의 조합으로 많은 문제를 해결했지만, 장바구니와 주문 도메인에서 동적 쿼리(사용자의 입력에 따라 조건이 달라지는 쿼리) 요구사항이 등장하면서 또 다른 위기가 찾아왔습니다.

문자열 기반의 JPQL은 동적 쿼리를 작성하기 매우 까다롭고, 컴파일 시점에 오타를 잡을 수 없다는 단점이 있었습니다. 이를 해결하기 위해 타입 세이프(Type-Safe)한 쿼리 빌더인 QueryDSL 도입을 결정했습니다.

2.1. 설정의 늪에 빠지다

당차게 build.gradle에 의존성을 추가했지만, 현실은 호락호락하지 않았습니다. 가장 먼저 Lombok과의 어노테이션 프로세서(Annotation Processor) 충돌 이 발생했습니다. QueryDSL의 Q-클래스를 생성하는 과정에서 Gradle 설정이 꼬여, 엔티티의 Getter조차 제대로 인식하지 못하는 빌드 에러가 쏟아졌습니다.

2.2. 마감 시간의 압박

프로젝트 내 다른 의존성과의 호환성 문제까지 겹치면서 원인을 알 수 없는 설정 지연이 계속되었습니다. 기능 구현 일정이 촉박한 상황에서, 완벽한 아키텍처를 세팅하는 데 더 이상 시간을 쏟을 수는 없었습니다. "당장 안정적으로 동작하는 코드를 만들고 기술 부채로 남겨두자" 는 현실적인 판단이 필요했습니다.

3. 현실적인 타협: JPQL 서브쿼리 및 조인 활용

결국 QueryDSL 도입을 잠정 보류하고, 기본적으로 제공되는 JPQL(@Query) 을 최대한 활용하는 방향으로 우회했습니다. 다소 쿼리 문자열을 직접 작성해야 하는 번거로움이 있지만, 이미 구성해 둔 탄탄한 엔티티 연관관계를 바탕으로 JOIN FETCH를 사용하여 N+1 문제를 깔끔하게 해결할 수 있었습니다.

3.1. CartItemRepository의 실제 적용 사례

장바구니에서 선택한 품목들을 주문서로 넘기거나 화면에 렌더링할 때 사용하기 위해 작성한 실제 CartItemRepository의 메서드입니다.

java
public interface CartItemRepository extends JpaRepository<CartItems, UUID> {

    // 기본적인 단일/리스트 조회는 쿼리 메서드 활용
    Optional<CartItems> findByCartAndProduct_ProductId(Cart cart, UUID productId);
    List<CartItems> findByCart_User_UserEmail(String userEmail);

    // N+1 문제 해결 및 복잡한 연관 데이터 로딩은 JPQL 활용
    @Query("""
            SELECT DISTINCT ci FROM CartItems ci
            JOIN FETCH ci.product p
            LEFT JOIN FETCH p.productImages pi
            WHERE ci.cartItemId IN :cartItemIds
            """)
    List<CartItems> findAllByIdWithProductAndImage(List<UUID> cartItemIds);
}

3.1.1. 쿼리 동작 원리 및 최적화 포인트

  1. JOIN FETCH ci.product p: 장바구니 품목(CartItems)을 조회할 때 연관된 상품 정보(Product)를 즉시 로딩(Eager)으로 한 번에 가져옵니다.
  2. LEFT JOIN FETCH p.productImages pi: 상품에 등록된 이미지(ProductImages)가 없을 수도 있으므로(Null 가능성) 일반 조인이 아닌 LEFT JOIN을 사용하되, 패치 조인을 결합하여 N+1 쿼리 발생을 원천 차단했습니다.
  3. DISTINCT의 사용: 1:N 관계인 이미지를 JOIN FETCH 할 경우 카테시안 곱(Cartesian Product)으로 인해 장바구니 품목 데이터가 이미지 수만큼 중복(뻥튀기)되어 반환됩니다. 이를 애플리케이션 단에서 걸러내기 위해 DISTINCT 키워드를 명시했습니다.
  4. IN 절 활용: 한 번의 쿼리로 요청된 여러 개의 cartItemIds 데이터를 한 번에 조회하여 데이터베이스 I/O를 최소화했습니다.

4. 마무리하며: 남겨진 기술 부채

엔티티 연관관계 매핑을 통한 JPA 쿼리 메서드의 적극적인 활용, 그리고 N+1 문제 같은 성능 이슈가 발생하는 지점에서만 명시적인 JPQL(JOIN FETCH)을 혼용하는 전략 덕분에 마감 시간 내에 복잡한 커머스 기능들을 무사히 구현해 냈습니다.

하지만 QueryDSL을 적용하지 못하고 남겨둔 쿼리들은 여전히 기술 부채로 남아있습니다. 특히, 사용자 검색 조건에 따라 WHERE 절이 동적으로 변해야 하는 '동적 쿼리'가 필요한 화면에서는 문자열을 이어 붙이는 하드 코딩을 피하기가 어렵습니다.

비록 완벽하진 않지만, 주어진 제약 조건 속에서 JPA와 JPQL의 장단점을 명확히 이해하고 적재적소에 혼용하는 법을 배운 의미 있는 삽질(?)이었습니다. 기술 부채는 갚을 때 비로소 성장이 된다고 합니다. 조만간 이 쿼리들을 더 나은 구조로 리팩토링하며 다시 한번 성장한 모습으로 글을 이어가 보겠습니다.

BlueNyang
작성자BlueNyang
라이선스
CC BY NC
BlueNyang

BlueNyang

BlueNyang의 개발 log

카테고리

  • Development
  • Framework
  • Language
  • Dev Tools
  • DevOps & Infra
  • Studies

페이지

© 2026 BlueNyang. All rights reserved.

Made with Nuxt.js and Directus