SNUXI 개발일지 - 3 [페이지네이션과 인터페이스 상속관계]
SNUXI 서비스에는, 채팅방에 참여한 사람들이 최근 n개의 채팅내역과, 과거의 채팅내역을 조회할 수 있는 기능을 만들어야했다. 뒤늦게라도 채팅방에 참여한 사람들이 과거의 채팅내역을 보면서 방 분위기를 파악할 수 있고 그에 따라 더욱 빠르게 택시팟이 성사되는 기대효과가 있기 때문이다.
그러나 한번에 DB에 저장한 모든 메시지 데이터를 불러온다면(또는, roomId를 통해 채팅방에 사람이 입장할 때마다 DB 테이블 전체를 1번씩 스캔한다면)
- DB에 부하가 심하게 걸리고
- 화면에 띄워야할 메시지 양이 너무 많아진다.
따라서, 대량의 데이터를 여러개의 페이지로 나누어서 불러오는 기능이 필수적이다. 이를 페이지네이션 이라고 한다.
페이지네이션에는 두 가지 기반의 방법이 있다. 첫번째는 오프셋 기반, 두번째는 커서 기반 방법이다.
1. 오프셋 기반
페이지 기반으로 데이터를 불러오는 방법이다. 개념적으로는 n번째 페이지의 데이터를 보여줘 라는 요청이지만, 한 페이지당 데이터 개수를 지정해주어야 한다.
SQL 쿼리로는
SELECT * FROM messages
ORDER BY id DESC
LIMIT 100 OFFSET 0;
대강 이런 식이 되겠다. 즉 이 쿼리는 OFFSET(= 0)개 만큼 건너뛰고, 최대 LIMIT(= 100)개의 데이터를 가져온다는 뜻이다.
그러나 이 방법은 다음과 같은 치명적인 문제점이 있다.
- OFFSET 개수 만큼의 데이터를 모두 지나친 다음(읽은 다음), 최대 LIMIT 개의 데이터를 가져온다.
- 실시간으로 계속 데이터가 추가되는 상황이라면 가져오는 데이터가 중복/누락될 수 있다.
성능 관점에서 가장 큰 문제는, OFFSET 개수만큼의 데이터를 모두 하나하나 읽고 지나친다(읽고 버린다)는 것이다. 10을 셀때 1부터 9까지 모든 수를 지나친 후 10을 반환하는 것과 같다.
또한 실시간으로 데이터가 계속 추가되는 상황에서도 문제가 발생할 수 있다. 다음 페이지로 넘어가려는데 데이터가 추가되어 이전 페이지에 있던 내용이 다음 페이지로 밀려날 수 있고, 이러한 페이지 건너뛰기 현상으로 인해 의도했던 데이터셋과 다른 데이터셋이 반환되는 중복/누락 현상이 발생할 위험이 있다.
따라서 성능 저하 문제와 정확성 문제를 피할 수 없다.
우리 서비스의 경우, 채팅방이 여러개 존재하며 각 채팅방의 메시지들을 하나의 DB table로 저장하는 구조이기 때문에 DB Update가 매우 빈번하고, 그 개수 또한 많을 것으로 예상된다.
따라서, 오프셋 기반 페이지네이션은 사용하기 부적절하며 두번째 방법인 커서 기반 페이지네이션을 사용하여야 한다.
2. 커서 기반
이전에 어디까지 데이터를 봤는지를 커서로 저장해두고, 이후에 요청 시 커서 다음 n개의 데이터를 요청하는 방식이다.
이전에 어디까지 보았는지 저장하고 있기 때문에, 오프셋 기반에서의 성능 문제가 발생하지 않는다. 또한, 데이터가 실시간으로 계속 추가되는 상황에서도 페이지 번호 기반으로 처리하는 것이 아니라 특정 데이터 이후의 n개와 같은 방식이므로 중복/누락 문제가 발생하지 않는다.
SQL 쿼리로 작성하면
SELECT * FROM messages
ORDER BY id DESC
LIMIT 100
OFFSET 0;
SELECT * FROM messages
ORDER BY id DESC
WHERE id < 1000
LIMIT 100
OFFSET 0;
첫번째 쿼리는 첫 조회(커서가 없는)이며, 두번째 쿼리는 2번째 이상부터 조회할때 이다. DB table에 id index 걸어두고, WHERE id < x와 같은 방식으로 접근하는 것이 시간이 훨씬 적게 든다.
B-Tree 내부 구조를 사용하므로 해당 쿼리가 실행될때 커서의 위치를 찾는(해당하는 데이터를 찾는) 시간복잡도는 O(logN + LIMIT) (LIMIT = 불러오고자 하는 데이터의 수) 수준이며, logN의 시간동안 B-Tree의 leaf에 도달한 후 leaf 끼리는 서로 리스트로 연결되어 있으므로 이러한 시간복잡도를 가진다.
오프셋 기반은 앞선 모든 데이터를 읽고 지나가야 하므로 O(N)의 시간복잡도를 가진다.
여기서 드는 의문이: 어차피 id가 n번째인거 찾아야 하는건 같은데, 왜 둘의 시간복잡도가 다른가? 디스크의 자료구조(B-Tree)가 달라지는것도 아닌데..
헷갈리면 안되는 것이, id = 1000000인 것과 앞에서부터 1000000은 다르다라는 것이다.
만약 중간에 데이터가 100개 삭제되었다고 하자. 그렇다면
- 커서 기반 : 그냥 B-Tree의 각 노드에 있는 x이상/x이하의 정보를 통해 1000000을 찾으면 된다.
- 오프셋 기반 : 삭제된 100개의 구멍들을 제외하고, 살아남은 것들 중 1000000번째 순서를 찾아야 한다. id = 1000000이어도 실제로는 999899번쨰일 수도 있다.
따라서 오프셋 기반의 경우 반드시 앞에서부터 순차적으로 데이터를 모두 읽어야 한다.
우리 서비스의 경우, ‘스크롤을 정확히 x번 했을때의 채팅 내역을 불러오는’ 기능보다는, 채팅방에 처음 딱 입장했을때 최근 n개의 채팅내역을 먼저 보여주고, 사용자가 스크롤을 할 경우 그다음 n개, 또 그다음 n개…를 불러오는 방식이 적절하다.
우리가 일반적으로 카카오톡 등의 메신저 어플을 사용할때도, 과거 채팅내역을 조회할 때 ‘스크롤을 정확히 1234번 했을때의 채팅 내역 목록을 가져오고 싶은데’라는 사용자도 없거니와 그런 기능도 없다. 과거 채팅내역이 궁금하면 위로 스크롤을 여러번 해서 가져오는 것이 훨씬 자연스럽다.
따라서, 우리 서비스에서는 커서 기반 페이지네이션을 통해 데이터를 불러오는 것이 성능과 설계 측면에서 훨씬 적합하다고 판단했다.
-
구현할 때는 JpaRepository의 findAllBy… 함수를 선언하여 쿼리 작성 없이 구현하였다(성능 상 큰 차이는 없지만, 걸고자 하는 조건의 수가 region id, room id 정도로 매우 적다. 따라서 가독성을 크게 해치지 않는다고 판단하여 findAllBy… 형태의 함수를 사용하였다).
-
repository.findAllBy…를 호출할 경우에, 해당 인터페이스의 proxy 객체가 호출을 가로채어, 함수 이름을 통해 쿼리를 자동으로 만들어준다.
-
정리를 하고 보니 서버 내부에 ‘커서’라는 걸로 따로 페이지 정보를 저장하고 있는것처럼 보일 수도 있을 것 같아서 추가. 서버는 내부에 따로 ‘커서’라는 것을 저장하고 있는 것이 아니다(Redis 등으로 커서를 저장하고 있는 방식도 쓰긴 하는데, 우리 서비스처럼 단순한 서비스에 이러한 방법을 도입할 경우 redis의 상태를 관리하는 로직 및 DB-redis 정합성 또한 신경써야 하므로 오버엔지니어링 이라고 판단했다).
-
만약 서버에서 커서를 저장하게 되면, 사용자 수에 비례해 각 사용자의 커서를 모두 저장해야 하며, 만약 서버가 여러대일 경우 1번 서버에서 사용자가 조회 후 2번 서버에서 같은 사용자가 조회 요청을 하게 되면 커서 정보가 없어서 불일치 문제가 발생한다. 따라서 클라이언트로부터 @RequestParam으로 커서 정보를 받아야 하며, 우리 서비스 코드의 ChatMessageController.kt에서 해당 방식으로 구현한 이유이다.
개념적인 정리는 이쯤하고, 실제 구현시 마주쳤던 Page/Pageable/PageRequest 인터페이스/클래스에 대해 정리해보고자 한다.
3. Page/Pageable interface와 PageRequest class
페이지 단위로 DB에서 데이터를 조회하기 위해, 페이지 번호/페이지 당 데이터 수(요청 정보) 및 실제 데이터 목록/페이지수(응답 정보) 등을 담기 위해 만든 인터페이스와 클래스이다.
(1) Pageable interface
페이지 데이터 요청을 표현하는 인터페이스로, 실제 구현을 살펴보면 아래와 같은 함수/필드들이 존재한다.
int getPageNumber();
int getPageSize();
boolean hasPrevious();
.... // 기타 등등
즉 다음 페이지가 있는지, 페이지 번호는 뭔지, 데이터는 몇개가 있는지 등을 표현하기 위한 규칙을 Pageable이라는 인터페이스로 정해놓은 것이다.
- 코드를 좀 더 뜯어보니 Unpaged라는, Pageable interface를 상속받은 final class가 존재한다. 앞선 getPageNumber(), getPageSize() 등의 함수를 호출하면 예외를 던져버리던데, 이는 페이지 구분/페이지 미구분 요청을 설계상 나누기 위한 의도인 것으로 추정된다.
(2) PageRequest class
Pageable interface를 구현한 AbstractpageRequest(abstract class)를 상속받은 class로, Pageable interface에서 정한 규약을 따라 요청을 구현한 가장 기본적인 클래스이다.
PageRequest.of(‘페이지 번호’, ‘사이즈’)와 같은 형식으로 pageable 객체를 생성할 수 있다.
코드를 좀 더 뜯어보니 QPageRequest(상속 관계는 같은) 클래스도 있던데, QueryDSL을 위한 Pageable 구현체라고 한다. 이 부분에 대해서는 좀 더 공부한 후 정리해보겠다.
(3) Page interface
Page interface는, 페이지네이션 요청에 따른 응답을 담기위해 정의한 interface로, 아래와 같은 필드들이 존재한다.
int getTotalpages();
long getTotalElements();
... // 기타 등등
해당 interface는 Slice
Page interface는 여기에 더해 총 데이터 수, 총 페이지 수 등의 추가적인 정보들을 저장할 수 있게 정의한 인터페이스이다.
즉, 위 3개의 인터페이스/클래스들은 페이지네이션의 요청과 응답의 형태를 정의하고 구현하기 위해 구현된 코드들이라고 할 수 있겠다.
페이지네이션 관련해서 단순 개념 이해를 넘어 실제 구현의 차이, 성능의 차이를 비교해보고 직접 spring 내부 코드를 뜯어보아 상속 관계를 정리해보았는데, 코드가 설계된 의도를 전체적으로 파악할 수 있었고 앞으로 페이지네이션 기능을 구현할 때 이러한 의도에 맞게 코드를 효율적으로 구성할 수 있을 것 같다.