왜 당신의 SQL 쿼리는 10분이 걸릴까? — EXPLAIN QUERY PLAN부터 인덱스 설계까지
EXPLAIN, 인덱스, WHERE vs HAVING — 쿼리가 느린 이유를 직접 진단하고 최적화하는 법

왜 당신의 SQL 쿼리는 10분이 걸릴까? — EXPLAIN QUERY PLAN부터 인덱스 설계까지
엑셀 집계 3시간 vs SQL 3초, 그런데 왜 어떤 쿼리는 10분이 걸리나
"이 CS 티켓 데이터 좀 뽑아주세요." 요청이 올 때마다 엑셀 필터와 VLOOKUP으로 30분씩 소모하고 계신가요? SQL을 배웠는데도 쿼리가 영원히 돌아가서 결국 포기한 적 있으신가요?
SQL을 "아는 것"과 "잘 쓰는 것"은 완전히 다른 문제입니다. 같은 결과를 내는 쿼리라도 작성 방식에 따라 3초 만에 끝날 수도, 10분이 걸릴 수도 있습니다. 이 글에서는 왜 쿼리가 느린지 진단하는 법과 처음부터 빠른 쿼리를 작성하는 기본기를 다룹니다.
1. "왜 이 에러가 나는 거지?" — SQL 실행 순서의 비밀
SQL을 배울 때 가장 먼저 배우는 문법 순서가 있습니다:
SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT그런데 이건 작성 순서일 뿐입니다. 데이터베이스가 실제로 쿼리를 실행하는 순서는 완전히 다릅니다.
실제 실행 순서
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT이 순서를 모르면 아래 같은 에러를 만났을 때 원인을 찾지 못합니다:
SELECT
ticket_id,
JULIANDAY(resolved_at) - JULIANDAY(created_at) AS resolution_days
FROM tickets
WHERE resolution_days > 1;Error: no such column: resolution_days왜 에러가 날까요? resolution_days는 SELECT에서 만든 alias인데, WHERE는 SELECT보다 먼저 실행됩니다. 아직 존재하지 않는 컬럼을 참조한 거죠.
해결 방법
-- 방법 1: 계산식을 WHERE에 직접 작성
WHERE JULIANDAY(resolved_at) - JULIANDAY(created_at) > 1
-- 방법 2: 서브쿼리로 감싸기
SELECT * FROM (
SELECT ticket_id,
JULIANDAY(resolved_at) - JULIANDAY(created_at) AS resolution_days
FROM tickets
) sub
WHERE resolution_days > 1;실무 팁: MySQL과 SQLite는 HAVING에서 alias를 허용하지만, PostgreSQL은 안 됩니다. 여러 DB를 쓴다면 alias 대신 원래 표현식을 쓰세요.
2. "내 쿼리가 왜 이렇게 느리지?" — EXPLAIN으로 진단하기
쿼리가 느릴 때 대부분 사람들은 "데이터가 많아서" 라고 생각합니다. 하지만 진짜 원인은 인덱스를 제대로 못 쓰고 있기 때문입니다.
10분 vs 0.02초 — 실제 차이를 보여드립니다
100만 건의 티켓 데이터에서 urgent 티켓을 찾는다고 가정해봅시다:
-- 쿼리 A: 인덱스 없는 컬럼으로 검색
SELECT * FROM tickets WHERE description LIKE '%error%';
-- 쿼리 B: 인덱스 있는 컬럼으로 검색
SELECT * FROM tickets WHERE priority = 'urgent';EXPLAIN으로 확인해보면:
EXPLAIN QUERY PLAN SELECT * FROM tickets WHERE description LIKE '%error%';
-- 결과: SCAN tickets
-- 의미: 100만 건 전체를 하나씩 확인 (Full Table Scan)
-- 예상 시간: 8.5초
EXPLAIN QUERY PLAN SELECT * FROM tickets WHERE priority = 'urgent';
-- 결과: SEARCH tickets USING INDEX idx_tickets_priority
-- 의미: 인덱스로 바로 점프 (Index Scan)
-- 예상 시간: 0.02초같은 데이터, 같은 결과인데 400배 차이입니다.
EXPLAIN 결과 읽는 법
SCAN tickets → Full Table Scan (느림 🐌)
SEARCH ... USING INDEX → Index Scan (빠름 🚀)
USING COVERING INDEX → Index Only Scan (가장 빠름 ⚡)3. "인덱스를 만들었는데 왜 안 쓰이지?" — 인덱스가 무시되는 5가지 상황
인덱스를 만들어도 쿼리 작성 방식에 따라 데이터베이스가 인덱스를 무시할 수 있습니다.
상황 1: 컬럼에 함수를 씌웠을 때
-- ❌ 인덱스 무시됨
SELECT * FROM tickets WHERE YEAR(created_at) = 2024;
-- 결과: SCAN tickets (Full Table Scan)
-- ✅ 인덱스 사용됨
SELECT * FROM tickets
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- 결과: SEARCH tickets USING INDEX idx_tickets_created_atYEAR(created_at)을 계산하려면 모든 행의 created_at을 확인해야 하기 때문에 인덱스를 쓸 수 없습니다.
상황 2: LIKE에 앞쪽 와일드카드를 썼을 때
-- ❌ 인덱스 무시됨 (앞에 %)
SELECT * FROM tickets WHERE category LIKE '%billing%';
-- ✅ 인덱스 사용 가능 (뒤에만 %)
SELECT * FROM tickets WHERE category LIKE 'billing%';인덱스는 정렬된 목차와 같습니다. "billing으로 시작하는 것"은 목차에서 찾을 수 있지만, "billing을 포함하는 것"은 전체를 뒤져야 합니다.
상황 3: 복합 인덱스의 순서를 어겼을 때
-- 복합 인덱스: (category, status)
CREATE INDEX idx_cat_status ON tickets(category, status);
-- ✅ 첫 번째 컬럼 포함 → 인덱스 사용됨
SELECT * FROM tickets WHERE category = 'billing';
SELECT * FROM tickets WHERE category = 'billing' AND status = 'open';
-- ❌ 첫 번째 컬럼 없음 → 인덱스 무시됨
SELECT * FROM tickets WHERE status = 'open';복합 인덱스는 왼쪽부터 순서대로 사용해야 합니다. 전화번호부에서 "이름"으로는 찾을 수 있지만, "전화번호 뒷자리"로는 못 찾는 것과 같습니다.
상황 4: 타입이 맞지 않을 때
-- ❌ ticket_id는 INTEGER인데 문자열로 비교
SELECT * FROM tickets WHERE ticket_id = '12345';
-- ✅ 타입 일치
SELECT * FROM tickets WHERE ticket_id = 12345;암묵적 타입 변환이 일어나면 인덱스를 사용하지 못할 수 있습니다.
상황 5: OR 조건이 여러 개일 때
-- ❌ OR 조건 → 인덱스 효율 떨어짐
SELECT * FROM tickets
WHERE priority = 'urgent' OR priority = 'high';
-- ✅ IN 조건 → 더 효율적
SELECT * FROM tickets
WHERE priority IN ('urgent', 'high');4. GROUP BY가 느린 이유 — Hash vs Sort
큰 데이터를 GROUP BY 하면 왜 오래 걸릴까요? 데이터베이스 내부에서 일어나는 일을 이해하면 최적화 방향이 보입니다.
두 가지 집계 방식
Hash Aggregation (메모리 충분할 때)
- 각 그룹을 해시 테이블에 저장
- 메모리에서 O(n)으로 빠르게 처리
- 단점: 메모리 많이 사용
Sort Aggregation (메모리 부족하거나 정렬 필요할 때)
- 먼저 그룹 키로 전체 정렬
- 정렬된 데이터를 순회하며 집계
- O(n log n)으로 상대적으로 느림
- 장점: 디스크 사용 가능, ORDER BY와 결합 시 유리
실제 쿼리 예시
-- 카테고리별 티켓 통계
SELECT
category,
COUNT(*) as total_tickets,
COUNT(DISTINCT customer_id) as unique_customers,
ROUND(AVG(satisfaction_score), 2) as avg_satisfaction
FROM tickets
WHERE created_at >= '2024-01-01'
GROUP BY category
ORDER BY total_tickets DESC;결과 예시:
| category | total_tickets | unique_customers | avg_satisfaction |
|---|---|---|---|
| technical | 3,412 | 2,891 | 3.8 |
| billing | 2,876 | 2,543 | 4.1 |
| general | 2,456 | 2,102 | 4.3 |
| refund | 1,567 | 1,234 | 3.2 |
최적화 팁: GROUP BY 대상 컬럼에 인덱스가 있으면 Sort Aggregation 시 정렬 비용이 줄어듭니다.
5. WHERE vs HAVING — 언제 뭘 써야 하나?
둘 다 "필터링"이지만 실행 시점과 성능에 큰 차이가 있습니다.
| WHERE | HAVING | |
|---|---|---|
| **실행 시점** | GROUP BY 이전 | GROUP BY 이후 |
| **필터링 대상** | 개별 행 | 그룹 |
| **집계 함수** | 사용 불가 | 사용 가능 |
| **성능** | 빠름 (데이터 줄이고 시작) | 느림 (다 집계 후 필터) |
잘못된 예시
-- ❌ 비효율: 전체 데이터 집계 후 필터링
SELECT agent_id, COUNT(*) as ticket_count
FROM tickets
GROUP BY agent_id
HAVING agent_id IN (1, 2, 3, 4, 5);이 쿼리는 모든 agent의 티켓을 다 세고 나서 5명만 남깁니다.
올바른 예시
-- ✅ 효율적: 먼저 필터링 후 집계
SELECT agent_id, COUNT(*) as ticket_count
FROM tickets
WHERE agent_id IN (1, 2, 3, 4, 5) -- 먼저 필터!
GROUP BY agent_id;이 쿼리는 5명의 티켓만 세기 때문에 훨씬 빠릅니다.
HAVING이 필요한 경우
집계 결과로 필터링할 때는 HAVING을 써야 합니다:
-- "50건 이상 처리하고 평균 만족도 4.0 이상인 상담원"
SELECT
agent_id,
COUNT(*) as ticket_count,
ROUND(AVG(satisfaction_score), 2) as avg_score
FROM tickets
WHERE created_at >= '2024-01-01' -- 행 필터 (WHERE)
AND status = 'resolved'
GROUP BY agent_id
HAVING COUNT(*) >= 50 -- 그룹 필터 (HAVING)
AND AVG(satisfaction_score) >= 4.0
ORDER BY avg_score DESC;6. 자주 터지는 실수 7가지
실수 1: SELECT *
-- ❌ 모든 컬럼 조회
SELECT * FROM tickets WHERE status = 'open';
-- ✅ 필요한 컬럼만
SELECT ticket_id, category, priority, created_at
FROM tickets WHERE status = 'open';문제점:
- 네트워크 대역폭 낭비
- Covering Index 사용 불가
- 스키마 변경 시 에러 위험
실수 2: NULL 비교에 = 사용
-- ❌ 항상 0건 반환
SELECT * FROM tickets WHERE resolved_at = NULL;
-- ✅ IS NULL 사용
SELECT * FROM tickets WHERE resolved_at IS NULL;SQL에서 NULL = NULL은 TRUE가 아니라 NULL(Unknown)입니다.
실수 3: 불필요한 DISTINCT
-- ❌ ticket_id는 이미 PK라서 유일함
SELECT DISTINCT ticket_id FROM tickets WHERE status = 'open';
-- ✅ DISTINCT 불필요
SELECT ticket_id FROM tickets WHERE status = 'open';DISTINCT는 정렬/해싱 비용이 들어갑니다. 이미 유일한 값에는 사용하지 마세요.
실수 4: COUNT에서 NULL 무시
SELECT
COUNT(*) as total_rows, -- 전체 행 수
COUNT(resolved_at) as resolved, -- NULL 제외 (해결된 티켓만)
COUNT(satisfaction_score) as rated -- 평점 있는 것만
FROM tickets;| total_rows | resolved | rated |
|---|---|---|
| 10,000 | 7,500 | 4,200 |
COUNT(*)와 COUNT(column)의 차이를 알아야 합니다.
실수 5: 서브쿼리 vs JOIN
-- ❌ 느림: 행마다 서브쿼리 실행 (Correlated Subquery)
SELECT t.*
FROM tickets t
WHERE t.agent_id IN (
SELECT agent_id FROM agents WHERE department = 'support'
);
-- ✅ 빠름: JOIN 한 번
SELECT t.*
FROM tickets t
JOIN agents a ON t.agent_id = a.agent_id
WHERE a.department = 'support';실수 6: 날짜 함수로 범위 검색
-- ❌ 인덱스 무시됨
WHERE DATE(created_at) = '2024-06-15'
-- ✅ 인덱스 사용됨
WHERE created_at >= '2024-06-15' AND created_at < '2024-06-16'실수 7: LIMIT 없이 대량 조회
-- ❌ 100만 건 한 번에 조회
SELECT * FROM tickets;
-- ✅ 페이지네이션
SELECT * FROM tickets ORDER BY ticket_id LIMIT 100 OFFSET 0;7. 바로 쓰는 실전 쿼리 패턴
패턴 1: 일별 KPI 대시보드
SELECT
DATE(created_at) as date,
COUNT(*) as new_tickets,
SUM(CASE WHEN status IN ('resolved', 'closed') THEN 1 ELSE 0 END) as closed,
ROUND(AVG(satisfaction_score), 2) as avg_satisfaction
FROM tickets
WHERE created_at >= DATE('now', '-30 days')
GROUP BY DATE(created_at)
ORDER BY date DESC;결과:
| date | new_tickets | closed | avg_satisfaction |
|---|---|---|---|
| 2024-06-15 | 234 | 198 | 4.2 |
| 2024-06-14 | 256 | 241 | 4.0 |
| 2024-06-13 | 223 | 215 | 4.1 |
패턴 2: 상담원 성과 분석
SELECT
agent_id,
COUNT(*) as total_tickets,
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END) as resolved,
ROUND(100.0 * SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END) / COUNT(*), 1) as resolution_rate,
ROUND(AVG(JULIANDAY(resolved_at) - JULIANDAY(created_at)) * 24, 1) as avg_hours,
ROUND(AVG(satisfaction_score), 2) as avg_score
FROM tickets
WHERE created_at >= DATE('now', '-30 days')
GROUP BY agent_id
HAVING COUNT(*) >= 10
ORDER BY resolution_rate DESC, avg_score DESC;결과:
| agent_id | total_tickets | resolved | resolution_rate | avg_hours | avg_score |
|---|---|---|---|---|---|
| 42 | 156 | 148 | 94.9 | 2.3 | 4.6 |
| 17 | 134 | 125 | 93.3 | 3.1 | 4.4 |
| 23 | 178 | 162 | 91.0 | 4.2 | 4.2 |
패턴 3: SLA 준수율 분석
WITH sla_check AS (
SELECT
priority,
ticket_id,
CASE
WHEN resolved_at IS NULL THEN 'pending'
WHEN priority = 'urgent' AND
(JULIANDAY(resolved_at) - JULIANDAY(created_at)) * 24 <= 4 THEN 'met'
WHEN priority = 'high' AND
(JULIANDAY(resolved_at) - JULIANDAY(created_at)) * 24 <= 24 THEN 'met'
WHEN priority = 'medium' AND
(JULIANDAY(resolved_at) - JULIANDAY(created_at)) * 24 <= 72 THEN 'met'
WHEN priority = 'low' AND
(JULIANDAY(resolved_at) - JULIANDAY(created_at)) * 24 <= 168 THEN 'met'
ELSE 'breached'
END as sla_status
FROM tickets
WHERE created_at >= DATE('now', '-30 days')
)
SELECT
priority,
COUNT(*) as total,
SUM(CASE WHEN sla_status = 'met' THEN 1 ELSE 0 END) as met,
SUM(CASE WHEN sla_status = 'breached' THEN 1 ELSE 0 END) as breached,
ROUND(100.0 * SUM(CASE WHEN sla_status = 'met' THEN 1 ELSE 0 END) /
NULLIF(SUM(CASE WHEN sla_status != 'pending' THEN 1 ELSE 0 END), 0), 1) as compliance_rate
FROM sla_check
GROUP BY priority
ORDER BY
CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2
WHEN 'medium' THEN 3 WHEN 'low' THEN 4 END;결과:
| priority | total | met | breached | compliance_rate |
|---|---|---|---|---|
| urgent | 89 | 78 | 8 | 90.7 |
| high | 234 | 198 | 21 | 90.4 |
| medium | 567 | 489 | 52 | 90.4 |
| low | 312 | 287 | 12 | 96.0 |
핵심 정리: 빠른 쿼리를 위한 3가지 원칙
- 실행 순서를 기억하라: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
- EXPLAIN을 습관화하라:
SCAN이 보이면 인덱스 문제 - 인덱스를 활용하라: 컬럼에 함수 X, LIKE 앞에 % X, 복합 인덱스는 왼쪽부터
이 세 가지만 기억해도 "왜 쿼리가 느리지?"에 스스로 답할 수 있습니다.
다음 글에서는 JOIN으로 매출이 2배가 되는 문제와 정확한 집계 방법을 다룹니다.