프로젝트에서 목록 조회 조건이 늘어나기 시작하면, 처음에는 Repository 메서드 이름을 조금 더 길게 만들거나 JPQL 문자열을 하나 더 추가하는 방식으로 버틸 수 있습니다. 문제는 그 방식이 오래가지 않는다는 점입니다.
진행 중인 일정은 계속 보여줘야 했고, 완료된 일정 중에서도 코스형 일정은 목록에 남겨야 했습니다. 단순한 상태 필터가 아니라 상태 + 타입 조합이 조회 정책이 된 셈입니다. 이때 필요한 것은 “QueryDSL 문법을 쓰는 것”이 아니라, 제품 요구사항에서 나온 조건을 코드 단위로 분리하고 조합할 수 있는 구조였습니다.
문자열 쿼리는 조건이 늘어날수록 책임 위치가 흐려집니다
Spring Data JPA와 JPQL은 고정된 단순 조회에는 충분합니다. 예를 들어 상태 하나로 목록을 가져오거나, 특정 식별자로 단건을 찾는 정도라면 파생 메서드가 더 읽기 쉽습니다.
List<TripPlan> findByStatus(Status status);한계는 조건이 화면 정책과 함께 바뀌기 시작할 때 드러납니다.
@Query("SELECT tp FROM TripPlan tp WHERE tp.status = 'ONGOING'")
List<TripPlan> findAllOngoingTripPlans();
@Query("SELECT tp FROM TripPlan tp WHERE tp.status = 'ONGOING' OR (tp.status = 'COMPLETED' AND tp.tripPlanType = 'COURSE')")
List<TripPlan> findAllOngoingAndCompletedCourseTripPlans();이런 코드는 동작 자체가 문제라기보다, 시간이 지나면서 다음 비용을 만듭니다.
- 조건 변경 시 문자열 쿼리와 메서드 이름을 함께 추적해야 합니다.
- 같은 조건이 여러 조회에 복사되면, 어떤 쿼리가 최신 정책을 반영하는지 확인하기 어려워집니다.
- 필드명이나 enum 값 변경이 컴파일 단계에서 충분히 드러나지 않을 수 있습니다.
- 검색 조건이 optional로 늘어나면 문자열 조립 코드가 쿼리보다 더 복잡해집니다.
실제 문제는 “동적 쿼리”보다 “조건 조합의 소유권”이었습니다
당시 필요한 목록 조회는 아래 정책을 만족해야 했습니다.
- 진행 중인 일정은 목록에 노출한다.
- 완료된 일정이라도 코스형 일정은 목록에 남긴다.
- 이후 검색, 정렬, 페이징 조건이 붙어도 같은 정책을 유지한다.
문자열 JPQL로도 구현할 수는 있습니다. 하지만 조건이 화면 요구사항과 직접 연결되어 있다면, 그 조건이 어느 계층에서 만들어지고 어디서 재사용되는지가 더 중요해집니다. 서비스 계층마다 status, type, sort, page 조건을 조금씩 조합하기 시작하면, QueryDSL을 쓰더라도 책임이 흩어지는 문제는 그대로 남습니다.
그래서 먼저 기준을 세웠습니다.
| 구분 | 유지한 방식 | QueryDSL을 적용한 기준 |
|---|---|---|
| 단순 단건 조회 | Spring Data JPA 파생 메서드 | 도입하지 않음 |
| 고정 조건 목록 | 파생 메서드 또는 JPQL | 조건 재사용이 생기면 검토 |
| 상태와 타입 조합 | Repository 구현체 내부에서 관리 | QueryDSL 적용 |
| 검색·정렬·페이징 조합 | 조건 메서드로 분리 | QueryDSL 적용 |
이 기준을 두면 QueryDSL은 “모든 조회를 바꾸는 도구”가 아니라, 복잡해진 조회 정책을 Repository 경계 안에 모아두는 도구가 됩니다.
QueryDSL은 조건을 이름 붙일 수 있게 해줍니다
QueryDSL을 적용하고 가장 크게 달라진 지점은 쿼리를 Java 코드로 쓴다는 사실 자체가 아니었습니다. 조건을 변수와 메서드로 나누고, 조합 방식을 코드에서 바로 읽을 수 있게 된 점이었습니다.

public class TripPlanRepositoryImpl implements TripPlanRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<TripPlan> findVisiblePlansForList(Long memberId) {
QTripPlan tripPlan = QTripPlan.tripPlan;
BooleanExpression owner = tripPlan.member.id.eq(memberId);
BooleanExpression ongoing = tripPlan.status.eq(Status.ONGOING);
BooleanExpression completedCourse = tripPlan.status.eq(Status.COMPLETED)
.and(tripPlan.tripPlanType.eq(TripPlanType.COURSE));
return queryFactory
.selectFrom(tripPlan)
.where(owner, ongoing.or(completedCourse))
.orderBy(tripPlan.createdAt.desc())
.fetch();
}
}이 구조에서는 조회 정책이 문자열 안에 묻히지 않습니다.
ongoing은 진행 중인 일정을 의미합니다.completedCourse는 완료됐지만 목록에 남길 코스형 일정을 의미합니다.ongoing.or(completedCourse)는 화면에 노출할 일정의 정책을 그대로 표현합니다.
조건이 늘어나도 의미 단위로 분리할 수 있기 때문에, 변경할 때 확인해야 할 범위가 줄어듭니다.
Q 클래스는 오타 방지보다 타입을 유지하는 데 더 의미가 있었습니다
QueryDSL은 엔티티를 기준으로 Q 클래스를 생성합니다.
public class QTripPlan extends EntityPathBase<TripPlan> {
public static final QTripPlan tripPlan = new QTripPlan("tripPlan");
public final EnumPath<Status> status = createEnum("status", Status.class);
public final EnumPath<TripPlanType> tripPlanType = createEnum("tripPlanType", TripPlanType.class);
public final DateTimePath<LocalDateTime> createdAt = createDateTime("createdAt", LocalDateTime.class);
}자주 언급되는 장점은 필드명 오타를 컴파일 시점에 잡을 수 있다는 점입니다. 하지만 실제로 더 체감된 장점은 조건을 조합하는 동안 필드 타입이 계속 유지된다는 점이었습니다.
예를 들어 status는 enum 비교만 허용되고, createdAt은 날짜 정렬과 비교 메서드를 제공합니다. 문자열로 쿼리를 조립할 때처럼 “필드명은 맞지만 값 타입이 어긋난 상태”를 런타임까지 끌고 갈 가능성이 줄어듭니다.
BooleanExpression과 BooleanBuilder는 목적을 나눠서 사용했습니다
동적 조건을 만들 때는 보통 BooleanExpression과 BooleanBuilder 중 하나를 사용합니다. 둘 중 하나가 항상 더 낫다기보다, 조건을 어떤 방식으로 관리할지에 따라 선택이 달라집니다.
재사용할 조건은 BooleanExpression으로 분리합니다
공통 조건은 작은 메서드로 분리하는 편이 읽기 좋았습니다.
private BooleanExpression requiredOwnerEq(Long memberId) {
if (memberId == null) {
throw new IllegalArgumentException("memberId is required for this query");
}
return tripPlan.member.id.eq(memberId);
}
private BooleanExpression statusEq(Status status) {
return status != null ? tripPlan.status.eq(status) : null;
}
private BooleanExpression visibleOnList() {
return tripPlan.status.eq(Status.ONGOING)
.or(tripPlan.status.eq(Status.COMPLETED)
.and(tripPlan.tripPlanType.eq(TripPlanType.COURSE)));
}QueryDSL의 where는 null 조건을 무시할 수 있기 때문에, optional 필터를 자연스럽게 다룰 수 있습니다.
return queryFactory
.selectFrom(tripPlan)
.where(requiredOwnerEq(memberId), visibleOnList())
.fetch();다만 이 편의성은 조심해서 써야 했습니다. 선택 필터가 null이라서 빠지는 것은 자연스럽지만, 반드시 들어가야 하는 tenant, owner, visibility 조건이 실수로 null이 되면 조회 범위가 조용히 넓어질 수 있습니다. 그래서 optional 조건과 required 조건을 같은 방식으로 다루지 않았습니다. optional helper는 null을 무시할 수 있지만, 조회 경계를 결정하는 값은 null이면 예외로 실패하게 두는 편이 안전했습니다.
입력 조합이 많은 검색은 BooleanBuilder가 더 단순할 수 있습니다
반대로 입력 필터가 많고, 분기 자체가 중요한 검색 화면에서는 BooleanBuilder가 더 직관적일 때도 있습니다.
BooleanBuilder builder = new BooleanBuilder();
if (status != null) {
builder.and(tripPlan.status.eq(status));
}
if (from != null) {
builder.and(tripPlan.createdAt.goe(from));
}
if (to != null) {
builder.and(tripPlan.createdAt.lt(to));
}| 선택 기준 | 권장 방식 |
|---|---|
| 여러 조회에서 반복되는 정책 조건 | BooleanExpression |
| 입력값 유무에 따라 분기가 많은 검색 조건 | BooleanBuilder |
| 서비스에서 의미를 호출해야 하는 조건 | Repository 메서드로 감싸기 |
Repository 경계를 먼저 정하지 않으면 QueryDSL도 흩어집니다
QueryDSL은 자유도가 높습니다. 그래서 서비스 계층에서 직접 조건을 만들기 시작하면, 문자열 JPQL을 쓰던 때와 다른 형태로 같은 문제가 반복됩니다.
나쁜 방향은 서비스가 조회 조건의 세부 구현을 알고 있는 구조입니다.
// 서비스 계층이 조회 정책의 세부 조건을 직접 조립하는 형태
BooleanExpression condition = tripPlan.status.eq(Status.ONGOING)
.or(tripPlan.status.eq(Status.COMPLETED)
.and(tripPlan.tripPlanType.eq(TripPlanType.COURSE)));이 방식은 처음에는 빠르지만, 화면 정책이 바뀔 때 서비스와 Repository를 함께 뒤져야 합니다. 그래서 서비스는 “무엇을 조회할지”만 호출하고, Repository가 “어떤 조건으로 가져올지”를 책임지도록 경계를 잡았습니다.
public interface TripPlanRepositoryCustom {
List<TripPlan> findVisiblePlansForList(Long memberId);
}이름은 단순하지만, 효과는 큽니다. 화면 정책은 findVisiblePlansForList라는 의도로 드러나고, 상태와 타입 조합은 Repository 구현체 안에 갇힙니다.
목록 조회에서는 content query와 count query를 분리해서 봐야 합니다
페이지 기반 목록 조회에서는 화면에 보여줄 데이터와 전체 개수를 함께 다루는 경우가 많습니다. 이때 content query와 count query를 같은 구조로 두면, 실제 목록보다 count가 더 비싼 쿼리가 될 수 있습니다.
특히 fetch join, 동적 where 절, 정렬이 섞이면 count query에는 불필요한 조인이 남기 쉽습니다. QueryDSL을 쓴다고 이 문제가 자동으로 사라지지는 않습니다.
List<TripPlanSummary> content = queryFactory
.select(new QTripPlanSummary(
tripPlan.id,
tripPlan.title,
tripPlan.status,
tripPlan.createdAt
))
.from(tripPlan)
.where(requiredOwnerEq(memberId), visibleOnList())
.orderBy(tripPlan.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(tripPlan.count())
.from(tripPlan)
.where(requiredOwnerEq(memberId), visibleOnList())
.fetchOne();이 구조에서 content query는 화면에 필요한 값과 정렬에 집중하고, count query는 조건을 만족하는 row 수만 세도록 단순하게 유지합니다. 무한 스크롤이라면 전체 count 대신 limit + 1 방식으로 다음 페이지 존재 여부만 확인하는 편도 검토할 수 있습니다.
Projection은 읽기 모델을 선명하게 만들 때만 사용했습니다
목록 API에서는 엔티티 전체가 필요하지 않은 경우가 많습니다. 이때 projection을 사용하면 필요한 컬럼만 조회하고, 응답 모델도 더 명확해집니다.
public record TripPlanSummary(
Long id,
String title,
Status status,
LocalDateTime createdAt
) {}다만 projection도 남용하면 화면별 DTO가 과도하게 늘어나고, 조회 쿼리와 응답 모델이 지나치게 결합될 수 있습니다. 그래서 기준을 단순하게 잡았습니다.
- 읽기 전용 목록이고 도메인 로직이 필요 없다면 projection을 사용합니다.
- 상태 변경이나 도메인 판단이 필요하면 엔티티를 조회합니다.
- projection은 API 응답을 위해 필요한 범위 안에서만 둡니다.
이 기준을 두면 projection은 성능 최적화 장치이면서 동시에 읽기 모델을 선명하게 만드는 도구가 됩니다.
QueryDSL 도입 후에도 확인해야 할 것은 실행 계획이었습니다
QueryDSL로 바꿨다고 쿼리가 자동으로 빨라지는 것은 아닙니다. 바뀐 것은 쿼리를 만드는 방식이지, DB가 데이터를 찾는 방식이 아닙니다.
도입 후에는 다음 지점을 함께 확인했습니다.
- where 절에 자주 등장하는 조건이 실제 인덱스 순서와 맞는지
- 정렬 기준이 인덱스와 충돌하지 않는지
- count query에 불필요한 join이나 order by가 남지 않았는지
- projection으로 줄인 컬럼이 실제 네트워크·객체 생성 비용을 줄이는지
- optional 필터 조합에서 특정 조건만 유독 느려지지 않는지
특히 조건 메서드가 늘어나면 코드만 보고는 “읽기 좋아졌다”고 느끼기 쉽습니다. 하지만 실제 요청에서는 특정 필터 조합이 인덱스를 타지 못하거나, count query가 content query보다 더 비싸지는 경우가 생길 수 있습니다. 그래서 QueryDSL 전환 후에도 실행 계획과 실제 응답 시간을 함께 봐야 했습니다.
도입 후 남은 기준
QueryDSL을 선택한 이유는 JPQL을 모두 대체하기 위해서가 아니었습니다. 단순 조회는 기존 Spring Data JPA 방식이 더 가볍고 명확했습니다.
다만 상태 + 타입처럼 제품 정책이 조회 조건으로 들어오고, 그 조건이 검색·정렬·페이징과 함께 커지기 시작하면 문자열 쿼리만으로는 책임 위치를 유지하기 어렵습니다. 이 지점에서 QueryDSL은 복잡한 조건을 코드로 조합하고, Repository 경계 안에서 조회 의도를 보존하게 해주는 정리 도구가 됐습니다.
전환 후 남긴 기준은 세 가지였습니다.
- 서비스는 조회 정책의 의도를 호출하고, 세부 조건 조립은 Repository 구현체 안에 둡니다.
- optional 조건과 required 조건을 구분해, 필수 조회 경계가
null로 조용히 사라지지 않게 합니다. - QueryDSL 코드가 읽기 좋아졌더라도, 자주 쓰는 조건 조합은 실행 계획과 인덱스 기준으로 다시 확인합니다.
이 기준이 없으면 QueryDSL도 문자열 JPQL의 문제를 다른 형태로 반복할 수 있습니다. 조건 메서드가 많아질수록 코드는 읽기 좋아 보이지만, 필수 조건이 null 처리에 묻히거나 count query가 더 비싸지는 문제는 그대로 남을 수 있습니다. 남는 검증 지점은 QueryDSL 도입 여부가 아니라, 조회 정책이 어느 경계에서 만들어지고 자주 쓰는 조건 조합이 실행 계획과 인덱스 기준으로 다시 확인되는가입니다.