정렬·제한·중복 제거
ORDER BY·LIMIT·DISTINCT의 책임을 분리하고 재현 가능한 페이지 경계를 만듭니다.
LIMIT 20만 붙인 게시글 목록은 매번 같은 20행을 준다는 보장이 없습니다.
관계형 결과는 ORDER BY가 없으면 순서가 정의되지 않으며, 같은 작성 시각을 가진 행이 있다면 한 컬럼 정렬만으로도 페이지 경계가 흔들릴 수 있습니다.
회원 게시판의 최신 게시글 화면을 만들면서 정렬 기준, 동률 해소 키, 행 제한, 중복 제거를 나눠 봅니다.
ORDER BY는 순서를, LIMIT은 그 순서에서 잘라낼 범위를, DISTINCT는 선택된 열 조합의 중복을 담당합니다.
정렬 없는 페이지 조회
다음 쿼리는 빠르게 3행을 보여 주지만 최신 목록이라는 요구를 표현하지 않습니다.
저장 엔진의 현재 읽기 경로가 우연히 기본 키 순서처럼 보여도 인덱스나 실행 계획이 바뀌면 결과 순서가 달라질 수 있습니다.
SELECT id, title, created_at
FROM query_post_demo
LIMIT 3 OFFSET 3;같은 데이터에서 실행 시점에 따라 두 번째 페이지가 다음처럼 겹치거나 빠지는 현상을 재현할 수 있습니다.
특히 첫 페이지를 읽은 뒤 새 행이 삽입되면 OFFSET 위치가 이동합니다.
오류 실행 결과첫 조회 2페이지: 4, 5, 6
새 게시글 삽입 후: 3, 4, 5
문제: id 3 중복, id 6 누락LIMIT은 정렬을 만들지 않습니다.
OFFSET은 앞의 행을 건너뛴다는 뜻이라 앞쪽 데이터가 추가·삭제되면 같은 숫자가 다른 경계를 가리킵니다.
한편 ORDER BY created_at DESC만 써도 동률인 행 사이 순서는 정해지지 않으므로 유일한 보조 키를 끝에 추가해야 합니다.
결정적인 정렬 순서
정렬은 첫 번째 표현식이 같을 때 두 번째, 다시 같을 때 세 번째 기준을 적용합니다.
최신 게시글이라면 created_at DESC, id DESC처럼 시간과 유일 키를 같은 방향으로 둬 전체 순서를 만듭니다.
LIMIT은 이 정의된 순서에서 앞부분을 선택합니다.
DISTINCT는 결과 열의 조합을 비교해 중복 행을 제거하므로 정렬이나 대표 행 선택 기능이 아닙니다.
ORDER BY와 DISTINCT는 표준 SQL입니다.
LIMIT ... OFFSET ...은 MySQL 문법이며 다른 DBMS는 FETCH FIRST, OFFSET ... FETCH 등을 사용할 수 있습니다.
MySQL은 LIMIT offset, count도 지원하지만 이 교재에서는 인자 의미가 드러나는 LIMIT count OFFSET offset을 사용합니다.
시간과 기본 키 정렬
작은 목록에서는 OFFSET 페이지네이션을 쓸 수 있지만, 정렬 기준은 반드시 완전해야 합니다.
첫 페이지는 다음처럼 최신 시각과 게시글 ID를 함께 사용합니다.
SELECT id, title, created_at
FROM query_post_demo
WHERE author_id = 1
ORDER BY created_at DESC, id DESC
LIMIT 3;동일한 작성 시각을 가진 행도 id가 순서를 결정합니다.
변경이 없는 같은 스냅샷에서는 언제나 같은 결과가 나옵니다.
개선 실행 결과id | title | created_at
12 | LIMIT 실습 | 2026-07-13 15:00:00
11 | 정렬 동률 | 2026-07-13 15:00:00
9 | SQL 함수 | 2026-07-13 13:00:00페이지가 깊어지거나 새 행 삽입 중에도 경계를 안정시키려면 마지막으로 본 (created_at, id)보다 작은 행을 찾는 키셋 방식이 적합합니다.
반면 임의의 페이지 번호로 곧장 이동해야 하고 데이터 변화에 따른 약간의 이동이 허용되면 OFFSET이 단순합니다.
요구가 다르므로 한 방식을 절대 규칙으로 만들지 않습니다.
커서 기반 페이지 처리
첫 페이지 마지막 행이 2026-07-13 13:00:00, ID 9였다면 다음 쿼리는 그 행 뒤에서 이어집니다.
복합 비교는 정렬 열과 같은 순서를 사용해야 합니다.
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;MySQL은 (created_at, id) < (?, ?) 같은 행 생성자 비교도 지원하지만 이식성과 경계 의미를 학습하기 위해 두 조건을 풀어 썼습니다.
어느 형태든 ORDER BY의 열, 방향, 동률 해소 키와 커서 조건이 일치해야 합니다.
DISTINCT author_id는 참여한 회원 목록처럼 정말 같은 값 조합을 하나로 줄일 때 사용합니다.
조인 실수로 행이 늘어난 원인을 감추려고 SELECT 전체에 DISTINCT를 붙이면 잘못된 관계 조건과 정렬 비용을 숨길 수 있습니다.
먼저 중복이 데이터 의미인지 조인 결함인지 확인하고, 대표 행이 필요하면 윈도 함수나 집계처럼 목적에 맞는 해법을 선택합니다.
페이지네이션은 단일 쿼리만 검증하면 부족합니다.
첫 페이지를 읽은 뒤 정렬상 앞에 새 행을 넣고 두 번째 페이지를 요청해 OFFSET과 키셋의 차이를 직접 확인하세요.
이어서 마지막 커서 행을 삭제했을 때도 다음 조건이 정상적으로 이어지는지 봅니다.
커서 값에 NULL이 포함될 수 있다면 사전식 비교 규칙이 더 복잡해지므로 정렬 키를 가능한 한 NOT NULL로 설계하거나 NULL 위치와 비교식을 함께 정의해야 합니다.
반환 행 수가 제한보다 작다는 이유만으로 마지막 페이지라고 단정하는 것도 데이터 변경 환경에서는 조심해야 합니다.
UI의 ‘다음 있음’ 신호가 필요하면 제한보다 한 행 더 읽어 존재만 확인하는 패턴을 사용할 수 있습니다.
정렬 기준은 화면 기능의 일부이므로 인덱스가 있다는 이유로 바꾸면 안 됩니다.
사용자가 ‘최근 게시글’과 ‘조회수 높은 글’을 선택할 수 있다면 각각의 완전한 ORDER BY와 커서 형식을 정의합니다.
커서에는 정렬 모드도 포함해 다른 정렬 API에 재사용되지 않게 해야 합니다.
대량 내보내기는 페이지 UI와 요구가 다릅니다.
모든 행을 안정적으로 순회해야 하므로 고정된 스냅샷, ID 범위 분할, 재시작 지점을 고려합니다.
DISTINCT를 포함한 쿼리에서 ORDER BY 열이 결과 열과 어떤 제약을 가지는지도 DBMS별로 확인해야 합니다.
목록의 ‘보이는 순서’와 배치의 ‘누락 없는 순회’는 같은 SQL처럼 보여도 일관성 목표가 다릅니다.
정렬 표현식에 함수가 들어가면 계산 비용과 인덱스 사용 가능성을 확인합니다.
예를 들어 ORDER BY DATE(created_at)는 날짜 안의 모든 시각을 동률로 만들므로 반드시 추가 기준이 필요합니다.
로캘에 따른 문자열 정렬은 사람이 기대하는 사전식 순서와 다를 수 있고 대소문자·악센트 구분 정책도 정렬 규칙에 달려 있습니다.
숫자로 보이는 문자열을 문자열 타입으로 저장하면 10이 2보다 먼저 올 수 있으므로 표시 형식과 정렬 타입을 분리합니다.
페이지 크기에는 상한을 두어 한 요청이 무제한 결과를 요구하지 못하게 해야 합니다.
정렬 키가 수정 가능한 값이면 이미 발급한 커서가 가리키는 위치도 이동합니다.
수정 빈도가 높은 점수나 제목을 커서에 쓸 때는 짧은 만료 시간, 읽기 시점 고정, 중복 허용 후 클라이언트 제거 중 어떤 일관성을 제공할지 정합니다.
총 페이지 수를 함께 보여 주려면 별도 건수 비용과 본문 조회 사이의 시점 차이도 설명해야 합니다.
정확한 총계가 필요하지 않은 화면은 다음 페이지 존재 여부만 제공하는 편이 단순합니다.
캐시된 첫 페이지와 실시간 다음 페이지를 섞을 때도 같은 정렬 버전인지 확인합니다.
커서는 서버가 서명해 임의 조작을 거절합니다.
이번 정렬 규칙은 뒤에서 인덱스와 실행 계획을 배울 때 그대로 측정 대상이 됩니다.
(author_id, status, created_at, id)처럼 필터와 정렬을 함께 지원하는 후보 인덱스를 생각할 수 있지만, 지금 문법만 보고 곧바로 추가하지는 않습니다.
실제 조회 빈도와 데이터 분포를 기록한 뒤 파일 정렬·읽은 행 수·쓰기 비용을 비교해야 합니다.
API가 내보낸 커서는 특정 ORDER BY의 위치를 표현하므로 서버가 정렬 열을 바꾸거나 정렬 규칙을 변경하면 이전 커서가 더는 같은 위치를 뜻하지 않습니다.
커서 형식에 버전과 만료를 포함하고, 목록 캐시도 같은 정렬 버전으로 구분합니다.
배치 내보내기에서는 마지막 성공 키를 체크포인트로 남겨 재시작 시 같은 사전식 조건을 적용합니다.
이렇게 SQL의 동률 해소 키가 화면 페이지, API 커서, 배치 복구 지점까지 하나의 규칙으로 이어집니다.
목록 API의 정렬 규칙
| 판단 축 | 확인할 질문 |
|---|---|
| 정렬 | 업무상 첫 기준과 ASC·DESC가 명시됐는가? |
| 동률 | 마지막에 유일 키를 넣어 전체 순서를 만들었는가? |
| 페이지 | 임의 이동과 삽입 안정성 중 무엇이 중요한가? |
| 중복 | DISTINCT가 데이터 의미를 표현하는가, 결함을 숨기는가? |
클라이언트에 커서를 제공할 때는 정렬 값과 ID를 함께 인코딩하고 서버에서 검증합니다.
정렬 정책을 바꾸면 기존 커서 의미도 바뀌므로 API 버전이나 만료 정책을 고려합니다.
연습 문제
조회수가 높은 순서로 공개 게시글 5개를 보여 주세요.
조회수가 같으면 최근 작성 시각, 그것도 같으면 큰 id가 먼저 와야 합니다.
ch3 fixture에서 첫 페이지 마지막 행은 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;세 열의 순서와 방향이 WHERE 커서 조건과 ORDER BY에서 일치하는지 확인합니다.
어느 하나라도 다르면 중복이나 누락이 생길 수 있습니다.
핵심 정리
ORDER BY가 없으면 관계형 결과의 행 순서는 정의되지 않습니다.- 동률을 해소하는 유일 키까지 넣어 완전한 정렬을 만듭니다.
- LIMIT은 순서를 자르며 OFFSET과 키셋은 서로 다른 제품 요구를 해결합니다.
- DISTINCT는 선택 열 조합의 중복 제거이지 조인 오류 치료제가 아닙니다.
다음 문서에서는 동등 비교가 성립하지 않는 NULL을 세 값 논리로 해석합니다.