QueryDSL Repository 가이드
목적: QueryDSL 기반 Repository 클래스 컨벤션 (Query 전용, 유연한 메서드 패턴)
1️⃣ 핵심 원칙
QueryDSL Repository 메서드 패턴
필수 메서드 (2개):
findById(Long id)- 단건 조회 →Optional<Entity>반환existsById(Long id)- 존재 여부 확인 →boolean반환
허용 메서드 패턴:
| 패턴 | 반환 타입 | 설명 | 예시 |
|——|———-|——|——|
| findBy* | Optional or List | 조건별 조회 | findByEmail, findByStatus |
| existsBy* | boolean | 조건별 존재 확인 | existsByEmail |
| search* | List | 복잡한 조건 조회 (Criteria) | searchOrders, searchProducts |
| count* | long | 개수 조회 | countByStatus, countByCriteria |
금지 메서드:
- ❌
findAll()- OOM 위험, search* 사용
규칙:
- ✅
@Repository클래스로 구현 - ✅
JPAQueryFactory생성자 주입 - ✅ QType을 static final 상수로 선언
- ✅ Join 절대 금지 (성능보다 정확성과 빠른 개발 우선)
- ✅ 동적 쿼리 (BooleanExpression)
- ❌ 비즈니스 로직 작성 금지
- ❌ Mapper 호출 금지 (Adapter에서)
- ❌ Transaction 관리 금지 (Service Layer에서)
- ❌ Join 사용 금지 (fetch join, left join, inner join 모두 금지)
이유:
- QueryDSL Repository는 Query 작업 (find, exists, count, search)만 담당
- 유연한 메서드 패턴으로 Application Layer QueryPort와 일관성 유지
- Join 금지로 복잡도 제거, N+1 문제는 Adapter에서 해결
- 타입 안전 쿼리로 컴파일 시점 검증
- 복잡한 동적 쿼리를 간결하게 표현
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을 금지하는가?
- N+1 문제는 Adapter에서 해결: QueryAdapter에서 Mapper로 변환할 때 추가 조회
- 빠른 개발: Join 없이 단순 쿼리만 작성
- 정확성 우선: 복잡한 Join 로직 실수 방지
- Aggregate 경계 유지: 각 Aggregate는 독립적으로 조회
관리자용 AdminQueryDslRepository: Join 허용
왜 관리자용은 Join을 허용하는가?
- 복잡한 리스트 조회: 관리자 화면은 여러 테이블 정보를 한 번에 표시
- 성능 우선: 각 Aggregate 별도 조회 → Application 조합은 비효율적
- 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 (관리자 전용)
핵심 원칙
규칙:
- ✅
*AdminQueryDslRepository네이밍 필수 - ✅ Join 허용 (Long FK 기반 명시적 조인)
- ✅ DTO Projection 권장 (Projections.constructor)
- ✅ 자유로운 메서드 정의 (4개 제한 없음)
- ❌ Entity 연관관계 어노테이션은 여전히 금지 (@OneToMany 등)
- ❌ Fetch Join 금지 (Entity 그래프 로딩 불필요)
사용 케이스:
- 관리자 주문 목록 (주문 + 회원 + 상품 정보)
- 관리자 대시보드 (복잡한 통계 쿼리)
- 리포트 생성 (다중 테이블 집계)
기본 템플릿
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 작성 시:
- 클래스 구조
@Repository어노테이션JPAQueryFactory생성자 주입- QType을 static final 상수로 선언
- 필수 메서드 (2개)
- findById(Long id) → Optional 반환
- existsById(Long id) → boolean 반환
- 허용 메서드 패턴
- findBy* → Optional 또는 List 반환
- existsBy* → boolean 반환
- search* → List 반환 (복잡한 조건 조회)
- count* → long 반환
- 쿼리 구성
- 동적 쿼리: private BooleanExpression 메서드
- 정렬 조건: private OrderSpecifier 메서드
- Offset/Cursor 페이징 지원
- 금지 사항
- findAll 금지 (OOM 위험)
- Join 절대 금지 (fetch join, left join, inner join)
- 비즈니스 로직 없음
- Mapper 호출 없음
- @Transactional 없음
📚 참고 문서
- jpa-repository-guide.md - JPA Repository 가이드
- lock-repository-guide.md - Lock Repository 가이드
- querydsl-repository-archunit.md - ArchUnit 규칙
- repository-test-guide.md - 테스트 가이드
- query-adapter-guide.md - QueryAdapter 가이드
- query-dto-guide.md - Query DTO 가이드
작성자: Development Team 최종 수정일: 2025-11-13 버전: 3.0.0 (유연한 메서드 패턴 + Join 금지)