정렬과 제한
데이터를 조회하면 결과의 순서를 지정하고, 가져올 행의 수를 제한해야 하는 경우가 대부분입니다. 게시판의 최신 글 10개, 가격이 높은 순서대로 5개 — 이것이 정렬과 페이지네이션입니다.
ORDER BY
ORDER BY는 SELECT 결과를 특정 컬럼 기준으로 정렬합니다. SQL 처리 순서에서 ORDER BY는 거의 마지막에 실행됩니다(SELECT 이후, LIMIT/FETCH 직전). ORDER BY가 없는 조회 결과는 저장 순서나 인덱스 순서처럼 보일 수 있지만, SQL 표준 관점에서는 순서가 보장되지 않습니다.
-- 오름차순 (기본값, ASC 생략 가능)
SELECT * FROM products ORDER BY price ASC;
SELECT * FROM products ORDER BY price; -- 동일
-- 내림차순
SELECT * FROM products ORDER BY price DESC;-- 카테고리 오름차순 → 같으면 가격 내림차순
SELECT * FROM products
ORDER BY category ASC, price DESC;동점이 생길 수 있는 기준으로 페이지네이션이나 TOP-N을 만들 때는 고유한 컬럼을 마지막 정렬 기준으로 추가하는 것이 안전합니다. 예를 들어 가격이 같은 상품이 많다면 ORDER BY price DESC, product_id ASC처럼 tie-breaker를 둡니다.
category name price
────────── ────────── ──────
가전 에어컨 1500000
가전 선풍기 50000
의류 코트 200000
의류 티셔츠 30000
전자 노트북 1200000
전자 마우스 25000-- 별칭으로 정렬
SELECT name, price * 0.9 AS sale_price
FROM products
ORDER BY sale_price DESC;
-- 컬럼 순서 번호로 정렬 (SELECT 절의 n번째 컬럼)
SELECT name, price, stock
FROM products
ORDER BY 2 DESC; -- 2번째 컬럼(price)으로 내림차순
-- 주의: 유지보수성이 낮아 실무에서는 권장하지 않음
-- 표현식으로 정렬
SELECT name, price, stock
FROM products
ORDER BY price * stock DESC; -- 재고 가치가 높은 순
-- CASE를 사용한 사용자 정의 정렬
SELECT name, status
FROM orders
ORDER BY
CASE status
WHEN 'PENDING' THEN 1
WHEN 'PROCESSING' THEN 2
WHEN 'SHIPPED' THEN 3
WHEN 'DELIVERED' THEN 4
WHEN 'CANCELLED' THEN 5
END;ORDER BY와 성능
ORDER BY는 데이터를 메모리(또는 디스크)에서 정렬하는 작업이므로 비용이 발생합니다. 특히 대량 데이터에서는 주의가 필요합니다.
정렬 컬럼과 방향이 인덱스 순서와 잘 맞으면 별도 정렬을 줄일 수 있습니다. 반대로 표현식 정렬, 컬럼 순서와 다른 복합 정렬, 큰 OFFSET은 정렬 비용과 읽기 비용을 키울 수 있습니다.
NULL 정렬
NULL 값이 포함된 컬럼을 정렬하면, DBMS마다 기본 동작이 다릅니다.
| DBMS | NULL 위치 (ASC) | NULL 위치 (DESC) |
|---|---|---|
| Oracle | 마지막 | 처음 |
| MySQL | 처음 | 마지막 |
| PostgreSQL | 마지막 | 처음 |
| SQL Server | 처음 | 마지막 |
-- Oracle / PostgreSQL: NULLS FIRST / NULLS LAST 지원
SELECT * FROM products ORDER BY category NULLS FIRST;
SELECT * FROM products ORDER BY category NULLS LAST;
-- MySQL: NULLS FIRST/LAST 미지원, CASE로 우회
SELECT * FROM products
ORDER BY
CASE WHEN category IS NULL THEN 1 ELSE 0 END, -- NULL을 뒤로
category ASC;
-- IS NULL을 사용한 NULL 후순위 처리
SELECT * FROM products
ORDER BY category IS NULL, category ASC; -- MySQL, PostgreSQL 등
-- SQL Server: boolean 표현식을 직접 정렬하지 않고 CASE 사용
SELECT * FROM products
ORDER BY
CASE WHEN category IS NULL THEN 1 ELSE 0 END,
category ASC;NULL의 기본 정렬 위치는 DBMS와 정렬 방향에 따라 다르므로, 사용자에게 보이는 목록에서는 기본값에 의존하지 말고 NULL 위치를 명시하는 편이 좋습니다.
category name
────────── ──────────
가전 에어컨
의류 코트
전자 노트북
NULL 아이템X ← Oracle ASC에서 NULL은 마지막결과 제한
결과에서 특정 개수의 행만 가져오는 문법이 DBMS마다 다릅니다.
Oracle
-- 처음 10행
SELECT * FROM products
ORDER BY price DESC
FETCH FIRST 10 ROWS ONLY;
-- 11~20행 (페이지 2)
SELECT * FROM products
ORDER BY price DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;
-- 상위 10% 행
SELECT * FROM products
ORDER BY price DESC
FETCH FIRST 10 PERCENT ROWS ONLY;
-- 동점 포함 (WITH TIES)
SELECT * FROM products
ORDER BY price DESC
FETCH FIRST 3 ROWS WITH TIES;
-- price가 같은 행이 있으면 3개 이상 반환될 수 있음WITH TIES는 ORDER BY 기준으로 마지막 행과 동점인 행을 함께 포함합니다. 동점 포함이 필요 없다면 고유한 tie-breaker를 ORDER BY에 추가해 정확히 원하는 개수만 반환되게 만들 수 있습니다.
-- ROWNUM은 ORDER BY보다 먼저 적용될 수 있으므로 서브쿼리 필요
SELECT * FROM (
SELECT * FROM products ORDER BY price DESC
) WHERE ROWNUM <= 10;
-- 페이지네이션 (11~20행)
SELECT * FROM (
SELECT a.*, ROWNUM rn FROM (
SELECT * FROM products ORDER BY price DESC
) a WHERE ROWNUM <= 20
) WHERE rn >= 11;
-- ROW_NUMBER 방식 (더 깔끔)
SELECT * FROM (
SELECT p.*, ROW_NUMBER() OVER (ORDER BY price DESC) AS rn
FROM products p
) WHERE rn BETWEEN 11 AND 20;MySQL
-- 처음 10행
SELECT * FROM products
ORDER BY price DESC
LIMIT 10;
-- 11~20행 (페이지 2)
SELECT * FROM products
ORDER BY price DESC
LIMIT 10 OFFSET 10;
-- 또는 (LIMIT offset, count 형식)
SELECT * FROM products
ORDER BY price DESC
LIMIT 10, 10;
-- 첫 번째 숫자가 OFFSET, 두 번째가 COUNTMySQL의 LIMIT 10, 10은 짧지만 숫자의 의미를 헷갈리기 쉽습니다. 협업 코드에서는 LIMIT 10 OFFSET 10처럼 의도가 드러나는 형태가 더 읽기 쉽습니다.
PostgreSQL
-- LIMIT 방식 (MySQL과 동일)
SELECT * FROM products
ORDER BY price DESC
LIMIT 10 OFFSET 10;
-- SQL 표준 (FETCH)
SELECT * FROM products
ORDER BY price DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;SQL Server
-- TOP (정렬 없이도 사용 가능하지만 비권장)
SELECT TOP 10 * FROM products
ORDER BY price DESC;
-- TOP WITH TIES (동점 포함)
SELECT TOP 3 WITH TIES * FROM products
ORDER BY price DESC;
-- OFFSET FETCH (SQL Server 2012+)
SELECT * FROM products
ORDER BY price DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;SQL Server의 OFFSET ... FETCH는 ORDER BY가 필요합니다. 모든 DBMS에서 행 제한을 쓸 때도 ORDER BY 없이 사용하면 “상위 N개”의 의미가 안정적이지 않습니다.
DBMS별 결과 제한 문법 비교
페이지네이션 패턴
OFFSET 방식의 동작과 문제
커서 기반 페이지네이션 (Keyset Pagination)
대량 데이터에서는 OFFSET 방식 대신 커서 기반 페이지네이션을 사용합니다.
커서 기반 방식은 “몇 번째 페이지로 바로 이동”보다는 “다음 묶음을 계속 읽기”에 적합합니다. 무한 스크롤, 최신순 피드, 로그 조회처럼 순차 탐색이 자연스러운 화면에서 효과가 큽니다.
-- 첫 페이지
SELECT product_id, name, price
FROM products
ORDER BY product_id ASC
LIMIT 10;
-- 다음 페이지 (마지막으로 본 product_id가 10이면)
SELECT product_id, name, price
FROM products
WHERE product_id > 10
ORDER BY product_id ASC
LIMIT 10;
-- 다음 페이지 (마지막 product_id가 20이면)
SELECT product_id, name, price
FROM products
WHERE product_id > 20
ORDER BY product_id ASC
LIMIT 10;성능:
WHERE product_id > 10 → 정렬 키 인덱스를 사용할 수 있으면 해당 위치 접근
→ OFFSET처럼 앞의 행을 읽고 버리지 않음
→ 깊은 페이지에서 OFFSET보다 안정적인 성능을 기대할 수 있음
데이터 일관성:
→ 정렬 키가 고유하고 안정적이면 중복/누락 가능성을 줄일 수 있음
→ 이전 페이지의 마지막 키 값 이후를 읽으므로 OFFSET보다 경계가 안정적복합 정렬 키의 커서 페이지네이션
단일 키가 아닌 복합 정렬 기준에서의 커서 방식은 조금 복잡합니다.
-- 가격 내림차순, 같으면 ID 오름차순으로 정렬
-- 마지막으로 본 행: price=50000, product_id=15
SELECT product_id, name, price
FROM products
WHERE (price < 50000)
OR (price = 50000 AND product_id > 15)
ORDER BY price DESC, product_id ASC
LIMIT 10;
-- 주의: price DESC, product_id ASC처럼 방향이 섞이면
-- row constructor 비교로 그대로 줄이면 의미가 달라질 수 있음
-- 이 경우에는 위의 OR 조건처럼 정렬 방향을 직접 풀어 쓰는 편이 안전커서에 쓰는 정렬 기준은 가능한 한 고유하고 변하지 않는 값이어야 합니다. 정렬 기준이 자주 바뀌는 컬럼이면 사용자가 페이지를 넘기는 동안 중복이나 누락이 생길 수 있습니다.
OFFSET vs 커서 방식 비교
| 항목 | OFFSET 방식 | 커서 방식 |
|---|---|---|
| 구현 난이도 | 쉬움 | 보통 |
| 성능 (깊은 페이지) | 느려지기 쉬움 | 정렬 키 조건이 맞으면 안정적 |
| 데이터 변경 대응 | 중복/누락 가능 | 가능성을 줄일 수 있음 |
| 특정 페이지 이동 | 가능 (OFFSET 계산) | 불가 (순차만) |
| 총 페이지 수 표시 | COUNT(*) 필요 | 별도 쿼리 필요 |
| 사용 사례 | 관리자 화면, 소규모 데이터 | 무한 스크롤, 대규모 데이터 |
TOP-N 분석 패턴
RANK / DENSE_RANK를 사용한 TOP-N
-- 카테고리별 가격 TOP 3 상품
SELECT * FROM (
SELECT
category,
name,
price,
RANK() OVER (PARTITION BY category ORDER BY price DESC) AS rnk
FROM products
) ranked
WHERE rnk <= 3;데이터: 100, 100, 90, 80
RANK(): 1, 1, 3, 4 (동점 후 건너뜀)
DENSE_RANK(): 1, 1, 2, 3 (동점 후 연속)
ROW_NUMBER(): 1, 2, 3, 4 (동점 무시, 고유)
TOP-3을 구할 때:
RANK: 동점을 모두 포함 (3개 이상 가능)
DENSE_RANK: 3번째 순위까지 포함
ROW_NUMBER: 정확히 3개만Oracle ROWNUM과 실행 순서 주의
-- 잘못된 방법: ORDER BY 전에 ROWNUM이 먼저 적용될 수 있음
SELECT * FROM products
WHERE ROWNUM <= 5
ORDER BY price DESC;
-- → 정렬 전 일부 행이 먼저 선택된 뒤 정렬될 수 있음 (의도와 다름!)
-- 올바른 방법: 서브쿼리로 정렬 후 ROWNUM 적용
SELECT * FROM (
SELECT * FROM products ORDER BY price DESC
) WHERE ROWNUM <= 5;ORDER BY 없는 SELECT의 순서 보장
DISTINCT와 정렬
DISTINCT는 중복 행을 제거합니다. ORDER BY와 함께 사용할 때 주의할 점이 있습니다.
-- 중복 제거
SELECT DISTINCT category FROM products;
-- DISTINCT + ORDER BY
SELECT DISTINCT category FROM products ORDER BY category;
-- 주의: ORDER BY에 사용할 컬럼은 SELECT 목록에 있어야 함
-- 아래는 DBMS에 따라 오류가 나거나 의미가 모호
SELECT DISTINCT category FROM products ORDER BY price;
-- → price가 SELECT에 없으므로 어떤 price로 정렬할지 모호-- DISTINCT는 내부적으로 정렬 또는 해싱을 수행
-- 대량 데이터에서 비용이 클 수 있음
-- 방법 1: DISTINCT (정렬 또는 해시 사용)
SELECT DISTINCT dept_id FROM employees;
-- 방법 2: GROUP BY (동일 결과, 실행 계획은 DBMS/인덱스에 따라 달라짐)
SELECT dept_id FROM employees GROUP BY dept_id;
-- 방법 3: EXISTS (부서 존재 여부만 필요할 때 유리할 수 있음)
SELECT dept_id FROM departments d
WHERE EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.dept_id);SQL 처리 순서와 정렬
SQL 쿼리의 논리적 처리 순서를 이해하면 ORDER BY, LIMIT의 동작을 명확히 파악할 수 있습니다.
아래 순서는 문법 규칙을 이해하기 위한 논리 순서입니다. 실제 실행 계획에서는 옵티마이저가 인덱스, 통계, 비용을 보고 접근 순서를 바꿀 수 있습니다.
SELECT category, AVG(price) AS avg_price -- 5. 컬럼 선택
FROM products -- 1. 테이블 접근
WHERE stock > 0 -- 2. 재고있는 것만
GROUP BY category -- 3. 카테고리별 그룹
HAVING AVG(price) > 10000 -- 4. 평균 1만원 이상
ORDER BY avg_price DESC -- 7. 평균가 내림차순
LIMIT 5; -- 8. 상위 5개만평균 가격이 같은 카테고리가 여러 개라면 결과의 마지막 순서가 매번 같다고 보장할 수 없습니다. 화면에 노출되는 TOP-N에는 ORDER BY avg_price DESC, category ASC처럼 tie-breaker를 추가하는 편이 안전합니다.
정렬 관련 실무 팁
정리
| 개념 | 대표 문법/패턴 | 용도 |
|---|---|---|
| 오름차순 정렬 | ORDER BY col ASC | 작은 값부터 |
| 내림차순 정렬 | ORDER BY col DESC | 큰 값부터 |
| 복합 정렬 | ORDER BY col1 ASC, col2 DESC | 다중 기준 |
| NULL 정렬 제어 | NULLS FIRST/LAST, CASE WHEN col IS NULL | NULL 위치 지정 |
| 결과 제한 | LIMIT n / FETCH FIRST n ROWS / TOP n | TOP-N 조회 |
| 페이지네이션 | LIMIT n OFFSET m / OFFSET FETCH | 페이지 단위 조회 |
| 커서 페이지네이션 | WHERE id > last_id LIMIT n | 대용량 순차 조회 |
| WITH TIES | FETCH FIRST n WITH TIES / TOP WITH TIES | 동점 포함 TOP-N |
정렬과 결과 제한은 SELECT 쿼리의 마무리 단계입니다. ORDER BY 없이는 결과 순서가 보장되지 않으며, 대용량 데이터에서는 OFFSET 방식의 한계를 인식하고 커서 기반 페이지네이션을 활용하는 것이 중요합니다. 인덱스가 정렬 순서와 일치하면 별도의 정렬 연산 없이 결과를 반환할 수 있으므로, 정렬 성능이 중요한 쿼리에서는 인덱스 설계도 함께 고려해야 합니다.
다음 절에서는 데이터를 넣고, 고치고, 지우는 INSERT, UPDATE, DELETE를 다루겠습니다.