QueryDSL 가이드 (8/8)

이전 편: [QueryDSL] 7. 성능 최적화

이 문서가 시리즈의 마지막 편입니다.

시리즈의 마지막 편이다. 지금까지 배운 내용을 종합하여 실무에서 자주 쓰는 패턴을 정리하고, 면접에서 나올 수 있는 질문을 다룬다.

graph LR SC["SearchCondition
(record)"] --> QR["Repository
조건 메서드"] QR --> QF["queryFactory
.where(조건...)"] QF --> JPQL["JPQL 생성"] JPQL --> SQL["SQL 변환
(Hibernate)"] SQL --> DB[(Database)]

검색 조건 클래스 설계

동적 쿼리 편에서 잠깐 다뤘던 검색 조건 객체를 실무 수준으로 설계한다.

record로 불변 설계

public record FeedSearchCondition(
        String title,
        String region,
        Long userId,
        Instant cursorCreatedAt,
        Long cursorId,
        int size
) {
    public FeedSearchCondition {
        if (size <= 0 || size > 100) {
            size = 20;  // 기본값과 상한선
        }
    }
}

검색 조건은 불변이어야 한다. 한번 생성된 뒤 값이 바뀌면 안 된다. record 클래스를 사용하면 불변이 보장되고, 컴팩트 생성자에서 기본값과 유효성 검증을 처리할 수 있다.

조건 메서드를 Repository에 배치

조건 메서드는 Repository 구현체의 private 메서드로 둔다.

@RequiredArgsConstructor
public class FeedRepositoryImpl implements FeedRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Feed> search(FeedSearchCondition condition) {
        return queryFactory
                .selectFrom(feed)
                .join(feed.user, user).fetchJoin()
                .where(
                        titleContains(condition.title()),
                        regionEq(condition.region()),
                        userIdEq(condition.userId()),
                        cursorCondition(condition.cursorCreatedAt(), condition.cursorId())
                )
                .orderBy(feed.createdAt.desc(), feed.id.desc())
                .limit(condition.size() + 1)
                .fetch();
    }

    private BooleanExpression titleContains(String title) {
        return title != null ? feed.title.contains(title) : null;
    }

    private BooleanExpression regionEq(String region) {
        return region != null ? feed.region.eq(region) : null;
    }

    private BooleanExpression userIdEq(Long userId) {
        return userId != null ? feed.user.id.eq(userId) : null;
    }

    private BooleanExpression cursorCondition(Instant cursorCreatedAt, Long cursorId) {
        if (cursorCreatedAt == null || cursorId == null) {
            return null;
        }
        return feed.createdAt.lt(cursorCreatedAt)
                .or(feed.createdAt.eq(cursorCreatedAt).and(feed.id.lt(cursorId)));
    }
}

limit(condition.size() + 1)에서 1을 더하는 이유는, 다음 페이지가 있는지 확인하기 위함이다. size + 1개를 가져왔으면 다음 페이지가 존재하고, 그 이하면 마지막 페이지다. 이 패턴은 QueryDSL 커서 조회 구현에서 자세히 다룬다.

커서 기반 페이지네이션 패턴

offset 페이징 대신 마지막으로 조회한 데이터의 위치를 기준으로 다음 페이지를 가져오는 방식이다.

단일 정렬 기준

정렬 기준이 유니크하면 구현이 단순하다.

private BooleanExpression cursorCondition(Long cursorId) {
    return cursorId != null ? feed.id.lt(cursorId) : null;
}

ID 내림차순 정렬에서, 마지막으로 본 ID보다 작은 것만 가져오면 된다.

복합 정렬 기준

정렬 기준이 유니크하지 않으면, 보조 정렬 기준이 필요하다. 예를 들어 createdAt 내림차순으로 정렬하는데, 같은 시간에 여러 건이 생성될 수 있다면 ID를 보조 기준으로 쓴다.

private BooleanExpression cursorCondition(Instant cursorCreatedAt, Long cursorId) {
    if (cursorCreatedAt == null || cursorId == null) {
        return null;
    }
    // createdAt이 커서보다 이전이거나,
    // createdAt이 같으면 id가 커서보다 작은 것
    return feed.createdAt.lt(cursorCreatedAt)
            .or(feed.createdAt.eq(cursorCreatedAt).and(feed.id.lt(cursorId)));
}
graph LR subgraph "커서 조건" C1["createdAt < cursor"] --> OR(OR) C2["createdAt = cursor
AND id < cursorId"] --> OR end OR --> R["다음 페이지 데이터"]

이 조건은 SQL의 (createdAt, id) < (cursorCreatedAt, cursorId)와 동일한 효과다. 커서 기반 페이지네이션의 설계와 구현에 대한 자세한 내용은 커서 기반 페이지네이션 시리즈를 참고한다.

동적 정렬 처리

API에서 정렬 기준을 파라미터로 받아야 할 때가 있다. Spring의 SortPageable에서 넘어온 정렬 조건을 QueryDSL의 OrderSpecifier로 변환해야 한다.

private OrderSpecifier<?>[] getOrderSpecifiers(Sort sort) {
    return sort.stream()
            .map(order -> {
                Order direction = order.isAscending() ? Order.ASC : Order.DESC;
                return switch (order.getProperty()) {
                    case "createdAt" -> new OrderSpecifier<>(direction, feed.createdAt);
                    case "title" -> new OrderSpecifier<>(direction, feed.title);
                    case "viewCount" -> new OrderSpecifier<>(direction, feed.viewCount);
                    default -> new OrderSpecifier<>(Order.DESC, feed.createdAt);
                };
            })
            .toArray(OrderSpecifier[]::new);
}
graph LR S["Sort(Pageable)"] -->|"createdAt,desc"| SW{switch} SW -->|createdAt| O1["OrderSpecifier
feed.createdAt.desc()"] SW -->|title| O2["OrderSpecifier
feed.title.asc()"] SW -->|unknown| O3["기본값
feed.createdAt.desc()"] O1 --> OB["orderBy(...)"] O2 --> OB O3 --> OB

이 메서드를 orderBy에 전달한다.

queryFactory
        .selectFrom(feed)
        .orderBy(getOrderSpecifiers(pageable.getSort()))
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();
문자열 기반 정렬의 위험

클라이언트가 보낸 정렬 필드 문자열을 그대로 Path로 변환하는 것은 위험하다. 존재하지 않는 필드를 넣으면 런타임 에러가 발생하고, 인덱스가 없는 컬럼으로 정렬하면 성능 문제가 생긴다. 위 예시처럼 허용된 필드만 switch로 매핑하는 것이 안전하다.

면접 Q&A

graph LR QD[QueryDSL] -->|생성| JPQL[JPQL] JPQL -->|변환| SQL[SQL] QD -.->|"제약 상속"| JPQL JPQL -.->|"FROM 서브쿼리 불가"| SQL
QueryDSL을 사용하는 이유가 무엇인가요?

타입 안전성동적 쿼리가 핵심이다. JPQL은 문자열이라 컴파일 타임에 검증이 안 되고, Criteria API는 타입 안전하지만 가독성이 매우 떨어진다. QueryDSL은 Java 코드로 쿼리를 작성하므로 컴파일 타임에 오류를 잡을 수 있고, IDE 자동완성도 지원한다. 동적 쿼리도 BooleanExpression을 활용하면 깔끔하게 작성할 수 있다.

[!QUESTION] Q클래스는 언제 생성되나요? 직접 수정해도 되나요?

Q클래스는 빌드 시점에 어노테이션 프로세서가 자동 생성한다. 직접 수정하면 안 된다. 다음 빌드 시 덮어써지기 때문이다. Q클래스를 바꾸고 싶으면 원본 엔티티를 수정해야 한다. 생성된 Q클래스는 버전 관리(git)에 포함하지 않는 것이 관례다.

[!QUESTION] JPAQueryFactory를 싱글톤으로 사용해도 동시성 문제가 없나요?

없다. 스프링이 주입하는 EntityManager는 프록시 객체다. 실제 메서드 호출 시 현재 트랜잭션에 바인딩된 실제 EntityManager에 위임한다. 각 트랜잭션이 독립된 영속성 컨텍스트를 사용하므로, 여러 스레드가 같은 JPAQueryFactory를 사용해도 안전하다.

[!QUESTION] BooleanBuilder와 BooleanExpression 중 어떤 것을 써야 하나요?

BooleanExpression을 반환하는 메서드 분리 방식이 권장된다. 조건을 재사용할 수 있고, where의 가변인자에 null을 전달하면 자동으로 무시되므로 동적 쿼리가 깔끔해진다. BooleanBuilder는 OR 조건이 복잡하게 중첩될 때 보조적으로 사용한다.

[!QUESTION] fetchResults()와 fetchCount()가 deprecated된 이유는?

복잡한 쿼리(다중 그룹핑, 서브쿼리 등)에서 자동 생성되는 count 쿼리가 정확하지 않은 결과를 반환하는 경우가 있었기 때문이다. 공식적으로 count 쿼리를 직접 작성하는 것을 권장한다.

[!QUESTION] QueryDSL은 FROM 절 서브쿼리를 지원하나요?

지원하지 않는다. QueryDSL은 JPQL 위에서 동작하고, JPQL 자체가 FROM 절 서브쿼리를 지원하지 않기 때문이다. 대안으로는 쿼리를 분리하거나, 네이티브 쿼리를 사용하거나, DB 뷰를 활용할 수 있다. 하이버네이트 6.1부터 FROM 절 서브쿼리를 지원하지만, QueryDSL API에서는 아직 사용할 수 없다.

[!QUESTION] (함정) QueryDSL이 SQL을 직접 생성하나요?

아니다. QueryDSL은 JPQL을 생성하고, JPQL은 하이버네이트(JPA 구현체)가 SQL로 변환한다. QueryDSL → JPQL → SQL 순서로 변환된다. 그래서 JPQL의 제약(FROM 절 서브쿼리 불가 등)을 그대로 받는다.

[!QUESTION] (함정) fetch join을 사용하면 무조건 성능이 좋아지나요?

아니다. 연관 엔티티에 실제로 접근할 때만 fetch join이 유리하다. 접근하지 않는 연관 엔티티까지 fetch join하면 오히려 불필요한 데이터를 전송하는 셈이다. 또한 컬렉션 fetch join은 페이징과 함께 사용할 수 없고, 둘 이상의 컬렉션을 동시에 fetch join할 수 없다. 상황에 맞게 fetch join, @BatchSize, 별도 쿼리 분리를 선택해야 한다.

[!QUESTION] (함정) 벌크 연산과 일반 JPA 변경 감지의 차이는?

변경 감지는 영속성 컨텍스트가 엔티티의 변경을 추적하여, 트랜잭션 커밋 시 자동으로 UPDATE 쿼리를 생성한다. 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리한다. 그래서 벌크 연산 후에는 영속성 컨텍스트와 DB의 상태가 불일치할 수 있고, entityManager.clear()로 캐시를 초기화해야 한다.