커서 기반 페이지네이션 (4/6)

이전 편: [스프링] 3. 커서 설계 전략

다음 편: [스프링] 5. 동적 정렬과 필터링

커서의 개념과 설계 전략을 이해했으니, 이제 QueryDSL로 실제 커서 조회를 구현한다. Spring Data JPA의 기본 기능으로는 커서 기반 쿼리를 만들 수 없기 때문에, 커스텀 Repository와 QueryDSL 조합이 필요하다.


커스텀 Repository 패턴

Spring Data JPA의 JpaRepositoryfindAll, findById 같은 기본 메서드만 제공한다. 커서 조건이 포함된 동적 쿼리를 실행하려면 커스텀 Repository를 만들어야 한다.

구조는 세 개의 파일로 나뉜다.

classDiagram direction BT class JpaRepository { <> +save() +findById() +findAll() } class FeedCustomRepository { <> +searchFeeds(condition) } class FeedRepository { <> } class FeedCustomRepositoryImpl { -JPAQueryFactory queryFactory +searchFeeds(condition) } FeedRepository --|> JpaRepository FeedRepository --|> FeedCustomRepository FeedCustomRepositoryImpl ..|> FeedCustomRepository

인터페이스 선언

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이다.

Impl 명명 규칙

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_atid를 조합해야 한다.

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)
graph TD Start([조회 시작]) --> IsCursorNull{커서가
존재하는가?} 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개 이하면 마지막 페이지다.

sequenceDiagram autonumber participant Client participant Service participant Repository participant DB Client->>Service: 피드 목록 요청 (limit=10) Service->>Repository: searchFeeds (limit=11) Repository->>DB: SELECT ... LIMIT 11 DB-->>Repository: 11개 데이터 반환 Repository-->>Service: List<Feed> (size=11) Note over Service: size(11) > limit(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 쿼리와의 비교

전체 건수가 필요한 경우에는 COUNT 쿼리를 별도로 날려야 한다. 하지만 "다음 페이지가 있는가?"만 판단하면 되는 무한 스크롤 UI에서는 limit+1이 훨씬 효율적이다.


fetchJoin과 N+1 방지

커서 조회에서도 N+1 문제를 신경 써야 한다. Feed를 조회할 때 작성자 정보나 날씨 정보가 함께 필요하면 fetchJoin을 걸어야 한다.

classDiagram direction LR class Feed { +UUID id +String content +Instant createdAt +User author +Weather weather } class User { +UUID id +String nickname } class Weather { +UUID id +SkyStatus sky } Feed "N" --> "1" User : fetchJoin 대상 Feed "N" --> "1" Weather : 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은 한 번의 쿼리로 연관 엔티티까지 함께 로딩한다.

컬렉션 fetchJoin 주의

@OneToMany 관계를 fetchJoin하면 데이터가 뻥튀기된다. limit과 함께 쓰면 메모리에서 페이징하게 되어 성능이 오히려 나빠진다. 컬렉션은 @BatchSize나 별도 쿼리로 처리하는 게 안전하다.


자주 하는 실수

limit 없이 커서 조회

커서 조건만 걸고 limit을 빠뜨리면 커서 이후의 모든 데이터를 한 번에 가져온다. 데이터가 많으면 OOM이 발생할 수 있다. 커서와 limit은 항상 함께 사용해야 한다.

[!DANGER] 정렬과 커서 조건의 불일치

ORDER BY created_at DESC인데 커서 조건이 created_at > :cursor이면, 이전 페이지 데이터가 다시 나온다. 정렬 방향과 비교 연산자가 반드시 짝을 이루는지 확인해야 한다.

[!DANGER] Impl 클래스에 @Repository 추가

FeedCustomRepositoryImpl@Repository를 붙이면 Spring이 두 번 등록을 시도해서 충돌이 날 수 있다. Spring Data JPA가 Impl 접미사로 자동 인식하므로 별도 어노테이션이 필요 없다.