Cursor Pagination 과 무한 스크롤

개요

최근 프로젝트를 진행하다 페이지 처리 관련 문제가 발생했고, 글을 통해 어떻게 해결했는지 정리하고자 한다.


요구사항

프로젝트 개발 요구사항은 아래와 같다.

  • 글 목록 조회를 무한 스크롤이 가능하도록 해야한다.
  • 작성일 내림차순 , 조회수 내림차순 , 댓글 수 내림차순 으로 조회할 수 있어야 한다.


무한 스크롤이랑 일반 게시판형이 뭐가 달라?

무한 스크롤이 가능하도록 페이지네이션을 하는 것은 처음이었다.

나는 단순히 게시판형 페이징 API 를 만들고, 스크롤 이벤트 마다 클라이언트가 API를 호출하면 되지 않을까 라고 생각했다.

하지만 이 생각이 틀렸음을 동료 덕분에 알았다.

땡큐 woooooooody!


무한 스크롤 vs 일반 게시판형

일반 게시판에서 페이지네이션을 구현할 때, 보통 OFFSETLIMIT 쿼리를 사용하여 구현한다.

성능적으로 좋지 않다고 하지만, 아주 단순하여 널리 사용되고 있다.

그렇다면 이것을 무한 스크롤에 적용을 하면 어떤 문제가 발생할까?

아래와 같이 게시글 테이블(ARTICLE)이 있다고 해보자.

ID TITLE BODY VIEWS COMMENT_COUNT
1 Article 1 Lorem ipsum dolor sit amet… 13 3
2 Article 2 Lorem ipsum dolor sit amet… 13 2
3 Article 3 Lorem ipsum dolor sit amet… 4 0
4 Article 4 Lorem ipsum dolor sit amet… 23 5
5 Article 5 Lorem ipsum dolor sit amet… 19 0
6 Article 6 Lorem ipsum dolor sit amet… 7 3

OFFSETLIMIT 을 사용하여 첫번째 페이지를 조회해보자.

페이지당 3개 아이템이 출력되고, 최신순으로 조회한다고 가정한다. (최신순 = ID 오름차순)

SELECT * FROM ARTICLE
  ORDER BY ID DESC
  LIMIT 3 OFFSET 0;

위 쿼리에 대한 결과는 아래와 같다.

Untitled



그럼 두번째 페이지를 조회해보자.

SELECT * FROM ARTICLE
  ORDER BY ID DESC
  LIMIT 3 OFFSET 3;

아래는 그 결과이다.

Untitled



여기까지는 아주 정상적으로 동작한다.


문제가 되는 상황

그렇다면 두번째 페이지를 조회하기 전에, 새로운 게시글이 추가되면 어떻게 될까?

Untitled



위 상태에서 아까와 동일한 쿼리로 두번째 페이지를 조회하면 아래와 같이 될 것이다.

-- 이전과 동일한 쿼리
SELECT * FROM ARTICLE
  ORDER BY ID DESC
  LIMIT 3 OFFSET 3;

Untitled



결과적으로 ID 가 4인 ARTICLE 이 중복되어 조회된다.

이런 현상이 단순한 게시판형 서비스라면 크게 문제되지 않을 수 있다.

하지만 무한 스크롤 형식의 서비스라면, 사용자가 직전에 확인한 ARTICLE 이 바로 다음에 다시 한번 중복되어 출력되는 심각한 문제가 있다.



중복 조회 해결 방법

그렇다면 이런 문제를 어떻게 해야할까?

그 답은 바로 Cursor 기반 Pagination 에 있다!


Cursor Pagination 이 뭐야?

페이징을 하는 방식에는 크게 OFFSET 기반 방식과 Cursor 기반 방식이 있다.

OFFSET 방식은 우리가 지금까지 살펴본 방식이다.

OFFSET 방식은 “어디까지 생략하고 조회할 것인지” 에 집중한다.

그에 반해, Cursor 방식은 “어떤 아이템 이후부터 조회해야 하는지” 에 포커스가 있다.

바로 알아보자.


Cursor Pagination 를 사용해보자.

아까 살펴본 OFFSET 방식 대신 Cursor 방식을 통해 Pagination 을 해보자.

첫번째 페이지를 조회하는 쿼리는 아래와 같다.

SELECT * FROM ARTICLE
	WHERE ID < 무한대
	ORDER BY ID DESC
	LIMIT 3;

OFFSET 이 전혀 사용되지 않은 것을 확인할 수 있다.

무한대 를 사용한 이유는 좀 더 지나서 설명하겠다.

결과적으로 위 쿼리를 실행하면, 아래와 같이 조회가 된다.

Untitled



OFFSET 을 사용하여 조회한 것과 동일한 결과이다.

계속해서 두번째 페이지를 조회해보자.

아래는 두번째 페이지를 조회하는 쿼리이다.

SELECT * FROM ARTICLE
  WHERE ID < 4
  ORDER BY ID DESC
	LIMIT 3;

WHERE 절을 보자. 마지막으로 조회된 ARTICLEID 인 4보다 작은 ARTICLE 만 조회하도록 조건이 걸렸다.

이것을 통해 OFFSET 을 전혀 사용하지 않고 원하는 결과를 얻을 수 있다.

Untitled

여기에서 핵심은 “바로 직전 아이템의 ID 4를 사용했다는 점” 이다.

“어떤 아이템 이후부터 조회할 것인지” 에 집중하게 되었고, 그 결과가 정상적으로 나왔다.

이러한 동작방식 때문에, 첫번째 페이지를 조회할 때 무한대 를 사용한다.


그럼 중복 조회되는 문제는?

OFFSET 방식을 사용할 때, 새로운 튜플이 추가되면 이전에 조회한 튜플이 다시 한번 더 조회되는 문제가 있었다.

다시 동일하게 두번째 페이지를 조회해보자.

Untitled



위 그림과 같은 상태에서 두번째 페이지를 조회하는 쿼리를 실행시켜보자.

-- 직전에 살펴본 쿼리와 동일하다
SELECT * FROM ARTICLE
  WHERE ID < 4
  ORDER BY ID DESC
	LIMIT 3;

그 결과는 아래와 같다.

Untitled

전혀 중복된 ARTICLE 이 조회되지 않았다!

왜냐하면 직전까지 받은 아이템의 ID 이후의 아이템들을 조회했기 때문이다.

이를 통해, 기존의 OFFSET Pagination 의 문제점을 해결할 수 있음을 확인했다.

참고로 Cursor 방식을 사용하는 것이 성능적으로도 우수하다!
자세한 것은 https://jojoldu.tistory.com/528 을 참고하자.



Cursor Pagination 의 한계

나는 이 방식을 통해, 무한 스크롤에 필요한 조회 API 를 구현하려고 했다.

하지만 또 다른 문제가 있었는데…

다시 요구사항을 보자.

  • 글 목록 조회를 무한 스크롤이 가능하도록 해야한다.
  • 작성일 내림차순 , 조회수 내림차순 , 댓글 수 내림차순 으로 조회할 수 있어야 한다.

위 요구사항에서 주목해야하는 것은 2번째 항목이다.

Cursor Pagination 을 사용하여 작성일 내림차순 조회를 무한 스크롤로 구현할 수 있다.

문제는 조회수 내림차순 , 댓글수 내림차순 조회이다.

그대로 Cursor Pagination 을 사용해서 조회수 내림차순 , 댓글수 내림차순 으로 조회를 하면 문제가 발생한다.


기존 방식의 Cursor 페이징으로 조회수 내림차순 조회

ID TITLE BODY VIEWS COMMENT_COUNT
1 Article 1 Lorem ipsum dolor sit amet… 13 3
2 Article 2 Lorem ipsum dolor sit amet… 13 2
3 Article 3 Lorem ipsum dolor sit amet… 4 0
4 Article 4 Lorem ipsum dolor sit amet… 23 5
5 Article 5 Lorem ipsum dolor sit amet… 19 0
6 Article 6 Lorem ipsum dolor sit amet… 7 3

위 테이블(ARTICLE)을 조회수 내림차순으로 첫번째 페이지를 조회한다면, 아래와 같이 쿼리를 작성해야 한다.

SELECT * FROM ARTICLE
	WHERE VIEWS < 무한대
	ORDER BY VIEWS DESC
	LIMIT 3;

WHERE 절에 조건으로 ID 대신 VIEWS 가 들어갔다.

그리고 정렬 기준이 VIEWS 가 된다.

그 결과는 아래와 같다.

Untitled



여기까지 살펴봤을 때, 정상적으로 동작한다.

바로 아래 쿼리를 실행시켜 다음 두번째 페이지를 조회해보자.

SELECT * FROM ARTICLE
	WHERE VIEWS < 13
	ORDER BY VIEWS DESC
	LIMIT 3;

첫번째 페이지에서 조회한 가장 마지막 ARTICLEVIEWS 가 13이므로, VIEWS < 13 을 사용하여 조회한다.

그 결과는 아래와 같다.

Untitled



결과가 우리가 원하는대로 나오지 않았다.

분명히 ID 가 1인 ARTICLE 부터 조회를 해야하지만 그렇지 않다.

그러면 조건절을 아래와 같이 바꾸면 해결이 될까?

-- WHERE 절 조건이 '<=' 으로 변경됨
SELECT * FROM ARTICLE
	WHERE VIEWS <= 13
	ORDER BY VIEWS DESC
	LIMIT 3;

그 결과는 아래와 같다.

Untitled



당연하게도 이미 조회했던 ID 가 2인 ARTICLE 이 중복되어 조회된다.

어떻게 해결해야 할까?


Cursor Pagination 은 유니크한 값으로 해야 한다!

문제는 VIEWS 라는 컬럼이 유니크한 값을 갖지 않는다는 것이다.

Cursor Pagination 을 사용할 때는 무조건 유니크한 값을 기준으로 처리해야 한다.

그렇지 않으면 위 문제가 발생한다.

위 문제를 해결하기 위해, 유니크한 값을 갖는 ID 컬럼과 함께 사용해보자!


정렬 기준 (ORDER BY) 추가

그 전에 우리는 VIEWS 가 같은 경우에 어떻게 정렬할 것인지 정해야 한다.

그리고 그 정렬 기준으로 유니크 컬럼을 사용해야 정상적으로 동작한다.

유니크 컬럼인 ID 를 사용해서 아래와 같이 정렬해보자.

SELECT * FROM ARTICLE
  ORDER BY VIEWS DESC, ID DESC;

VIEWS 를 기준으로 내림차순으로 정렬을 하고, 만약 VIEWS 값이 동일하다면 유니크 컬럼인 ID 을 기준으로 다시 내림차순 정렬을 한다.


중복 가능한 컬럼과 유니크 컬럼으로 Cursor Pagination 구현

자 그러면 이제 본격적으로 문제를 해결해보자.

다시 두번째 페이지를 조회해보자.

아래 그림은 첫번째 페이지를 조회한 상태이다.

Untitled



여기에서 아래 쿼리를 실행시켜 두번째 페이지를 조회해보자.

SELECT * FROM ARTICLE
  WHERE (VIEWS < 13) OR (VIEWS = 13 AND ID < 2)
  ORDER BY VIEWS DESC, ID DESC
	LIMIT 3;

역시 주목해야하는 포인트는 WHERE 절의 조건이다.

  • (VIEWS < 13)
    • 가장 마지막에 조회한 ARTICLEVIEWS 보다 작은 것을 조회한다.
    • 아까 우리가 살펴본 문제(동일한 VIEWS 를 갖는 ARTICLE 이 누락되는 문제)가 발생한다.
    • 따라서 다음 조건이 추가되었다.
  • (VIEWS = 13 AND ID < 2)
    • VIEWS 가 동일하게 13이고, 가장 마지막에 조회한 ARTICLEID 보다 작은 것을 조회한다.
    • ID 는 유니크한 값을 갖기 때문에, (VIEWS < 13) 조건 만으로는 누락시킬 수 있는(동일한 VIEWS 값을 갖는) ARTICLE 까지 조회할 수 있게 된다.

(VIEWS < 13) 조건과 (VIEWS = 13 AND ID < 2) 조건을 함께 사용함으로써 누락되는 ARTICLE 이 없도록 방지할 수 있게 되었다!

실제로 그 결과는 아래와 같다.

Untitled



정상적으로 동작한다.


새 아이템이 추가되어도 괜찮을까?

그렇다면 새로운 ARTICLE 이 추가되었을 땐 어떻게 될까?

첫번째 페이지를 조회했다는 전제하에 설명하겠다.

Untitled



위 상태에서 새로운 아이템이 추가되면 아래와 같이 테이블의 상태가 변경된다.

Untitled



이렇게 새로 추가된 ARTICLE 이 엄청난 주목을 받아 조회수가 폭발했다고 해보자!

그러면 아래와 같이 테이블 상태가 변경될 것이다.

Untitled



이제 아래 쿼리로 두번째 페이지를 다시 조회해보자.

-- 직전에 사용한 쿼리와 동일
SELECT * FROM ARTICLE
  WHERE (VIEWS < 13) OR (VIEWS = 13 AND ID < 2)
  ORDER BY VIEWS DESC, ID DESC
	LIMIT 3;

그 결과는 아래와 같다.

Untitled



결과적으로 문제없이 동작한다.



정리

이번 이슈로 페이징 처리에 대해 더 깊이 있는 고민을 해볼 수 있었다.

지금까지 페이징 처리를 단순하게 생각해왔지만, 고려해볼 것이 많이 주제였다는 것을 알았다.

이번 글에서 “무한 스크롤에서의 중복 아이템 문제” 만을 다뤘지만, 사실 더 많은 이야기가 있다.

페이징 처리를 어떻게 구현하느냐에 따라 성능적으로도 많은 차이를 가져올 수 있다는 것, 역시 더 심도있게 조사해봐야하는 주제이다.

이번 글을 통해, 보다 많은 사람들이 무한 스크롤에 대한 궁금증을 풀었기를 바란다.



Reference