오프셋 기반 페이지네이션과 커서 기반 페이지네이션
오프셋 기반
SELECT *
FROM table
ORDER BY create_at DESC
limit 10
offset 0
- limit 와 offsey을 사용해서 조회
- 페이지 단위 구분
1. 오프셋 기반의 데이터 중복 문제
SELECT *
FROM post
ORDER BY modified_up_time DESC
limit 1
offset 0
에서 한 페이지를 넘기면
SELECT *
FROM post
ORDER BY modified_up_time DESC
limit 1
offset 1
으로 검색을 할텐데, 그 사이에 여기서 누군가가 새로운 글을 썼다고 가정했을 때 페이지를 넘겼는데도 똑같은 게시글이 조회가 되는 경우가 생긴다.
2. 오프셋 기반의 성능 저하 문제
SELECT *
FROM post
ORDER BY modified_up_time DESC
limit 10
offset 10000000000
이렇게 offset 값이 클 때 앞에 있는 모든 데이터를 읽어야 하기 때문에 성능 저하가 생긴다.
위 쿼리는 앞의 100억개의 데이터를 읽고, 그 다음 10개의 데이터를 읽어서 응답한다.
일반적인 사용자라면 100억개의 데이터를 읽을 확률이 적겠지만... 추후 엘라스틱 서치 기능을 추가할 때 색인 생성을 위해 몇백번째 데이터를 조회할 수도 있고 사용자가 임의로 page=1000000 이렇게 넣어서 요청할 수도 있다.
아래는 직접 확인한 성능 저하 차이다.
지연시간이 거의 13배나 늘어난 걸 볼 수 있다.
위 그래프는 offset 에서 읽어야하는 데이터가 많아질 수록 늘어나는 지연시간을 나타낸 그래프다
Offset과 Cursor의 차이가 극명하게 나는 걸 볼 수 있는데 offset이 4백만이라면 네트워크 지연시간이 7초 가량된다.
그럼 사용자는 7초를 기다려야 하는 것인데...
구글 코어 웹 바이탈 보고서를 보면 100ms 감소할 때마다 웹 전환율이 1.3% 증가한다는데, 7초면 서비스에서는 그냥 서비스 안하겠다는 소리다
커서 기반 페이지 네이션
Cursor 이라는 개념을 사용한다.
Cursor는 클라이언트가 받은 데이터 중 마지막에 있는 식별자 값이 된다.
그러니까
1,2,3,4,5,6,7,8,9,10
11,12,13,14,15,16,17,18,19,20
이렇게 있다면 첫번째의 Cursor는 10
두번째의 Cursor는 20이 된다.
진짜 간단함!!!!
SELECT *
FROM post
WHERE id > cursorId # id 커서 보다 큰 id 주세요~
ORDER BY id ASC # id 기준으로 오름차순 정렬해주세요~
LIMIT 10 # 10개만~~
이게 끝이다!!! 이게 끝임!!!!!!!
숫자가 30까지 있고 limit 10이라고 하면
# 첫 쿼리
SELECT *
FROM post
ORDER BY id ASC # id 기준으로 오름차순 정렬해주세요~
LIMIT 10 # 10개만~~
# 두번째 쿼리
SELECT *
FROM post
WHERE id > 10 # 10 보다 큰 id 주세요~
ORDER BY id ASC # id 기준으로 오름차순 정렬해주세요~
LIMIT 10 # 10개만~~
# 세번째 쿼리
SELECT *
FROM post
WHERE id > 20 # 20 보다 큰 id 주세요~
ORDER BY id ASC # id 기준으로 오름차순 정렬해주세요~
LIMIT 10 # 10개만~~
# 마지막 쿼리
SELECT *
FROM post
WHERE id > 30 # 30 보다 큰 id 주세요~
ORDER BY id ASC # id 기준으로 오름차순 정렬해주세요~
LIMIT 10 # 10개만~~
# 마지막에 불러온 데이터가 없으면 마지막 페이지라는 뜻이니 더이상 요청 못하게 만들면 됨
이게 끝이다 진짜로!!! 엄청 쉽다.
QueryDsl 로 구현
자 우선 QueryDsl로 구현 한 이유는 동적 쿼리에 대해서 엄청 유연하기 때문에 사용한다.
구현한 코드
@Override
public List<PostGetListResponseDto> findAllPostListWithFavorite(
Member member, LocalDateTime cursorTime) {
return queryFactory
.select(Projections.fields(PostGetListResponseDto.class,
post.id.as("postId"),
post.member.id.as("memberId"),
post.title,
post.imageUrl,
post.modifiedUpTime,
post.viewCnt,
favorite.post.count().as("favoriteCnt"),
favoriteStatus(member).as("favoriteStatus")))
.from(post)
.where(isNotDeleted(), lessThanCursorTime(cursorTime))
.leftJoin(favorite)
.on(favorite.post.eq(post))
.groupBy(post.id)
.orderBy(post.modifiedUpTime.desc(), post.id.desc())
.limit(12)
.fetch();
}
쉽다고 말했지만 코드로 보니까 조금 길어서 당황 했을 텐데...
@Override
public List<PostGetListResponseDto> findAllPostListWithFavorite(
Member member, LocalDateTime cursorTime) {
return queryFactory
// .select(Projections.fields(PostGetListResponseDto.class,
// post.id.as("postId"),
// post.member.id.as("memberId"),
// post.title,
// post.imageUrl,
// post.modifiedUpTime,
// post.viewCnt,
// favorite.post.count().as("favoriteCnt"),
// favoriteStatus(member).as("favoriteStatus")))
.from(post)
.where(isNotDeleted(), lessThanCursorTime(cursorTime))
// .leftJoin(favorite)
// .on(favorite.post.eq(post))
// .groupBy(post.id)
.orderBy(post.modifiedUpTime.desc(), post.id.desc())
.limit(12)
.fetch();
}
// 위에 다 제거 후 이 부분만 보면 됨
@Override
public List<PostGetListResponseDto> findAllPostListWithFavorite(
Member member, LocalDateTime cursorTime) {
return queryFactory
.select(post)
.from(post)
.where(isNotDeleted(), lessThanCursorTime(cursorTime))
.orderBy(post.modifiedUpTime.desc(), post.id.desc())
.limit(12)
.fetch();
}
생각보다 짧다~~
저기서 lessThanCursorTime은
private BooleanExpression lessThanCursorTime(LocalDateTime cursorTime) {
return cursorTime != null ? post.modifiedUpTime.lt(cursorTime) : null;
}
이건데 cursorTime이 null 이면 위 where절 추가 안하고 값이 들어왔으면 그것보다 작은 것들을 반환하는 거다
이게 끝임!!! 근데 여기서 문제점
커서는 Unique한 값을 사용해야한다.
현재 구현한 코드를 보면 Unique하지 않은 시간값을 기준으로 검색을 한다.
그러면 ~~보다 작은 or 큰 이니까 문제가 생길 수 있다.
만약 진짜 예외적인 상황으로 시간이 겹친다면?
ID | TIME |
1 | 2024-01-16 12:00.000000 |
2 | 2024-01-16 12:01.000000 |
3 | 2024-01-16 12:02.000000 |
4 | 2024-01-16 12:03.000000 |
5 | 2024-01-16 12:04.000000 |
여기서 5번 ID랑 겹치는 시간이 뒤로 3개가 더 있고, 하나 더 있는데 그건 안겹친다고 가정을 하자
ID | TIME |
6 | 2024-01-16 12:04.000000 |
7 | 2024-01-16 12:04.000000 |
8 | 2024-01-16 12:04.000000 |
9 | 2024-01-17 10:00:000000 |
이렇게 더 있음!
근데 현재 문제는 중복된 값을 방지하기 위해 <= 가 아닌 <를 쓰는 건데
2024-01-16 12:04.000000(Cursor) < post.TIME 을 검색 한다고 하면
ID | TIME |
9 | 2024-01-17 10:00:000000 |
중복 된 값은 검색에서 제외되고 뛰어 넘어서 9번만 나올 것이다.
위와 같은 상황이 나면 안되니까 보통 Time을 기준으로 정렬하게 할거면 id + time 을 기준으로 유니크한 값을 만들어내서 쓴다.
2024011612040000000000000001 이런식으로 CustomId를 만들면 해결된다.
2024011612040000000000000002 만약 위와 시간이 겹치더라도 이렇게 중복되지 않는다.
'TIL' 카테고리의 다른 글
TIL 2024-01-19 CORS 란? (0) | 2024.01.20 |
---|---|
TIL 2024-01-17 QueryDsl Enum Field.... (0) | 2024.01.18 |
TIL 2024-01-15 프록시 서버 (0) | 2024.01.16 |
TIL 2024-01-12 TransactionlEventListener 와 비동기 메서드 (0) | 2024.01.12 |
TIL 2024-01-11 NoSQL RDBMS 차이 (0) | 2024.01.12 |