리스트 형태의 응답은 어떻게 내려줘야되지?
응답 데이터의 갯수가 많은 상황이 있다. 대표적으로 목록을 조회해야되는 상황이 그러한 예시이다.
친구 목록, 상품 목록, 게시글 목록 등등 여러 개의 데이터를 응답으로 내려줘야 되는 상황에서 고려할 것은 당연하게도 데이터의 크기이다.
게시글 목록을 조회하려고 서버에 요청했는데, 게시글의 수가 100만개라고 가정해보자.
한 응답에 모두 실어서 보내는 것은 불가능하다고 볼 수 있다. 따라서 여러 개의 데이터를 마치 페이지처럼 특정 단위로 몇 개씩 묶어서 반환해줘야 하고, 이것을 페이지네이션 이라 한다.
페이지 방식과 스크롤 방식
web이나 app 서비스를 이용하다 보면 목록을 조회하는 방식에는 크게 2가지가 있다는 것을 알 수 있다.
- 페이지 방식: 목록 맨 아래에 페이지 번호가 명시되어 있고, 유저는 특정 페이지를 클릭하여 해당 페이지의 내용을 볼 수 있다.
- 스크롤 방식: 페이지 번호가 따로 없고, 아래로 스크롤하면 계속해서 새로운 컨텐츠가 로드되는 방식을 말한다.
각각의 방식에 따라 서버 쪽에서 구현해야하는 내용도 조금 다르다.
- 페이지 방식 -> JPA의 pageable을 사용하면 쉽게 구현 가능
- 스크롤 방식 -> cursor 역할을 하는 대상을 만들어서 구현
페이지 방식에서 생각해봐야 할 문제들
구현하는 입장에서는 페이지 넘버가 있는 페이지 방식이 훨씬 간단하다.
앞서 언급했듯이 JPA의 pageable을 활용하면 page size랑 page number만 받아서 넘겨주면 내부 로직은 알아서 구현이 되어있기에 빠르게 개발할 수 있다. 하지만 고려해야할 것이 있다.
- 사용성 측면: 데이터의 일관성이 떨어진다. 만약 게시글 작성이 빈번하게 이루어진다면 게시글 목록 조회 시에 이미 조회되었던 페이지가 다시 조회되는 문제가 발생할 수 있다.
- 성능 측면: 데이터의 양이 많아질 수록 성능의 저하가 발생할 수 있다. JPA의 pageable을 사용하면 offset을 이용한 쿼리가 날아간다. offset 쿼리는 특정 페이지만 조회하는 것이 아니라 (offset + limit) 개의 record를 탐색 후 필요 없는 부분을 제외하고 반환하는 방식이다. 따라서, offset이 커질수록 성능이 떨어진다.
# 예시 (page size 10에 3페이지 요청)
SELECT *
FROM cards
ORDER BY id DESC
LIMIT 10 OFFSET 20;
offset 대신 cursor를 사용하는 방식
앞서 언급한 단점들을 보완하기 위해서 다음과 같이 cursor를 사용하는 방식으로 구현하기로 정했다.
# 예시 (page size 10에 cursorId 이전 값들 요청)
SELECT *
FROM cards
WHERE id < %cursorId
ORDER BY id DESC
LIMIT 10;
cursor를 활용한 스크롤 방식에서 생각해봐야 할 문제들
cursor 방식에서 가장 중요한 것은 어떤 것을 cursor Id로 사용하는가 이다.
다음과 같은 예시를 보자.
id | title | created_at |
1 | 제목 1 | 2024-03-03 |
2 | 제목 2 | 2024-03-03 |
3 | 제목 3 | 2024-03-03 |
4 | 제목 4 | 2024-03-03 |
5 | 제목 5 | 2024-03-04 |
6 | 제목 6 | 2024-03-05 |
7 | 제목 7 | 2024-03-05 |
'created_at' 을 next cursor id로 사용하게 될 경우, 다음과 같은 문제가 발생한다.
- (page size는 3이라고 가정) 최초에 요청하면 id 1 ~ 3까지의 게시글을 응답으로 내려주고, next cursor id로 마지막 응답값인 id 3번의 created_at인 '2024-03-03'을 응답으로 내려준다.
- 따라서, 다음 요청을 받으면 서버에서는 아래와 같은 쿼리가 날아간다.
- id가 4번인 게시글은 응답에서 누락된다.
# id 5 ~ 7 까지의 게시글이 응답으로 나온다.
SELECT *
FROM cards
WHERE created_at > '2024-03-03'
ORDER BY created_at
LIMIT 3;
이렇게 아무 값이나 cursor id로 설정해버리면 데이터 조회 중 특정 데이터가 누락되는 상황이 발생할 수 있다.
따라서, cursor id는 다음과 같은 조건들을 만족해야된다.
- unique한 값
- sequencial한 값
cursor id를 커스텀하여 만들어보자
앞서 말한 2가지 조건을 만족하는 가장 좋은 column은 역시 pk이다.
하지만 게시글 목록은 사용자의 요구사항에 맞춰 그 응답을 제공해야하고, 그 요구사항은 다양하다(ex. 최신순, 낮은 가격순 등등).
그래서 나비 장터에서는 사용자의 다양한 요구사항에 따라 custom cursor id를 만들어서 조회하도록 구현했다.
- “정렬 기준”과 “unique & sequencial”한 pk를 조합하여 커스텀 키를 제작
- 예시) 정렬 조건이 ‘최신순’ 인 경우
- 커스텀 cursor id = 게시글 생성날짜 와 pk의 조합으로 생성
- 게시글 생성날짜(2023.12.02 22:29:52) + pk(23) —> “2023120222295200000023”
// cursorId = {게시글 생성 날짜}와 {pk}의 조합
// 예시) "2023120222295200000023"
public BooleanExpression cursorId(String cursorId) {
if (cursorId == null) {
return null;
}
// 생성일자
StringTemplate dateCursorTemplate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
card.createdDate,
ConstantImpl.create("%Y%m%d%H%i%s")
);
// pk
StringExpression pkCursorTemplate = StringExpressions.lpad(
card.cardId.stringValue(),
8,
'0'
);
return dateCursorTemplate.concat(pkCursorTemplate).lt(cursorId);
}
결론
offset 페이징 대신 cursor 페이징으로 구현함으로써 다음과 같은 효과를 얻을 수 있었다..!
- 일관성 있는 응답 제공: 데이터의 추가 및 삭제에 따라 보여지는 페이지는 영향을 받지 않는다.
- 성능 개선: 대용량의 데이터셋과도 잘 맞는다.
'Project > 나비 장터' 카테고리의 다른 글
[나비 장터] 같은 자원을 생성하려고 동시에 시도하는 경우엔 어떻게 해야 돼? (0) | 2024.03.09 |
---|---|
[나비 장터] 조회수 로직 개선기(cache와 write-back 전략) (1) | 2024.03.08 |
[나비 장터] 다건 삽입 성능을 개선시켜보자(feat. bulk insert) (1) | 2024.03.07 |
[나비 장터] 프로젝트 소개 (0) | 2024.03.05 |