판매자가 자신의 통계를 기간별로 조회하는 메서드의 테스트 코드를 작성하는 과정에서 기존 코드에서 QueryDsl 버전에 맞지 않는 구조적 문제를 발견했고, 기존 코드도 함께 수정했다. 우리 코드에서는 Querydsl 5.0.0을 사용하고 있는데, 5.x버전에서는 Expressions.constant(null)은 NPE를 던지기 때문에 from이나 to가 null이면 메서드 자체가 실패했기 때문이다.
무엇이 바뀌었나?
Projections.constructor() 방식에서 Tuple 기반 쿼리로 변경하여 집계 결과만 SQL로 가져오고, 조회 기간인 from/to는 Java에서 직접 응답 객체에 바인딩하도록 수정
# CustomSellerDailyStatisticsRepositoryImpl.java
package com.example.allinmarket.domain.sellerdailystatistics.repository;
import com.example.allinmarket.domain.sellerdailystatistics.entity.QSellerDailyStatistics;
import com.example.allinmarket.seller.dailystatistics.dto.DailyStatisticsResponse;
import com.querydsl.core.Tuple;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import java.time.LocalDate;
@RequiredArgsConstructor
public class CustomSellerDailyStatisticsRepositoryImpl implements CustomSellerDailyStatisticsRepository {
private final JPAQueryFactory queryFactory;
@Override
public DailyStatisticsResponse findRangedStatistics(Long sellerId, LocalDate from, LocalDate to) {
QSellerDailyStatistics s = QSellerDailyStatistics.sellerDailyStatistics;
// Expressions.constant(null)은 QueryDSL 5.x에서 NPE를 던지므로
// from/to는 Tuple로 집계 후 Java에서 직접 바인딩한다
Tuple row = queryFactory
.select(
s.seller.id,
s.totalOrders.sum(),
s.totalItems.sum(),
s.totalSales.sum(),
s.totalRefunds.sum(),
s.refundAmount.sum(),
s.netSales.sum()
)
.from(s)
.where(
s.seller.id.eq(sellerId),
from != null ? s.statDate.goe(from) : null,
to != null ? s.statDate.loe(to) : null
)
.groupBy(s.seller.id)
.fetchOne();
if (row == null) return null;
return new DailyStatisticsResponse(
row.get(s.seller.id),
from,
to,
row.get(s.totalOrders.sum()),
row.get(s.totalItems.sum()),
row.get(s.totalSales.sum()),
row.get(s.totalRefunds.sum()),
row.get(s.refundAmount.sum()),
row.get(s.netSales.sum())
);
}
}
Tuple이란?
Java에는 언어 차원의 Tuple 타입이 없다. 대신 개념적으로는 "서로 다른 타입의 값 여러 개를 하나로 묶은 불변 컨테이너"를 뜻한다. QueryDSL에서 Projections.constructor()나 .tuple()을 쓸 때 나오는 com.querydsl.core.Tuple도 같은 개념으로, 쿼리 결과를 타입 안전하게 묶는 용도로 사용되는 인터페이스다. .select()에 여러 Expression을 넘겼을 때, 각 컬럼 값을 하나의 행으로 묶어서 담는 컨테이너 역할을 한다. 즉, Tuple 하나가 데이터 레코드(행)이다. 한 레코드 안에 column별 값은 select로 받은 expression 자체를 key로 사용하여 꺼낸다. 인덱스 대신 expression 자체를 사용하기 때문에 타입 안정성이 보장된다. 내부적으로는 Expression<?>[]와 Object[] 두 배열을 들고 있고, get 호출 시 expression 배열에서 위치를 찾아 value 배열에서 꺼내는 구조다. 실무에서는 Tuple을 Repository 밖으로 노출하지 않는 게 관례다. DTO Projection(Projections.constructor, @QueryProjection)으로 바로 변환하는 걸 권장한다. 현 프로젝트의 경우 Tuple로 가져온 값을 DTO로 만들어 return하기 때문에 Tuple이 밖으로 노출되지 않는다.