💻 PROJECT/Team Projects

[ 🚚 ONESTOP ] Spring Data JPA 집계 쿼리 - @Query, JPQL, N+1 트레이드오프

eunjiom 2026. 5. 19. 20:06

구현 기능

  • 관리자가 주문/배송 현황을 한 번에 조회할 수 있는 대시보드 API를 구현했다.
GET /api/admin/dashboard
- 상태별 주문 수 (PENDING_PAYMENT, PAID, CANCELLED)
- 오늘 주문 수
- 오늘 매출 (취소 주문 제외)
- 상태별 배송 수 (ACCEPT, INSTRUCT, DEPARTURE, DELIVERING, FINAL_DELIVERY)
  • 이슈에는 /orders와 /deliveries 두 개로 분리하려 했는데, 대시보드 특성상 한 번에 보여주는 게 더 자연스럽다고 판단해서 하나의 엔드포인트로 통합했다.

사용한 기술과 선택 이유

1. Spring Data JPA 쿼리 메서드

주문 수 집계에 사용한 방식

long countByStatus(OrderStatus status);
long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
  • Spring Data JPA의 메서드 네이밍 규칙을 활용했다. 별도의 JPQL 없이 메서드 이름만으로 쿼리가 자동 생성된다.

왜 선택했나? 단순한 count 쿼리는 메서드 네이밍으로 충분히 표현 가능하고 코드가 간결하다. 복잡한 조건이 없는 경우 JPQL을 직접 작성하는 것보다 유지보수가 쉽다.

2. @Query (JPQL)

오늘 매출 집계에 사용한 방식

@Query("select coalesce(sum(o.finalPrice), 0) from Order o " +
       "where o.createdAt between :start and :end " +
       "and o.status = :status")
Long sumFinalPriceByCreatedAtBetween(
    @Param("start") LocalDateTime start,
    @Param("end") LocalDateTime end,
    @Param("status") OrderStatus status
);

 

왜 JPQL을 선택했나? sum + coalesce + 상태 필터를 조합해야 하는데 메서드 네이밍만으로는 표현이 불가능하다. 이런 경우 @Query로 직접 작성하는 게 맞다.

 

처음에 status를 하드코딩했다가 수정한 이유

처음에는 아래처럼 작성했다.

// 처음 코드 (잘못된 방법)
@Query("...and o.status = 'PAID'")
  • AI 리뷰에서 지적받았다. OrderStatus enum이 바뀌면 쿼리 문자열도 같이 수정해야 하는 문제가 생긴다. 파라미터로 받도록 수정해서 재사용성을 높였다.

coalesce를 쓴 이유 주문이 하나도 없을 때 sum은 null을 반환한다. coalesce(sum(...), 0)으로 감싸면 null 대신 0을 반환해서 NPE를 방지할 수 있다. 추가로 서비스 레이어에서도 Optional.ofNullable(...).orElse(0L)로 한 번 더 null 안전 처리를 했다.

3. @Modifying(clearAutomatically = true)

  • 배치 업데이트 쿼리에 적용했다. (판매자 강제 비활성화에서 이미 사용)
@Modifying(clearAutomatically = true)
@Query("update Product p set p.status = :status where p.seller.id = :sellerId")
int updateStatusBySellerId(...);

 

clearAutomatically = true란? 배치 업데이트 후 영속성 컨텍스트를 자동으로 클리어해서 이후 조회 시 DB의 최신 상태를 반환하도록 보장한다. 이 옵션이 없으면 업데이트가 반영됐음에도 캐시된 이전 데이터를 읽어올 수 있다.

다른 기술과 비교

집계 방식 비교

방식 장점 단점 사용하면 좋은 곳
JPA 쿼리 메서드 간결, 유지보수 쉬움 복잡한 쿼리 표현 불가 단순 count, exists
@Query JPQL 복잡한 쿼리 표현 가능, 타입 안전 직접 작성 필요 sum, 복합 조건 쿼리
QueryDSL 동적 쿼리, 컴파일 타임 오류 감지 설정 복잡, 학습 필요 검색 조건이 동적인 경우
Native Query DB 최적화 쿼리 직접 사용 DB 종속성, 타입 불안전 극한의 성능 최적화 필요 시
  • 대시보드는 고정된 집계 쿼리라 QueryDSL이나 Native Query까지 쓸 필요는 없었다.

대시보드 데이터 조회 방식 비교

방법 1. Enum 순회 + 개별 count 쿼리 (현재 방식)

Map<OrderStatus, Long> orderStatusCount = Stream.of(OrderStatus.values())
        .collect(Collectors.toMap(
                status -> status,
                status -> orderRepository.countByStatus(status)
        ));
  • 상태가 3개면 쿼리 3번 발생한다.

방법 2. GROUP BY로 한 번에 조회

@Query("select o.status, count(o) from Order o group by o.status")
List<Object[]> countGroupByStatus();
  • 쿼리 1번으로 전체 상태별 카운트를 가져올 수 있다.

왜 현재 방식을 선택했나?

AI 리뷰에서 N+1 문제를 지적받았지만 MVP 단계에서는 무시했다. 이유는 두 가지다.

첫째, 대시보드는 관리자만 보는 화면이라 호출 빈도가 낮다. 상태 수만큼 쿼리가 나가더라도 실제 성능 영향이 거의 없다.

둘째, Object[] 형태로 받아서 Map으로 변환하는 코드가 오히려 더 복잡해진다. 상태가 3~5개 수준에서는 가독성이 더 중요하다.

트래픽이 많아지거나 대시보드가 더 복잡해지면 그때 GROUP BY 방식으로 리팩토링하면 된다.

AI 리뷰 보고 수정한 것

status 하드코딩 제거

// 수정 전 (잘못된 방법)
@Query("...and o.status = 'PAID'")
Long sumFinalPriceByCreatedAtBetween(LocalDateTime start, LocalDateTime end);

// 수정 후
@Query("...and o.status = :status")
Long sumFinalPriceByCreatedAtBetween(
    @Param("start") LocalDateTime start,
    @Param("end") LocalDateTime end,
    @Param("status") OrderStatus status
);
  • OrderStatus.PAID를 파라미터로 넘기도록 변경했다. 이렇게 하면 enum 값이 바뀌어도 컴파일 타임에 오류가 잡히고, 다른 상태의 매출도 조회할 수 있어 재사용성이 높아진다.