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

이전 편: [스프링] 4. QueryDSL 커서 조회 구현

다음 편: [스프링] 6. API 통합과 면접 대비

실제 API에서는 "최신순", "좋아요순" 같은 정렬 옵션과 "맑음 필터", "키워드 검색" 같은 필터가 함께 들어온다. 커서 조회에 이런 동적 조건을 어떻게 결합하는지 다룬다.

SortBy Enum 설계

정렬 기준을 문자열로 받으면 오타나 잘못된 값이 들어올 수 있다. Enum으로 정의하면 컴파일 타임에 안전하게 관리할 수 있다.

@Getter
@RequiredArgsConstructor
public enum SortBy {
    CREATED_AT("createdAt"),
    LIKE_COUNT("likeCount");

    private final String value;
}

각 enum 상수에 실제 엔티티 필드명을 매핑해둔다. QueryDSL에서 동적 정렬을 만들 때 이 값을 사용한다.

classDiagram direction LR class SortBy { <> CREATED_AT LIKE_COUNT +getValue() String } class FeedEntity { +createdAt Instant +likeCount Long +id UUID } SortBy ..> FeedEntity : "Maps to Field"

SortDirection은 단순히 방향만 나타내면 된다.

public enum SortDirection {
    ASCENDING,
    DESCENDING
}

동적 OrderSpecifier 생성

QueryDSL의 orderByOrderSpecifier를 받는다. 클라이언트가 보낸 SortBySortDirection에 따라 런타임에 정렬 조건을 만들어야 한다.

private OrderSpecifier<?> createOrderSpecifier(
        SortBy sortBy, SortDirection direction) {

    Order order = direction == SortDirection.ASCENDING
            ? Order.ASC : Order.DESC;

    return switch (sortBy) {
        case CREATED_AT -> new OrderSpecifier<>(order, feed.createdAt);
        case LIKE_COUNT -> new OrderSpecifier<>(order, feed.likeCount);
    };
}

switch 표현식으로 enum 값마다 Q클래스의 경로를 매핑한다. 새로운 정렬 기준이 추가되면 enum 상수와 case만 추가하면 된다.

실제 쿼리에서는 보조 정렬을 항상 함께 건다.

return queryFactory
        .selectFrom(feed)
        .where(/* ... */)
        .orderBy(
                createOrderSpecifier(condition.sortBy(), condition.sortDirection()),
                new OrderSpecifier<>(Order.DESC, feed.id)
        )
        .limit(condition.limit() + 1)
        .fetch();

보조 정렬로 id를 추가하는 이유는 1차 정렬 값이 동일한 행이 있을 때 순서를 보장하기 위해서다.

flowchart TD Start([정렬 조건 생성]) --> Params[SortBy & SortDirection 수신] Params --> Primary[1차 정렬: OrderSpecifier 생성
Enum 매핑 필드 적용] Primary --> Secondary[2차 정렬: 고유 ID 보조 정렬
feed.id DESC 고정] Secondary --> Final[OrderBy 절에 순차적으로 적용]

커서 조건과 정렬의 연동

정렬 기준이 바뀌면 커서 조건도 함께 바뀌어야 한다. created_at 기준 정렬이면 커서도 created_at으로, like_count 기준이면 커서도 like_count로 비교해야 한다.

private BooleanExpression cursorCondition(
        SortBy sortBy, SortDirection direction,
        String cursor, UUID idAfter) {

    if (cursor == null || idAfter == null) {
        return null;
    }

    return switch (sortBy) {
        case CREATED_AT -> timeCursorCondition(
                Instant.parse(cursor), idAfter, direction);
        case LIKE_COUNT -> countCursorCondition(
                Long.parseLong(cursor), idAfter, direction);
    };
}

각 정렬 기준별로 커서 조건 메서드를 분리하면 가독성이 좋아진다.

private BooleanExpression timeCursorCondition(
        Instant cursor, UUID idAfter, SortDirection direction) {

    if (direction == SortDirection.DESCENDING) {
        return feed.createdAt.lt(cursor)
                .or(feed.createdAt.eq(cursor)
                        .and(feed.id.lt(idAfter)));
    }
    return feed.createdAt.gt(cursor)
            .or(feed.createdAt.eq(cursor)
                    .and(feed.id.gt(idAfter)));
}

정렬 방향에 따라 비교 연산자가 바뀌는 것에 주의하자. DESClt, ASCgt다.

flowchart TD Start{커서 데이터 비교} --> Main{1차 기준 비교
createdAt/likeCount} Main -- "기준값 미만 (lt)" --> Accept([데이터 포함]) Main -- "기준값 동일 (eq)" --> Sub{2차 기준 비교
feed.id} Sub -- "ID 미만 (lt)" --> Accept Sub -- "ID 이상 (ge)" --> Reject([데이터 제외]) Main -- "기준값 초과 (gt)" --> Reject
정렬 기준과 커서 조건은 반드시 짝을 이뤄야 한다

like_count로 정렬하면서 커서 조건은 created_at으로 거는 실수가 흔하다. 정렬 기준이 바뀌면 커서 조건의 비교 대상도 반드시 바꿔야 한다.

동적 필터 조건

필터는 BooleanExpression을 반환하는 메서드로 각각 분리한다. 값이 null이면 null을 반환해서 조건을 무시한다.

private BooleanExpression keywordLike(String keyword) {
    if (keyword == null || keyword.isBlank()) {
        return null;
    }
    return feed.content.containsIgnoreCase(keyword);
}

private BooleanExpression skyStatusEqual(SkyStatus skyStatus) {
    if (skyStatus == null) {
        return null;
    }
    return feed.weather.skyStatus.eq(skyStatus);
}

private BooleanExpression precipitationTypeEqual(PrecipitationType type) {
    if (type == null) {
        return null;
    }
    return feed.weather.precipitationType.eq(type);
}

private BooleanExpression authorIdEqual(UUID authorId) {
    if (authorId == null) {
        return null;
    }
    return feed.author.id.eq(authorId);
}

QueryDSL의 where에 이 메서드들을 나열하면 null인 조건은 자동으로 제외된다.

.where(
        cursorCondition(condition.sortBy(), condition.sortDirection(),
                        condition.cursor(), condition.idAfter()),
        keywordLike(condition.keywordLike()),
        skyStatusEqual(condition.skyStatusEqual()),
        precipitationTypeEqual(condition.precipitationTypeEqual()),
        authorIdEqual(condition.authorIdEqual())
)

이 패턴이 QueryDSL의 가장 큰 장점이다. 필터가 추가될 때마다 메서드 하나만 만들고 where에 추가하면 된다. if-else 체인으로 쿼리를 조립할 필요가 없다.

null 무시 원리

QueryDSL의 where(Predicate... conditions)는 가변 인자를 받는다. 배열 안에 null이 있으면 해당 조건을 건너뛴다. 이 동작 덕분에 "값이 없으면 null 반환" 패턴이 자연스럽게 동작한다.

전체 쿼리 조립

지금까지 만든 조각들을 합치면 완성된 Repository 메서드가 된다.

@Override
public List<Feed> searchFeeds(FeedSearchCondition condition) {
    return queryFactory
            .selectFrom(feed)
            .join(feed.author, user).fetchJoin()
            .join(feed.weather, weather).fetchJoin()
            .where(
                    cursorCondition(condition.sortBy(), condition.sortDirection(),
                                    condition.cursor(), condition.idAfter()),
                    keywordLike(condition.keywordLike()),
                    skyStatusEqual(condition.skyStatusEqual()),
                    precipitationTypeEqual(condition.precipitationTypeEqual()),
                    authorIdEqual(condition.authorIdEqual())
            )
            .orderBy(
                    createOrderSpecifier(condition.sortBy(),
                                         condition.sortDirection()),
                    new OrderSpecifier<>(Order.DESC, feed.id)
            )
            .limit(condition.limit() + 1)
            .fetch();
}

각 조건이 독립적인 메서드로 분리되어 있어서, 어떤 조건이 적용되는지 한눈에 파악할 수 있다.

자주 하는 실수

필터 메서드에서 빈 문자열을 무시하지 않음

keyword가 빈 문자열일 때 null 체크만 하면 LIKE '%%' 조건이 들어간다. 전체 데이터를 반환하긴 하지만 불필요한 LIKE 연산이 실행된다. isBlank() 체크를 함께 해야 한다.

[!DANGER] 정렬 기준 변경 시 커서를 초기화하지 않음

클라이언트가 "최신순"에서 "좋아요순"으로 정렬을 바꾸면서 이전 커서를 그대로 보내면, created_at 값을 like_count로 비교하게 된다. 정렬 기준이 바뀌면 커서를 null로 초기화해서 첫 페이지부터 다시 조회해야 한다.

[!DANGER] 보조 정렬 키를 빼먹음

1차 정렬만 걸고 id 보조 정렬을 빼먹으면, 같은 값을 가진 행의 순서가 쿼리마다 달라질 수 있다. 커서 조건의 동점 처리와 맞물려 데이터 누락이 발생한다.