정렬·제한·중복 제거
ORDER BY는 순서를, LIMIT은 그 순서에서 잘라 낼 범위를, DISTINCT는 고른 열 조합의 중복을 맡는다는 것을 고정된 실습 데이터로 확인합니다. 같은 값이 있을 때 유일한 키로 순서를 완성하는 방법과, OFFSET 대신 마지막으로 본 행 뒤에서 이어 읽는 키셋 페이지 처리를 다룹니다.
LIMIT 20만 붙인 게시글 목록은 매번 같은 20행을 준다는 보장이 없습니다. 관계형 결과는 ORDER BY가 없으면 순서가 정해지지 않고, 작성 시각이 같은 행이 있으면 열 하나로 정렬해도 페이지가 나뉘는 위치가 흔들릴 수 있습니다.
이 글에서는 최신 게시글 화면을 만들면서 정렬 기준과 같은 값을 가르는 키, 행의 제한, 중복의 제거를 나누어 봅니다. ORDER BY는 순서를, LIMIT은 그 순서에서 잘라 낼 범위를, DISTINCT는 고른 열 조합의 중복을 맡습니다. 3장 1편에서 만든 query_post_demo를 그대로 씁니다.
정렬 없는 LIMIT과 밀리는 OFFSET
다음 쿼리는 최대 세 행을 돌려주지만, 어떤 세 행인지는 정하지 않았습니다. 저장 엔진이 지금 읽는 경로가 우연히 기본 키의 순서처럼 보여도, 인덱스나 실행 계획이 바뀌면 결과의 순서가 달라질 수 있습니다.
SELECT id, title, created_at
FROM query_post_demo
LIMIT 3 OFFSET 3;LIMIT은 순서를 만들지 않습니다. 그래서 이 쿼리의 결과는 교재에 적지 않으며, 지금 실행해 본 결과를 다음에도 같은 결과로 기대해서도 안 됩니다.
순서를 정하더라도 OFFSET에는 다른 문제가 있습니다. OFFSET은 앞의 행을 몇 개 건너뛴다는 뜻이어서, 앞쪽에 행이 추가되거나 지워지면 같은 숫자가 다른 위치를 가리킵니다. 새 글일수록 id가 크다고 보고 id의 내림차순으로 두 번째 페이지를 읽은 뒤, 맨 앞에 새 글 하나를 넣고 다시 읽어 봅니다. 실습 데이터는 그대로 두어야 하므로 트랜잭션 안에서 넣고 되돌립니다.
SELECT id FROM query_post_demo ORDER BY id DESC LIMIT 3 OFFSET 3;
START TRANSACTION;
INSERT INTO query_post_demo (
id, author_id, title, content, view_count, created_at, published_at, status
) VALUES (
15, 1, 'OFFSET 실험', '새 글이 맨 앞에 끼어듭니다.', 0, '2026-07-15 09:00:00', NULL, 'DRAFT'
);
SELECT id FROM query_post_demo ORDER BY id DESC LIMIT 3 OFFSET 3;
ROLLBACK;id
11
10
9
id
12
11
10첫 페이지에서 14, 13, 12를 본 사용자가 새 글이 들어온 뒤 두 번째 페이지를 요청하면, 이미 본 12를 다시 보게 되고 9는 세 번째 페이지로 밀려납니다. 반대로 첫 페이지의 행 하나가 지워지면, 두 번째 페이지의 첫 행이 첫 페이지로 올라가 아무도 보지 못한 채 지나갑니다. 데이터가 계속 바뀌는 목록이라면 이 밀림을 감안해야 합니다.
재현 가능한 정렬 순서
정렬은 첫 번째 표현식이 같을 때 두 번째 기준을, 그것도 같을 때 세 번째 기준을 적용합니다. ORDER BY created_at DESC만 쓰면 작성 시각이 같은 행끼리의 순서는 정해지지 않으므로, 끝에 유일한 키를 더해야 전체의 순서가 하나로 정해집니다. 최신 게시글이라면 created_at DESC, id DESC처럼 시각과 유일한 키를 같은 방향으로 둡니다.
LIMIT은 이렇게 정해진 순서에서 앞부분을 고릅니다. DISTINCT는 결과 열의 조합을 비교해 같은 행을 하나로 줄이는 것이며, 정렬하거나 대표 행을 고르는 기능이 아닙니다.
ORDER BY와 DISTINCT는 표준 SQL입니다. LIMIT ... OFFSET ...은 MySQL의 문법이고, 표준 SQL과 다른 DBMS는 OFFSET ... FETCH FIRST 같은 형태를 쓰기도 합니다. MySQL은 LIMIT offset, count도 받아들이지만, 이 교재는 인자의 뜻이 드러나는 LIMIT count OFFSET offset을 씁니다.
더 깊이: LIMIT이 정렬의 방식과 같은 값의 순서를 바꾸는 이유
ORDER BY에 LIMIT을 함께 쓰면, MySQL은 정렬된 결과의 앞쪽 행을 필요한 수만큼 찾는 즉시 정렬을 멈추고 나머지는 정렬하지 않습니다. 인덱스의 순서를 그대로 쓸 수 있으면 앞에서부터 몇 행만 읽고 끝나므로 아주 빠릅니다. 반면 인덱스로 정렬할 수 없어 파일 정렬(filesort)을 해야 한다면, 조건에 맞는 행을 LIMIT이 없을 때처럼 모두 고른 뒤 그 대부분을 정렬해야 앞의 행이 정해집니다. 이때 옵티마이저는 임시 파일을 쓰지 않고 메모리 안에서 정렬하는 방식을 고를 수 있습니다.
이 차이는 같은 값의 순서에서도 드러납니다. MySQL 8.4 매뉴얼은 ORDER BY의 열 값이 같은 행들은 서버가 어떤 순서로 돌려줘도 되고 실행 계획에 따라 달라질 수 있다고 적으며, 실행 계획을 바꾸는 요인으로 LIMIT 자체를 듭니다. 그래서 ORDER BY created_at DESC만 쓴 쿼리에서 같은 시각인 두 행의 순서는, LIMIT 없이 실행할 때와 LIMIT 3을 붙일 때 서로 다를 수 있습니다. 표준 SQL이 요구하는 것은 ORDER BY에 적은 열의 순서뿐이므로 이것은 오류가 아니며, 언제나 같은 순서를 얻으려면 유일한 키를 마지막 기준으로 더하는 수밖에 없습니다.
DISTINCT와 LIMIT을 함께 쓰면 서로 다른 행을 지정한 수만큼 찾는 즉시 멈춥니다. LIMIT 0은 곧바로 빈 결과를 돌려주므로, 결과 행 없이 쿼리가 올바른지나 결과 열의 타입만 확인하고 싶을 때 쓸 수 있습니다.
시각과 기본 키로 정렬한다
작은 목록이라면 OFFSET으로 페이지를 나누어도 되지만, 정렬 기준만큼은 완전해야 합니다. 작성자 1의 최신 글 첫 페이지는 다음처럼 작성 시각과 게시글 id를 함께 씁니다.
SELECT id, title, created_at
FROM query_post_demo
WHERE author_id = 1
ORDER BY created_at DESC, id DESC
LIMIT 3;id | title | created_at
12 | LIMIT 실습 | 2026-07-13 15:00:00.000000
11 | 정렬 동률 | 2026-07-13 15:00:00.000000
9 | SQL 함수 | 2026-07-13 13:00:00.00000012번과 11번은 작성 시각이 같지만, 두 번째 기준인 id DESC가 12번을 앞에 둡니다. 데이터가 바뀌지 않는 한 이 쿼리는 언제나 같은 결과를 냅니다.
페이지가 깊어지거나 읽는 사이에 새 행이 들어와도 페이지를 안정적으로 나누려면, 마지막으로 본 (created_at, id)보다 뒤에 오는 행을 찾는 키셋(keyset) 방식이 알맞습니다. 반면 임의의 페이지 번호로 곧장 이동해야 하고 데이터가 바뀌어 조금 밀리는 것을 받아들일 수 있다면 OFFSET이 단순합니다. 요구가 다르므로 어느 한 방식을 절대적인 규칙으로 삼지 않습니다.
마지막 행 뒤에서 이어 읽는다
첫 페이지의 마지막 행은 작성 시각이 2026-07-13 13:00:00인 9번입니다. 다음 쿼리는 그 행 뒤에서 이어 읽습니다. 정렬이 내림차순이므로 "뒤"는 시각이 더 이르거나, 시각이 같으면서 id가 더 작은 행입니다.
SELECT id, title, created_at
FROM query_post_demo
WHERE author_id = 1
AND (
created_at < '2026-07-13 13:00:00'
OR (created_at = '2026-07-13 13:00:00' AND id < 9)
)
ORDER BY created_at DESC, id DESC
LIMIT 3;id | title | created_at
5 | 정렬 연습 | 2026-07-12 13:30:00.000000
2 | SQL WHERE | 2026-07-11 09:00:00.000000
1 | SELECT 기초 | 2026-07-10 08:00:00.000000두 페이지를 이으면 작성자 1의 여섯 행이 빠짐없이 한 번씩 나옵니다. MySQL은 (created_at, id) < (?, ?) 같은 행 생성자의 비교도 지원하지만, 여기서는 다른 DBMS로 옮기기 쉽고 비교의 뜻이 드러나도록 두 조건으로 풀어 썼습니다. 어느 형태든 ORDER BY의 열과 방향, 같은 값을 가르는 키가 커서의 조건과 일치해야 합니다.
같은 시각은 ID로 풀고 마지막 행 뒤에서 이어 읽기
시각과 ID를 모두 DESC로 둔다. 커서보다 작은 사전식 값을 읽는다. 각 단계의 값과 전환 이유를 비교합니다.
PAGINATION · TIME + UNIQUE KEY
같은 시각은 ID로 풀고 마지막 행 뒤에서 이어 읽기
author_id=1의 고정 fixture를 created_at DESC, id DESC로 정렬합니다. 첫 페이지 마지막 (시각, id=9)이 다음 페이지의 경계입니다.
세로 연결선은 정렬상 뒤로 읽는 순서입니다. 점선은 첫 페이지 마지막 값 뒤의 경계이고, 같은 시각에서는 더 작은 ID만 이어 읽습니다.
커서와 ORDER BY는 같은 열·방향을 사용합니다.
created_at < cursor_time OR (created_at = cursor_time AND id < cursor_id)
이 결과는 데이터가 고정된 fixture입니다. 키셋은 위치 숫자 대신 값을 기준으로 이어 읽지만 수정 가능한 정렬 키나 다른 스냅샷까지 자동 고정하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 정렬부터 다음 페이지까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 같은 시각의 동률을 ID로 해소한 순서와 첫 페이지 뒤의 커서 경계 |
| 관계 설명 | 첫 페이지의 12,11,9 뒤에서 시간과 ID 커서가 다음 5,2,1을 고릅니다. 12와 11은 같은 15시지만 큰 ID가 먼저 옵니다. 같은 6행이 모바일에서도 같은 순서로 이어집니다. |
| 노드·선 표기 | 커서 뒤의 다음 3행 |
| 노드·선 표기 | 동률 15:00은 12 → 11 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 정렬 시각과 ID를 모두 DESC로 둔다. |
|
|
| 첫 페이지 LIMIT 3을 읽는다. |
|
|
| 다음 페이지 커서보다 작은 사전식 값을 읽는다. |
|
|
같은 시각은 ID로 풀고 마지막 행 뒤에서 이어 읽기
시각과 ID를 모두 DESC로 둔다. 커서보다 작은 사전식 값을 읽는다. 각 단계의 값과 전환 이유를 비교합니다.
PAGINATION · TIME + UNIQUE KEY
같은 시각은 ID로 풀고 마지막 행 뒤에서 이어 읽기
author_id=1의 고정 fixture를 created_at DESC, id DESC로 정렬합니다. 첫 페이지 마지막 (시각, id=9)이 다음 페이지의 경계입니다.
세로 연결선은 정렬상 뒤로 읽는 순서입니다. 점선은 첫 페이지 마지막 값 뒤의 경계이고, 같은 시각에서는 더 작은 ID만 이어 읽습니다.
커서와 ORDER BY는 같은 열·방향을 사용합니다.
created_at < cursor_time OR (created_at = cursor_time AND id < cursor_id)
이 결과는 데이터가 고정된 fixture입니다. 키셋은 위치 숫자 대신 값을 기준으로 이어 읽지만 수정 가능한 정렬 키나 다른 스냅샷까지 자동 고정하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 정렬부터 다음 페이지까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 같은 시각의 동률을 ID로 해소한 순서와 첫 페이지 뒤의 커서 경계 |
| 관계 설명 | 첫 페이지의 12,11,9 뒤에서 시간과 ID 커서가 다음 5,2,1을 고릅니다. 12와 11은 같은 15시지만 큰 ID가 먼저 옵니다. 같은 6행이 모바일에서도 같은 순서로 이어집니다. |
| 노드·선 표기 | 커서 뒤의 다음 3행 |
| 노드·선 표기 | 동률 15:00은 12 → 11 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 정렬 시각과 ID를 모두 DESC로 둔다. |
|
|
| 첫 페이지 LIMIT 3을 읽는다. |
|
|
| 다음 페이지 커서보다 작은 사전식 값을 읽는다. |
|
|
DISTINCT는 고른 열의 조합을 줄인다
DISTINCT는 글을 쓴 회원의 목록처럼, 정말 같은 값의 조합을 하나로 줄여야 할 때 씁니다. 다음 두 쿼리를 비교하면 DISTINCT가 행 전체가 아니라 SELECT 목록에 고른 열의 조합을 비교한다는 것이 드러납니다.
SELECT DISTINCT author_id
FROM query_post_demo
WHERE status = 'PUBLISHED'
ORDER BY author_id;
SELECT DISTINCT author_id, title
FROM query_post_demo
WHERE title = '설치 메모';author_id
1
2
3
author_id | title
3 | 설치 메모공개 글 10개의 작성자는 세 명으로 줄었습니다. 4번과 7번은 서로 다른 글이지만, 고른 열인 작성자와 제목이 같으므로 한 행이 되었습니다. id를 SELECT 목록에 넣으면 두 행은 다시 구별됩니다.
조인을 잘못해 행이 늘어난 것을 감추려고 SELECT 전체에 DISTINCT를 붙이면, 잘못된 관계 조건과 중복을 없애는 비용이 함께 숨습니다. 먼저 중복이 데이터의 뜻인지 조인의 결함인지 확인하고, 대표 행이 필요하다면 집계나 윈도 함수처럼 목적에 맞는 방법을 고릅니다.
페이지 처리에서 따질 것
페이지 처리는 쿼리 하나만 확인해서는 부족합니다. 첫 페이지를 읽은 뒤 정렬상 앞에 새 행을 넣고 두 번째 페이지를 요청해 OFFSET과 키셋의 차이를 직접 보고, 커서가 가리키는 마지막 행을 지웠을 때도 다음 페이지가 바르게 이어지는지 확인합니다. 커서의 값에 NULL이 들어갈 수 있으면 비교의 규칙이 훨씬 복잡해지므로, 정렬 키는 되도록 NOT NULL로 설계하거나 NULL의 위치와 비교식을 함께 정의합니다.
돌려받은 행이 제한보다 적다고 해서 마지막 페이지라고 단정하는 것도, 데이터가 바뀌는 환경에서는 조심해야 합니다. 화면에 "다음 페이지 있음"을 보여야 한다면, 제한보다 한 행을 더 읽어 다음 행이 있는지만 확인하는 방법을 씁니다. 페이지의 크기에는 상한을 두어, 한 요청이 끝없는 결과를 요구하지 못하게 합니다.
정렬 기준은 화면 기능의 일부이므로, 인덱스가 있다는 이유로 바꾸면 안 됩니다. 사용자가 "최근 글"과 "조회수가 높은 글"을 고를 수 있다면 각각의 완전한 ORDER BY와 커서의 형식을 정의하고, 커서에 정렬 방식도 담아 다른 정렬에서 재사용되지 않게 합니다. 정렬 키가 수정될 수 있는 값이라면 이미 내준 커서가 가리키는 위치도 움직이므로, 커서의 만료 시간이나 읽는 시점의 고정 같은 방법을 정합니다.
서버가 정렬의 열이나 정렬 규칙을 바꾸면 이전에 내준 커서는 더 이상 같은 위치를 뜻하지 않습니다. 그래서 커서에는 정렬 값과 id를 함께 담아 서버가 서명하고, 형식의 버전과 만료를 넣어 함부로 고친 커서나 오래된 커서를 거절합니다. 목록의 캐시도 같은 정렬 버전으로 구별합니다.
대량 내보내기는 화면의 페이지와 요구가 다릅니다. 모든 행을 빠짐없이 한 번씩 읽어야 하므로 고정된 스냅샷과 id 범위의 분할을 고려하고, 마지막으로 성공한 키를 체크포인트로 남겨 다시 시작할 때 같은 비교 조건을 적용합니다. 이렇게 SQL에서 같은 값을 가르는 키가 화면의 페이지와 API의 커서, 배치의 재시작 지점까지 하나의 규칙으로 이어집니다.
정렬 표현식의 함정도 있습니다. ORDER BY DATE(created_at)는 하루 안의 모든 시각을 같은 값으로 만들므로 추가 기준이 꼭 필요하고, 문자열의 정렬은 정렬 규칙에 따라 대소문자와 악센트를 다루는 방식이 다릅니다. 숫자처럼 보이는 값을 문자열 타입으로 저장하면 '10'이 '2'보다 앞에 오므로, 보여 주는 형식과 정렬에 쓰는 타입을 나누어 둡니다.
(author_id, status, created_at, id)처럼 필터와 정렬을 함께 돕는 인덱스를 떠올릴 수 있지만, 문법만 보고 곧바로 만들지는 않습니다. 실제 조회의 빈도와 데이터의 분포를 기록한 뒤, 정렬에 드는 비용과 읽은 행의 수, 쓰기의 비용을 7장 4편에서 비교합니다.
| 따져 볼 것 | 질문 |
|---|---|
| 정렬 | 업무상의 첫 기준과 ASC, DESC를 분명히 적었는가 |
| 같은 값 | 마지막에 유일한 키를 넣어 전체의 순서를 정했는가 |
| 페이지 | 임의 이동과 삽입에 대한 안정성 가운데 무엇이 중요한가 |
| 중복 | DISTINCT가 데이터의 뜻을 나타내는가, 결함을 숨기는가 |
정리
ORDER BY가 없으면 결과의 순서는 정해지지 않으며,LIMIT은 순서를 만들지 않습니다.- 같은 값이 있을 수 있는 정렬에는 끝에 유일한 키를 더해 전체의 순서를 정합니다.
- OFFSET은 앞쪽에 행이 추가되거나 지워지면 밀리고, 키셋은 마지막으로 본 값 뒤에서 이어 읽습니다.
DISTINCT는 고른 열 조합의 중복을 줄일 뿐, 조인의 오류를 고쳐 주지 않습니다.
연습 문제
1. 조회수 순서의 커서
조회수가 높은 순서로 공개 게시글을 다섯 개씩 보여 주려고 합니다. 조회수가 같으면 작성 시각이 최근인 글이, 그것도 같으면 id가 큰 글이 먼저 와야 합니다. 첫 페이지와, 첫 페이지의 마지막 행 뒤에서 이어지는 다음 페이지를 구해 보세요.
해설 보기
첫 페이지는 세 정렬 기준을 모두 내림차순으로 두고 다섯 행을 읽습니다.
SELECT id, title, view_count, created_at
FROM query_post_demo
WHERE status = 'PUBLISHED'
ORDER BY view_count DESC, created_at DESC, id DESC
LIMIT 5;id | title | view_count | created_at
2 | SQL WHERE | 50 | 2026-07-11 09:00:00.000000
12 | LIMIT 실습 | 45 | 2026-07-13 15:00:00.000000
11 | 정렬 동률 | 40 | 2026-07-13 15:00:00.000000
3 | NULL 복습 | 40 | 2026-07-11 10:00:00.000000
5 | 정렬 연습 | 35 | 2026-07-12 13:30:00.00000011번과 3번은 조회수가 40으로 같지만, 11번의 작성 시각이 더 최근이어서 앞에 옵니다. 마지막 행은 조회수 35, 작성 시각 2026-07-12 13:30:00, id 5입니다. 세 기준이 모두 내림차순이므로 그 뒤는 더 작은 값이며, 앞의 열이 같을 때만 다음 열을 비교하는 조건으로 적습니다.
SELECT id, title, view_count, created_at
FROM query_post_demo
WHERE status = 'PUBLISHED'
AND (
view_count < 35
OR (view_count = 35 AND created_at < '2026-07-12 13:30:00')
OR (
view_count = 35
AND created_at = '2026-07-12 13:30:00'
AND id < 5
)
)
ORDER BY view_count DESC, created_at DESC, id DESC
LIMIT 5;id | title | view_count | created_at
6 | MySQL 인덱스 | 25 | 2026-07-12 15:00:00.000000
1 | SELECT 기초 | 25 | 2026-07-10 08:00:00.000000
10 | LIKE 복습 | 20 | 2026-07-13 14:00:00.000000
7 | 설치 메모 | 10 | 2026-07-12 16:00:00.000000
4 | 설치 메모 | 10 | 2026-07-11 11:00:00.0000006번과 1번처럼 조회수가 같을 때도 작성 시각이 먼저 비교됩니다. 두 페이지를 합치면 공개 게시글 10행이 한 번씩 나타나며, 커서 조건과 ORDER BY의 열 순서와 방향 가운데 하나라도 다르면 중복이나 누락이 생깁니다.
2. 커서의 행이 지워지면
본문의 첫 페이지를 읽은 뒤 커서가 가리키던 9번이 지워졌다고 해 봅시다. 트랜잭션 안에서 DELETE FROM query_post_demo WHERE id = 9;를 실행하고, 본문의 키셋 다음 페이지와 LIMIT 3 OFFSET 3으로 읽은 두 번째 페이지를 비교한 뒤 ROLLBACK해 보세요.
해설 보기
키셋의 다음 페이지는 여전히 5, 2, 1입니다. 커서의 조건은 9번 행이 있는지가 아니라 그 행의 값과 비교하므로, 행이 지워져도 이어 읽을 위치는 그대로입니다.
OFFSET으로 읽으면 정렬된 목록이 12, 11, 5, 2, 1로 바뀌어 세 행을 건너뛴 두 번째 페이지는 2와 1뿐입니다. 5번은 이미 읽은 첫 페이지의 자리로 올라가 버려 사용자는 끝내 보지 못합니다. 앞에 행이 추가될 때 중복이 생기듯, 지워질 때는 누락이 생깁니다.
3. 다음 페이지가 있는지 알기
본문의 첫 페이지에서, 다음 페이지가 있는지를 별도의 COUNT 없이 알려면 어떻게 하면 되나요?
해설 보기
보여 줄 행보다 한 행을 더 읽습니다. 첫 페이지 쿼리를 LIMIT 4로 실행하면 12, 11, 9, 5의 네 행이 오므로, 처음 세 행만 보여 주고 다음 페이지가 있다고 표시합니다. 네 번째 행은 존재를 확인하는 데만 쓰고 커서는 세 번째 행인 9번의 값으로 만듭니다. 정확한 총 건수가 필요하지 않은 화면이라면, 본문과 따로 세는 COUNT의 비용과 두 조회 사이의 시점 차이를 피할 수 있습니다.
다음 글 NULL과 세 값 논리에서는 같다는 비교가 성립하지 않는 NULL을, 참과 거짓에 알 수 없음을 더한 세 값 논리로 해석합니다.