SQL 레벨업 Ch.7 — 서브쿼리
서브쿼리가 성능을 떨어뜨리는 이유와, 윈도우 함수로 결합과 테이블 접근 횟수를 줄이는 방법을 정리했다.
시리즈 · BackEnd 서적5 / 17
- SQL 레벨업 Ch.1 — DBMS 아키텍처
- SQL 레벨업 Ch.3 — SQL의 조건 분기
- SQL 레벨업 Ch.4~5 — 집약과 자르기, 반복문
- SQL 레벨업 Ch.6 — 결합
- SQL 레벨업 Ch.7 — 서브쿼리
- SQL 레벨업 Ch.8 — SQL의 순서
- SQL 레벨업 Ch.9 — 갱신과 데이터 모델
- SQL 레벨업 Ch.10 — 인덱스 사용
- 오브젝트 Ch.1 — 객체, 설계
- 오브젝트 Ch.2 — 객체지향 프로그래밍
- 오브젝트 Ch.3~4 — 역할, 책임, 협력 / 설계 품질과 트레이드오프
- 오브젝트 Ch.5 — 책임 할당하기
- 오브젝트 Ch.6 — 메시지와 인터페이스
- 오브젝트 Ch.8 — 의존성 관리하기
- 오브젝트 Ch.9 — 유연한 설계
- 오브젝트 Ch.10, 12 — 상속과 코드 재사용, 다형성
- 오브젝트 Ch.13 — 서브클래싱과 서브타이핑
서브쿼리는 SQL 내부에서 만들어지는 일시적인 테이블이다. 기능적으로는 테이블과 차이가 없어 SQL은 둘을 같은 것으로 취급한다.
- 테이블: 영속적으로 데이터를 저장한다.
- 뷰: 정의는 영속적이지만 데이터는 저장하지 않는다. 접근할 때마다 SELECT 구문이 실행된다.
- 서브쿼리: 비영속적이며 생존 기간(스코프)이 SQL 구문 실행 중으로 한정된다.
서브쿼리의 문제점
성능 문제는 서브쿼리가 실체적인 데이터를 저장하고 있지 않다는 점에서 나온다.
- 연산 비용 추가: 접근할 때마다 SELECT 구문을 실행해 데이터를 만들어야 한다.
- 데이터 I/O 비용 발생: 연산 결과를 어딘가에 써 두어야 한다. 데이터양이 크면 저장소의 파일에 쓰게 되고(TEMP 탈락), 접근 속도가 급격히 떨어진다.
- 최적화를 받을 수 없음: 제약과 인덱스가 있는 테이블과 달리 서브쿼리에는 메타 정보가 없어, 옵티마이저가 쿼리 해석에 필요한 정보를 얻지 못한다.
예제: 고객별 최소 순번 레코드
| cust_id(고객 ID) | seq(순번) | price(구입 가격) |
|---|---|---|
| A | 1 | 500 |
| A | 2 | 1000 |
| A | 3 | 700 |
| B | 5 | 100 |
| B | 6 | 5000 |
| B | 7 | 600 |
| C | 20 | 200 |
| C | 3 | 150 |
구입 시기가 오래될수록 순번(seq)이 작다. 고객별로 순번이 가장 작은 레코드를 구해 보자.
서브쿼리와 결합을 사용한 방법
고객별 최소 순번을 구하는 서브쿼리(R2)를 만들고 원래의 Receipts 테이블과 결합한다.
SELECT R1.cust_id, R1.seq, R1.price
FROM Receipts R1
INNER JOIN
(SELECT cust_id, MIN(seq) AS min_seq
FROM Receipts
GROUP BY cust_id) R2
ON R1.cust_id = R2.cust_id
AND R1.seq = R2.min_seq;
이 쿼리의 단점은 다음과 같다.
- 코드가 여러 계층으로 만들어져 가독성이 떨어진다.
- 서브쿼리가 일시적인 영역(메모리 또는 디스크)에 확보되어 오버헤드가 생긴다.
- 서브쿼리는 인덱스나 제약 정보가 없어 최적화되지 못한다.
- 결합이 필요해 비용이 높고 실행 계획 변동 리스크가 있다.
- Receipts 테이블 스캔이 2번 발생한다.
윈도우 함수로 결합 제거
SELECT cust_id, seq, price
FROM (SELECT cust_id, seq, price,
ROW_NUMBER()
OVER (PARTITION BY cust_id
ORDER BY seq) AS row_seq
FROM Receipts) WORK
WHERE WORK.row_seq = 1;
ROW_NUMBER로 고객별 구매 이력에 순번을 붙이고 1번만 고른다. 결합이 사라지고 테이블 접근이 1회로 줄어든다.
응용: 최소 순번과 최대 순번의 가격 차이
SELECT cust_id,
SUM(CASE WHEN min_seq = 1 THEN price ELSE 0 END)
- SUM(CASE WHEN max_seq = 1 THEN price ELSE 0 END) AS diff
FROM (SELECT cust_id, price,
ROW_NUMBER() OVER (PARTITION BY cust_id
ORDER BY seq) AS min_seq,
ROW_NUMBER() OVER (PARTITION BY cust_id
ORDER BY seq DESC) AS max_seq
FROM Receipts) WORK
WHERE WORK.min_seq = 1
OR WORK.max_seq = 1
GROUP BY cust_id;
최소 순번의 가격과 최대 순번의 가격은 서로 다른 레코드에 있어 바로 뺄 수 없지만, GROUP BY cust_id로 한 레코드로 집약하면 계산할 수 있다.
책에 실린 서브쿼리 버전과 비교하면 읽기 쉽고, 테이블 스캔 횟수도 4회에서 1회로 줄어든다.