Devin.KR
로그인

SQL ORDER BY 정렬과 페이징 - NULL 정렬 위치, LIMIT OFFSET FETCH (SQL 초급 3단원)

개발자 조회 2

이 단원에서 배우는 것

2단원까지 오면서 필요한 행만 골라내는 데까지는 왔다. 그런데 골라낸 행이 어떤 순서로 나오는지는 아직 아무도 보장해 주지 않았다. 1단원에서 DISTINCT 결과를 보며 "순서는 보장되지 않는다"고 넘어갔던 그 부분이다. 이번 단원에서 ORDER BY 로 순서를 확정하고, 그 위에 "앞에서 N건만" 같은 행 제한을 얹어 목록 화면의 페이징을 만든다. 처리 순서에서 ORDER BY 가 마지막이라는 점이 여기서 결정적으로 쓰인다.

  • 여러 컬럼으로 정렬하고, NULL 이 앞뒤 어디로 가는지 DB별로 구분한다.
  • LIMIT·OFFSET·FETCH FIRST 로 행을 제한하고, 네 DB 의 문법 차이를 정리한다.
  • OFFSET 페이징이 왜 뒤로 갈수록 느려지는지 알고, 키셋 페이징으로 바꾼다.

개념

관계형 데이터베이스의 테이블은 집합이다. 집합에는 순서가 없다. 그래서 ORDER BY 를 붙이지 않은 조회의 행 순서는 문법적으로 아무 의미가 없다. 지금 넣은 순서대로 나오는 것처럼 보이더라도 그건 우연이다. 인덱스가 하나 생기거나, 실행 계획이 바뀌거나, 병렬 처리가 켜지면 순서가 달라진다. "어제까지 잘 나오던 목록이 오늘 순서가 뒤집혔다"는 장애의 절반은 ORDER BY 를 안 쓴 쿼리에서 나온다.

행 제한도 마찬가지다. "상위 10건"이라는 말 자체가 정렬이 정해진 다음에만 뜻이 있다. 그래서 ORDER BY 없는 LIMIT 은 "아무 10건"과 같다. SQL Server 가 OFFSET ... FETCH 를 쓸 때 ORDER BY 를 문법적으로 강제하는 이유도 이것이다.

표준 SQL 문법과 예제

ORDER BY

-- 연봉 내림차순, 같으면 사번 오름차순
SELECT emp_id, emp_name, salary
FROM   emp
ORDER BY salary DESC, emp_id ASC;

ASC 가 기본값이라 생략해도 된다. 방향은 컬럼마다 따로 지정한다. ORDER BY a, b DESC 는 a 오름차순, b 내림차순이지 둘 다 내림차순이 아니다.

1단원에서 정리한 처리 순서 덕분에, ORDER BY 에서는 SELECT 절의 별칭을 쓸 수 있다. WHERE 에서는 못 쓰던 그 별칭이다.

SELECT emp_name, salary * 1.05 AS new_salary
FROM   emp
ORDER BY new_salary DESC;   -- 별칭 사용 가능

ORDER BY 2 DESC 처럼 SELECT 절의 몇 번째 컬럼인지를 번호로 쓸 수도 있다. 동작은 하지만 쓰지 않는 편이 낫다. 나중에 SELECT 절에 컬럼 하나가 끼어들면 정렬 기준이 조용히 다른 컬럼으로 옮겨간다.

NULL 은 어디로 정렬되는가

SELECT emp_name, bonus
FROM   emp
ORDER BY bonus, emp_id;

MySQL 에서의 결과다.

+----------+---------+
| emp_name | bonus   |
+----------+---------+
| 김유신   |    NULL |
| 을지문덕 |    NULL |
| 윤봉길   |    NULL |
| 홍범도   |    NULL |
| 김구     |    NULL |
| 신채호   |  200.00 |
| 이순신   |  300.00 |
| 강감찬   |  500.00 |
| 안중근   |  900.00 |
| 유관순   | 1000.00 |
+----------+---------+
10 rows in set (0.00 sec)

같은 쿼리를 PostgreSQL 이나 Oracle 에서 돌리면 NULL 이 맨 뒤로 간다. 표준 SQL 은 어느 쪽이 기본인지 정하지 않았고, 대신 명시하는 문법을 뒀다.

-- 표준 문법. PostgreSQL 과 Oracle 에서 동작한다
SELECT emp_name, bonus FROM emp ORDER BY bonus NULLS LAST, emp_id;

-- MySQL 8.0 과 MariaDB 에는 이 구문이 없다. 불리언을 앞에 세워 우회한다
-- (bonus IS NULL) 은 NULL 인 행에서 1, 아닌 행에서 0 이다
SELECT emp_name, bonus FROM emp ORDER BY (bonus IS NULL), bonus, emp_id;

행 제한과 페이징

10건씩 3페이지를 보여준다면 21번째 행부터 10건이 필요하다. OFFSET 은 "건너뛸 행 수"이므로 (페이지번호 - 1) × 페이지크기가 된다.

-- MySQL / MariaDB / PostgreSQL
SELECT emp_id, emp_name, salary
FROM   emp
ORDER BY salary DESC, emp_id
LIMIT 10 OFFSET 20;

-- 표준 SQL. PostgreSQL, Oracle 12c 이상, SQL Server 2012 이상
SELECT emp_id, emp_name, salary
FROM   emp
ORDER BY salary DESC, emp_id
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

DB별 차이

항목MySQL / MariaDBPostgreSQLOracleSQL Server
ASC 정렬에서 NULL 위치맨 앞맨 뒤맨 뒤맨 앞
NULLS FIRST / LAST없음. (컬럼 IS NULL) 을 앞세워 우회지원지원없음. CASE 로 우회
행 제한 문법LIMIT n OFFSET mLIMIT n OFFSET m 과 표준 FETCH 둘 다12c 이상 OFFSET ... FETCH, 11g 이하는 ROWNUMTOP n 또는 2012 이상 OFFSET ... FETCH
OFFSET·FETCH 에 ORDER BY 필수아니오아니오아니오. 없으면 문법 오류
한글·대소문자 정렬 기준컬럼 콜레이션DB 로케일(COLLATE 지정 가능)NLS_SORT컬럼 콜레이션

Oracle 11g 이하에서 ROWNUM 으로 페이징할 때는 반드시 정렬을 서브쿼리 안에서 끝내야 한다. ROWNUM 은 정렬 전에 매겨지기 때문에 WHERE ROWNUM <= 10 ORDER BY salary DESC 라고 쓰면 아무 10건을 뽑아서 정렬한 결과가 나온다.

-- Oracle 11g 이하의 올바른 형태
SELECT * FROM (
    SELECT emp_id, emp_name, salary FROM emp ORDER BY salary DESC, emp_id
) WHERE ROWNUM <= 10;

실무에서 자주 틀리는 것

1. 정렬 기준이 유일하지 않아 페이지 경계에서 행이 새거나 겹친다

ORDER BY salary DESC LIMIT 10 OFFSET 0 으로 1페이지를, 같은 정렬로 OFFSET 10 을 줘서 2페이지를 가져온다고 하자. 연봉이 같은 사원이 여럿이면 두 번의 실행에서 그들 사이의 순서가 달라질 수 있다. 그러면 어떤 사원은 1·2페이지에 두 번 나오고, 어떤 사원은 어느 페이지에도 안 나온다. 데이터가 누락됐다는 문의는 대개 여기서 시작된다.

해결은 간단하다. 정렬 목록의 마지막에 유일한 값(대개 기본키)을 붙인다. 위 예제들이 전부 ORDER BY salary DESC, emp_id 로 끝나는 이유다.

2. OFFSET 이 커질수록 느려지는 것을 방치한다

LIMIT 10 OFFSET 100000 은 100,010 행을 읽어서 앞의 100,000 행을 버리는 동작이다. 페이지가 뒤로 갈수록 선형으로 느려진다. 무한 스크롤이나 배치 처리처럼 순차적으로 넘기는 경우라면, 마지막으로 본 값을 기억했다가 그 다음부터 읽는 방식으로 바꾼다. 키셋 페이징 또는 seek 방식이라고 부른다.

-- 1페이지
SELECT emp_id, emp_name, salary
FROM   emp
ORDER BY emp_id
LIMIT 10;

-- 다음 페이지: 직전 페이지의 마지막 emp_id 가 1010 이었다면
SELECT emp_id, emp_name, salary
FROM   emp
WHERE  emp_id > 1010
ORDER BY emp_id
LIMIT 10;

이 방식은 인덱스에서 시작 위치를 바로 찾으므로 몇 페이지째든 속도가 같다. 대신 "37페이지로 점프" 같은 임의 접근은 안 된다. 화면 성격에 따라 고른다.

3. 숫자를 문자열 컬럼에 넣어 두고 정렬한다

주문번호나 코드가 VARCHAR 인데 값이 숫자라면 정렬은 사전순이다. 2, 10, 9 는 '10', '2', '9' 순으로 나온다. 숫자로 정렬하려고 ORDER BY CAST(code AS UNSIGNED) 를 붙이면 정렬 대상이 계산식이 되어 인덱스 정렬을 못 쓰고 매번 파일 정렬을 한다. 애초에 숫자는 숫자 타입으로 저장한다. 이 이야기는 4단원에서 이어서 다룬다.

4. 정렬 비용을 공짜로 여긴다

ORDER BY 는 인덱스 순서와 일치하면 공짜지만, 아니면 결과 전체를 메모리나 임시 파일에 놓고 정렬한다. MySQL 실행 계획의 Using filesort, PostgreSQL 의 Sort (external merge Disk: ...) 가 그 신호다. 목록 화면에서 자주 쓰는 정렬 조합이 있다면 그 순서대로 복합 인덱스를 만드는 것이 가장 확실한 해법이다.

스스로 확인하기

  1. 연봉이 높은 순으로 상위 3명의 이름과 연봉을 조회하라. 연봉이 같을 때도 결과가 흔들리지 않게 하라.
  2. 보너스를 오름차순으로 정렬하되 보너스가 없는 사원을 맨 뒤에 놓아라. MySQL 8.0 기준으로 작성하라.
  3. 입사일이 늦은 순으로 정렬한 목록에서 4번째부터 3명을 조회하라(2페이지, 페이지당 3건).
-- 1
SELECT emp_name, salary
FROM   emp
ORDER BY salary DESC, emp_id
LIMIT 3;

-- 2
SELECT emp_name, bonus
FROM   emp
ORDER BY (bonus IS NULL), bonus, emp_id;
-- PostgreSQL / Oracle 이라면 ORDER BY bonus NULLS LAST, emp_id

-- 3
SELECT emp_name, hire_date
FROM   emp
ORDER BY hire_date DESC, emp_id
LIMIT 3 OFFSET 3;