본문 바로가기
팀 프로젝트/플러스 프로젝트

성능 개선 4편 & 트러블슈팅: 커서 기반 페이지네이션 + 전략 패턴

by pon9 2025. 2. 5.

커서 기반 페이지네이션이란?

데이터베이스에서 대량의 데이터를 효율적으로 탐색하기 위한 기법이다.

커서 기반 페이지네이션은 특정 커서(정렬된 컬럼의 마지막 id값)를 기준으로 다음 데이터를 가져와서 인덱스를 탈 수 있어 성능 저하 없이 빠르게 데이터를 불러올 수 있다.

기존 오프셋 기반 페이지네이션에서 오프셋이 커질수록 발생하는 성능 저하 문제나 데이터 중복/누락 문제를 효과적으로 방지할 수 있다.

 

물론 단점도 존재한다. 임의의 페이지로 바로 이동하기 어렵다는 점이다. 페이지 번호 대신 마지막 데이터의 커서를 사용하기 때문에, 사용자가 특정 페이지를 지정해서 바로 접근하는 기능을 구현하기 힘들다.

 

또 다른 단점은 구현 복잡도가 증가한다는 점이다. 정렬 기준이나 필터 조건이 다양해지면 커서를 통한 데이터 조회가 예상치 못한 결과를 낼 수 있기에 안정적인 구현을 위한 신중한 설계가 요구된다.

 

 

구현 대상

기존에 Page<>타입을 사용하던 것들을 모두 커서 기반으로 바꾸려고 한다.

우리 서비스는 거래소-경매장이라, '키워드 검색'이 중요해서 앞서 언급했던 단점인 '임의의 페이지로 이동하기 어렵다는 점' 을 체감하기 힘들다.

 

총 4개(거래소 인기 아이템, 경매장 인기 아이템 / 거래소 전체 아이템, 경매장 전체 아이템)가 있는데,

'인기 아이템 조회'의 구현은 정렬조건이 이미 정해져있고, 파라미터 값도 간단해서 구현하기 쉬웠다.

 

하지만 '전체 아이템 조회' 의 로직을 살펴보면,

디폴트 정렬조건은 '랜덤'이고, 사용자는 거래소 기준으론 총 3개의 정렬을 사용 가능하다(itemName, price, amount)

(경매장은 4개다. itemName, startPrice, currentMaxPrice, dueDate)

여기에 searchKeyword를 파라미터로 itemName으로 검색까지 가능해서 구현의 복잡도가 꽤 높다.

지금부터 이 복잡한 조회 api 2개를 확장성이 뛰어난 커서 기반 페이지네이션을 적용해보자

 

 

Switch case vs Strategy

말이 versus지 무조건 후자를 사용할거다.

그 이유는, strategy 인터페이스를 잘 만들어 놓으면 market에서도 auction에서도 사용가능하고, 추가 검색 조건이 생겼을 때도 간편하게 추가 가능하기 때문이다. 지금만 해도 조건이 7개인데, switch-case로 감당이 가능한가? 안된다..

public interface CursorStrategy<V> {
    /**
     * 커서 조건을 생성해 반환
     * @param order         정렬 방향
     * @param id            마지막 행의 id (tie-breaker)
     * @param cursorValues  추가 커서 값들 (가격, 수량, 아이템명 등)
     * @return              커서 조건 Predicate
     */
    Predicate buildCursorPredicate(Order order, Long id, V cursorValues);
}

CursorStrategy 인터페이스다.

tie-breaker(동일한 값이 있을 때 순서를 결정하기 위한 추가 조건)로 마지막 행의 id를 사용하도록 한다.

predicate는 querydsl에서 where조건을 표현하는 객체다. 쿼리 조건을 동적으로 구성하거나 재사용할 수 있다.

제네릭을 이용해 다양한 cursorValues(record class)를 소화 가능하도록 구현했다.

public List<MarketListResponseDto> findAllMarketItems(
    String searchKeyword,
    String sortBy,
    String sortDirection,
    Long lastItemId,
    MarketCursorValues marketCursorValues
) {
    Order order = "DESC".equalsIgnoreCase(sortDirection) ? Order.DESC : Order.ASC;
    if (lastItemId != null) {
        //sortBy에 따라 커서 전략 선택
        CursorStrategy<MarketCursorValues> cursorStrategy = getCursorStrategy(sortBy);
        builder.and(cursorStrategy.buildCursorPredicate(order, lastItemId, marketCursorValues));
    }

    return queryFactory
        .select(new QMarketListResponseDto(
        ...
        .where(builder)
        .groupBy(item.id, item.name)
        .orderBy(determineSorting(order, sortBy))
        .limit(PAGE_COUNT)
        .fetch();
    }

private CursorStrategy<MarketCursorValues> getCursorStrategy(String sortBy) {
    return switch (sortBy) {
        case "price" -> new PriceCursorStrategy();
        case "amount" -> new AmountCursorStrategy();
        default -> new MarketDefaultCursorStrategy();
    };
}

BooleanBuilder로 where절을 표현하고, 이곳에서 커서 전략도 선택한다.

controller에서 reqparam으로 들어오는 sortBy값에 따라 커서 전략이 달라진다.

PAGE_COUNT는 param으로 받지 않고 자체적으로 10으로 고정했다.

itemName 정렬조건은 빼버렸다. 구현복잡도도 늘어나고, 별로 쓸모없는 기능이라 판단했다.

 

 

Postman 속도체크!

아무래도 api url이랑 비즈니스 로직이 좀 복잡해지는건 있당

sortBy와 last- 키워드를 맞춰주지 않으면 예외를 뱉도록 설계해놓았다.

경매장 메인페이지에 대한 조회속도가 16초에서 7초로 줄었다!!!!!!!

 

 

트러블슈팅: where절엔 집계함수가 들어올 수 없다

한가지 오류가 생겼다. where절에서 지금 모든 전략을 처리하고 있어서, 집계함수가 들어와있는 경우에는 곤란해진다.

where절에서는 집계함수를 사용할 수 없다.

그냥 간단하게 having으로 옮기자

(사실 서브쿼리도 시도해봤는데, 응답 속도가 너무너무 느려서 포기했다.)

Predicate havingClause = getCursorStrategy(sortBy).buildCursorPredicate(order, lastItemId, marketCursorValues);

.groupBy(item.id, item.name)
.having(havingClause)
.orderBy(determineSorting(order, sortBy))

public class PriceCursorStrategy implements CursorStrategy<MarketCursorValues> {

    @Override
    public Predicate buildCursorPredicate(Order order, Long lastItemId, MarketCursorValues marketCursorValues) {
        Long lastPrice = marketCursorValues.lastPrice();

        if (Order.DESC.equals(order)) {
            return Expressions.booleanTemplate(
                    "(MIN({0}) < {1}) OR (MIN({0}) = {1} AND {2} < {3})",
                    QMarket.market.price, lastPrice, QItem.item.id, lastItemId
            );
        } else {
            return Expressions.booleanTemplate(
                    "(MIN({0}) > {1}) OR (MIN({0}) = {1} AND {2} > {3})",
                    QMarket.market.price, lastPrice, QItem.item.id, lastItemId
            );
        }
    }
}

전략패턴을 그대로 having절에서 사용할 수 있게 옮겼다.

정말 다행히도 market에서만 집계함수를 사용하고 있어서 예외처리를 할 필요가 없었다. 만약 auction에 집계함수가 들어가게 된다면.. 슬프게도 처리가 필요할거다 ㅠㅠ 확장성이 떨어졌다.

having절을 사용할 때 주의점은 where과 다르게 groupBy뒤에 위치해야 한다는 점이다. 집계된 결과(그룹된 데이터)에서 필터링할 때 필요하기 때문이다

됐당 ㅇㅅㅇv

거기다 거래소 메인페이지의 조회속도가 1분 13초에서 29초로 줄었다

풀텍스트인덱스가 들어오면 얼마나 더 빨라지려나 ㅎㅎ