정산(settlement) 시스템 의 조회 계층 은 전부 QueryDSL 로 짰다. 단순 CRUD 면 Spring Data JPA 로 충분 하지만, 정산 은 동적 검색 · 대용량 페이징 · 집계 · 대사(reconciliation) 가 핵심 이라 — 문자열 JPQL 로는 감당 이 안 된다. 이 글 은 실제 코드 로 QueryDSL 을 어떻게 붙였는지 정리 한다.


1. 왜 QueryDSL 인가 — 3가지 이유

  • 타입 안전 — 컬럼명 오타 가 컴파일 에러. 문자열 JPQL 은 런타임 에야 터진다.
  • 동적 쿼리 — 검색 조건 이 8개 인데 다 optional. ifwhere 를 조립 해야 하는데, JPQL 문자열 concat 은 지옥 이다.
  • 프로젝션 — 엔티티 를 통째 로 로딩 하지 않고 필요한 컬럼만 DTO 로 바로 뽑는다. N+1 도, 불필요 로딩 도 없다.

2. 설정 — Gradle + Bean 하나

Spring Boot 3 / Jakarta 기준. QueryDSL 5.0.0 의 jakarta 분류자 를 써야 한다:

implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta")
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")

// 생성 위치 를 build/generated/querydsl 로 고정 (clean 시 함께 삭제)
val querydslDir = layout.buildDirectory.dir("generated/querydsl")
tasks.withType<JavaCompile>().configureEach {
    options.generatedSourceOutputDirectory.set(querydslDir.get().asFile)
}

APT(Annotation Processor) 가 @Entity 마다 Q타입 (QSettlementJpaEntity) 을 생성 한다. 이게 타입 안전 의 실체 — 컬럼 을 settlement.settlementDate 처럼 필드 로 접근 한다.

그리고 Bean 하나:

@Configuration
public class QueryDslConfig {
    @PersistenceContext private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

3. 헥사고날 — QueryDSL 은 어댑터

여기 가 설계 포인트. 정산 은 헥사고날(ports & adapters) 이라, QueryDSL 코드 가 애플리케이션 계층 에 새는 것 을 막아야 한다.

application/port/out/QuerySettlementPort   ← 인터페이스 (도메인 언어)
        ▲ 구현
adapter/out/persistence/querydsl/
    SettlementQueryRepositoryImpl          ← QueryDSL 은 여기 갇힘

애플리케이션 서비스 는 QuerySettlementPort 만 안다. JPAQueryFactory · Projections · BooleanBuilder 같은 QueryDSL 타입 은 어댑터 밖 으로 절대 안 나간다. 나중 에 조회 를 네이티브 SQL 이나 다른 기술 로 갈아도 포트 시그니처 는 그대로 — 코어 는 무사 하다.

4. 프로젝션 — 엔티티 안 부르고 DTO 로 바로

정산 상세 는 settlement · payment · order · user · product 5개 를 조인 한다. 엔티티 로 로딩 하면 N+1 지옥 이다. QueryDSL 은 Projections.constructor단일 쿼리 + DTO 직행:

List<SettlementDetailDto> items = queryFactory
    .select(Projections.constructor(SettlementDetailDto.class,
            settlement.id,
            settlement.paymentAmount,
            settlement.netAmount,
            settlement.status,
            settlement.settlementDate,
            order.orderId,
            payment.paymentMethod,
            user.email,
            product.name.coalesce("")
    ))
    .from(settlement)
    .join(payment).on(settlement.paymentId.eq(payment.paymentId))
    .join(order).on(settlement.orderId.eq(order.orderId))
    .join(user).on(order.userId.eq(user.userId))
    .leftJoin(product).on(order.productId.eq(product.productId))
    .where(where)
    .orderBy(buildOrderSpecifier(condition))
    .limit(fetchSize + 1)
    .fetch();

5개 테이블 을 쿼리 한 방 에 조인 해서 필요한 필드만 DTO 로 채운다. 엔티티 그래프 를 안 만드니 지연로딩 도, N+1 도 원천 봉쇄.

5. 동적 검색 — BooleanBuilder

검색 조건 8개 가 전부 optional. BooleanBuilder있는 조건만 and 로 쌓는다:

private BooleanBuilder buildSearchWhere(SettlementSearchCondition c) {
    BooleanBuilder b = new BooleanBuilder();

    if (hasText(c.getStatus()))     b.and(settlement.status.eq(c.getStatus()));
    if (c.getStartDate() != null)   b.and(settlement.settlementDate.goe(c.getStartDate()));
    if (c.getEndDate() != null)     b.and(settlement.settlementDate.loe(c.getEndDate()));
    if (c.getUserId() != null)      b.and(order.userId.eq(c.getUserId()));
    if (hasText(c.getProductName()))b.and(product.name.containsIgnoreCase(c.getProductName()));
    if (Boolean.TRUE.equals(c.getIsRefunded()))
        b.and(settlement.refundedAmount.gt(BigDecimal.ZERO));

    return b;
}

null 이면 조건 이 안 붙는다. 이걸 JPQL 문자열 로 짜면 — WHERE 1=1 뒤 에 조건 을 concat 하는, 그 악명 높은 코드 가 된다. QueryDSL 은 이 문제 를 우아하게 없앤다.

6. 커서 페이징 — OFFSET 을 버린 이유

정산 목록 은 수백만 건. OFFSET 100000 LIMIT 20뒤로 갈수록 느려진다 — DB 가 앞 10만 건 을 세고 버리기 때문. 그래서 커서 기반 으로 갔다. 복합 커서 (settlementDate, id):

private BooleanExpression cursorCondition(LocalDate cursorDate, Long cursorId, boolean isDesc) {
    if (isDesc) {
        return settlement.settlementDate.lt(cursorDate)
                .or(settlement.settlementDate.eq(cursorDate)
                        .and(settlement.id.lt(cursorId)));   // 날짜 같으면 id 로 tie-break
    }
    return settlement.settlementDate.gt(cursorDate)
            .or(settlement.settlementDate.eq(cursorDate)
                    .and(settlement.id.gt(cursorId)));
}

날짜 로 자르고, 날짜 가 같은 경계 는 id 로 끊는다. 인덱스 (settlement_date, id) 를 그대로 타서 O(log n) — 100만 번째 페이지 도 첫 페이지 와 같은 속도. limit(fetchSize + 1)1건 더 뽑아 hasNext 를 판정 하고, 마지막 행 의 (date, id) 를 다음 커서 로 반환 한다.

7. 집계 — CASE WHEN · coalesce · date_trunc

일/월별 요약 은 순수 집계. QueryDSL 로 조건부 카운트 와 합계 를:

// 상태별 건수 = SUM(CASE WHEN status = ? THEN 1 ELSE 0)
private NumberExpression<Long> statusCount(String status) {
    return Expressions.cases()
            .when(settlement.status.eq(status)).then(1L)
            .otherwise(0L)
            .sum();
}

// 합계는 NULL 방어까지 — SUM(...) 이 null 이면 0
settlement.paymentAmount.sum().coalesce(BigDecimal.ZERO)

월별 그룹핑 은 PostgreSQL 의 date_trunc 가 필요 한데, QueryDSL 기본 에 없다. dateTemplate 으로 SQL 함수 를 직접 끼운다:

var monthExpr = Expressions.dateTemplate(LocalDate.class,
        "CAST(date_trunc('month', {0}) AS date)", settlement.settlementDate);
// 이후 select · groupBy · orderBy 에 monthExpr 재사용

DSL 이 못 감싸는 DB 고유 함수 도 이렇게 탈출구 가 있다. 완전히 갇히지 않는다는 게 실무 에서 중요.

8. 정산 다운 활용 — 대사(Reconciliation)

QueryDSL 이 진짜 로 빛나는 정산 특유 의 쿼리 — 불일치 탐지. payments.amount ≠ settlements.payment_amount 면 정산 생성 버그, 환불액 이 어긋나면 반영 누락:

.where(
    settlement.settlementDate.goe(startDate),
    settlement.settlementDate.loe(endDate),
    payment.amount.ne(settlement.paymentAmount)
        .or(payment.refundedAmount.ne(settlement.refundedAmount))
)

두 테이블 의 값 을 컬럼 대 컬럼 으로 비교 하는 조건 을 타입 안전 하게 표현 한다. 이런 대사 쿼리 를 문자열 로 관리 하면 유지보수 가 불가능 하다.

9. 동적 정렬 — switch 로 OrderSpecifier

정렬 기준 도 런타임 파라미터. OrderSpecifier[] 를 스위치 로 만든다 (2차 정렬 로 항상 id 를 붙여 정렬 안정성 확보):

return switch (sortBy) {
    case "amount" -> desc
        ? new OrderSpecifier[]{settlement.paymentAmount.desc(), settlement.id.desc()}
        : new OrderSpecifier[]{settlement.paymentAmount.asc(), settlement.id.asc()};
    default -> desc
        ? new OrderSpecifier[]{settlement.settlementDate.desc(), settlement.id.desc()}
        : new OrderSpecifier[]{settlement.settlementDate.asc(), settlement.id.asc()};
};

정리 — QueryDSL 이 정산 에 준 것

요구 QueryDSL 해법
컬럼 오타 방지 Q타입 → 컴파일 타임 검증
optional 8조건 검색 BooleanBuilder 동적 and
수백만 건 페이징 복합 커서 (date, id) → O(log n)
5테이블 조회 N+1 Projections.constructor 단일 쿼리 DTO
상태별 집계 Expressions.cases() + coalesce
DB 고유 함수 dateTemplate 탈출구
대사 불일치 컬럼 대 컬럼 ne().or()

그리고 이 모든 것 을 헥사고날 어댑터 안 에 가둬서 — 애플리케이션 코어 는 QueryDSL 을 모른다. 조회 기술 은 언제든 갈 수 있고, 도메인 은 그대로 남는다. 타입 안전 · 동적 · 프로젝션 이 세 가지 가, 문자열 JPQL 로는 못 짤 조회 계층 을 가능 하게 했다.


관련: 백엔드 설계 의 세 축