쓰는 순서와 실행되는 순서는 다르다

헷갈리는 SQL 오류의 대부분은 한 가지 사실에서 나옵니다. 작성하는 절의 순서와 데이터베이스가 평가하는 순서가 다르다는 것입니다. SELECT를 맨 앞에 쓰지만 엔진이 그것을 처리하는 시점은 뒤쪽입니다. 그래서 이건 실패합니다.

SELECT price * quantity AS total
FROM order_items
WHERE total > 1000;          -- 오류: total 이라는 컬럼이 없음

그런데 이건 됩니다.

SELECT price * quantity AS total
FROM order_items
ORDER BY total DESC;         -- 정상

별칭 쪽은 아무것도 달라지지 않았습니다. 달라진 것은 그 절이 SELECT보다 먼저 도는지 나중에 도는지뿐입니다.

두 순서를 나란히

작성 순서논리적 실행 순서
1. SELECT1. FROM / JOIN — 대상 행을 구성
2. FROM2. WHERE — 개별 행을 걸러냄
3. JOIN3. GROUP BY — 행을 그룹으로 묶음
4. WHERE4. HAVING — 그룹을 걸러냄
5. GROUP BY5. SELECT — 출력 컬럼을 계산 (별칭은 여기서 생긴다)
6. HAVING6. DISTINCT — 중복 행 제거
7. ORDER BY7. ORDER BY — 결과 정렬
8. LIMIT8. LIMIT / OFFSET — 결과를 잘라냄

오른쪽 열을 위에서 아래로 읽기만 해도 SQL의 ‘왜?’ 대부분이 풀립니다. 이는 표준이 정의하는 논리적 순서이며, 실제 처리 순서는 결과만 같다면 옵티마이저가 자유롭게 바꿉니다.

왜 WHERE에서는 별칭이 안 되고 ORDER BY에서는 되는가

SELECT는 5번째, WHERE는 2번째입니다. WHERE가 도는 시점에 별칭은 아직 존재하지 않습니다. ORDER BY는 7번째, 즉 SELECT 이후이므로 그때는 존재합니다.

이식성 있는 해결책은 식을 그대로 다시 쓰는 것입니다.

SELECT price * quantity AS total
FROM order_items
WHERE price * quantity > 1000;

또는 서브쿼리로 감싸면 바깥쪽에서 별칭이 실제 컬럼이 됩니다.

SELECT * FROM (
    SELECT price * quantity AS total FROM order_items
) t
WHERE t.total > 1000;

이 규칙을 어디까지 완화하는지는 방언마다 다릅니다. MySQL은 GROUP BYHAVING에서 별칭을 허용하고, PostgreSQL은 GROUP BYORDER BY에서는 허용하지만 HAVING에서는 안 됩니다. 다만 WHERE에서 별칭을 허용하는 주요 데이터베이스는 없습니다.

WHERE와 HAVING의 구분

이 둘은 바꿔 쓸 수 없고, 이유는 역시 순서입니다. WHERE(2번째)는 그룹화 전의 행을, HAVING(4번째)은 집계 후의 그룹을 걸러냅니다.

SELECT customer_id, COUNT(*) AS order_count
FROM orders
WHERE status = 'paid'          -- 먼저 행을 거른다: 결제 완료 주문만
GROUP BY customer_id
HAVING COUNT(*) >= 3;          -- 그다음 그룹을 거른다: 3건 이상

바꿔 놓으면 의미가 완전히 달라집니다. HAVING status = 'paid'는 행이 아니라 그룹에 대한 질문이 되고, 반대로 WHERE COUNT(*) >= 3은 아예 오류입니다. 2번째 시점에는 아직 아무것도 세지 않았기 때문입니다.

실무 기준은 단순합니다. 조건에 집계 함수가 나오면 HAVING, 아니면 WHERE. 집계가 없는 조건을 WHERE에 두면 엔진이 이른 단계에서 행을 버릴 수 있어 속도 면에서도 유리합니다.

GROUP BY: 데이터베이스마다 다른 규칙

그룹화할 때 SELECT에 쓴 컬럼은 GROUP BY에 포함되어 있거나 집계 함수로 감싸여 있어야 합니다. 그렇지 않으면 묶인 여러 행 중 어느 값을 보여줄지 정할 수 없습니다.

-- 깨진 쿼리: 어느 name을 카운트와 함께 보여줄지 정해지지 않음
SELECT customer_id, name, COUNT(*) FROM orders GROUP BY customer_id;

PostgreSQL은 이를 명확히 거부합니다. MySQL도 기본값으로 거부합니다 — 5.7.5부터 ONLY_FULL_GROUP_BY 모드가 기본 활성화되어 있기 때문입니다. 예전 설정의 MySQL은 이를 받아들이고 임의의 한 행 값을 돌려줬기 때문에, 서버를 올린 순간 기존 쿼리가 깨지는 형태로 드러납니다(expression is not in GROUP BY clause and contains nonaggregated column). 대처는 모드를 끄는 것이 아니라 컬럼을 GROUP BY에 추가하거나 MAX(name)처럼 집계하는 것입니다.

집계와 NULL

숫자가 조용히 달라지므로 다음 두 가지는 외워 둘 만합니다.

NULL을 세는가
COUNT(*)센다(행 수 자체)
COUNT(컬럼)세지 않는다(해당 컬럼이 NULL인 행은 건너뜀)
COUNT(DISTINCT 컬럼)세지 않는다(NULL이 아닌 서로 다른 값의 수)
SUM / AVG / MAX / MIN세지 않는다(NULL 무시)

AVG(컬럼)은 행 수가 아니라 NULL이 아닌 값의 개수로 나눕니다. NULL을 0으로 취급하려면 AVG(COALESCE(컬럼, 0))처럼 명시하세요.

가장 크게 영향을 주는 곳이 LEFT JOIN 뒤입니다. 매칭되지 않은 행은 오른쪽이 NULL이 되므로 COUNT(*)는 주문이 0건인 고객도 1로 세어 버립니다. COUNT(o.id)라면 올바르게 0이 됩니다.

SELECT c.id, COUNT(o.id) AS order_count      -- COUNT(*)가 아님
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;

LIMIT은 마지막에 돈다, 그리고 동점에 약하다

LIMIT은 8번째로 정렬 이후에 적용됩니다. 여기서 두 가지 결론이 나옵니다.

집계가 가벼워지지는 않는다. GROUP BY가 있는 쿼리에 LIMIT 10을 붙여도 전체를 그룹화한 뒤 대부분을 버릴 뿐입니다.

고유한 정렬 키가 없으면 페이징이 깨진다. created_at에 동점이 있으면 페이지를 넘기는 과정에서 같은 행이 두 번 나오거나 아예 나오지 않을 수 있습니다. 고유한 컬럼을 타이브레이커로 추가하세요.

ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

구문에는 방언 차이가 있습니다. PostgreSQL과 MySQL은 모두 LIMIT n OFFSET m을 받습니다(MySQL에는 인자 순서가 반대인 옛 LIMIT m, n도 있습니다). SQL Server는 OFFSET m ROWS FETCH NEXT n ROWS ONLY입니다.

JOIN과 WHERE의 함정

순서에서 비롯된 사고 중 가장 흔한 것입니다. JOIN은 1번째, WHERE는 2번째 — 즉 오른쪽 테이블에 대한 WHERE 조건은 외부 조인이 NULL을 채운 뒤에 실행되고, 그 NULL 행은 조건을 통과하지 못해 사라집니다.

-- 사실상 INNER JOIN이 되어 버린다
SELECT * FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';

-- 결제 완료 주문이 없는 고객도 남는다
SELECT * FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid';

조인을 거르는 조건은 ON에, 최종 결과를 거르는 조건은 WHERE 씁니다. JOIN 종류별 결과 행 수의 차이는 SQL JOIN 완전 레퍼런스 에 정리해 두었습니다.

순서를 외우지 않고 조립하기

순서를 아는 것과 매번 올바른 위치에 절을 쓰는 것은 다른 문제입니다. Visual SQL Builder 는 테이블·조인 조건·필터·그룹화·정렬을 폼에서 고르면 올바른 작성 순서로 절을 출력합니다. 요소를 더할 때마다 생성된 SQL이 바뀌는 것이 보이므로, 어떤 조건이 실제로 어디에 들어가야 하는지 확인하는 용도로도 쓸 수 있습니다.

기존 쿼리를 읽을 때는 SQL 포매터 로 정렬하면 절의 경계가 눈에 들어오고, SQL to ER 다이어그램 을 쓰면 조인하는 테이블 사이의 관계를 그림으로 확인할 수 있습니다. 아직 테이블 설계 단계라면 CREATE TABLE 레퍼런스 가 타입과 제약을 정리해 두었습니다. 모두 브라우저 안에서 완결되며 붙여 넣은 쿼리나 스키마가 외부로 전송되지 않습니다.

정리

  • 실행 순서는 FROMWHEREGROUP BYHAVINGSELECTDISTINCTORDER BYLIMIT
  • 별칭은 SELECT에서 생기므로 WHERE에서는 보이지 않고 ORDER BY에서는 보인다
  • 집계가 없는 조건은 WHERE, 집계가 있는 조건은 HAVING
  • MySQL은 5.7.5부터 ONLY_FULL_GROUP_BY가 기본값
  • COUNT(*)는 행 수, COUNT(컬럼)은 NULL을 건너뜀. LEFT JOIN 뒤에는 거의 후자가 맞다
  • LIMIT은 마지막. 안정적인 페이징에는 ORDER BY에 고유한 타이브레이커가 필요
  • 조인을 거르려면 ON, 결과를 거르려면 WHERE

자주 묻는 질문

별칭이 ORDER BY에서는 되고 WHERE에서는 안 되는 이유는 무엇인가요?

WHERESELECT보다 먼저, ORDER BY는 나중에 평가되기 때문입니다. 별칭은 SELECT가 만들기 때문에 WHERE가 도는 시점에는 아직 존재하지 않습니다. WHERE에는 식을 그대로 쓰거나, 서브쿼리로 감싸 별칭을 실제 컬럼으로 만드세요.

WHERE와 HAVING은 어떻게 구분하나요?

WHERE는 그룹화 전의 개별 행을, HAVING은 집계 후의 그룹을 거릅니다. 조건에 COUNT()SUM() 같은 집계 함수가 나오면 HAVING에 둘 수밖에 없습니다. 그 외에는 WHERE에 두는 편이 행을 먼저 버릴 수 있어 유리합니다.

MySQL에서 “expression is not in GROUP BY clause” 오류가 납니다

MySQL 5.7.5부터 기본 활성화된 ONLY_FULL_GROUP_BY 때문입니다. SELECT에 쓴 컬럼은 모두 GROUP BY에 포함하거나 집계 함수로 감싸야 합니다. 예전 MySQL을 대상으로 쓰인 쿼리가 업그레이드에서 깨지는 전형적인 사례이므로, 모드를 끄기보다 컬럼을 GROUP BY에 추가하거나 집계하는 것이 정공법입니다.

LIMIT을 붙이면 집계 쿼리가 빨라지나요?

기본적으로 빨라지지 않습니다. LIMIT은 그룹화와 정렬이 모두 끝난 뒤에 적용되므로 데이터베이스는 결과 전체를 만든 다음 버립니다. 가볍게 하려면 WHERE로 먼저 대상을 줄이거나 그룹화를 돕는 인덱스를 검토하세요.

붙여 넣은 SQL이 서버로 전송되나요?

아니요. Visual SQL BuilderSQL 포매터 는 모두 브라우저 안에서 완결되며, 입력한 쿼리나 테이블 이름이 외부로 전송되는 일은 없습니다.