Skip to the content.

QueryDSL Repository 가이드

목적: QueryDSL 기반 Repository 클래스 컨벤션 (Query 전용, 유연한 메서드 패턴)


1️⃣ 핵심 원칙

QueryDSL Repository 메서드 패턴

필수 메서드 (2개):

  1. findById(Long id) - 단건 조회 → Optional<Entity> 반환
  2. existsById(Long id) - 존재 여부 확인 → boolean 반환

허용 메서드 패턴: | 패턴 | 반환 타입 | 설명 | 예시 | |——|———-|——|——| | findBy* | Optional or List | 조건별 조회 | findByEmail, findByStatus | | existsBy* | boolean | 조건별 존재 확인 | existsByEmail | | search* | List | 복잡한 조건 조회 (Criteria) | searchOrders, searchProducts | | count* | long | 개수 조회 | countByStatus, countByCriteria |

금지 메서드:

규칙:

이유:


2️⃣ 기본 템플릿

package com.company.adapter.out.persistence.order.repository;

import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.company.adapter.out.persistence.order.entity.OrderJpaEntity;
import com.company.adapter.out.persistence.order.entity.QOrderJpaEntity;
import com.company.application.order.dto.query.SearchOrderQuery;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

/**
 * OrderQueryDslRepository - Order QueryDSL Repository
 *
 * <p>QueryDSL 기반 조회 쿼리를 처리하는 전용 Repository입니다.</p>
 *
 * <p><strong>필수 메서드 (2개):</strong></p>
 * <ul>
 *   <li>findById(Long id): 단건 조회 → Optional 반환</li>
 *   <li>existsById(Long id): 존재 여부 확인 → boolean 반환</li>
 * </ul>
 *
 * <p><strong>허용 메서드 패턴:</strong></p>
 * <ul>
 *   <li>findBy* → Optional 또는 List 반환</li>
 *   <li>existsBy* → boolean 반환</li>
 *   <li>search* → List 반환 (복잡한 조건 조회)</li>
 *   <li>count* → long 반환</li>
 * </ul>
 *
 * <p><strong>금지 사항:</strong></p>
 * <ul>
 *   <li>❌ findAll 금지 (OOM 위험)</li>
 *   <li>❌ Join 절대 금지 (fetch join, left join, inner join)</li>
 *   <li>❌ 비즈니스 로직 금지</li>
 *   <li>❌ Mapper 호출 금지</li>
 * </ul>
 *
 * @author Development Team
 * @since 1.0.0
 */
@Repository
public class OrderQueryDslRepository {

    private final JPAQueryFactory queryFactory;
    private static final QOrderJpaEntity qOrder = QOrderJpaEntity.orderJpaEntity;

    public OrderQueryDslRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    /**
     * ID로 Order 단건 조회
     *
     * @param id Order ID
     * @return OrderJpaEntity (Optional)
     */
    public Optional<OrderJpaEntity> findById(Long id) {
        return Optional.ofNullable(
            queryFactory.selectFrom(qOrder)
                .where(qOrder.id.eq(id))
                .fetchOne()
        );
    }

    /**
     * ID로 Order 존재 여부 확인
     *
     * @param id Order ID
     * @return 존재 여부
     */
    public boolean existsById(Long id) {
        Integer count = queryFactory
            .selectOne()
            .from(qOrder)
            .where(qOrder.id.eq(id))
            .fetchFirst();

        return count != null;
    }

    /**
     * 검색 조건으로 Order 목록 조회
     *
     * <p>Offset 페이징과 Cursor 페이징을 모두 지원합니다.</p>
     *
     * @param criteria 검색 조건 (SearchOrderQuery)
     * @return OrderJpaEntity 목록
     */
    public List<OrderJpaEntity> findByCriteria(SearchOrderQuery criteria) {
        var query = queryFactory
            .selectFrom(qOrder)
            .where(buildSearchConditions(criteria));

        // Cursor 페이징
        if (criteria.lastId() != null) {
            query = query.where(qOrder.id.gt(criteria.lastId()));
        }

        // Offset 페이징
        if (criteria.page() != null && criteria.size() != null) {
            query = query
                .offset((long) criteria.page() * criteria.size())
                .limit(criteria.size());
        } else if (criteria.size() != null) {
            // Cursor 전용 (size+1 조회)
            query = query.limit(criteria.size() + 1);
        }

        // 정렬
        if (criteria.sortBy() != null) {
            query = query.orderBy(buildOrderSpecifier(criteria));
        }

        return query.fetch();
    }

    /**
     * 검색 조건으로 Order 개수 조회
     *
     * @param criteria 검색 조건 (SearchOrderQuery)
     * @return Order 개수
     */
    public long countByCriteria(SearchOrderQuery criteria) {
        Long count = queryFactory
            .select(qOrder.count())
            .from(qOrder)
            .where(buildSearchConditions(criteria))
            .fetchOne();

        return count != null ? count : 0L;
    }

    /**
     * 검색 조건 구성 (Private 헬퍼 메서드)
     *
     * <p>BooleanExpression을 사용하여 동적 쿼리를 구성합니다.</p>
     */
    private BooleanExpression buildSearchConditions(SearchOrderQuery criteria) {
        BooleanExpression expression = null;

        // 조건 1: 주문 번호
        if (criteria.orderNumber() != null && !criteria.orderNumber().isBlank()) {
            expression = qOrder.orderNumber.containsIgnoreCase(criteria.orderNumber());
        }

        // 조건 2: 상태
        if (criteria.status() != null) {
            BooleanExpression statusCondition = qOrder.status.eq(criteria.status());
            expression = expression != null ? expression.and(statusCondition) : statusCondition;
        }

        // 조건 3: 날짜 범위
        if (criteria.startDate() != null) {
            BooleanExpression dateCondition = qOrder.createdAt.goe(criteria.startDate());
            expression = expression != null ? expression.and(dateCondition) : dateCondition;
        }

        if (criteria.endDate() != null) {
            BooleanExpression dateCondition = qOrder.createdAt.loe(criteria.endDate());
            expression = expression != null ? expression.and(dateCondition) : dateCondition;
        }

        return expression;
    }

    /**
     * 정렬 조건 구성 (Private 헬퍼 메서드)
     */
    private OrderSpecifier<?> buildOrderSpecifier(SearchOrderQuery criteria) {
        String sortBy = criteria.sortBy();
        boolean isAsc = "ASC".equalsIgnoreCase(criteria.sortDirection());

        return switch (sortBy.toLowerCase()) {
            case "id" -> isAsc ? qOrder.id.asc() : qOrder.id.desc();
            case "ordernumber" -> isAsc ? qOrder.orderNumber.asc() : qOrder.orderNumber.desc();
            case "status" -> isAsc ? qOrder.status.asc() : qOrder.status.desc();
            default -> isAsc ? qOrder.createdAt.asc() : qOrder.createdAt.desc();
        };
    }
}

3️⃣ 예시

✅ 올바른 예시

// ✅ 유연한 메서드 패턴
@Repository
public class OrderQueryDslRepository {
    private final JPAQueryFactory queryFactory;
    private static final QOrderJpaEntity qOrder = QOrderJpaEntity.orderJpaEntity;

    public OrderQueryDslRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    // ✅ 필수 1. 단건 조회
    public Optional<OrderJpaEntity> findById(Long id) {
        return Optional.ofNullable(
            queryFactory.selectFrom(qOrder)
                .where(qOrder.id.eq(id))
                .fetchOne()
        );
    }

    // ✅ 필수 2. 존재 여부 확인
    public boolean existsById(Long id) {
        Integer count = queryFactory
            .selectOne()
            .from(qOrder)
            .where(qOrder.id.eq(id))
            .fetchFirst();

        return count != null;
    }

    // ✅ 허용 패턴: findBy* (조건별 조회)
    public Optional<OrderJpaEntity> findByOrderNumber(String orderNumber) {
        return Optional.ofNullable(
            queryFactory.selectFrom(qOrder)
                .where(qOrder.orderNumber.eq(orderNumber))
                .fetchOne()
        );
    }

    // ✅ 허용 패턴: findBy* (목록 조회)
    public List<OrderJpaEntity> findByStatus(OrderStatus status) {
        return queryFactory.selectFrom(qOrder)
            .where(qOrder.status.eq(status))
            .fetch();
    }

    // ✅ 허용 패턴: existsBy* (조건별 존재 확인)
    public boolean existsByOrderNumber(String orderNumber) {
        Integer count = queryFactory.selectOne()
            .from(qOrder)
            .where(qOrder.orderNumber.eq(orderNumber))
            .fetchFirst();
        return count != null;
    }

    // ✅ 허용 패턴: search* (복잡한 조건 조회)
    public List<OrderJpaEntity> searchOrders(SearchOrderQuery criteria) {
        return queryFactory.selectFrom(qOrder)
            .where(buildConditions(criteria))
            .fetch();
    }

    // ✅ 허용 패턴: count* (개수 조회)
    public long countByStatus(OrderStatus status) {
        Long count = queryFactory.select(qOrder.count())
            .from(qOrder)
            .where(qOrder.status.eq(status))
            .fetchOne();
        return count != null ? count : 0L;
    }

    // ✅ Private 헬퍼 메서드
    private BooleanExpression buildConditions(SearchOrderQuery criteria) {
        // 동적 쿼리 구성
    }
}

❌ 위반 예시

// ❌ Join 사용 금지
@Repository
public class OrderQueryDslRepository {
    public List<OrderJpaEntity> findWithCustomer(Long customerId) {  // ❌ 추가 메서드
        return queryFactory.selectFrom(qOrder)
            .join(qCustomer).on(qOrder.customerId.eq(qCustomer.id))  // ❌ Join 금지
            .where(qCustomer.id.eq(customerId))
            .fetch();
    }
}

// ❌ findAll 금지 (OOM 위험)
@Repository
public class OrderQueryDslRepository {
    public List<OrderJpaEntity> findAll() {  // ❌ OOM 위험!
        return queryFactory.selectFrom(qOrder).fetch();
    }
}

// ❌ Fetch Join 금지
@Repository
public class ProductQueryDslRepository {
    public List<ProductJpaEntity> findWithCategory(Long categoryId) {
        return queryFactory.selectFrom(qProduct)
            .join(qProduct.category, qCategory).fetchJoin()  // ❌ Fetch Join 금지
            .where(qCategory.id.eq(categoryId))
            .fetch();
    }
}

// ❌ Mapper 호출 금지
@Repository
public class OrderQueryDslRepository {
    private final OrderJpaEntityMapper mapper;  // ❌

    public List<OrderDomain> findByCriteria(SearchOrderQuery criteria) {  // ❌
        List<OrderJpaEntity> entities = queryFactory.selectFrom(qOrder).fetch();
        return entities.stream()
            .map(mapper::toDomain)  // ❌ Adapter에서 처리
            .toList();
    }
}

// ❌ @Transactional 사용 금지
@Repository
@Transactional  // ❌ Service Layer에서 관리
public class OrderQueryDslRepository {
}

4️⃣ Join 정책 (일반 vs 관리자)

일반 QueryDslRepository: Join 금지

왜 Join을 금지하는가?

  1. N+1 문제는 Adapter에서 해결: QueryAdapter에서 Mapper로 변환할 때 추가 조회
  2. 빠른 개발: Join 없이 단순 쿼리만 작성
  3. 정확성 우선: 복잡한 Join 로직 실수 방지
  4. Aggregate 경계 유지: 각 Aggregate는 독립적으로 조회

관리자용 AdminQueryDslRepository: Join 허용

왜 관리자용은 Join을 허용하는가?

  1. 복잡한 리스트 조회: 관리자 화면은 여러 테이블 정보를 한 번에 표시
  2. 성능 우선: 각 Aggregate 별도 조회 → Application 조합은 비효율적
  3. DTO Projection: Entity가 아닌 DTO로 직접 조회하여 영속성 컨텍스트 부담 없음

네이밍 컨벤션:

일반:     *QueryDslRepository       (Join 금지, 4개 메서드)
관리자:   *AdminQueryDslRepository  (Join 허용, 자유로운 메서드)

N+1 해결 방법 (Adapter에서 - 일반용)

// ❌ QueryDslRepository에서 Join (금지!)
@Repository
public class OrderQueryDslRepository {
    public List<OrderJpaEntity> findWithCustomer(Long customerId) {
        return queryFactory.selectFrom(qOrder)
            .join(qCustomer).on(qOrder.customerId.eq(qCustomer.id))  // ❌
            .where(qCustomer.id.eq(customerId))
            .fetch();
    }
}

// ✅ QueryAdapter에서 N+1 해결
@Component
public class OrderQueryAdapter implements OrderQueryPort {
    private final OrderQueryDslRepository orderRepository;
    private final CustomerQueryDslRepository customerRepository;  // ✅
    private final OrderJpaEntityMapper mapper;

    @Override
    public List<OrderDomain> findByCriteria(SearchOrderQuery criteria) {
        // 1. Order 조회
        List<OrderJpaEntity> orders = orderRepository.findByCriteria(criteria);

        // 2. Customer ID 추출
        Set<Long> customerIds = orders.stream()
            .map(OrderJpaEntity::getCustomerId)
            .collect(Collectors.toSet());

        // 3. Customer 일괄 조회 (N+1 해결)
        Map<Long, CustomerJpaEntity> customerMap = customerRepository
            .findByIds(customerIds)
            .stream()
            .collect(Collectors.toMap(CustomerJpaEntity::getId, Function.identity()));

        // 4. Mapper로 변환 (Customer 정보 포함)
        return orders.stream()
            .map(order -> mapper.toDomain(order, customerMap.get(order.getCustomerId())))
            .toList();
    }
}

5️⃣ Query Adapter에서 사용

@Component
public class OrderQueryAdapter implements OrderQueryPort {

    private final OrderQueryDslRepository queryDslRepository;  // ✅ QueryDSL Repository
    private final OrderJpaEntityMapper mapper;

    public OrderQueryAdapter(
        OrderQueryDslRepository queryDslRepository,
        OrderJpaEntityMapper mapper
    ) {
        this.queryDslRepository = queryDslRepository;
        this.mapper = mapper;
    }

    @Override
    public Optional<OrderDomain> findById(OrderId orderId) {
        return queryDslRepository.findById(orderId.getValue())
            .map(mapper::toDomain);
    }

    @Override
    public boolean existsById(OrderId orderId) {
        return queryDslRepository.existsById(orderId.getValue());
    }

    @Override
    public List<OrderDomain> findByCriteria(SearchOrderQuery criteria) {
        List<OrderJpaEntity> entities = queryDslRepository.findByCriteria(criteria);
        return entities.stream()
            .map(mapper::toDomain)
            .toList();
    }

    @Override
    public long countByCriteria(SearchOrderQuery criteria) {
        return queryDslRepository.countByCriteria(criteria);
    }
}

6️⃣ 동적 쿼리 구성 패턴

BooleanExpression 조합

private BooleanExpression buildSearchConditions(SearchOrderQuery criteria) {
    BooleanExpression expression = null;

    // 조건 1: 주문 번호
    if (criteria.orderNumber() != null && !criteria.orderNumber().isBlank()) {
        expression = qOrder.orderNumber.containsIgnoreCase(criteria.orderNumber());
    }

    // 조건 2: 상태
    if (criteria.status() != null) {
        BooleanExpression statusCondition = qOrder.status.eq(criteria.status());
        expression = expression != null ? expression.and(statusCondition) : statusCondition;
    }

    // 조건 3: 날짜 범위
    if (criteria.startDate() != null) {
        BooleanExpression dateCondition = qOrder.createdAt.goe(criteria.startDate());
        expression = expression != null ? expression.and(dateCondition) : dateCondition;
    }

    return expression;
}

정렬 조건 구성

private OrderSpecifier<?> buildOrderSpecifier(SearchOrderQuery criteria) {
    String sortBy = criteria.sortBy() != null ? criteria.sortBy() : "createdAt";
    boolean isAsc = "ASC".equalsIgnoreCase(criteria.sortDirection());

    return switch (sortBy.toLowerCase()) {
        case "id" -> isAsc ? qOrder.id.asc() : qOrder.id.desc();
        case "ordernumber" -> isAsc ? qOrder.orderNumber.asc() : qOrder.orderNumber.desc();
        case "status" -> isAsc ? qOrder.status.asc() : qOrder.status.desc();
        default -> isAsc ? qOrder.createdAt.asc() : qOrder.createdAt.desc();
    };
}

7️⃣ 페이징 전략

Offset 페이징

public List<OrderJpaEntity> findByCriteria(SearchOrderQuery criteria) {
    var query = queryFactory
        .selectFrom(qOrder)
        .where(buildSearchConditions(criteria));

    // Offset 페이징
    if (criteria.page() != null && criteria.size() != null) {
        query = query
            .offset((long) criteria.page() * criteria.size())
            .limit(criteria.size());
    }

    return query.fetch();
}

Cursor 페이징

public List<OrderJpaEntity> findByCriteria(SearchOrderQuery criteria) {
    var query = queryFactory
        .selectFrom(qOrder)
        .where(buildSearchConditions(criteria));

    // Cursor 페이징
    if (criteria.lastId() != null) {
        query = query.where(qOrder.id.gt(criteria.lastId()));
    }

    // size+1 조회 (hasNext 판단용)
    if (criteria.size() != null) {
        query = query.limit(criteria.size() + 1);
    }

    return query.fetch();
}

8️⃣ Admin QueryDslRepository (관리자 전용)

핵심 원칙

규칙:

사용 케이스:

기본 템플릿

package com.company.adapter.out.persistence.order.repository;

import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.company.adapter.out.persistence.order.entity.QOrderJpaEntity;
import com.company.adapter.out.persistence.member.entity.QMemberJpaEntity;
import com.company.application.order.dto.query.AdminOrderListQuery;
import com.company.application.order.dto.response.AdminOrderResponse;
import org.springframework.stereotype.Repository;

import java.util.List;

/**
 * OrderAdminQueryDslRepository - 관리자 전용 QueryDSL Repository
 *
 * <p>관리자 화면에서 필요한 복잡한 조회를 담당합니다.</p>
 *
 * <p><strong>일반 QueryDslRepository와 차이점:</strong></p>
 * <ul>
 *   <li>✅ Join 허용 (Long FK 기반)</li>
 *   <li>✅ DTO Projection 직접 사용</li>
 *   <li>✅ 메서드 제한 없음</li>
 * </ul>
 *
 * @author Development Team
 * @since 1.0.0
 */
@Repository
public class OrderAdminQueryDslRepository {

    private final JPAQueryFactory queryFactory;

    private static final QOrderJpaEntity qOrder = QOrderJpaEntity.orderJpaEntity;
    private static final QMemberJpaEntity qMember = QMemberJpaEntity.memberJpaEntity;

    public OrderAdminQueryDslRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    /**
     * 관리자 주문 목록 조회 (Join + DTO Projection)
     *
     * <p>주문과 회원 정보를 한 번에 조회합니다.</p>
     */
    public List<AdminOrderResponse> findOrderListWithMember(AdminOrderListQuery criteria) {
        return queryFactory
            .select(Projections.constructor(
                AdminOrderResponse.class,
                qOrder.id,
                qOrder.orderNumber,
                qOrder.status,
                qOrder.totalAmount,
                qOrder.createdAt,
                qMember.id,
                qMember.name,
                qMember.email
            ))
            .from(qOrder)
            .leftJoin(qMember).on(qOrder.memberId.eq(qMember.id))  // ✅ Long FK 기반 조인
            .where(buildConditions(criteria))
            .orderBy(qOrder.createdAt.desc())
            .offset(criteria.offset())
            .limit(criteria.limit())
            .fetch();
    }

    /**
     * 관리자 주문 목록 개수 (Join 포함)
     */
    public long countOrderListWithMember(AdminOrderListQuery criteria) {
        Long count = queryFactory
            .select(qOrder.count())
            .from(qOrder)
            .leftJoin(qMember).on(qOrder.memberId.eq(qMember.id))
            .where(buildConditions(criteria))
            .fetchOne();

        return count != null ? count : 0L;
    }

    private BooleanExpression buildConditions(AdminOrderListQuery criteria) {
        BooleanExpression expression = null;

        // 주문 상태 필터
        if (criteria.status() != null) {
            expression = qOrder.status.eq(criteria.status());
        }

        // 회원명 검색 (Join된 테이블 조건)
        if (criteria.memberName() != null && !criteria.memberName().isBlank()) {
            BooleanExpression nameCondition = qMember.name.containsIgnoreCase(criteria.memberName());
            expression = expression != null ? expression.and(nameCondition) : nameCondition;
        }

        return expression;
    }
}

일반 vs 관리자 비교

항목 QueryDslRepository AdminQueryDslRepository
네이밍 *QueryDslRepository *AdminQueryDslRepository
Join ❌ 금지 ✅ 허용 (Long FK 기반)
메서드 수 4개 고정 자유
반환 타입 Entity DTO Projection 권장
사용처 일반 API 관리자 화면

9️⃣ 디렉토리 구조

adapter-out/persistence-mysql/
└─ src/main/java/
   └─ com/company/adapter/out/persistence/
       └─ order/
           ├─ entity/
           │  └─ OrderJpaEntity.java
           ├─ repository/
           │  ├─ OrderRepository.java              (JPA - Command)
           │  ├─ OrderQueryDslRepository.java      (QueryDSL - Query, 4개 메서드)
           │  ├─ OrderAdminQueryDslRepository.java ⭐ (Admin - Join 허용)
           │  └─ OrderLockRepository.java          ⭐ (Lock 전용)
           └─ adapter/
              ├─ OrderCommandAdapter.java          (JPA + Lock 사용)
              ├─ OrderQueryAdapter.java            (QueryDSL 사용)
              └─ OrderAdminQueryAdapter.java       ⭐ (Admin QueryDSL 사용)

🔟 체크리스트

QueryDSL Repository 작성 시:


📚 참고 문서


작성자: Development Team 최종 수정일: 2025-11-13 버전: 3.0.0 (유연한 메서드 패턴 + Join 금지)