Theme:

SELECT name, COUNT(*) FROM users WHERE age > 20 GROUP BY name HAVING COUNT(*) > 1 ORDER BY name LIMIT 10 — 이 쿼리에서 어떤 절이 가장 먼저 실행될까요?

SQL을 작성할 때 우리는 SELECT부터 쓰지만, MySQL은 SELECT를 가장 먼저 실행하지 않습니다. SQL의 논리적 실행 순서를 이해하면 왜 특정 위치에서 별칭을 못 쓰는지, 왜 WHERE에서 집계 함수를 쓸 수 없는지 명확해집니다.

논리적 실행 순서

SQL의 논리적 처리 순서는 작성 순서와 다릅니다.

PLAINTEXT
작성 순서:  SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT

실행 순서:  FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT

번호를 매기면 다음과 같습니다.

SQL
(5) SELECT    column, AGG(column)
(1) FROM      table
(2) WHERE     조건
(3) GROUP BY  column
(4) HAVING    AGG(column) 조건
(6) ORDER BY  column
(7) LIMIT     n

각 단계의 역할

1단계: FROM — 데이터 소스 결정

SQL
FROM users u
JOIN orders o ON u.id = o.user_id
  • 사용할 테이블과 JOIN을 결정합니다
  • JOIN이 있으면 카티션 곱(Cartesian Product)을 만든 후 ON 조건으로 필터링합니다
  • 이 시점에서 가상의 임시 결과 집합이 생성됩니다

2단계: WHERE — 행 단위 필터링

SQL
WHERE u.age > 20 AND o.amount > 1000
  • FROM의 결과에서 조건에 맞는 개별 행만 남깁니다
  • 집계 함수를 사용할 수 없습니다 (아직 그룹화 전이므로)
  • SELECT의 별칭을 사용할 수 없습니다 (SELECT가 아직 실행되지 않았으므로)
SQL
-- 에러: WHERE에서 별칭 사용 불가
SELECT price * quantity AS total
FROM orders
WHERE total > 1000;  -- ERROR!

-- 올바른 방법
SELECT price * quantity AS total
FROM orders
WHERE price * quantity > 1000;

3단계: GROUP BY — 그룹화

SQL
GROUP BY u.department
  • WHERE를 통과한 행들을 지정한 컬럼 값이 같은 것끼리 그룹으로 묶습니다
  • 이 시점부터 개별 행이 아닌 그룹 단위로 처리됩니다
  • GROUP BY에 포함되지 않은 컬럼은 SELECT에서 집계 함수로만 사용 가능합니다
SQL
-- MySQL의 ONLY_FULL_GROUP_BY 모드 (기본 활성화)
-- GROUP BY에 없는 컬럼을 SELECT에 쓰면 에러

SELECT department, name, COUNT(*)  -- name은 어떤 값을 보여줘야 하는가?
FROM users
GROUP BY department;  -- ERROR! (ONLY_FULL_GROUP_BY 활성화 시)

-- 올바른 방법
SELECT department, COUNT(*), MAX(name)
FROM users
GROUP BY department;

4단계: HAVING — 그룹 단위 필터링

SQL
HAVING COUNT(*) > 5
  • GROUP BY로 만들어진 그룹을 필터링합니다
  • WHERE와 달리 집계 함수를 사용할 수 있습니다
  • WHERE로 처리할 수 있는 조건은 WHERE에 쓰는 것이 성능상 유리합니다
SQL
-- 나쁜 예: HAVING으로 개별 행 조건 처리
SELECT department, COUNT(*)
FROM users
GROUP BY department
HAVING department = 'Engineering';  -- 모든 행을 그룹화한 후 필터링

-- 좋은 예: WHERE로 먼저 필터링
SELECT department, COUNT(*)
FROM users
WHERE department = 'Engineering'    -- 그룹화 전에 필터링
GROUP BY department;

5단계: SELECT — 컬럼 선택과 연산

SQL
SELECT department, COUNT(*) AS member_count
  • 출력할 컬럼을 선택하고, 계산/별칭을 적용합니다
  • 이 시점에서 별칭(alias)이 생성됩니다
  • DISTINCT가 있으면 이 단계에서 중복을 제거합니다

6단계: ORDER BY — 정렬

SQL
ORDER BY member_count DESC
  • SELECT의 결과를 정렬합니다
  • SELECT의 별칭을 사용할 수 있습니다 (SELECT 이후에 실행되므로)
  • 인덱스를 활용하지 못하면 filesort가 발생합니다
SQL
-- ORDER BY에서는 별칭 사용 가능
SELECT price * quantity AS total
FROM orders
ORDER BY total DESC;  -- OK!

7단계: LIMIT — 결과 제한

SQL
LIMIT 10 OFFSET 20
  • 최종 결과에서 지정한 수만큼만 반환합니다
  • 정렬 후 적용되므로, ORDER BY 없이 LIMIT을 쓰면 결과가 비결정적입니다
  • OFFSET이 크면 성능 문제가 발생합니다 (앞의 행을 모두 읽어야 하므로)
SQL
-- 느린 페이징: OFFSET이 클수록 느려짐
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 100000;

-- 빠른 페이징: 커서 기반
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;

논리적 순서 vs 물리적 순서

논리적 실행 순서는 SQL 표준이 정의한 결과가 어떻게 만들어져야 하는지의 순서입니다. 실제 MySQL 옵티마이저는 같은 결과를 보장하면서 더 효율적인 물리적 실행 순서를 결정합니다.

SQL
-- 논리적으로는 FROM → WHERE → SELECT
-- 물리적으로 옵티마이저가 인덱스를 사용하면:
EXPLAIN SELECT name FROM users WHERE id = 1;
-- type: const (인덱스로 바로 접근, 풀 스캔 없음)

옵티마이저가 변경할 수 있는 것들:

  • JOIN 순서 변경 (드라이빙 테이블 결정)
  • 서브쿼리를 JOIN으로 변환
  • 불필요한 정렬 제거
  • 인덱스 활용을 위한 접근 방식 변경
SQL
-- EXPLAIN으로 물리적 실행 계획 확인
EXPLAIN FORMAT=TREE
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.age > 20
GROUP BY u.name
HAVING COUNT(o.id) > 3
ORDER BY order_count DESC
LIMIT 10;

실행 순서로 이해하는 흔한 실수들

1. WHERE에서 별칭 사용

SQL
-- 에러: WHERE는 SELECT보다 먼저 실행
SELECT YEAR(created_at) AS created_year FROM orders
WHERE created_year = 2026;

-- 수정: 원래 표현식 사용
SELECT YEAR(created_at) AS created_year FROM orders
WHERE YEAR(created_at) = 2026;

-- 더 좋은 방법: 인덱스 활용 가능한 형태
SELECT YEAR(created_at) AS created_year FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

2. WHERE에서 집계 함수 사용

SQL
-- 에러: WHERE 시점에는 그룹화 전
SELECT department, AVG(salary) FROM employees
WHERE AVG(salary) > 5000
GROUP BY department;

-- 수정: HAVING 사용
SELECT department, AVG(salary) FROM employees
GROUP BY department
HAVING AVG(salary) > 5000;

3. GROUP BY와 SELECT 불일치

SQL
-- sql_mode에 ONLY_FULL_GROUP_BY가 활성화되어 있으면 에러
SELECT department, name, COUNT(*)
FROM employees
GROUP BY department;
-- name이 GROUP BY에 없고 집계 함수로 감싸지도 않음

-- 수정 방법 1: GROUP BY에 추가
SELECT department, name, COUNT(*)
FROM employees
GROUP BY department, name;

-- 수정 방법 2: 집계 함수 사용
SELECT department, GROUP_CONCAT(name), COUNT(*)
FROM employees
GROUP BY department;

4. ORDER BY에서 SELECT에 없는 컬럼 사용

SQL
-- DISTINCT와 함께 SELECT에 없는 컬럼으로 정렬하면 에러
SELECT DISTINCT department FROM employees
ORDER BY salary;  -- department별로 어떤 salary를 기준으로 해야 하는가?

서브쿼리의 실행 순서

서브쿼리는 위치에 따라 실행 시점이 달라집니다.

SQL
-- FROM절 서브쿼리 (파생 테이블): FROM 단계에서 실행
SELECT * FROM (SELECT department, COUNT(*) AS cnt FROM employees GROUP BY department) sub
WHERE sub.cnt > 5;

-- WHERE절 서브쿼리: WHERE 단계에서 실행
SELECT * FROM employees
WHERE department_id IN (SELECT id FROM departments WHERE active = 1);

-- SELECT절 서브쿼리 (스칼라): SELECT 단계에서 각 행마다 실행 (성능 주의!)
SELECT name,
       (SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) AS order_count
FROM users;

EXPLAIN으로 실행 계획 확인

SQL
-- 기본 EXPLAIN
EXPLAIN SELECT * FROM users WHERE age > 20;

-- 상세 포맷
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE age > 20;

-- 실제 실행 통계 포함 (MySQL 8.0.18+)
EXPLAIN ANALYZE SELECT * FROM users WHERE age > 20;

EXPLAIN 결과에서 확인할 핵심 정보:

  • type: ALL(풀스캔), index, range, ref, const 순으로 좋음
  • key: 사용된 인덱스
  • rows: 예상 스캔 행 수
  • Extra: Using filesort, Using temporary 등 추가 작업

정리

  • SQL의 논리적 실행 순서는 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT입니다
  • WHERE에서 별칭이나 집계 함수를 쓸 수 없는 이유는 SELECT보다 먼저 실행되기 때문입니다
  • WHERE로 먼저 걸러내고 HAVING은 그룹 필터링에만 사용하는 것이 성능상 유리합니다
  • 논리적 순서와 물리적 순서는 다르며, 옵티마이저가 비용 기반으로 물리적 순서를 결정합니다
  • EXPLAIN으로 실제 실행 계획을 확인하는 습관을 들이면 좋습니다
댓글 로딩 중...