SQL은 내가 작성한 문법 순서대로 실행될 것이라 막연히 생각했었다. 사실 어쩌면 순서에 대해 깊게 생각해보지 않았던 것 같다. 하지만 곰곰이 생각해보면 SELECT가 가장 먼저 실행될 수는 없다는 걸 알 수 있다. 궁금증이 생겨서 SQL이 실제로 어떤 순서로 쿼리를 처리하는지 학습하려고 한다.
우리가 작성하는 SQL은 사람이 읽기 쉬운 형태고,
실제로 DB는 처리하기 좋은 순서로 실행한다.
SQL은 일반적으로 SELECT → FROM → WHERE 순으로 작성하지만, DB 엔진이 실제로 처리하는 순서는 다르다.
이것을 SELECT 절의 처리 순서 또는 Logical Query Processing Order라고 한다.
1. FROM: 테이블 또는 뷰를 가져온다.
2. JOIN / ON: 조인 조건에 맞는 행을 연결한다.
3. WHERE: 조건에 맞는 행을 필터링한다.
4. GROUP BY: 데이터를 그룹화한다.
5. HAVING: 그룹화된 결과에 조건을 건다.
6. SELECT: 최종으로 보여줄 컬럼을 선택한다.
7. DISTINCT: 중복 제거
8. ORDER BY: 정렬
9. LIMIT: 결과 개수 제한
예시로
SELECT COUNT(*)쿼리에서WHERE과HAVING을 헷갈리면 잘못된 결과를 도출할 수 있다고 한다. 또한,SELECT에서 사용하는별칭(alias)은WHERE에서 사용할 수 없는데, 이는SELECT가WHERE보다 나중에 처리되기 때문이라고 한다. 즉, 순서를 이해하고 있다면, 쿼리문 작성 또는 예측이 더 수월해질 것이다.
-- 테이블 구조
crew(id, nickname, part_id) -- 크루
part(id, name) -- 파트
mission(id, name, deadline) -- 미션
mission_record(crew_id, mission_id, completed) -- 미션 기록-- 최대한 다양한 키워드를 위해 구성된 예시이다.
-- 미션 마감 기한이 지난 미션 중,
-- 모든 크루가 완료한 미션의 파트 목록을 완료한 파트 이름 기준으로 정렬해서 3개만 조회
SELECT DISTINCT p.name AS part_name
FROM part p
JOIN crew c ON p.id = c.part_id
JOIN mission_record mr ON c.id = mr.crew_id
JOIN mission m ON mr.mission_id = m.id
WHERE m.deadline < CURRENT_DATE
GROUP BY p.name, mr.mission_id
HAVING COUNT(DISTINCT c.id) = (SELECT COUNT(*)
FROM crew
WHERE part_id = p.id)
ORDER BY p.name
LIMIT 3;FROM part p
JOIN crew c ON p.id = c.part_id
JOIN mission_record mr ON c.id = mr.crew_id
JOIN mission m ON mr.mission_id = m.idFROM 절에서 part 테이블을 기준으로 JOIN을 진행한다.
이때, ON 절에서 조인 조건을 설정한다.
part, crew, mission_record, mission 테이블을 조인한다.
WHERE m.deadline < CURRENT_DATEWHERE 절에서 mission 테이블의 deadline이 현재 날짜보다 이전인 행을 필터링한다.
마감 기한이 지난 미션만 남게 될 것이다.
GROUP BY p.name, mr.mission_idGROUP BY 절에서 p.name, mr.mission_id를 통해 파트와 미션 조합으로 그룹을 만든다.
HAVING COUNT(DISTINCT c.id) = (SELECT COUNT(*)
FROM crew
WHERE part_id = p.id)HAVING 절에서는 각 파트의 미션에 대해 해당 파트 소속 크루 모두가 완료한 경우만 남긴다.
왼쪽의 COUNT(DISTINCT c.id)는 실제로 미션을 완료한 크루 수이며,
오른쪽의 서브쿼리는 해당 파트에 속한 전체 크루 수다.
두 수가 같다는 것은 그 파트의 모든 크루가 해당 미션을 완료했다는 의미다.
SELECT DISTINCT p.name AS part_nameSELECT 절에서는 part 테이블의 name을 선택한다.
이때 DISTINCT를 함께 사용하여 중복된 파트 이름을 제거한다.
또한 AS를 통해 결과 컬럼명을 part_name으로 지정한다.
ORDER BY p.nameORDER BY 절에서 part 테이블의 name을 기준으로 정렬한다. 즉, 파트 이름을 기준으로 오름차순으로 정렬된다.
LIMIT 3;LIMIT 절에서 결과 개수를 제한한다.
3개만 조회하도록 설정되어 있다.
즉, 정렬된 결과 중 상위 3개만 남게 된다.
실행 순서를 이해하면 쿼리 작성이 수월해진다. 예시로 알아보자.
SELECT c.id AS crew_id
FROM crew c
WHERE crew_id = 1 -- 오류 발생WHERE 절은 SELECT 절보다 먼저 실행된다. SELECT에서 정의한 crew_id라는 별칭을 사용할 수 없다. 이런 경우에는 별칭 대신 실제 컬럼명을 사용하거나, 공통된 조건이 필요하다면 WITH절을 사용한 서브쿼리로 분리하는 것이 낫다.
-- 모든 크루가 완료한 미션만 조회하고 싶은데...
-- 잘못된 예시
SELECT mission_id
FROM mission_record
WHERE COUNT(*) > 3 -- 오류 발생
-- 올바른 예시
SELECT mission_id
FROM mission_record
GROUP BY mission_id
HAVING COUNT(*) > 3WHERE 절은 그룹화되기 전에 실행된다. 따라서 집계 함수(COUNT, SUM, 등)를 사용할 수 없다. 이런 조건은 HAVING 절에서 써야 한다.
SELECT c.part_id, COUNT(*) -- 오류 발생
FROM crew c;전체 집계는 단독으로는 가능하지만, 컬럼과 함께 쓰려면 반드시 GROUP BY가 필요하다.
-- 모든 크루의 파트별 수를 조회
SELECT c.part_id, COUNT(*)
FROM crew c
GROUP BY c.part_id;SELECT c.id AS crew_id
FROM crew c
ORDER BY crew_id; -- 별칭 사용 가능ORDER BY 절은 SELECT 절 이후에 실행되므로, 별칭을 사용할 수 있다. 즉, ORDER BY 절에서 crew_id라는 별칭을 사용할 수 있다.
논리적 실행 순서는 단순한 암기가 아닌 것 같다.
쿼리를 제대로 이해하고, 오류를 줄이며, 성능까지 고려할 수 있는 중요한 개념이다.
이제 쿼리문을 볼 때, 눈앞에 보이는 순서 뒤에 숨겨진 처리 흐름도 함께 그리는 연습을 하면 좋을 것 같다.