icon

안동민 개발노트

4장 : SQL 기초 — DML

정렬과 제한


데이터를 조회하면 결과의 순서를 지정하고, 가져올 행의 수를 제한해야 하는 경우가 대부분입니다. 게시판의 최신 글 10개, 가격이 높은 순서대로 5개 — 이것이 정렬과 페이지네이션입니다.


ORDER BY

ORDER BY는 SELECT 결과를 특정 컬럼 기준으로 정렬합니다. SQL 처리 순서에서 ORDER BY는 거의 마지막에 실행됩니다(SELECT 이후, LIMIT/FETCH 직전).

기본 정렬
-- 오름차순 (기본값, 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;
복합 정렬 실행 결과
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는 데이터를 메모리(또는 디스크)에서 정렬하는 작업이므로 비용이 발생합니다 특히 대량 데이터에서는 주의가 필요합니다.

ORDER BY 성능 고려사항
인덱스가 있는 컬럼으로 정렬
  → 인덱스가 이미 정렬되어 있으므로 추가 정렬 불필요
  → 매우 빠름 (Index Scan)

인덱스가 없는 컬럼으로 정렬
  → 전체 결과를 메모리에 올려 정렬 (Sort Operation)
  → sort_buffer_size(MySQL), SORT_AREA_SIZE(Oracle) 이내면 메모리 정렬
  → 초과하면 디스크 임시 파일 사용 (큰 성능 저하)

실행 계획 확인
  EXPLAIN SELECT * FROM products ORDER BY price;
  → "Using filesort" 표시 = 추가 정렬 수행 (MySQL)
  → "Sort" 노드 표시 (PostgreSQL)

NULL 정렬

NULL 값이 포함된 컬럼을 정렬하면, DBMS마다 기본 동작이 다릅니다.

DBMSNULL 위치 (ASC)NULL 위치 (DESC)
Oracle마지막처음
MySQL처음마지막
PostgreSQL마지막처음
SQL Server처음마지막
NULL 정렬 제어
-- 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 전용
NULL 정렬 동작 예시 (Oracle ASC 기본)
category    name
──────────  ──────────
가전        에어컨
의류        코트
전자        노트북
NULL        아이템X     ← Oracle ASC에서 NULL은 마지막

결과 제한

결과에서 특정 개수의 행만 가져오는 문법이 DBMS마다 다릅니다.

Oracle

Oracle: FETCH FIRST (12c+)
-- 처음 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개 이상 반환될 수 있음
Oracle: ROWNUM (전통 방식)
-- ROWNUM은 WHERE보다 먼저 부여되므로 서브쿼리 필요
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

MySQL: LIMIT
-- 처음 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, 두 번째가 COUNT

PostgreSQL

PostgreSQL: LIMIT + OFFSET 또는 FETCH
-- 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

SQL Server: TOP / OFFSET FETCH
-- 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;

DBMS별 결과 제한 문법 비교

DBMS별 TOP-N 문법 비교
┌────────────┬───────────────────────────────────────┐
│    DBMS    │ 처음 10행 조회 문법                   │
├────────────┼───────────────────────────────────────┤
│ Oracle 12c+│ FETCH FIRST 10 ROWS ONLY              │
│ Oracle 11g │ WHERE ROWNUM <= 10 (서브쿼리 필요)    │
│ MySQL      │ LIMIT 10                              │
│ PostgreSQL │ LIMIT 10 또는 FETCH FIRST 10 ROWS ONLY│
│ SQL Server │ TOP 10 또는 FETCH NEXT 10 ROWS ONLY   │
│ SQL 표준   │ FETCH FIRST 10 ROWS ONLY              │
└────────────┴───────────────────────────────────────┘

페이지네이션 패턴

OFFSET 방식의 동작과 문제

OFFSET 방식의 문제
OFFSET 방식의 내부 동작
  OFFSET 990 LIMIT 10 → 1000행을 읽고 앞의 990행을 버린 후 10행 반환

페이지 1: OFFSET 0   → 10행 읽기 → 빠름
페이지 100: OFFSET 990 → 1000행 읽기 → 느림!
페이지 1000: OFFSET 9990 → 10000행 읽기 → 매우 느림!
페이지 10000: OFFSET 99990 → 100000행 읽기 → 극도로 느림!

문제 1: 성능 저하 (깊은 페이지일수록 심각)
문제 2: 데이터 변경 시 중복/누락 (INSERT/DELETE 발생 시)
OFFSET 방식의 데이터 변경 문제
사용자가 페이지 1을 보는 동안 새 글이 1건 추가됨
  페이지 1: [A, B, C, D, E]  (OFFSET 0 LIMIT 5)
  
  -- 새 글 F가 추가됨 (최신순 정렬)
  
  페이지 2: [E, F, G, H, I]  (OFFSET 5 LIMIT 5)
  → E가 페이지 1과 2에서 중복 노출!

커서 기반 페이지네이션 (Keyset Pagination)

대량 데이터에서는 OFFSET 방식 대신 커서 기반 페이지네이션을 사용합니다.

커서 기반 페이지네이션
-- 첫 페이지
SELECT product_id, name, price
FROM products
ORDER BY product_id
LIMIT 10;

-- 다음 페이지 (마지막으로 본 product_id가 10이면)
SELECT product_id, name, price
FROM products
WHERE product_id > 10
ORDER BY product_id
LIMIT 10;

-- 다음 페이지 (마지막 product_id가 20이면)
SELECT product_id, name, price
FROM products
WHERE product_id > 20
ORDER BY product_id
LIMIT 10;
커서 기반 방식의 장점
성능:
  WHERE product_id > 10 → 인덱스로 바로 해당 위치 접근
  → 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;

-- MySQL에서는 Row Constructor 비교도 가능
SELECT product_id, name, price
FROM products
WHERE (price, product_id) < (50000, 15)
ORDER BY price DESC, product_id ASC
LIMIT 10;

OFFSET vs 커서 방식 비교

항목OFFSET 방식커서 방식
구현 난이도쉬움보통
성능 (깊은 페이지)매우 느림일정
데이터 변경 대응중복/누락 가능안전
특정 페이지 이동가능 (OFFSET 계산)불가 (순차만)
총 페이지 수 표시COUNT(*) 필요별도 쿼리 필요
사용 사례관리자 화면, 소규모 데이터무한 스크롤, 대규모 데이터

TOP-N 분석 패턴

RANK / DENSE_RANK를 사용한 TOP-N

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;
RANK vs DENSE_RANK vs ROW_NUMBER
데이터: 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과 실행 순서 주의

Oracle ROWNUM 함정
-- 잘못된 방법: ORDER BY 전에 ROWNUM 부여
SELECT * FROM products
WHERE ROWNUM <= 5
ORDER BY price DESC;
-- → 임의의 5행을 먼저 선택한 후 정렬 (의도와 다름!)

-- 올바른 방법: 서브쿼리로 정렬 후 ROWNUM 적용
SELECT * FROM (
    SELECT * FROM products ORDER BY price DESC
) WHERE ROWNUM <= 5;

ORDER BY 없는 SELECT의 순서 보장

정렬 보장 여부
SELECT * FROM products;
→ 결과 순서가 보장되지 않음!

SQL 표준: ORDER BY가 없으면 결과 순서는 미정의
  → DBMS가 내부적으로 가장 효율적인 순서로 반환
  → 테이블 풀 스캔 → 저장된 순서 (보통 INSERT 순서)
  → 인덱스 스캔 → 인덱스 순서
  → 병렬 쿼리 → 스레드별 반환 순서

실무 원칙
  → 순서가 필요하면 반드시 ORDER BY 명시
  → "이전에는 ID 순서로 나왔으니까" 가정하지 말 것
  → DBMS 버전 업이나 통계 갱신으로 실행 계획이 바뀌면
    결과 순서도 달라질 수 있음

DISTINCT와 정렬

DISTINCT는 중복 행을 제거합니다. ORDER BY와 함께 사용할 때 주의할 점이 있습니다.

DISTINCT 기본
-- 중복 제거
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와 성능
-- DISTINCT는 내부적으로 정렬 또는 해싱을 수행
-- 대량 데이터에서 비용이 클 수 있음

-- 방법 1: DISTINCT (정렬 또는 해시 사용)
SELECT DISTINCT dept_id FROM employees;

-- 방법 2: GROUP BY (동일 결과, 인덱스 활용 가능)
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의 동작을 명확히 파악할 수 있습니다.

SQL 논리적 처리 순서
1. FROM      → 테이블 접근
2. WHERE     → 행 필터링
3. GROUP BY  → 그룹화
4. HAVING    → 그룹 필터링
5. SELECT    → 컬럼 선택, 별칭 부여
6. DISTINCT  → 중복 제거
7. ORDER BY  → 정렬 (이 시점에 별칭 사용 가능)
8. LIMIT/FETCH → 결과 제한

핵심
  → ORDER BY는 SELECT 이후에 실행되므로 별칭 사용 가능
  → WHERE에서는 별칭 사용 불가 (SELECT 이전에 실행)
  → LIMIT은 ORDER BY 이후에 실행되므로 정렬된 결과에서 제한
처리 순서 예시
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개만

정렬 관련 실무 팁

ORDER BY 실무 체크리스트
✅ 자주 사용하는 정렬 컬럼에 인덱스 생성
   CREATE INDEX idx_products_price ON products(price);

✅ 복합 정렬 시 복합 인덱스 고려
   ORDER BY category ASC, price DESC
   → CREATE INDEX idx_cat_price ON products(category ASC, price DESC);

✅ LIMIT과 함께 사용할 때 인덱스 커버링 활용
   SELECT product_id, price FROM products
   ORDER BY price DESC LIMIT 10;
   → INDEX(price, product_id)이면 테이블 접근 없이 인덱스만으로 처리

✅ 정렬 버퍼 크기 확인 및 조정
   MySQL: SHOW VARIABLES LIKE 'sort_buffer_size';
   Oracle: ALTER SESSION SET SORT_AREA_SIZE = 2097152;

✅ 불필요한 ORDER BY 제거
   서브쿼리 내의 ORDER BY는 대부분 무의미
   (외부 쿼리가 결과를 재정렬하므로)

정리

개념문법(표준/MySQL)용도
오름차순 정렬ORDER BY col ASC작은 값부터
내림차순 정렬ORDER BY col DESC큰 값부터
복합 정렬ORDER BY col1 ASC, col2 DESC다중 기준
NULL 정렬 제어NULLS FIRST / NULLS LASTNULL 위치 지정
결과 제한LIMIT n / FETCH FIRST n ROWSTOP-N 조회
페이지네이션LIMIT n OFFSET m페이지 단위 조회
커서 페이지네이션WHERE id > last_id LIMIT n대용량 페이지 처리
WITH TIESFETCH FIRST n WITH TIES동점 포함 TOP-N

정렬과 결과 제한은 SELECT 쿼리의 마무리 단계입니다. ORDER BY 없이는 결과 순서가 보장되지 않으며, 대용량 데이터에서는 OFFSET 방식의 한계를 인식하고 커서 기반 페이지네이션을 활용하는 것이 중요합니다. 인덱스가 정렬 순서와 일치하면 별도의 정렬 연산 없이 결과를 반환할 수 있으므로, 정렬 성능이 중요한 쿼리에서는 인덱스 설계도 함께 고려해야 합니다.

다음 절에서는 데이터를 넣고, 고치고, 지우는 INSERT, UPDATE, DELETE를 다루겠습니다.

목차