Data & Analytics

왜 당신의 SQL 쿼리는 10분이 걸릴까? — EXPLAIN QUERY PLAN부터 인덱스 설계까지

EXPLAIN, 인덱스, WHERE vs HAVING — 쿼리가 느린 이유를 직접 진단하고 최적화하는 법

왜 당신의 SQL 쿼리는 10분이 걸릴까? — EXPLAIN QUERY PLAN부터 인덱스 설계까지

왜 당신의 SQL 쿼리는 10분이 걸릴까? — EXPLAIN QUERY PLAN부터 인덱스 설계까지

엑셀 집계 3시간 vs SQL 3초, 그런데 왜 어떤 쿼리는 10분이 걸리나

"이 CS 티켓 데이터 좀 뽑아주세요." 요청이 올 때마다 엑셀 필터와 VLOOKUP으로 30분씩 소모하고 계신가요? SQL을 배웠는데도 쿼리가 영원히 돌아가서 결국 포기한 적 있으신가요?

SQL을 "아는 것"과 "잘 쓰는 것"은 완전히 다른 문제입니다. 같은 결과를 내는 쿼리라도 작성 방식에 따라 3초 만에 끝날 수도, 10분이 걸릴 수도 있습니다. 이 글에서는 왜 쿼리가 느린지 진단하는 법처음부터 빠른 쿼리를 작성하는 기본기를 다룹니다.

1. "왜 이 에러가 나는 거지?" — SQL 실행 순서의 비밀

SQL을 배울 때 가장 먼저 배우는 문법 순서가 있습니다:

sql
SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT

그런데 이건 작성 순서일 뿐입니다. 데이터베이스가 실제로 쿼리를 실행하는 순서는 완전히 다릅니다.

실제 실행 순서

text
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT

이 순서를 모르면 아래 같은 에러를 만났을 때 원인을 찾지 못합니다:

sql
SELECT
    ticket_id,
    JULIANDAY(resolved_at) - JULIANDAY(created_at) AS resolution_days
FROM tickets
WHERE resolution_days > 1;
text
Error: no such column: resolution_days

왜 에러가 날까요? resolution_days는 SELECT에서 만든 alias인데, WHERE는 SELECT보다 먼저 실행됩니다. 아직 존재하지 않는 컬럼을 참조한 거죠.

해결 방법

sql
-- 방법 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 티켓을 찾는다고 가정해봅시다:

sql
-- 쿼리 A: 인덱스 없는 컬럼으로 검색
SELECT * FROM tickets WHERE description LIKE '%error%';

-- 쿼리 B: 인덱스 있는 컬럼으로 검색
SELECT * FROM tickets WHERE priority = 'urgent';

EXPLAIN으로 확인해보면:

sql
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 결과 읽는 법

text
SCAN tickets          → Full Table Scan (느림 🐌)
SEARCH ... USING INDEX → Index Scan (빠름 🚀)
USING COVERING INDEX   → Index Only Scan (가장 빠름 ⚡)

3. "인덱스를 만들었는데 왜 안 쓰이지?" — 인덱스가 무시되는 5가지 상황

인덱스를 만들어도 쿼리 작성 방식에 따라 데이터베이스가 인덱스를 무시할 수 있습니다.

상황 1: 컬럼에 함수를 씌웠을 때

sql
-- ❌ 인덱스 무시됨
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_at

YEAR(created_at)을 계산하려면 모든 행의 created_at을 확인해야 하기 때문에 인덱스를 쓸 수 없습니다.

상황 2: LIKE에 앞쪽 와일드카드를 썼을 때

sql
-- ❌ 인덱스 무시됨 (앞에 %)
SELECT * FROM tickets WHERE category LIKE '%billing%';

-- ✅ 인덱스 사용 가능 (뒤에만 %)
SELECT * FROM tickets WHERE category LIKE 'billing%';

인덱스는 정렬된 목차와 같습니다. "billing으로 시작하는 것"은 목차에서 찾을 수 있지만, "billing을 포함하는 것"은 전체를 뒤져야 합니다.

상황 3: 복합 인덱스의 순서를 어겼을 때

sql
-- 복합 인덱스: (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: 타입이 맞지 않을 때

sql
-- ❌ ticket_id는 INTEGER인데 문자열로 비교
SELECT * FROM tickets WHERE ticket_id = '12345';

-- ✅ 타입 일치
SELECT * FROM tickets WHERE ticket_id = 12345;

암묵적 타입 변환이 일어나면 인덱스를 사용하지 못할 수 있습니다.

상황 5: OR 조건이 여러 개일 때

sql
-- ❌ 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와 결합 시 유리

실제 쿼리 예시

sql
-- 카테고리별 티켓 통계
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;

결과 예시:

categorytotal_ticketsunique_customersavg_satisfaction
technical3,4122,8913.8
billing2,8762,5434.1
general2,4562,1024.3
refund1,5671,2343.2
최적화 팁: GROUP BY 대상 컬럼에 인덱스가 있으면 Sort Aggregation 시 정렬 비용이 줄어듭니다.

5. WHERE vs HAVING — 언제 뭘 써야 하나?

둘 다 "필터링"이지만 실행 시점과 성능에 큰 차이가 있습니다.

WHEREHAVING
**실행 시점**GROUP BY 이전GROUP BY 이후
**필터링 대상**개별 행그룹
**집계 함수**사용 불가사용 가능
**성능**빠름 (데이터 줄이고 시작)느림 (다 집계 후 필터)

잘못된 예시

sql
-- ❌ 비효율: 전체 데이터 집계 후 필터링
SELECT agent_id, COUNT(*) as ticket_count
FROM tickets
GROUP BY agent_id
HAVING agent_id IN (1, 2, 3, 4, 5);

이 쿼리는 모든 agent의 티켓을 다 세고 나서 5명만 남깁니다.

올바른 예시

sql
-- ✅ 효율적: 먼저 필터링 후 집계
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을 써야 합니다:

sql
-- "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 *

sql
-- ❌ 모든 컬럼 조회
SELECT * FROM tickets WHERE status = 'open';

-- ✅ 필요한 컬럼만
SELECT ticket_id, category, priority, created_at
FROM tickets WHERE status = 'open';

문제점:

  • 네트워크 대역폭 낭비
  • Covering Index 사용 불가
  • 스키마 변경 시 에러 위험

실수 2: NULL 비교에 = 사용

sql
-- ❌ 항상 0건 반환
SELECT * FROM tickets WHERE resolved_at = NULL;

-- ✅ IS NULL 사용
SELECT * FROM tickets WHERE resolved_at IS NULL;

SQL에서 NULL = NULLTRUE가 아니라 NULL(Unknown)입니다.

실수 3: 불필요한 DISTINCT

sql
-- ❌ 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 무시

sql
SELECT
    COUNT(*) as total_rows,           -- 전체 행 수
    COUNT(resolved_at) as resolved,   -- NULL 제외 (해결된 티켓만)
    COUNT(satisfaction_score) as rated -- 평점 있는 것만
FROM tickets;
total_rowsresolvedrated
10,0007,5004,200

COUNT(*)COUNT(column)의 차이를 알아야 합니다.

실수 5: 서브쿼리 vs JOIN

sql
-- ❌ 느림: 행마다 서브쿼리 실행 (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: 날짜 함수로 범위 검색

sql
-- ❌ 인덱스 무시됨
WHERE DATE(created_at) = '2024-06-15'

-- ✅ 인덱스 사용됨
WHERE created_at >= '2024-06-15' AND created_at < '2024-06-16'

실수 7: LIMIT 없이 대량 조회

sql
-- ❌ 100만 건 한 번에 조회
SELECT * FROM tickets;

-- ✅ 페이지네이션
SELECT * FROM tickets ORDER BY ticket_id LIMIT 100 OFFSET 0;

7. 바로 쓰는 실전 쿼리 패턴

패턴 1: 일별 KPI 대시보드

sql
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;

결과:

datenew_ticketsclosedavg_satisfaction
2024-06-152341984.2
2024-06-142562414.0
2024-06-132232154.1

패턴 2: 상담원 성과 분석

sql
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_idtotal_ticketsresolvedresolution_rateavg_hoursavg_score
4215614894.92.34.6
1713412593.33.14.4
2317816291.04.24.2

패턴 3: SLA 준수율 분석

sql
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;

결과:

prioritytotalmetbreachedcompliance_rate
urgent8978890.7
high2341982190.4
medium5674895290.4
low3122871296.0

핵심 정리: 빠른 쿼리를 위한 3가지 원칙

  1. 실행 순서를 기억하라: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
  2. EXPLAIN을 습관화하라: SCAN이 보이면 인덱스 문제
  3. 인덱스를 활용하라: 컬럼에 함수 X, LIKE 앞에 % X, 복합 인덱스는 왼쪽부터

이 세 가지만 기억해도 "왜 쿼리가 느리지?"에 스스로 답할 수 있습니다.

다음 글에서는 JOIN으로 매출이 2배가 되는 문제정확한 집계 방법을 다룹니다.