『SQL 코딩의 기술』 176~194쪽을 읽으며 따로 표시한 내용은 세 가지였다. 인덱스를 활용하기 쉬운 검색 조건, LEFT JOIN에서 필터를 두는 위치, GROUP BY와 HAVING의 처리 순서다.
책의 문장을 다시 옮기기보다, SQL Server 예제로 직접 설명할 수 있는 기준만 남겼다. 같은 SQL도 데이터 분포와 인덱스, DBMS 옵티마이저에 따라 실행 계획이 달라지므로 “이렇게 쓰면 항상 빠르다”는 규칙으로 외우지 않는 것이 중요하다.
SARGable 조건은 열을 그대로 비교한다
SARGable은 검색 인수로 사용할 수 있는 조건을 뜻한다. 일반적으로 인덱스가 걸린 열에 함수나 계산을 적용하면 범위 탐색이 어려워질 수 있다.
예를 들어 1950년에 생성된 행을 찾는다고 하자.
WHERE YEAR(created_at) = 1950
이 조건은 모든 후보 행에 YEAR()를 계산해야 할 수 있다. 날짜 열 자체의 범위로 바꾸면 B-tree 인덱스의 정렬 순서를 활용할 여지가 생긴다.
WHERE created_at >= '19500101'
AND created_at < '19510101'
문자열도 같은 관점으로 볼 수 있다.
WHERE LEFT(last_name, 1) = 'S'
WHERE last_name LIKE 'S%'
뒤쪽 와일드카드는 호환되는 인덱스와 데이터 정렬 규칙에서 범위 탐색에 활용될 수 있다. 반대로 LIKE '%S'처럼 앞에 와일드카드가 있으면 일반적인 B-tree 인덱스로 시작 위치를 찾기 어렵다.
숫자 열의 계산도 상수 쪽으로 옮길 수 있다.
WHERE price * 1.10 > 10000
WHERE price > 10000 / 1.10
다만 자료형과 반올림 규칙이 바뀌지 않는지 확인해야 한다. DECIMAL의 정밀도나 정수 나눗셈 때문에 두 식의 경계 결과가 달라질 수도 있다.
NULL을 기본값처럼 취급하는 조건도 자주 보인다.
WHERE ISNULL(status, 'V') = 'V'
의도를 더 직접 쓰면 다음과 같다.
WHERE status = 'V'
OR status IS NULL
첫 번째 식보다 열에 함수를 적용하지 않지만, 그렇다고 OR가 항상 인덱스를 잘 쓰거나 UNION ALL로 나누면 무조건 빨라지는 것은 아니다. 선택도, 통계, 인덱스 구성에 따라 옵티마이저가 scan이나 여러 seek의 조합을 고를 수 있다. 실제 실행 계획과 논리적 읽기 수를 함께 확인해야 한다.
계산 열 인덱스나 표현식 인덱스를 지원하는 DBMS라면 함수 결과를 별도로 인덱싱하는 설계도 가능하다. 결국 SARGable 문장은 출발점이고, 인덱스와 데이터 분포가 함께 맞아야 한다.
LEFT JOIN의 필터 위치는 결과 행을 바꾼다
모든 고객을 남기되 2026년 주문만 붙이고 싶다고 하자.
SELECT c.customer_id, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.ordered_at >= '20260101';
오른쪽 테이블의 주문이 없는 고객은 o.ordered_at이 NULL이다. WHERE 조건을 통과하지 못하므로 결과에서 사라지고, 의도와 달리 내부 조인처럼 동작한다.
왼쪽 고객을 모두 유지하려면 매칭 조건을 ON에 둔다.
SELECT c.customer_id, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.ordered_at >= '20260101';
ON은 어떤 오른쪽 행을 연결할지 결정하고, WHERE는 조인이 끝난 결과 중 어떤 행을 남길지 결정한다. 성능보다 먼저 결과의 의미가 달라진다는 점을 봐야 한다.
“주문이 한 번도 없는 고객”을 찾는 경우에는 다음처럼 NOT EXISTS가 의도를 더 분명하게 표현할 때가 많다.
SELECT c.customer_id
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
);
WHERE, GROUP BY, HAVING은 적용 대상이 다르다
그룹 집계에서는 논리적 처리 순서를 기억하면 조건의 위치가 명확해진다.
FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
WHERE는 그룹을 만들기 전 개별 행을 거른다.GROUP BY는 남은 행을 그룹으로 묶는다.HAVING은 집계가 끝난 그룹을 거른다.ORDER BY는 최종 결과를 정렬한다.
SELECT department_id, COUNT(*) AS employee_count
FROM employees
WHERE active = 1
GROUP BY department_id
HAVING COUNT(*) >= 5
ORDER BY employee_count DESC;
집계하지 않은 열을 SELECT에 쓴다면 일반적으로 GROUP BY에도 포함해야 한다. 특정 DBMS나 모드에서 허용되더라도 어떤 행의 값을 선택할지 모호해질 수 있으므로 명시적으로 작성하는 편이 낫다.
ROLLUP과 GROUPING SETS는 필요한 소계를 표현한다
SQL Server의 ROLLUP(a, b, c)는 오른쪽부터 그룹 표현식을 하나씩 제거하며 소계를 만든다.
(a, b, c)
(a, b)
(a)
()
필요한 조합이 정해져 있다면 GROUPING SETS로 직접 나열할 수 있다.
SELECT region, product, SUM(amount) AS total_amount
FROM sales
GROUP BY GROUPING SETS (
(region, product),
(region),
()
);
이는 여러 GROUP BY 결과를 UNION ALL로 이어 붙인 것과 같은 의미에 가깝다. 동일한 그룹이 여러 집합에서 만들어지면 중복이 남을 수 있다. 합계 행의 NULL과 실제 데이터의 NULL을 구분해야 한다면 GROUPING()이나 GROUPING_ID()를 함께 사용한다.
실행 계획으로 가설을 확인한다
문장을 바꿨다면 SQL Server Management Studio에서 실제 실행 계획을 켜고 대표 데이터로 실행한다. 다음 항목을 같이 본다.
- index seek인지 scan인지 이름만 보지 말고 실제 읽은 행 수를 본다.
- 추정 행 수와 실제 행 수의 차이를 확인한다.
- predicate와 seek predicate가 어디에 적용됐는지 확인한다.
- 논리적 읽기 수와 실행 시간을 여러 번 비교한다.
- 변환 전후 결과 행이 완전히 같은지 먼저 검증한다.
실행 계획 하나만으로 모든 입력의 성능을 보장할 수는 없다. 매개변수 값과 통계 갱신, 캐시 상태에 따라 계획과 실행 시간이 달라질 수 있다.
이번 범위에서 얻은 기준은 단순하다. 열을 가능한 한 그대로 비교하고, 조인 필터가 결과 보존 범위를 바꾸는지 확인하며, 행 필터와 그룹 필터를 분리한다. 인덱스 자체의 동작은 단일 인덱스의 동작 원리에서, 여러 열을 묶는 기준은 복합 인덱스와 실무 인덱스 설계에서 이어서 볼 수 있다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| REST API URI 설계: DB 테이블이 아니라 도메인 리소스를 이름 짓는 법 (1) | 2022.10.02 |
|---|---|
| Spring @Valid 입력 검증: null·빈 문자열·날짜·@Validated 구분 (0) | 2022.07.30 |
| SQL 학습 메모: NULL 안전한 안티 조인과 날짜 범위 조건 (0) | 2022.07.14 |
| Django QuerySet 학습노트: Lazy Evaluation·조회·Slicing·F Expression (0) | 2022.07.05 |
| 데이터베이스 키와 정규화 학습 메모: 자연키·대리키·복합키 선택 기준 (0) | 2022.07.05 |
댓글