Steady - 개발 스터디 및 프로젝트 인원 모집 사이트
[ 배포 링크 ] [ 프로젝트 깃허브 ]
자체적으로 제공하는 폼과 유저 평가를 통해 외부 서비스에 의존하지 않고 검증된 스터디 인원을 모집할 수 있는 서비스입니다.
개요
보통 위 이미지와 같이 숫자로 페이지를 표시하는 방식(네비게이션)에는 Offset 기반의 페이징 기법이 사용됩니다. Offset 기반의 페이징 방식은 대용량 데이터를 처리하는 데에는 한계가 있다는 단점이 있는데, 이런 한계에 가로막혀 네비게이션을 포기하고 서비스 요구사항을 변경하기엔 자존심이 상했습니다. 그래서 떠올린 방안은 바로 Cursor 기반의 페이징 기법에 네비게이션을 적용하자는 것이었습니다.
Cursor는 보통 무한 스크롤 방식의 페이지를 구현하기 위해서 사용되지만, 저는 이 Cursor를 통해서도 표현하고 싶은 `페이지 수 * 10`개의 데이터를 조회해오고 해당 데이터와 함께 `이전 커서, 다음 커서`에 대한 정보를 담아서 반환한다면 Cursor 기반임에도 페이지를 표현할 수 있다고 생각하였습니다.
구현 과정
이번 구현 과정에서 가장 중요하게 생각한 부분은 이전 커서와 다음 커서에 대한 정보를 담는것 이었습니다. 아이디어는 다음과 같습니다. ( 한 번에 5페이지씩 표기하는 경우 )
- 데이터 50개를 한 번에 조회하여 5개의 페이지를 미리 구성할 수 있게 한다.
- 이전 5개의 페이지를 조회하기 위해 사용될 이전 커서는 현재 커서의 반대 방향으로 `(페이지 수 * 10) + 1`개의 데이터를 조회하여 가져온다.
- 다음 5개의 페이지를 조회하기 위한 다음 커서는 현재 커서를 통해 조회한 데이터의 마지막 값에서 가져온다.
-- 현재 페이지를 가져오는 쿼리 (ex : 6, 7, 8, 9, 10)
SELECT DISTINCT
s.*
FROM
steadies s
JOIN
steady_stacks ss
ON s.id = ss.steady_id
JOIN
steady_positions sp
ON s.id = sp.steady_id
LEFT JOIN
steady_likes sl
ON s.id = sl.steady_id
WHERE s.promoted_at < CURSOR
ORDER BY s.promoted_at DESC LIMIT 50;
-- 이전 페이지를 가져오는 쿼리 (ex : 1, 2, 3, 4, 5)
SELECT DISTINCT
s.*
FROM
steadies s
JOIN
steady_stacks ss
ON s.id = ss.steady_id
JOIN
steady_positions sp
ON s.id = sp.steady_id
LEFT JOIN
steady_likes sl
ON s.id = sl.steady_id
WHERE s.promoted_at >= CURSOR
ORDER BY s.promoted_at ASC LIMIT 51)
위와 같이 쿼리를 작성하면 5개의 페이지를 보여준다고 가정했을 때 첫 번째 쿼리를 통해서 5개의 페이지에 해당하는 결과와 `다음 커서`를 표현할 수 있습니다. 두 번째 쿼리를 통해서는 첫 번째 쿼리보다 이전의 5개의 페이지에 대한 결과를 가져올 수 있으며 정렬 방향에 따라 가장 첫 번째 또는 가장 마지막의 값을 통해서 `이전 커서`를 표현할 수 있습니다.
하지만, 위 쿼리만으로는 완벽하게 구현할 수가 없었습니다. 왜냐하면 두 번째 쿼리의 결과가 limit보다 작은 경우 이전 커서를 가져와 다시 첫 번째 쿼리에 대입하면 `<` 부등호로 인해 커서에 해당하는 데이터가 누락되기 때문입니다.
누락이 발생하는 이유는 다음과 같습니다.
- 첫 번째 쿼리에서 부등호를 `<=` 으로 변경하면 커서에 해당하는 데이터가 다음 페이지에도 중복되어 보여지기 때문에 이를 방지하고자 `<` 부등호를 사용해야 한다.
- `<` 부등호를 사용함으로써 커서에 해당하는 데이터는 포함되지 않는다. 따라서 이전 페이지 조회를 위해서 조회하려는 이전 페이지보다 더 이전에 해당하는 페이지의 마지막 값 (다음 커서) 을 조회해야 한다. 그렇기에 `(페이지 수 * 10) + 1` 을 LIMIT 조건으로 사용한다.
- 이는 6, 7, 8, 9, 10 페이지에서 1, 2, 3, 4, 5 페이지로 이동할 때 (페이지수 * 10) + 1만큼 조회하더라도 1페이지 첫 번째 데이터보다 이전의 값이 존재하지 않기 때문에 1페이지 첫 번째 데이터가 커서가 되어버리고, 이로 인해 1페이지 첫 번째 데이터가 누락된다.
이러한 부분은 애플리케이션에서 보완할 수 있었습니다.
QueryDsl 메서드
@Override
public SteadyFilterResponse findAllByFilterCondition(UserInfo userInfo, FilterConditionDto condition, Pageable pageable) {
List<Steady> steadies = jpaQueryFactory
.selectFrom(steady)
.distinct()
.innerJoin(steadyStack)
.on(steady.id.eq(steadyStack.steady.id))
.innerJoin(steadyPosition)
.on(steady.id.eq(steadyPosition.steady.id))
.leftJoin(steadyLike)
.on(steady.id.eq(steadyLike.steady.id))
.where(filterCursorBuilder(condition, false), filterConditionBuilder(userInfo, condition))
.orderBy(orderBySort(pageable.getSort(), Steady.class))
.limit(pageable.getPageSize())
.fetch();
List<Steady> prev = jpaQueryFactory
.selectFrom(steady)
.distinct()
.innerJoin(steadyStack)
.on(steady.id.eq(steadyStack.steady.id))
.innerJoin(steadyPosition)
.on(steady.id.eq(steadyPosition.steady.id))
.leftJoin(steadyLike)
.on(steady.id.eq(steadyLike.steady.id))
.where(filterCursorBuilder(condition, true), filterConditionBuilder(userInfo, condition))
.orderBy(reverseOrderBySort(pageable.getSort(), Steady.class))
.limit(pageable.getPageSize() + 1)
.fetch();
// 이전 쿼리를 조회한 결과가 limit의 크기보다 작은 경우 이전 커서 정보를 넣지 않음
Steady prevCursor = prev.size() < pageable.getPageSize() + 1 ? null : prev.get(prev.size() - 1);
return new SteadyFilterResponse(steadies, prevCursor);
}
private BooleanBuilder filterCursorBuilder(FilterConditionDto condition, boolean prev) {
BooleanBuilder builder = new BooleanBuilder();
Cursor cursor = condition.cursor();
if (prev) {
builder.and(filter(cursor.getPromotedAt(), steady.promotion.promotedAt::goe));
builder.and(filter(cursor.getDeadline(), steady.deadline::lt));
} else {
builder.and(filter(cursor.getPromotedAt(), steady.promotion.promotedAt::lt));
builder.and(filter(cursor.getDeadline(), steady.deadline::goe));
}
return builder;
}
위와 같이 이전 쿼리를 조회한 결과가 limit의 크기보다 작은 경우 이전 커서 정보를 반환하지 않고 null을 반환합니다. 이후 Service 계층에서 커서 정보가 null인 경우 현재 시각을 넣어줌으로써 가장 첫 페이지부터 데이터를 조회할 수 있도록 구성하였습니다.
실행 결과
실행 결과를 보면 위 두 가지 케이스에서 의도한대로 `prevCursr (이전 커서)`가 null 인 것을 확인할 수 있습니다. 좌측의 경우 이전 페이지가 존재하지 않기 때문에 이전 커서 정보가 필요하지 않고 우측의 경우 첫 페이지를 조회하기 위해선 현재 시각을 이용하여 이전 커서 정보가 필요하지 않습니다.
11 ~ 15 페이지를 조회하는 경우에는 이전 페이지가 첫 페이지에 해당하지 않기 때문에 요청 결과에 이전 커서 정보가 잘 담겨서 오는 것을 확인할 수 있었습니다.
성능 비교
기존의 Deferred Join을 활용한 Offset 방식의 쿼리와 이번에 개선한 Cursor 방식의 쿼리의 성능을 비교해보았습니다. 단순 조회 쿼리의 성능만을 보기 위하여 Offset 방식은 count 쿼리를 제외하였고, Cursor 방식은 이전 커서 조회 쿼리를 제외하고 테스트를 진행하였습니다.
더미 데이터
테이블 | 데이터 수 |
steady | 3,000,000 |
steady_stacks | 3,000,000 |
steady_position | 3,000,000 |
steady_like | 6,000,000 |
사용된 쿼리
-- Deferred Join 쿼리
SELECT DISTINCT s.*
FROM steadies s
JOIN (SELECT DISTINCT s.id
FROM steadies s
LEFT JOIN steady_likes sl
ON s.id = sl.steady_id
ORDER BY s.id ASC
LIMIT 1500000, 10) AS s2
ON s.id = s2.id
JOIN steady_stacks ss
ON s.id = ss.steady_id
JOIN steady_positions sp
ON s.id = sp.steady_id;
-- Cursor 쿼리
SELECT DISTINCT
s.*
FROM
steadies s
JOIN
steady_stacks ss
ON s.id = ss.steady_id
JOIN
steady_positions sp
ON s.id = sp.steady_id
LEFT JOIN
steady_likes sl
ON s.id = sl.steady_id
WHERE s.id > 1500000
ORDER BY s.id ASC LIMIT 10;
위 더미 데이터와 쿼리를 기준으로 1,500,000 번째 데이터부터 조회할 때의 성능은 다음과 같았습니다.
- Deferred Join → 17.462초
- Cursor → 0.019초
Cursor 방식이 무려 `99%` 가량 빠른 것을 확인할 수 있었습니다.
Cursor 실행 계획
실행 계획을 살펴보면 Index range scan이 사용되었다는 것을 알 수 있습니다. 이는 검색해야할 범위가 정해졌을 때 사용되는 방식인데 Index range scan이 가능한 이유는 B+ Tree의 leaf 노드끼리는 단방향 연결리스트로 구성되어있기 때문입니다. 즉, 조회한 인덱스에서 다음 인덱스로 순차적인 탐색이 가능하여 인덱스 만으로 지정 범위를 탐색할 수 있게 됩니다.
인덱스가 생성되지 않은 Cursor를 사용하면 어떻게 되는지 궁금하여 해당 실행 계획도 확인해보았습니다.
인덱스가 없는 Cursor 실행 계획
`type = index` 로 언뜻보면 Cursor의 인덱스를 사용한 것처럼 보입니다. 하지만 여기서 index의 의미는 Index full scan을 의미합니다. 실행 계획을 보면 PRIMARY를 키로 사용한 것을 알 수 있는데, PK를 통해 생성되는 Clustered Index의 리프 노드에는 실제 데이터의 모든 값이 존재하기 때문에 Clustered Index를 풀 스캔하여 처리한 것으로 보입니다.
filtered 항목을 보면 100이 아닌 33인 것을 볼 수 있는데, Cursor에 해당하는 컬럼에 인덱스가 없기 때문에 비효율적으로 동작했음을 알 수 있습니다.
마무리
Offset 에서 Cursor 방식으로의 변환 자체는 크게 어렵지 않았지만 페이지 네비게이션을 어떻게 구현할 것인지에 대한 아이디어를 떠올리는 과정이 어려웠던 것 같습니다. 떠올린 아이디어를 바탕으로 구현한 기능이 실제로 잘 동작할지 미지수였기에 여러 시행착오를 겪어야 했지만, 끝내 최초에 계획했던 요구사항을 변경하지 않고 지켜낼 수 있었다는 점에서 굉장히 뿌듯합니다.
다만, 개발에는 은 탄환이 없듯이 Cursor 방식도 만능은 아니었습니다. 우선 인덱스를 기반으로 원하는 데이터에 바로 접근하는 것이기 때문에 인덱스 생성이 필수적이며 해당 데이터가 몇 번째 페이지에 있는지 알 수 없기 때문에 원하는 페이지로 바로 이동하는 것이 불가능합니다. 이러한 단점 때문에 이번 과정에서 이전 커서, 다음 커서를 구현하게 되었습니다.
또한 Cursor로 사용되는 컬럼은 고유해야합니다. PK를 Cursor로 사용할 수 있다면 다행이지만, 그렇지 않은 경우도 존재합니다. 이런 경우 커스텀한 Cursor를 만들어서 사용는 것이 안전합니다. 예를 들어 `생성일 + PK`와 같은 형태로 최대한 중복되지 않도록 Cursor를 구성하여 해결이 가능합니다.
'DB' 카테고리의 다른 글
동시성 테스트를 통한 성능 개선하기 (1) - DeadLock (MySQL) (0) | 2024.04.02 |
---|---|
조회 성능 최적화(2) - 슬로우 쿼리 튜닝하기 (1) | 2024.02.25 |