풀 스캔
쿼리 앞에 EXPLAIN QUERY PLAN을 붙이면 쿼리를 실행하지 않고 데이터베이스가 선택한 계획을 보여 줍니다.
1
EXPLAIN QUERY PLAN
SELECT * FROM products WHERE name = 'Quill Helmet';
결과 — 1행
| id | parent | notused | detail |
|---|---|---|---|
| 2 | 0 | 216 | SCAN products |
브라우저의 새 탭에서 Planche가 열리고, 샘플 데이터베이스가 로드된 채로 이 쿼리가 들어갑니다. 이 페이지의 다른 예제도 같은 탭을 다시 씁니다. F9를 누르기 전까지는 아무것도 실행되지 않습니다.
SCAN products
SCAN은 데이터베이스가 테이블의 모든 행을 읽어 하나씩 확인한다는 뜻입니다. products.name에는 인덱스가 없어서 다른 경로가 없습니다. 400행에서는 차이를 느끼기 어렵지만, 테이블이 커질수록 비용도 함께 커집니다. 천만 행 테이블에서는 같은 쿼리가 실행될 때마다 천만 행을 읽습니다.
인덱스 검색
price에는 인덱스가 있습니다.
2
EXPLAIN QUERY PLAN
SELECT * FROM products WHERE price > 2000;
결과 — 1행
| id | parent | notused | detail |
|---|---|---|---|
| 3 | 0 | 202 | SEARCH products USING INDEX idx_products_price (price>?) |
SEARCH products USING INDEX idx_products_price (price>?)
SEARCH는 인덱스를 이용했다는 표시입니다. 데이터베이스는 가격이 2000을 넘기 시작하는 위치를 인덱스에서 찾아 그 뒤의 행만 읽습니다. 다른 엔진도 비슷한 구분을 다른 이름으로 표시합니다. PostgreSQL은 Seq Scan과 Index Scan, MySQL의 EXPLAIN은 type: ALL과 type: range, SQL Server는 Table Scan과 Index Seek를 사용합니다.
조인
3
EXPLAIN QUERY PLAN
SELECT o.order_number, c.last_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'delivered';
결과 — 2행
| id | parent | notused | detail |
|---|---|---|---|
| 4 | 0 | 61 | SEARCH o USING INDEX idx_orders_status (status=?) |
| 9 | 0 | 45 | SEARCH c USING INTEGER PRIMARY KEY (rowid=?) |
SEARCH o USING INDEX idx_orders_status (status=?)
SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
반복문처럼 읽으면 됩니다. 상태 인덱스로 배송 완료 주문을 찾고, 주문마다 기본 키로 고객을 찾습니다. 안쪽 단계에 SCAN이 있으면 주의하세요. 바깥 행마다 테이블 전체를 읽는다는 뜻이라, 개발 데이터에서는 빨랐던 조인이 운영 환경에서 오래 걸릴 수 있습니다. 외래 키 컬럼에는 인덱스가 필요합니다. SQLite와 MySQL을 포함한 대부분의 데이터베이스는 이를 자동으로 만들지 않습니다.
인덱스를 잃는 법
orders.ordered_at에는 인덱스가 있습니다. 아래 쿼리는 그 컬럼으로 거르는데도 테이블 전체를 읽습니다.
4
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE substr(ordered_at, 1, 4) = '2025';
결과 — 1행
| id | parent | notused | detail |
|---|---|---|---|
| 2 | 0 | 216 | SCAN orders |
SCAN orders
인덱스에는 ordered_at 값이 저장돼 있을 뿐 substr(ordered_at, 1, 4)의 결과까지 저장되지는 않습니다. 컬럼을 함수로 감싸면 인덱스의 정렬 순서를 활용할 수 없어 모든 행에서 표현식을 계산해야 합니다. WHERE YEAR(created_at) = 2025, WHERE UPPER(email) = ..., WHERE price * 1.1 > 100도 같은 경우입니다.
인덱스 컬럼 자체를 비교하도록 조건을 바꾸면 됩니다.
5
EXPLAIN QUERY PLAN
SELECT * FROM orders
WHERE ordered_at >= '2025-01-01'
AND ordered_at < '2026-01-01';
결과 — 1행
| id | parent | notused | detail |
|---|---|---|---|
| 3 | 0 | 163 | SEARCH orders USING INDEX idx_orders_ordered_at (ordered_at… |
SEARCH orders USING INDEX idx_orders_ordered_at (ordered_at>? AND ordered_at<?)
같은 행을 찾지만 이제 인덱스를 활용합니다. 인덱스가 있는 컬럼은 비교식에서 그대로 두고, 계산은 반대쪽 값에 적용하세요. 날짜 함수를 반열린 구간 조건으로 바꾸는 경우가 대표적입니다.
ORDER BY가 공짜가 될 때
6
EXPLAIN QUERY PLAN
SELECT * FROM products ORDER BY price DESC LIMIT 20;
결과 — 1행
| id | parent | notused | detail |
|---|---|---|---|
| 5 | 0 | 222 | SCAN products USING INDEX idx_products_price |
SCAN products USING INDEX idx_products_price
세 번째 형태는 인덱스를 정렬에 활용하는 경우입니다. 검색 조건은 없지만 인덱스가 이미 가격순이므로 데이터베이스는 인덱스를 따라 20행만 읽으면 됩니다. 별도 정렬도 필요 없습니다. 인덱스가 없다면 400행을 읽어 정렬한 뒤 380행을 버려야 합니다.
인덱스는 WHERE뿐 아니라 ORDER BY ... LIMIT에도 도움이 됩니다. “X 기준 상위 20개” 쿼리라면 관련 인덱스가 있는지 확인하세요. 적절한 인덱스가 있으면 필요한 행만 읽고, 없으면 테이블 전체를 읽을 수 있습니다.
짧은 체크리스트
- 거르고 조인하는 컬럼에 인덱스를 두세요. 특히 외래 키는 자동으로 생기는 일이 드뭅니다.
WHERE에서 인덱스가 걸린 컬럼을 함수로 감싸지 마세요.- 가능하면
LIKE '%abc%'보다LIKE 'abc%'를 쓰세요. 앞의 와일드카드는 인덱스를 쓸 수 없습니다. - 인덱스에도 비용이 있습니다. 쓰기마다 전부 갱신되므로, 모든 컬럼이 아니라 실제로 조회하는 컬럼에만 두세요.
- 추측하지 말고 측정하세요. 바꾸기 전과 후에
EXPLAIN을 찍어 비교하면 됩니다.
다음은
여기까지가 기본 과정입니다. 다음 방언 비교 레슨에서는 페이징, UPSERT, 문자열·날짜 처리를 MySQL, PostgreSQL, SQL Server, Oracle, SQLite에서 각각 어떻게 쓰는지 살펴봅니다. 이 문법은 엔진마다 달라서 쿼리를 다른 데이터베이스로 옮길 때 수정이 필요할 수 있습니다.