페이징
OFFSET FETCH 와 키 기반 페이징을 비교하고, 총 건수를 세는 값을 따집니다.
뒤로 갈수록 느려집니다
2.7 에서 배운 OFFSET … FETCH 로 게시판 목록을
20개씩 냅니다. 몇 쪽을 보느냐에 따라 읽는 양이 달라집니다.
SET STATISTICS IO ON; -- 자유 게시판 16만 6천 건을 20개씩 봅니다. SELECT num, title FROM Board.POSTS_BIG WHERE board_num = 2 ORDER BY group_num DESC, sort_no OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY; -- 1쪽 SELECT num, title FROM Board.POSTS_BIG WHERE board_num = 2 ORDER BY group_num DESC, sort_no OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY; -- 5,000쪽
| 몇 쪽 | OFFSET | 논리적 읽기 |
|---|---|---|
| 1쪽 | 0 | 3 |
| 100쪽 | 2,000 | 24 |
| 1,000쪽 | 20,000 | 188 |
| 5,000쪽 | 100,000 | 916 |
| 마지막 쪽 | 166,640 | 1,515 |
계획을 보면 까닭이 한 줄로 드러납니다.
SET SHOWPLAN_TEXT ON; GO SELECT num, title FROM Board.POSTS_BIG WHERE board_num = 2 ORDER BY group_num DESC, sort_no OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY; GO
OFFSET 은 건너뛰는 것이 아니라 읽고
버리는 것입니다. 100,000 을 건너뛰려면 100,000행을 실제로 읽어야
합니다. 5.1 에서 본 "아래에서 몇 행이 올라오는가" 가 그대로
드러납니다.
대부분의 서비스에서는 이것이 문제가 되지 않습니다. 사람은 3쪽 넘게 넘기지 않습니다. 문제가 되는 것은 사람이 아니라 기계입니다 — 목록을 처음부터 끝까지 긁어 가는 수집기나, 전체를 내려받는 배치가 마지막 쪽까지 훑습니다.
몇 번째가 아니라 어디서부터
"100,000행을 건너뛴 다음 20건" 이 아니라 "이 값 다음부터 20건" 으로 적으면 건너뛸 것이 없습니다. 이것을 키셋 페이징이라고 합니다.
앞 쪽의 마지막 행의 정렬 키를 받아 그다음부터 읽습니다.
이 목록은 group_num DESC, sort_no 로 정렬하므로
그 둘을 넘깁니다.
-- 앞 쪽 마지막 행이 group_num = 200002, sort_no = 0 이었다고 합니다. SELECT TOP (20) num, title FROM Board.POSTS_BIG WHERE board_num = 2 AND group_num <= 200002 AND (group_num < 200002 OR sort_no > 0) ORDER BY group_num DESC, sort_no;
| 몇 쪽 | OFFSET/FETCH | 키셋 |
|---|---|---|
| 1쪽 | 3 | 3 |
| 100쪽 | 24 | 3 |
| 1,000쪽 | 188 | 3 |
| 5,000쪽 | 916 | 3 |
| 마지막 쪽 | 1,515 | 3 |
조건 모양이 중요합니다
같은 뜻인데 인덱스를 사용하지 못하는 모양이 있습니다. 5.4 가 여기서 다시 나옵니다.
-- 흔히 이렇게 적습니다. 뜻은 맞지만 인덱스로 들어가지 못합니다. WHERE board_num = 2 AND (group_num < 200002 OR (group_num = 200002 AND sort_no > 0)) -- 이렇게 적어야 SEEK 에 들어갑니다. WHERE board_num = 2 AND group_num <= 200002 AND (group_num < 200002 OR sort_no > 0)
| 적는 모양 | 논리적 읽기 | 계획 |
|---|---|---|
| OR 로만 묶음 | 916 | SEEK:(board_num=2) 뿐 |
| 범위를 따로 적음 | 3 | SEEK:(board_num=2 AND group_num<=…) |
범위 조건을 하나 더 적어 주는 것이 요령입니다.
group_num <= 200002 는 뒤의
OR 조건에 이미 들어 있어 결과를 바꾸지 않지만,
엔진에게 어디서부터 읽으면 되는지 알려 줍니다.
키셋 페이징을 적었다면 반드시 계획을 확인하십시오. 조건이
SEEK: 에 들어갔는지만 보면 됩니다. 들어가지
않았다면 고생만 하고 얻은 것이 없습니다.
둘 중 무엇을 씁니까
| OFFSET/FETCH | 키셋 | |
|---|---|---|
| 깊은 쪽 | 느려집니다 | 그대로입니다 |
| "7쪽으로" 처럼 건너뛰기 | 됩니다 | 되지 않습니다 |
| 쪽 번호 보이기 | 됩니다 | 되지 않습니다 |
| 읽는 중에 글이 늘면 | 같은 글이 두 번 보입니다 | 그렇지 않습니다 |
| 적기 | 간단합니다 | 정렬 키를 넘겨야 합니다 |
쪽 번호를 늘어놓는 화면이라면 OFFSET 이
맞습니다. 사람이 5,000쪽으로 바로 가는 일은 없고, 있어도 그 한 번은
916장이면 됩니다.
"더 보기" 나 무한 스크롤이라면 키셋이 맞습니다. 어차피 다음 쪽으로만 가고, 읽는 도중에 새 글이 올라와도 목록이 흔들리지 않습니다.
목록을 내려받는 API 라면 키셋만 두는 편이 낫습니다.
?page=99999 같은 요청을 누가 보낼지 모릅니다.
키셋에는 애초에 그런 매개 변수가 없습니다.
세는 것이 목록보다 비쌉니다
쪽 번호를 내려면 전부 몇 건인지 알아야 합니다. 그런데 세는 일은 목록을 가져오는 일보다 훨씬 비쌉니다.
SET STATISTICS IO ON; -- 목록 20건 SELECT TOP (20) num, title FROM Board.POSTS_BIG WHERE board_num = 2 ORDER BY group_num DESC, sort_no; -- 총 건수 SELECT COUNT(*) FROM Board.POSTS_BIG WHERE board_num = 2; -- 한 번에 내려는 시도 SELECT TOP (20) num, title, COUNT(*) OVER () AS 총건수 FROM Board.POSTS_BIG WHERE board_num = 2 ORDER BY group_num DESC, sort_no;
| 무엇 | 논리적 읽기 |
|---|---|
| 목록 20건 | 3 |
| COUNT(*) | 1,515 |
| COUNT(*) OVER () 로 함께 | 444,708 |
COUNT(*) OVER () 는 편해 보이지만 가장
비쌉니다. TOP (20) 이 있어도
총 건수를 알려면 전부 세어야 하고, 그 값을 모든 행에 붙인
뒤에야 20건을 자릅니다.
세지 않는 방법들
| 방법 | 언제 |
|---|---|
| 첫 쪽에서만 세고 화면이 들고 다닙니다 | 쪽을 넘기는 동안 건수가 조금 달라져도 되는 경우 |
| 한 건 더 가져와 "다음 쪽 있음" 만 판단합니다 | 더 보기·무한 스크롤 |
| 건수를 따로 표에 두고 갱신합니다 | 게시판마다 글 수를 늘 보여 주는 경우(3.6) |
| 통계에서 대략만 읽습니다 | "약 50만 건" 으로 충분한 경우 |
-- 20 대신 21건을 가져옵니다. 21건이 오면 다음 쪽이 있습니다. SELECT TOP (21) num, title FROM Board.POSTS_BIG WHERE board_num = 2 AND group_num <= 200002 AND (group_num < 200002 OR sort_no > 0) ORDER BY group_num DESC, sort_no; -- 표 전체의 대략적인 행 수는 세지 않고 읽습니다. SELECT SUM(row_count) FROM sys.dm_db_partition_stats WHERE object_id = OBJECT_ID('Board.POSTS_BIG') AND index_id IN (0, 1);
| 무엇 | 논리적 읽기 | 값 |
|---|---|---|
| 21건 가져오기 | 3 | 21건이면 다음 쪽 있음 |
| 통계에서 읽기 | 4 | 500,000 |
정렬이 유일하지 않으면 흔들립니다
ORDER BY hit_count 로 20개씩 가져온다고 합시다.
이 표에는 같은 조회수가 500건씩 있습니다.
같은 값끼리의 순서는 정해져 있지 않습니다.
그래서 1쪽과 2쪽에 같은 글이 겹쳐 나오거나 어떤 글이 아예 빠질 수 있습니다. 계획이 바뀌면 순서도 바뀌기 때문입니다. 우연히 잘 나오는 동안에는 아무도 눈치채지 못합니다.
-- 위험합니다. hit_count 가 같은 행끼리의 순서가 정해져 있지 않습니다. ORDER BY hit_count DESC -- 유일한 열을 마지막에 붙입니다. ORDER BY hit_count DESC, num DESC
정렬 키의 마지막에는 언제나 유일한 열을 두십시오. 대개 기본 키입니다. 그러면 순서가 하나로 정해지고, 키셋 페이징도 그 열로 이어 갈 수 있습니다.
Board.POSTS 의 목록은
group_num DESC, sort_no 로 정렬합니다(3.9). 이
둘의 조합은 글마다 유일하므로 안전합니다. 계층 정렬을 두면 페이징이
함께 안정됩니다.
직접 해보기
Board.POSTS 의 자유 게시판을 계층 정렬 그대로
20개씩 냅니다. 키셋 방식 프로시저를 만들어 보세요.
첫 쪽과 다음 쪽을 같은 프로시저로 처리하십시오.
관리자 화면이 아래 문장으로 목록을 냅니다. 검색 조건이 없을 때 뒤쪽이 느립니다. 무엇을 확인하고 어떻게 고치겠습니까. 쪽 번호는 그대로 두어야 합니다.
SELECT num, title, user_num, reg_date, COUNT(*) OVER () AS 총건수 FROM Board.POSTS_BIG WHERE board_num = 2 ORDER BY reg_date DESC OFFSET @skip ROWS FETCH NEXT 20 ROWS ONLY;
연습 1에서 만든 것을 지웁니다.
DROP PROCEDURE Board.P_POST_PAGE;
Board.POSTS_BIG 은 5.7 · 5.8 에서 계속 사용합니다.
OFFSET은 건너뛰는 것이 아니라 읽고 버리는 것입니다. 같은 20건을 내는데 1쪽은 3장, 마지막 쪽은 1,515장입니다.- 키셋 페이징은 "몇 번째" 가 아니라 "이 값 다음부터" 로 적습니다. 어느 쪽이든 3장입니다.
- 키셋도 조건 모양을 틀리면 소용없습니다.
OR로만 묶으면 916장 그대로이고,group_num <= @g를 따로 적어야SEEK:에 들어갑니다(5.4). - 쪽 번호를 보여야 하면
OFFSET, 더 보기·무한 스크롤·API 라면 키셋입니다. - 총 건수를 세는 일이 목록보다 비쌉니다. 목록 3장,
COUNT(*)1,515장,COUNT(*) OVER ()444,708장입니다. - 세지 않는 방법들이 있습니다 — 첫 쪽에서만 세기 · 한 건 더 가져와 다음 쪽 여부만 보기 · 건수를 표에 두기 · 통계에서 대략만 읽기.
- 정렬 키의 마지막에는 유일한 열을 두십시오. 같은 값끼리의 순서는 정해져 있지 않아 쪽마다 겹치거나 빠질 수 있습니다.
- 페이징이 느리면 정렬 열에 인덱스가 있는지부터 보십시오.
Sort가 붙어 있으면 그것이 먼저입니다(5.2).