SQL 실행 순서 — SELECT 문이 내부에서 처리되는 과정
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의 논리적 처리 순서는 작성 순서와 다릅니다.
작성 순서: SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT
실행 순서: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
번호를 매기면 다음과 같습니다.
(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 — 데이터 소스 결정
FROM users u
JOIN orders o ON u.id = o.user_id
- 사용할 테이블과 JOIN을 결정합니다
- JOIN이 있으면 카티션 곱(Cartesian Product)을 만든 후 ON 조건으로 필터링합니다
- 이 시점에서 가상의 임시 결과 집합이 생성됩니다
2단계: WHERE — 행 단위 필터링
WHERE u.age > 20 AND o.amount > 1000
- FROM의 결과에서 조건에 맞는 개별 행만 남깁니다
- 집계 함수를 사용할 수 없습니다 (아직 그룹화 전이므로)
- SELECT의 별칭을 사용할 수 없습니다 (SELECT가 아직 실행되지 않았으므로)
-- 에러: 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 — 그룹화
GROUP BY u.department
- WHERE를 통과한 행들을 지정한 컬럼 값이 같은 것끼리 그룹으로 묶습니다
- 이 시점부터 개별 행이 아닌 그룹 단위로 처리됩니다
- GROUP BY에 포함되지 않은 컬럼은 SELECT에서 집계 함수로만 사용 가능합니다
-- 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 — 그룹 단위 필터링
HAVING COUNT(*) > 5
- GROUP BY로 만들어진 그룹을 필터링합니다
- WHERE와 달리 집계 함수를 사용할 수 있습니다
- WHERE로 처리할 수 있는 조건은 WHERE에 쓰는 것이 성능상 유리합니다
-- 나쁜 예: 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 — 컬럼 선택과 연산
SELECT department, COUNT(*) AS member_count
- 출력할 컬럼을 선택하고, 계산/별칭을 적용합니다
- 이 시점에서 별칭(alias)이 생성됩니다
- DISTINCT가 있으면 이 단계에서 중복을 제거합니다
6단계: ORDER BY — 정렬
ORDER BY member_count DESC
- SELECT의 결과를 정렬합니다
- SELECT의 별칭을 사용할 수 있습니다 (SELECT 이후에 실행되므로)
- 인덱스를 활용하지 못하면 filesort가 발생합니다
-- ORDER BY에서는 별칭 사용 가능
SELECT price * quantity AS total
FROM orders
ORDER BY total DESC; -- OK!
7단계: LIMIT — 결과 제한
LIMIT 10 OFFSET 20
- 최종 결과에서 지정한 수만큼만 반환합니다
- 정렬 후 적용되므로, ORDER BY 없이 LIMIT을 쓰면 결과가 비결정적입니다
- OFFSET이 크면 성능 문제가 발생합니다 (앞의 행을 모두 읽어야 하므로)
-- 느린 페이징: 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 옵티마이저는 같은 결과를 보장하면서 더 효율적인 물리적 실행 순서를 결정합니다.
-- 논리적으로는 FROM → WHERE → SELECT
-- 물리적으로 옵티마이저가 인덱스를 사용하면:
EXPLAIN SELECT name FROM users WHERE id = 1;
-- type: const (인덱스로 바로 접근, 풀 스캔 없음)
옵티마이저가 변경할 수 있는 것들:
- JOIN 순서 변경 (드라이빙 테이블 결정)
- 서브쿼리를 JOIN으로 변환
- 불필요한 정렬 제거
- 인덱스 활용을 위한 접근 방식 변경
-- 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에서 별칭 사용
-- 에러: 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에서 집계 함수 사용
-- 에러: 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_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에 없는 컬럼 사용
-- DISTINCT와 함께 SELECT에 없는 컬럼으로 정렬하면 에러
SELECT DISTINCT department FROM employees
ORDER BY salary; -- department별로 어떤 salary를 기준으로 해야 하는가?
서브쿼리의 실행 순서
서브쿼리는 위치에 따라 실행 시점이 달라집니다.
-- 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으로 실행 계획 확인
-- 기본 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으로 실제 실행 계획을 확인하는 습관을 들이면 좋습니다
댓글 로딩 중...