커서 기반 페이지네이션이란?
데이터베이스에서 대량의 데이터를 효율적으로 탐색하기 위한 기법이다.
커서 기반 페이지네이션은 특정 커서(정렬된 컬럼의 마지막 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초로 줄었다
풀텍스트인덱스가 들어오면 얼마나 더 빨라지려나 ㅎㅎ
'팀 프로젝트 > 플러스 프로젝트' 카테고리의 다른 글
조회성능 개선 마무리(저장용 글) (0) | 2025.02.06 |
---|---|
성능 개선 3편: Index 적용하고 비교분석하기 (0) | 2025.02.04 |
성능 개선 2편: Index 설계하기 (with Explain analyze, Explain) (0) | 2025.02.04 |