커서의 개념과 설계 전략을 이해했으니, 이제 QueryDSL로 실제 커서 조회를 구현한다. Spring Data JPA의 기본 기능으로는 커서 기반 쿼리를 만들 수 없기 때문에, 커스텀 Repository와 QueryDSL 조합이 필요하다.
커스텀 Repository 패턴
Spring Data JPA의 JpaRepository는 findAll, findById 같은 기본 메서드만 제공한다. 커서 조건이 포함된 동적 쿼리를 실행하려면 커스텀 Repository를 만들어야 한다.
구조는 세 개의 파일로 나뉜다.
인터페이스 선언
public interface FeedCustomRepository {
List<Feed> searchFeeds(FeedSearchCondition condition);
}
커서 조회에 필요한 메서드를 선언하는 인터페이스다. 이름은 자유지만 관례적으로 Custom을 붙인다.
기존 FeedRepository에 이 인터페이스를 추가로 상속시킨다.
public interface FeedRepository
extends JpaRepository<Feed, UUID>, FeedCustomRepository {
}
이렇게 하면 서비스 레이어에서 FeedRepository 하나만 주입받아도 기본 CRUD와 커스텀 쿼리를 모두 사용할 수 있다.
구현 클래스
@RequiredArgsConstructor
public class FeedCustomRepositoryImpl implements FeedCustomRepository {
private final JPAQueryFactory queryFactory;
@Override
public List<Feed> searchFeeds(FeedSearchCondition condition) {
return queryFactory
.selectFrom(feed)
.join(feed.author, user).fetchJoin()
.join(feed.weather, weather).fetchJoin()
.where(
cursorCondition(condition.cursor(), condition.idAfter())
)
.orderBy(feed.createdAt.desc(), feed.id.desc())
.limit(condition.limit() + 1)
.fetch();
}
}
클래스 이름이 중요하다. 반드시 커스텀 인터페이스 이름 + Impl이어야 Spring Data JPA가 자동으로 구현체를 인식한다. FeedCustomRepository + Impl = FeedCustomRepositoryImpl이다.
Spring Data JPA는 Impl 접미사를 기준으로 구현체를 찾는다. 이름이 틀리면 빈 등록이 안 되고 No qualifying bean 에러가 발생한다. 오타에 주의하자.
커서 조건 구현
커서 조건은 BooleanExpression을 반환하는 메서드로 분리한다.
단순 커서
created_at만 사용하는 가장 기본적인 형태다.
private BooleanExpression cursorCondition(Instant cursor) {
if (cursor == null) {
return null;
}
return feed.createdAt.lt(cursor);
}
QueryDSL에서 WHERE 조건에 null을 넘기면 해당 조건이 무시된다. 커서가 없으면 첫 페이지를 반환하는 셈이다.
lt는 less than으로, DESC 정렬에서 "이전 페이지의 마지막 값보다 작은 것"을 의미한다. ASC 정렬이면 gt를 써야 한다.
복합 커서
이전 편에서 다룬 동점 문제를 해결하려면 created_at과 id를 조합해야 한다.
private BooleanExpression cursorCondition(Instant cursor, UUID idAfter) {
if (cursor == null || idAfter == null) {
return null;
}
return feed.createdAt.lt(cursor)
.or(feed.createdAt.eq(cursor)
.and(feed.id.lt(idAfter)));
}
SQL로 풀어쓰면 이렇다.
WHERE created_at < :cursor
OR (created_at = :cursor AND id < :idAfter)
존재하는가?} IsCursorNull -- No --> FirstPage[첫 페이지 조회
WHERE 조건 없음] IsCursorNull -- Yes --> CompositeCond{복합 커서
조건 적용} CompositeCond --> Cond1[createdAt < cursor] CompositeCond --> Cond2[createdAt == cursor
AND id < idAfter] Cond1 --> Result[BooleanExpression 반환] Cond2 --> Result Result --> End([쿼리 실행])
"커서 시각보다 이전이거나, 같은 시각이면 id가 더 작은 것"을 가져온다. ORDER BY created_at DESC, id DESC와 짝을 이루는 조건이다.
- DESC 정렬 → lt (다음 페이지는 더 작은 값)
- ASC 정렬 → gt (다음 페이지는 더 큰 값)
이 관계를 헷갈리면 데이터가 역순으로 나오거나 무한 루프에 빠진다.
limit + 1 패턴
다음 페이지가 있는지 확인하는 가장 효율적인 방법이다.
클라이언트가 10개를 요청하면 11개를 조회한다. 11개가 돌아왔으면 다음 페이지가 존재하는 것이고, 10개 이하면 마지막 페이지다.
hasNext = true 설정 Service->>Service: 마지막(11번째) 항목 제거 Service-->>Client: 10개 피드 + hasNext: true
.limit(condition.limit() + 1)
Service 레이어에서 11번째 항목을 제거하고 hasNext 플래그를 설정한다.
boolean hasNext = feeds.size() > request.limit();
if (hasNext) {
feeds.removeLast();
}
별도의 COUNT 쿼리 없이 다음 페이지 존재 여부를 알 수 있으므로 쿼리가 한 번으로 끝난다. 이때 커서 값은 제거한 여분의 항목이 아니라 반환된 페이지의 마지막 항목에서 가져와야 한다. 이 부분이 헷갈리면 커서는 북마크다를 참고한다.
전체 건수가 필요한 경우에는 COUNT 쿼리를 별도로 날려야 한다. 하지만 "다음 페이지가 있는가?"만 판단하면 되는 무한 스크롤 UI에서는 limit+1이 훨씬 효율적이다.
fetchJoin과 N+1 방지
커서 조회에서도 N+1 문제를 신경 써야 한다. Feed를 조회할 때 작성자 정보나 날씨 정보가 함께 필요하면 fetchJoin을 걸어야 한다.
return queryFactory
.selectFrom(feed)
.join(feed.author, user).fetchJoin()
.join(feed.weather, weather).fetchJoin()
.where(/* ... */)
.orderBy(feed.createdAt.desc(), feed.id.desc())
.limit(condition.limit() + 1)
.fetch();
join만 쓰면 SQL JOIN은 실행되지만 엔티티는 프록시로 남아서 접근 시점에 추가 쿼리가 발생한다. fetchJoin은 한 번의 쿼리로 연관 엔티티까지 함께 로딩한다.
@OneToMany 관계를 fetchJoin하면 데이터가 뻥튀기된다. limit과 함께 쓰면 메모리에서 페이징하게 되어 성능이 오히려 나빠진다. 컬렉션은 @BatchSize나 별도 쿼리로 처리하는 게 안전하다.
자주 하는 실수
커서 조건만 걸고 limit을 빠뜨리면 커서 이후의 모든 데이터를 한 번에 가져온다. 데이터가 많으면 OOM이 발생할 수 있다. 커서와 limit은 항상 함께 사용해야 한다.
[!DANGER] 정렬과 커서 조건의 불일치
ORDER BY created_at DESC인데 커서 조건이 created_at > :cursor이면, 이전 페이지 데이터가 다시 나온다. 정렬 방향과 비교 연산자가 반드시 짝을 이루는지 확인해야 한다.
[!DANGER] Impl 클래스에 @Repository 추가
FeedCustomRepositoryImpl에 @Repository를 붙이면 Spring이 두 번 등록을 시도해서 충돌이 날 수 있다. Spring Data JPA가 Impl 접미사로 자동 인식하므로 별도 어노테이션이 필요 없다.