MSSQL 5.6 · 5부. 성능

페이징

OFFSET FETCH 와 키 기반 페이징을 비교하고, 총 건수를 세는 값을 따집니다.

예상 학습 시간 20분 난이도 고급
문제

뒤로 갈수록 느려집니다

2.7 에서 배운 OFFSET … FETCH 로 게시판 목록을 20개씩 냅니다. 몇 쪽을 보느냐에 따라 읽는 양이 달라집니다.

SQL
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쪽
결과 — 언제나 20건을 내는데
몇 쪽OFFSET논리적 읽기
1쪽03
100쪽2,00024
1,000쪽20,000188
5,000쪽100,000916
마지막 쪽166,6401,515
1쪽과 마지막 쪽이 500배입니다. 내보내는 것은 언제나 20건입니다.

계획을 보면 까닭이 한 줄로 드러납니다.

SQL
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
결과
|--Top(OFFSET EXPRESSION:((100000)),TOP EXPRESSION:((20))) |--Index Seek(OBJECT:([POSTS_BIG].[IX_POSTS_BIG_list]), SEEK:([board_num]=(2)) ORDERED FORWARD)
아래에서 100,020행을 올려 보내고, 위에서 앞의 100,000행을 버립니다.

OFFSET 은 건너뛰는 것이 아니라 읽고 버리는 것입니다. 100,000 을 건너뛰려면 100,000행을 실제로 읽어야 합니다. 5.1 에서 본 "아래에서 몇 행이 올라오는가" 가 그대로 드러납니다.

대부분의 서비스에서는 이것이 문제가 되지 않습니다. 사람은 3쪽 넘게 넘기지 않습니다. 문제가 되는 것은 사람이 아니라 기계입니다 — 목록을 처음부터 끝까지 긁어 가는 수집기나, 전체를 내려받는 배치가 마지막 쪽까지 훑습니다.

해법

몇 번째가 아니라 어디서부터

"100,000행을 건너뛴 다음 20건" 이 아니라 "이 값 다음부터 20건" 으로 적으면 건너뛸 것이 없습니다. 이것을 키셋 페이징이라고 합니다.

앞 쪽의 마지막 행의 정렬 키를 받아 그다음부터 읽습니다. 이 목록은 group_num DESC, sort_no 로 정렬하므로 그 둘을 넘깁니다.

SQL
-- 앞 쪽 마지막 행이 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쪽33
100쪽243
1,000쪽1883
5,000쪽9163
마지막 쪽1,5153
계획
|--Top(TOP EXPRESSION:((20))) |--Index Seek(OBJECT:([POSTS_BIG].[IX_POSTS_BIG_list]), SEEK:([board_num]=(2) AND [group_num] <= (200002)), WHERE:([group_num]<(200002) OR [sort_no]>(0)) ORDERED FORWARD)
그 자리로 바로 들어가 20건만 읽고 끝냅니다.

조건 모양이 중요합니다

같은 뜻인데 인덱스를 사용하지 못하는 모양이 있습니다. 5.4 가 여기서 다시 나옵니다.

SQL
-- 흔히 이렇게 적습니다. 뜻은 맞지만 인덱스로 들어가지 못합니다.
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)
결과 — 5,000쪽에서
적는 모양논리적 읽기계획
OR 로만 묶음916SEEK:(board_num=2) 뿐
범위를 따로 적음3SEEK:(board_num=2 AND group_num<=…)
OR 로만 묶으면 board_num 으로만 찾아 들어가고 나머지는 걸러 냅니다. OFFSET 과 다를 것이 없습니다.

범위 조건을 하나 더 적어 주는 것이 요령입니다. group_num <= 200002 는 뒤의 OR 조건에 이미 들어 있어 결과를 바꾸지 않지만, 엔진에게 어디서부터 읽으면 되는지 알려 줍니다.

키셋 페이징을 적었다면 반드시 계획을 확인하십시오. 조건이 SEEK: 에 들어갔는지만 보면 됩니다. 들어가지 않았다면 고생만 하고 얻은 것이 없습니다.

비교

둘 중 무엇을 씁니까

OFFSET/FETCH키셋
깊은 쪽느려집니다그대로입니다
"7쪽으로" 처럼 건너뛰기됩니다되지 않습니다
쪽 번호 보이기됩니다되지 않습니다
읽는 중에 글이 늘면같은 글이 두 번 보입니다그렇지 않습니다
적기간단합니다정렬 키를 넘겨야 합니다

쪽 번호를 늘어놓는 화면이라면 OFFSET 이 맞습니다. 사람이 5,000쪽으로 바로 가는 일은 없고, 있어도 그 한 번은 916장이면 됩니다.

"더 보기" 나 무한 스크롤이라면 키셋이 맞습니다. 어차피 다음 쪽으로만 가고, 읽는 도중에 새 글이 올라와도 목록이 흔들리지 않습니다.

목록을 내려받는 API 라면 키셋만 두는 편이 낫습니다. ?page=99999 같은 요청을 누가 보낼지 모릅니다. 키셋에는 애초에 그런 매개 변수가 없습니다.

총 건수

세는 것이 목록보다 비쌉니다

쪽 번호를 내려면 전부 몇 건인지 알아야 합니다. 그런데 세는 일은 목록을 가져오는 일보다 훨씬 비쌉니다.

SQL
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
한 번에 내려던 것이 500배가 되었습니다. 16만 6천 행마다 총 건수를 붙이기 때문입니다.

COUNT(*) OVER () 는 편해 보이지만 가장 비쌉니다. TOP (20) 이 있어도 총 건수를 알려면 전부 세어야 하고, 그 값을 모든 행에 붙인 뒤에야 20건을 자릅니다.

세지 않는 방법들

방법언제
첫 쪽에서만 세고 화면이 들고 다닙니다쪽을 넘기는 동안 건수가 조금 달라져도 되는 경우
한 건 더 가져와 "다음 쪽 있음" 만 판단합니다더 보기·무한 스크롤
건수를 따로 표에 두고 갱신합니다게시판마다 글 수를 늘 보여 주는 경우(3.6)
통계에서 대략만 읽습니다"약 50만 건" 으로 충분한 경우
SQL
-- 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건 가져오기321건이면 다음 쪽 있음
통계에서 읽기4500,000
통계에서 읽는 값은 조건을 걸 수 없고 정확하지도 않습니다. 표 전체의 대략적인 크기를 볼 때만 사용합니다.
주의

정렬이 유일하지 않으면 흔들립니다

ORDER BY hit_count 로 20개씩 가져온다고 합시다. 이 표에는 같은 조회수가 500건씩 있습니다. 같은 값끼리의 순서는 정해져 있지 않습니다.

그래서 1쪽과 2쪽에 같은 글이 겹쳐 나오거나 어떤 글이 아예 빠질 수 있습니다. 계획이 바뀌면 순서도 바뀌기 때문입니다. 우연히 잘 나오는 동안에는 아무도 눈치채지 못합니다.

SQL
-- 위험합니다. hit_count 가 같은 행끼리의 순서가 정해져 있지 않습니다.
ORDER BY hit_count DESC

-- 유일한 열을 마지막에 붙입니다.
ORDER BY hit_count DESC, num DESC

정렬 키의 마지막에는 언제나 유일한 열을 두십시오. 대개 기본 키입니다. 그러면 순서가 하나로 정해지고, 키셋 페이징도 그 열로 이어 갈 수 있습니다.

Board.POSTS 의 목록은 group_num DESC, sort_no 로 정렬합니다(3.9). 이 둘의 조합은 글마다 유일하므로 안전합니다. 계층 정렬을 두면 페이징이 함께 안정됩니다.

연습

직접 해보기

1. 실습 게시판을 키셋으로 넘깁니다 난이도 하

Board.POSTS 의 자유 게시판을 계층 정렬 그대로 20개씩 냅니다. 키셋 방식 프로시저를 만들어 보세요. 첫 쪽과 다음 쪽을 같은 프로시저로 처리하십시오.

CREATE OR ALTER PROCEDURE Board.P_POST_PAGE @board_num int, @group_num int = NULL, -- 앞 쪽 마지막 행의 값. 첫 쪽이면 NULL 입니다. @sort_no int = NULL, @size int = 20 AS BEGIN SET NOCOUNT ON; SELECT TOP (@size + 1) -- 한 건 더 가져와 다음 쪽이 있는지 봅니다 num, title, user_num, reg_date, depth, group_num, sort_no FROM Board.POSTS WHERE board_num = @board_num AND (@group_num IS NULL -- 첫 쪽 OR (group_num <= @group_num -- 범위를 따로 적습니다 AND (group_num < @group_num OR sort_no > @sort_no))) ORDER BY group_num DESC, sort_no; END GO -- 첫 쪽 EXEC Board.P_POST_PAGE @board_num = 2; -- 첫 쪽 20번째 행이 group_num = 325, sort_no = 0 이었으므로 다음 쪽은 EXEC Board.P_POST_PAGE @board_num = 2, @group_num = 325, @sort_no = 0; -- 21건이 오면 다음 쪽이 있다는 뜻입니다. 화면에는 20건만 내보냅니다. -- 주의: @group_num IS NULL 을 OR 로 묶었으므로 첫 쪽과 다음 쪽의 -- 계획이 하나로 만들어집니다. 매개 변수가 NULL 인지에 따라 -- 읽는 양이 달라지므로(5.3), 목록이 커지면 OPTION (RECOMPILE) 을 -- 붙이거나 프로시저를 둘로 나누는 편이 낫습니다.
2. 관리자 목록이 뒤로 갈수록 느립니다 난이도 중

관리자 화면이 아래 문장으로 목록을 냅니다. 검색 조건이 없을 때 뒤쪽이 느립니다. 무엇을 확인하고 어떻게 고치겠습니까. 쪽 번호는 그대로 두어야 합니다.

SQL
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) 총 건수부터 뗍니다. 가장 비싼 자리입니다. -- COUNT(*) OVER () 는 444,708장입니다. -- 첫 쪽에서만 한 번 세고 화면이 들고 다니게 합니다. SELECT COUNT(*) FROM Board.POSTS_BIG WHERE board_num = 2; -- 1,515장, 첫 쪽에서만 -- (2) 정렬 열에 인덱스가 없습니다. -- ORDER BY reg_date DESC 인데 IX_POSTS_BIG_list 는 -- (board_num, group_num DESC, sort_no) 라 정렬을 받아 주지 못합니다. -- Sort 가 붙고 16만 6천 행을 다 읽어 정렬합니다(5.1). -- -- |--Top(OFFSET EXPRESSION:((20000)),TOP EXPRESSION:((20))) -- |--Sort(TOP 20020, ORDER BY:([reg_date] DESC)) -- |--Index Seek(([IX_POSTS_BIG_list]), SEEK:([board_num]=(2))) -- -- 1,000쪽이 1,515장입니다. 인덱스를 두면 150장이 되고 Sort 도 사라집니다. CREATE INDEX IX_POSTS_BIG_admin ON Board.POSTS_BIG (board_num, reg_date DESC, num DESC) INCLUDE (title, user_num); -- (3) 정렬 키가 유일하지 않습니다. -- reg_date 가 같은 글이 있으면 쪽마다 순서가 흔들립니다. -- 위 인덱스처럼 num 을 마지막에 붙이고 문장도 맞춥니다. ORDER BY reg_date DESC, num DESC -- 고친 문장 SELECT num, title, user_num, reg_date FROM Board.POSTS_BIG WHERE board_num = 2 ORDER BY reg_date DESC, num DESC OFFSET @skip ROWS FETCH NEXT 20 ROWS ONLY; -- 남는 것은 OFFSET 자체입니다. -- 쪽 번호를 유지해야 하므로 OFFSET 은 그대로 둡니다. -- 대신 뒤쪽으로 갈수록 느린 것은 남습니다. -- 관리자 화면이라 사람이 몇 명 되지 않고, -- 1,000쪽을 눌러도 한 번에 188장이면 견딜 만합니다. -- 정말 끝까지 훑어야 한다면 (내보내기 같은 것) -- 쪽 번호를 버리고 키셋으로 도는 별도 경로를 두십시오. -- OFFSET 으로 8,000쪽을 도는 것과 읽는 양이 수천 배 차이입니다. DROP INDEX IX_POSTS_BIG_admin ON Board.POSTS_BIG;

연습 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).