안녕하세요, 503입니다.
이번 발표스터디의 4주차 주제로는 Pagination으로 정해보았습니다.
웹페이지를 로드할 때, 동시에 모든 데이터를 다 불러와서 처리하게 된다면 성능에 큰 문제가 생기겠죠? 따라서 페이지네이션은 거의 필연적으로 구현하게 되는 기능입니다.
특정한 정렬기준 + 필요한 개수
의 조건에 맞춰 데이터를 가져오는 것
보통 필요한 개수를 지정하고 상황에 맞춰 정렬기준이 조건에 추가되는데, 이러한 조건을 맞춰 데이터를 가져오는 것을 페이지네이션, 줄여서 페이징이라고 합니다.
페이지네이션을 처리하는 방법으로는 크게
이 두 가지가 있습니다.
DB의 limit, offset 쿼리를 사용하여 구분하여 ‘페이지’ 단위로 구분하여 요청/응답하게 구현
우리가 아는 일반적인 방법의 페이징입니다.
몇 번째
에 있는지 집중MySQL에서는 LIMIT 쿼리를 사용하면 간단히 가능합니다.
SELECT * FROM post ORDER BY id DESC LIMIT 5,10; // 5개 skip후 10개 요청
SELECT * FROM post ORDER BY id DESC LIMIT 15 OFFSET 10;
SELECT * FROM post ORDER BY id DESC LIMIT 25 OFFSET 10; .....
클라이언트가 가져간 마지막 row의 순서상 다음 row들을 n개 요청/응답하게 구현
어떤 데이터의 다음
에 있다는데에 집중cursor
가 가리키는 것 다음부터 n개의 데이터를 요청하는 식아래와 같은 데이터가 있고 생성일자와 수정일자로 정렬한다고 가정해봅시다.
id | 생성일자 | 수정일자 |
---|---|---|
123 | 1 | 17 |
124 | 2 | 18 |
114 ✅ | 4 | 19 |
115 | 4 | 20 |
생성일자와 수정일자는 이해하기 쉽게 간략히 표현했습니다.
이때, id가 114
의 다음 페이지의 데이터를 얻기 위한 쿼리는 하기와 같습니다.
SELECT * FROM post
WHERE (생성일자 > 4) or (생성일자 = 4 and 수정일자 > 19 )
ORDER BY 생성일자 ASC, 수정일자 ASC LIMIT 4;
- 대부분의 RDBMS는 WHERE에
OR-clause
를 사용하면 인덱싱을 제대로 못 태움.
=> OR 연산자에 관계된 모든 컬럼이 복합키로 설정되어 있지 않다면 인덱스를 사용하지 않고 전체조회(풀 테이블 스캔)을 함 (후자가 더 빨라서..)- 클라이언트가
ORDER BY
에 걸려있는 모든 필드를 알아야하고, 매 페이지 요청시마다 이 값들을 전부 보내야 함.
⇒ 그러므로 WHERE절에 걸리는 조건들을 이용해서 고유한 값인 cursor(커서)
를 만듭니다.
각 생성/수정일자를 최대 4자라고 가정하면 이 두가지를 합쳐서 “00040022” 와 같은 식으로 가공하여 cursor(커서)
값으로 사용합니다.
id | 생성일자 | 수정일자 | cursor |
---|---|---|---|
123 | 1 | 17 | 00010017 |
124 | 2 | 18 | 00020018 |
114 | 4 | 19 | 00040019 ✅ |
115 | 4 | 20 | 00040020 |
아까 위의 쿼리를 cursor
를 사용하도록 수정하면 하기와 같습니다.
SELECT *, CONCAT(LPAD(POW(생성일자,4,'0'), LPAD(POW(수정일자,4,'0')) AS `cursor`
FROM post
WHERE cursor > "00040019"
ORDER BY cursor ASC
LIMIT 4;
CONCAT
: MySQL 내장함수, 문자열 합침 (숫자인 경우 문자열 자동변환)LPAD
: MySQL 내장함수, 문자열 / 숫자를 지정된 길이의 문자열로 채움 (왼쪽)숫자값인 데이터를 LPAD
를 이용해 6자 길이의 고정문자열로 만들고 이를 CONCAT
으로 붙여 커스텀 cursor
를 생성합니다.
오프셋 페이징은 비교적 구현하기에 간단하지만, 문제점이 크게 2가지가 있는데요.
예시를 들며 설명해보겠습니다.
유저1이 1페이지에 접속해서 5개의 데이터(가~마
)를 확인했습니다.
이후, 유저2가 새 데이터 바
와 사
를 새롭게 등록했습니다.
❓ 이 때, 유저1이 2페이지에 접속하면 어떻게 될까요?
⇒ 1페이지에서 본 데이터 라,마
를 중복해서 보게됩니다.
다른 예시를 들어보겠습니다.
웹사이트에 많은 사람들이 동시에 접속해서 많은 글을 작성한다고 가정해봅시다! 초당 10개의 새로운 데이터가 들어오고, 한 페이지 당 10개의 데이터를 보여줍니다.
❓ 이 때, 1페이지의 10개의 데이터를 본 사람이 1초 뒤 2페이지를 눌렀을 때 어떻게 될까요?
=> 1페이지에서 봤던 10개의 데이터를 중복해서 그대로 보게 됩니다.
유저가 1페이지에 접속해서 5개의 데이터(가~마
)를 확인합니다.
이후 관리자가 가,나
의 데이터를 삭제했습니다.
이 때, 사용자가 2페이지를 클릭하면 볼 수 있는 데이터는 무엇일까요?
⇒ 사용자는 재접속하지 않는 이상 a,b
를 볼 수 없게됩니다. (데이터 누락)
offset 페이징은 데이터가 수정되는 것을 신경쓰지 않으며, 단순히 쿼리 결과문의 다음 5개 데이터를 받기 때문에 이련 현상이 발생합니다.
데이터는 정렬 기준(ORDER BY)에 따라서 row의 순서가 바뀌게 되는데, DB는 모든 경우에 따른 rownum을 가지고 있지 않기 때문에, 해당 row가 몇번째 순서를 갖는지 알지 못합니다.
그렇기 때문에 SQL 내장 OFFSET
절의 내부 동작방식을 본다면
요청한 데이터를 바로 조회❌
=> 이전의 데이터를 모두 조회 후 그 ResultSet에서 offset을 조건으로 잘라내는 방식
입니다. 이는 row의 수가 많아지고 offset 값이 올라갈수록 쿼리의 퍼포먼스가 떨어지는 문제가 발생합니다.
offset vs cursor 페이징의 시간 복잡성을 비교해보면 아래와 같습니다.
그렇다고 무조건 오프셋 페이징을 사용하면 안되는 것일까요? ❌
커서 기반 페이징이 효율적이며, 가능한 사용되는 것이 좋지만 단점이 존재하지 않는 것은 아닙니다.
커서 페이징을 사용하려면 특징 지점으로 커서를 지정할 수 있어야하고, 때문에 필요한 요구사항으로 아래와 같은 조건이 충족되어야합니다.
❗ 정렬할 컬럼에 중복된 값이 존재하면 안되고, 순차적이어야 한다.
이러한 요구사항 때문에 대부분의 커서 페이징은 timestamp
컬럼을 기준으로 합니다. 작은 단위의 timestamp
는 순차적이고 고유하기 때문입니다.
고유하지 않은 컬럼으로 정렬한다면 오프셋 페이징보다 더 느려질 수 있다.
오프셋 페이징의 구현이 무척 쉽기 때문에 그에 비해 커서 페이징이 더 까다롭게 느껴질 수 있습니다.
따라서 시간이 부족하고, 실시간 데이터가 아니라면 굳이 커서로 할 필요는 없습니다.