SQL SELECT 결과 오류: LEFT JOIN 누락·GROUP BY 집계·NOT IN의 NULL 함정


1장. SQL은 실행됐는데 왜 보고서 숫자는 틀릴까#

다음 SQL이 정상적으로 실행됐다고 하겠습니다.

SELECT
    d.dept_name,
    COUNT(*) AS staff_count,
    AVG(e.salary) AS avg_salary
FROM department AS d
LEFT JOIN employee AS e
    ON e.dept_id = d.dept_id
GROUP BY d.dept_name;

문법 오류도 없습니다.

DBMS도 결과를 반환합니다.

그런데 직원이 한 명도 없는 영업 부서의 직원 수가 1로 표시됩니다.

이번에는 프로젝트 테이블까지 조인했더니 개발팀 급여 합계가 실제보다 커졌습니다.

또 다른 쿼리는 관리자 아닌 직원을 찾으려고 NOT IN을 사용했는데 결과가 한 명도 나오지 않습니다.

이런 문제의 공통점은 SQL 문법 오류가 아니라 결과의 의미가 틀렸다는 것입니다.

SQL에서는 다음 두 문장이 완전히 다릅니다.

SQL이 실행된다.
SQL이 원하는 업무 결과를 만든다.

복잡한 조회를 정확하게 만들려면 먼저 한 가지를 정해야 합니다.

결과 한 행은 무엇을 의미하는가?

직원 한 명인지, 부서 한 곳인지, 주문 한 건인지, 프로젝트 참여 한 건인지부터 정해야 합니다.


2장. 이번 실습에서 사용할 데이터#

다음과 같은 부서가 있습니다.

부서ID 부서명
10 개발
20 운영
30 영업

직원 데이터는 다음과 같습니다.

직원ID 이름 부서ID 급여 관리자ID
1 가람 10 5000 NULL
2 나래 10 4000 1
3 다온 20 4000 1
4 라온 NULL NULL 1
5 마루 20 6000 NULL

이 데이터에는 일부러 세 가지 특수 상황을 넣었습니다.

라온
→ 아직 부서가 없음

라온
→ 급여도 미입력

영업 부서
→ 직원이 한 명도 없음

NULL과 외부 조인, 집계를 이해하기 좋은 조건입니다.


3장. 실습 테이블 만들기#

CREATE TABLE department (
    dept_id integer PRIMARY KEY,
    dept_name varchar(30) NOT NULL
);

CREATE TABLE employee (
    emp_id integer PRIMARY KEY,
    emp_name varchar(30) NOT NULL,
    dept_id integer REFERENCES department(dept_id),
    salary integer,
    manager_id integer
);

데이터를 입력합니다.

INSERT INTO department VALUES
    (10, '개발'),
    (20, '운영'),
    (30, '영업');

INSERT INTO employee VALUES
    (1, '가람', 10, 5000, NULL),
    (2, '나래', 10, 4000, 1),
    (3, '다온', 20, 4000, 1),
    (4, '라온', NULL, NULL, 1),
    (5, '마루', 20, 6000, NULL);

이후 예제는 이 데이터를 기준으로 계산합니다.


4장. SELECT에서 가장 먼저 확인할 것은 결과 한 행의 의미다#

다음 조회를 보겠습니다.

SELECT
    emp_name,
    salary
FROM employee;

결과 한 행은 직원 한 명입니다.

가람 | 5000
나래 | 4000
다온 | 4000
라온 | NULL
마루 | 6000

이번에는 다음 조회를 보겠습니다.

SELECT
    dept_id,
    AVG(salary)
FROM employee
GROUP BY dept_id;

이 결과의 한 행은 직원이 아닙니다.

부서 한 그룹입니다.

같은 employee 테이블을 읽었지만 결과 단위가 달라졌습니다.

복잡한 SQL에서 오류가 생기는 이유 상당수는 이 결과 단위를 놓치는 데서 시작합니다.


5장. SELECT 별칭은 출력 이름만 바꾼다#

SELECT
    emp_name AS employee_name,
    salary AS monthly_salary
FROM employee;

AS는 결과에 표시되는 이름을 바꿉니다.

원본 테이블의 컬럼명을 변경하는 것은 아닙니다.

employee.emp_name
→ 그대로 유지

결과 표시
→ employee_name

가독성을 높이는 용도로 사용하면 좋습니다.


6장. ORDER BY가 없으면 결과 순서를 기대하면 안 된다#

다음 SQL이 있습니다.

SELECT
    emp_id,
    emp_name,
    salary
FROM employee;

현재 우연히 emp_id 순서대로 보일 수 있습니다.

그렇다고 이 순서가 보장되는 것은 아닙니다.

급여가 높은 순으로 보고 싶다면 명시적으로 정렬합니다.

SELECT
    emp_id,
    emp_name,
    salary
FROM employee
ORDER BY salary DESC NULLS LAST;

결과는 다음과 같습니다.

마루 | 6000
가람 | 5000
나래 | 4000
다온 | 4000
라온 | NULL

동점까지 안정적으로 정렬하려면 보조 기준을 추가할 수 있습니다.

ORDER BY
    salary DESC NULLS LAST,
    emp_id;

7장. LIMIT를 사용하려면 먼저 정렬 기준을 정해야 한다#

급여가 높은 두 명을 찾습니다.

SELECT
    emp_id,
    emp_name,
    salary
FROM employee
ORDER BY
    salary DESC NULLS LAST,
    emp_id
LIMIT 2;

결과는 다음 두 명입니다.

직원ID 이름 급여
5 마루 6000
1 가람 5000

정렬 없이 LIMIT 2만 사용하는 것은 “어떤 두 명이든 상관없다”는 의미에 가까워질 수 있습니다.


8장. DISTINCT는 행의 의미를 모른 채 붙이면 위험하다#

다음 SQL을 실행합니다.

SELECT DISTINCT salary
FROM employee
ORDER BY salary NULLS LAST;

결과는:

4000
5000
6000
NULL

입니다.

나래와 다온은 모두 급여가 4000이므로 하나로 합쳐졌습니다.

하지만 직원 자체가 중복된 것은 아닙니다.

다음처럼 직원ID까지 포함하면:

SELECT DISTINCT
    emp_id,
    salary
FROM employee;

두 직원은 그대로 남습니다.

DISTINCT는 SELECT 결과 열 전체 조합의 중복을 제거합니다.

따라서 조회 결과가 이상하다고 무작정 DISTINCT를 붙이면 실제로 필요한 서로 다른 행까지 하나로 합쳐질 수 있습니다.


9장. WHERE는 그룹을 만들기 전에 행을 고른다#

개발 부서에서 급여 4500 이상인 직원을 찾겠습니다.

SELECT
    emp_id,
    emp_name,
    salary
FROM employee
WHERE dept_id = 10
  AND salary >= 4500;

개발 부서에는 가람과 나래가 있습니다.

가람 | 5000
나래 | 4000

급여 조건을 적용하면 가람만 남습니다.

직원ID 이름 급여
1 가람 5000

10장. AND와 OR는 결과 집합 자체를 바꾼다#

다음 조건은:

WHERE dept_id = 10
  AND salary >= 4500

두 조건을 모두 만족해야 합니다.

결과는 가람 한 명입니다.

반면:

WHERE dept_id = 10
   OR salary >= 4500

은 둘 중 하나만 만족해도 됩니다.

결과는:

가람
나래
마루

입니다.

나래는 급여 4500 미만이지만 개발 부서이므로 포함됩니다.

마루는 운영 부서이지만 급여가 6000이므로 포함됩니다.


11장. AND와 OR가 섞이면 괄호가 매우 중요하다#

개발 또는 운영 부서 직원 가운데 급여 4500 이상을 찾는다고 하겠습니다.

의도는 다음과 같습니다.

개발 또는 운영
AND
급여 4500 이상

따라서 SQL은 다음처럼 작성하는 것이 명확합니다.

SELECT
    emp_id,
    emp_name,
    dept_id,
    salary
FROM employee
WHERE
    (dept_id = 10 OR dept_id = 20)
    AND salary >= 4500;

결과는 가람과 마루입니다.

그런데 괄호를 빼고:

WHERE dept_id = 10
   OR dept_id = 20
  AND salary >= 4500;

라고 작성하면 일반적인 연산 우선순위 때문에 AND가 먼저 평가됩니다.

실제 의미는 다음과 비슷합니다.

dept_id = 10

OR

(
 dept_id = 20
 AND salary >= 4500
)

따라서 개발 부서의 나래도 급여와 관계없이 포함됩니다.


12장. NULL은 빈 문자열도 0도 아니다#

라온의 급여는 NULL입니다.

salary = NULL

이라고 생각하기 쉽지만 SQL에서 NULL은 일반 값처럼 비교하지 않습니다.

다음 조건은 올바른 NULL 검사 방법이 아닙니다.

WHERE salary = NULL;

NULL 여부는 다음처럼 확인합니다.

WHERE salary IS NULL;

결과:

라온

반대는 다음과 같습니다.

WHERE salary IS NOT NULL;

13장. NULL이 들어가면 참과 거짓만으로 끝나지 않는다#

SQL 조건은 NULL 때문에 세 가지 결과를 가질 수 있습니다.

TRUE
FALSE
UNKNOWN

예를 들어 라온의 급여는 NULL입니다.

다음 조건:

salary > 4000

은 참도 거짓도 아닙니다.

비교할 실제 급여가 없기 때문에 UNKNOWN입니다.

WHERE는 TRUE인 행만 남깁니다.

따라서 라온은 제외됩니다.


14장. salary <> 4000에도 NULL 직원은 나오지 않는다#

다음 SQL을 보겠습니다.

SELECT
    emp_name
FROM employee
WHERE salary <> 4000;

일상 언어로 생각하면:

4000이 아닌 직원

이므로 급여가 없는 라온도 들어갈 것처럼 느껴질 수 있습니다.

하지만 SQL에서는:

NULL <> 4000

이 UNKNOWN입니다.

따라서 결과는:

가람
마루

입니다.

라온까지 포함해야 한다면 요구를 명확히 표현해야 합니다.

WHERE salary <> 4000
   OR salary IS NULL;

15장. NULL을 0으로 바꾸는 순간 데이터의 의미가 바뀐다#

급여 데이터가 다음 두 건이라고 하겠습니다.

4000
NULL

일반 AVG는 NULL을 제외합니다.

SELECT AVG(salary)
FROM employee;

NULL은 평균 분모에 포함되지 않습니다.

하지만 다음처럼 쓰면:

SELECT AVG(COALESCE(salary, 0))
FROM employee;

미입력 급여를 실제 0으로 해석하게 됩니다.

이것은 단순한 표시 변경이 아닙니다.

급여 미입력

을:

급여 0

으로 바꾼 것입니다.

업무적으로 같은 의미인지 먼저 확인해야 합니다.


16장. COUNT 별표와 COUNT 컬럼은 다르다#

다음 집계를 실행합니다.

SELECT
    dept_id,
    COUNT(*) AS row_count,
    COUNT(salary) AS salary_count
FROM employee
GROUP BY dept_id;

결과를 보면:

부서ID COUNT 별표 COUNT 급여
10 2 2
20 2 2
NULL 1 0

부서가 없는 라온도 행 자체는 하나입니다.

따라서:

COUNT(*) = 1

입니다.

하지만 급여는 NULL입니다.

그래서:

COUNT(salary) = 0

입니다.

핵심은 다음과 같습니다.

COUNT(*)
→ 행을 센다

COUNT(컬럼)
→ NULL이 아닌 값을 센다

17장. SUM과 AVG도 NULL을 제외한다#

다음 집계를 실행합니다.

SELECT
    dept_id,
    SUM(salary) AS total_salary,
    AVG(salary) AS avg_salary
FROM employee
GROUP BY dept_id
ORDER BY dept_id NULLS LAST;

결과는 다음과 같습니다.

부서ID 합계 평균
10 9000 4500
20 10000 5000
NULL NULL NULL

라온 그룹에는 실제 급여값이 하나도 없습니다.

그래서 SUM도 AVG도 NULL입니다.

표시상 0을 보여줘야 한다면:

COALESCE(SUM(salary), 0)

을 사용할 수 있습니다.

하지만 이것은 출력 표현을 0으로 바꾸는 것이지 원본 급여가 0이라는 뜻은 아닙니다.


18장. 직원이 없는 영업 부서는 GROUP BY 결과에도 없다#

다음 SQL은 employee에서 시작합니다.

SELECT
    dept_id,
    COUNT(*)
FROM employee
GROUP BY dept_id;

영업 부서 30에는 직원이 없습니다.

employee 테이블에 dept_id = 30인 행 자체가 없으므로 그룹도 만들어지지 않습니다.

따라서 영업 부서는 결과에 나오지 않습니다.

“모든 부서를 보여 달라”는 요구라면 employee가 아니라 department에서 출발해야 합니다.


19장. WHERE와 HAVING은 적용 시점이 다르다#

WHERE는 그룹을 만들기 전 행을 거릅니다.

HAVING은 그룹을 만든 후 그룹을 거릅니다.

예를 들어 급여 4500 이상인 직원만 대상으로 부서별 평균을 계산하고, 그 평균이 5500을 넘는 부서만 찾겠습니다.

SELECT
    dept_id,
    COUNT(*) AS staff_count,
    AVG(salary) AS avg_salary
FROM employee
WHERE salary >= 4500
GROUP BY dept_id
HAVING AVG(salary) > 5500;

WHERE를 먼저 적용하면 남는 직원은:

가람 5000
마루 6000

입니다.

따라서 그룹 결과는:

부서 10 → 평균 5000
부서 20 → 평균 6000

이고 HAVING을 통과하는 것은 부서 20뿐입니다.


20장. WHERE 조건을 HAVING으로 옮기면 같은 쿼리가 아니다#

다음 두 요구는 다릅니다.

첫 번째:

급여 4000 이상인 직원만 대상으로 부서 평균을 구한다.

WHERE salary >= 4000
GROUP BY dept_id

두 번째:

모든 직원을 포함해 부서 평균을 계산한 뒤 평균 4000 이상인 부서를 고른다.

GROUP BY dept_id
HAVING AVG(salary) >= 4000

둘 다 4000이라는 숫자가 들어가지만 계산 대상이 다릅니다.

WHERE와 HAVING의 위치를 바꾸면 결과가 달라질 수 있습니다.


21장. JOIN은 테이블을 붙이는 것이 아니라 행의 조합을 만든다#

직원과 부서를 내부 조인해 보겠습니다.

SELECT
    e.emp_id,
    e.emp_name,
    d.dept_name
FROM employee AS e
JOIN department AS d
    ON d.dept_id = e.dept_id
ORDER BY e.emp_id;

결과는:

직원ID 이름 부서
1 가람 개발
2 나래 개발
3 다온 운영
5 마루 운영

라온은 부서ID가 NULL이므로 일치하는 부서가 없습니다.

내부 조인에서는 빠집니다.


22장. LEFT JOIN은 왼쪽 행을 보존한다#

이번에는 직원에서 출발해 LEFT JOIN합니다.

SELECT
    e.emp_id,
    e.emp_name,
    d.dept_name
FROM employee AS e
LEFT JOIN department AS d
    ON d.dept_id = e.dept_id
ORDER BY e.emp_id;

결과는:

직원ID 이름 부서
1 가람 개발
2 나래 개발
3 다온 운영
4 라온 NULL
5 마루 운영

라온도 남습니다.

오른쪽 부서가 없으므로 dept_name만 NULL로 채워집니다.


23장. 모든 부서를 보고 싶다면 부서를 왼쪽에 둬야 한다#

영업 부서에는 직원이 없습니다.

모든 부서를 표시하려면 department에서 시작합니다.

SELECT
    d.dept_id,
    d.dept_name,
    e.emp_name
FROM department AS d
LEFT JOIN employee AS e
    ON e.dept_id = d.dept_id
ORDER BY
    d.dept_id,
    e.emp_id;

결과는 다음처럼 됩니다.

개발 | 가람
개발 | 나래
운영 | 다온
운영 | 마루
영업 | NULL

영업 부서는 오른쪽 직원이 없어도 왼쪽 행이므로 유지됩니다.


24장. LEFT JOIN에서 ON과 WHERE는 같은 위치가 아니다#

모든 부서를 보여주되 이름이 가람인 직원만 붙이고 싶다고 하겠습니다.

조건을 ON에 둡니다.

SELECT
    d.dept_id,
    d.dept_name,
    e.emp_name
FROM department AS d
LEFT JOIN employee AS e
    ON e.dept_id = d.dept_id
   AND e.emp_name = '가람'
ORDER BY d.dept_id;

결과:

부서ID 부서 직원
10 개발 가람
20 운영 NULL
30 영업 NULL

모든 부서가 남습니다.


25장. 같은 조건을 WHERE로 옮기면 행이 사라진다#

SELECT
    d.dept_id,
    d.dept_name,
    e.emp_name
FROM department AS d
LEFT JOIN employee AS e
    ON e.dept_id = d.dept_id
WHERE e.emp_name = '가람'
ORDER BY d.dept_id;

결과는 개발 부서 한 행뿐입니다.

왜 그럴까요?

LEFT JOIN 직후에는 운영과 영업도 다음처럼 존재합니다.

운영 | NULL
영업 | NULL

그런데:

WHERE e.emp_name = '가람'

을 적용하면:

NULL = '가람'

은 UNKNOWN입니다.

WHERE에서는 제거됩니다.

따라서 운영과 영업이 사라집니다.


26장. LEFT JOIN 뒤 오른쪽 컬럼을 WHERE에 쓴다고 항상 INNER JOIN이 되는 것은 아니다#

다음 SQL을 생각해 보겠습니다.

SELECT
    d.dept_name
FROM department AS d
LEFT JOIN employee AS e
    ON e.dept_id = d.dept_id
WHERE e.emp_id IS NULL;

이 쿼리는 오히려 직원이 없는 부서를 찾습니다.

결과:

영업

따라서 다음 설명은 지나치게 단순합니다.

LEFT JOIN 뒤 오른쪽 테이블 컬럼을 WHERE에 쓰면
항상 INNER JOIN처럼 된다.

정확히는 WHERE 조건이 NULL 확장 행을 남기는지 제거하는지를 봐야 합니다.


27장. 직원 없는 부서를 COUNT 별표로 세면 1명이 될 수 있다#

다음 SQL을 실행합니다.

SELECT
    d.dept_id,
    d.dept_name,
    COUNT(*) AS joined_rows,
    COUNT(e.emp_id) AS employee_count
FROM department AS d
LEFT JOIN employee AS e
    ON e.dept_id = d.dept_id
GROUP BY
    d.dept_id,
    d.dept_name
ORDER BY d.dept_id;

결과:

부서ID 부서 조인 결과 행 수 실제 직원 수
10 개발 2 2
20 운영 2 2
30 영업 1 0

영업 부서는 직원이 없지만 LEFT JOIN 때문에 결과 행 하나가 만들어집니다.

영업 | NULL

COUNT 별표는 이 행을 셉니다.

따라서 1입니다.

하지만 COUNT(e.emp_id)는 NULL을 세지 않으므로 0입니다.


28장. OUTER JOIN에서 인원수를 셀 때 식별자를 세는 이유#

직원 수를 세려는 목적이라면 다음이 더 명확합니다.

COUNT(e.emp_id)

왜냐하면 실제 직원이 존재할 때만 emp_id가 있기 때문입니다.

반면:

COUNT(*)

는 조인 결과 행 자체를 셉니다.

질문의 단위가 다릅니다.

몇 개의 조인 결과 행이 있는가?

몇 명의 실제 직원이 있는가?

동일하지 않을 수 있습니다.


29장. 자기 조인은 같은 테이블을 서로 다른 역할로 사용한다#

직원 테이블의 manager_id가 다른 직원의 emp_id를 가리킨다고 하겠습니다.

직원과 관리자를 함께 보고 싶다면 같은 테이블을 두 번 사용합니다.

SELECT
    e.emp_name AS employee_name,
    m.emp_name AS manager_name
FROM employee AS e
LEFT JOIN employee AS m
    ON m.emp_id = e.manager_id
ORDER BY e.emp_id;

결과는:

가람 | NULL
나래 | 가람
다온 | 가람
라온 | 가람
마루 | NULL

같은 employee 테이블이지만:

e
→ 직원

m
→ 관리자

라는 서로 다른 역할을 갖습니다.


30장. 조인을 추가했는데 합계가 커지는 이유#

프로젝트 참여 데이터가 있다고 하겠습니다.

가람 → P1
가람 → P2
나래 → P1

가람은 프로젝트 두 개에 참여합니다.

이를 직원 테이블과 조인하면 개발 부서 데이터는 다음처럼 됩니다.

가람 5000 P1
가람 5000 P2
나래 4000 P1

직원 가람 한 명이 두 행이 됐습니다.

이 상태에서 급여를 합하면:

5000 + 5000 + 4000
=
14000

이 됩니다.

실제 개발 부서 급여는:

5000 + 4000
=
9000

입니다.


31장. JOIN은 값이 아니라 행을 증식시킨다#

문제는 가람의 급여 5000이라는 값 자체가 중복되었다는 것이 아닙니다.

가람이라는 직원 행이 프로젝트 참여 수만큼 반복된 것입니다.

이를 Mermaid로 표현하면 다음과 같습니다.

flowchart LR
    E["가람 1행"] --> P1["프로젝트 P1"]
    E --> P2["프로젝트 P2"]
    P1 --> R1["가람 5000"]
    P2 --> R2["가람 5000"]

집계를 하기 전에 결과 행 단위가 직원에서 프로젝트 참여로 바뀌었다는 것이 핵심입니다.


32장. SUM DISTINCT로 중복을 없애는 것은 일반적인 해결책이 아니다#

다음처럼 작성할 수 있습니다.

SUM(DISTINCT e.salary)

현재 개발 부서는:

5000
4000

이므로 우연히 9000이 나옵니다.

하지만 가람과 나래의 급여가 둘 다 5000이라고 가정하겠습니다.

실제 직원 두 명의 급여 합계는:

5000 + 5000
=
10000

입니다.

그런데:

SUM(DISTINCT salary)

은 서로 다른 금액만 합하므로:

5000

이 됩니다.

직원 중복과 값 중복은 서로 다른 문제입니다.


33장. 조인 전 먼저 상대 데이터를 원하는 단위로 집계할 수 있다#

프로젝트 참여 건수를 직원 한 명당 한 행으로 먼저 정리합니다.

WITH project_member(emp_id, project_id) AS (
    VALUES
        (1, 'P1'),
        (1, 'P2'),
        (2, 'P1')
),
project_count AS (
    SELECT
        emp_id,
        COUNT(*) AS project_count
    FROM project_member
    GROUP BY emp_id
)
SELECT
    e.dept_id,
    COUNT(e.emp_id) AS employee_count,
    SUM(e.salary) AS salary_total,
    SUM(COALESCE(p.project_count, 0)) AS participation_count
FROM employee AS e
LEFT JOIN project_count AS p
    ON p.emp_id = e.emp_id
WHERE e.dept_id IS NOT NULL
GROUP BY e.dept_id
ORDER BY e.dept_id;

결과:

부서ID 직원 수 급여 합계 프로젝트 참여
10 2 9000 3
20 2 10000 0

이제 직원당 한 행이 유지되므로 급여 합계가 부풀지 않습니다.


34장. EXISTS는 값보다 존재 여부를 물을 때 적합하다#

다른 직원을 관리하는 직원만 찾고 싶다고 하겠습니다.

SELECT
    m.emp_id,
    m.emp_name
FROM employee AS m
WHERE EXISTS (
    SELECT 1
    FROM employee AS e
    WHERE e.manager_id = m.emp_id
);

결과는 가람 한 명입니다.

가람을 관리자ID로 가진 직원이 존재하기 때문입니다.

여기서 필요한 것은 관리받는 직원의 모든 데이터가 아닙니다.

한 명이라도 존재하는가?

만 확인하면 됩니다.


35장. 스칼라 서브쿼리는 하나의 값을 반환해야 한다#

직원마다 부서명을 찾는 다음 SQL을 보겠습니다.

SELECT
    e.emp_name,
    (
        SELECT d.dept_name
        FROM department AS d
        WHERE d.dept_id = e.dept_id
    ) AS dept_name
FROM employee AS e
ORDER BY e.emp_id;

department.dept_id는 기본키입니다.

따라서 직원 하나에 대해 서브쿼리가 최대 한 행만 반환합니다.

라온은 부서가 없으므로 부서명은 NULL이 됩니다.

스칼라 서브쿼리가 둘 이상의 행을 반환하면 오류가 발생할 수 있습니다.


36장. 상관 서브쿼리가 논리적으로 행마다 의존한다고 매번 물리적으로 재실행되는 것은 아니다#

다음과 같은 상관 서브쿼리를 보면:

SELECT ...
FROM employee e
WHERE EXISTS (
    SELECT ...
    FROM ...
    WHERE ... = e.emp_id
);

논리적으로는 바깥 행의 값을 사용합니다.

하지만 DBMS가 반드시 한 행마다 서브쿼리를 처음부터 다시 실행하는 것은 아닙니다.

옵티마이저가 내부적으로 조인이나 세미 조인 등 다른 방식으로 변환할 수도 있습니다.

따라서:

상관 서브쿼리
=
무조건 느림

이라고 단정해서는 안 됩니다.

실행 계획을 확인해야 합니다.


37장. NOT IN은 NULL 때문에 예상과 완전히 다른 결과를 만들 수 있다#

관리자가 아닌 직원을 찾는다고 하겠습니다.

다음 SQL을 사용해 보겠습니다.

SELECT emp_id
FROM employee
WHERE emp_id NOT IN (
    SELECT manager_id
    FROM employee
);

서브쿼리 결과는 다음과 같습니다.

NULL
1
1
1
NULL

NULL이 포함되어 있습니다.

그 결과 전체 쿼리가 예상과 달리 0행을 반환할 수 있습니다.


38장. 왜 NOT IN에 NULL이 하나만 있어도 문제가 될까#

직원ID 2를 검사해 보겠습니다.

논리적으로 NOT IN은 다음과 비슷합니다.

2 <> NULL
AND
2 <> 1
AND
2 <> 1
AND
2 <> 1
AND
2 <> NULL

2 <> 1은 TRUE입니다.

하지만:

2 <> NULL

은 UNKNOWN입니다.

TRUE와 UNKNOWN을 AND하면 최종 결과가 TRUE라고 확정되지 않습니다.

WHERE에서는 제외됩니다.

직원ID 3, 4, 5도 같은 문제를 겪습니다.

결과적으로 아무 행도 남지 않을 수 있습니다.


39장. NOT EXISTS로 존재하지 않는 관계를 표현할 수 있다#

관리자가 아닌 직원을 찾는다면 다음처럼 작성할 수 있습니다.

SELECT
    m.emp_id,
    m.emp_name
FROM employee AS m
WHERE NOT EXISTS (
    SELECT 1
    FROM employee AS e
    WHERE e.manager_id = m.emp_id
)
ORDER BY m.emp_id;

결과는:

2 | 나래
3 | 다온
4 | 라온
5 | 마루

입니다.

가람만 다른 직원의 관리자로 참조되고 있으므로 제외됩니다.


40장. NOT IN도 오른쪽 NULL을 제거하면 사용할 수 있는 경우가 있다#

다음처럼 명시적으로 NULL을 제거할 수 있습니다.

SELECT emp_id
FROM employee
WHERE emp_id NOT IN (
    SELECT manager_id
    FROM employee
    WHERE manager_id IS NOT NULL
);

이제 오른쪽에는:

1
1
1

만 남습니다.

결과는 관리자ID로 사용되지 않은 직원들입니다.

하지만 왼쪽 값 자체가 NULL일 수 있는 업무에서는 별도의 의미 검토가 필요합니다.

NOT EXISTS와 NOT IN 중 어느 쪽이 항상 빠르다고 단정할 수도 없습니다.

옵티마이저와 데이터 분포, 실행 계획을 확인해야 합니다.


41장. 전체 평균과 부서 평균을 비교하는 서브쿼리#

전체 급여 평균을 계산해 보겠습니다.

NULL은 제외되므로:

5000 + 4000 + 4000 + 6000
=
19000

급여가 입력된 직원은 4명입니다.

19000 / 4
=
4750

부서별 평균은:

개발
4500

운영
5000

입니다.

전체 평균보다 높은 부서를 찾으면 운영 부서가 나옵니다.

SELECT
    q.dept_id,
    q.avg_salary
FROM (
    SELECT
        dept_id,
        AVG(salary) AS avg_salary
    FROM employee
    WHERE salary IS NOT NULL
    GROUP BY dept_id
) AS q
WHERE q.avg_salary > (
    SELECT AVG(salary)
    FROM employee
);

결과:

20 | 5000

42장. UNION은 두 결과를 합치고 중복을 제거한다#

부서 10 직원은:

{1, 2}

급여 5000 이상 직원은:

{1, 5}

다음 SQL을 실행합니다.

SELECT emp_id
FROM employee
WHERE dept_id = 10

UNION

SELECT emp_id
FROM employee
WHERE salary >= 5000;

결과는:

1
2
5

입니다.

1은 양쪽에 있지만 한 번만 남습니다.


43장. UNION ALL은 중복을 그대로 보존한다#

SELECT emp_id
FROM employee
WHERE dept_id = 10

UNION ALL

SELECT emp_id
FROM employee
WHERE salary >= 5000;

결과는 개념적으로:

1
2
1
5

입니다.

직원 1이 두 조건에 모두 포함되기 때문에 두 번 나옵니다.

UNION ALL은 중복 제거 비용이 필요 없다는 장점이 있을 수 있지만, 업무적으로 중복 행을 허용할지를 먼저 결정해야 합니다.


44장. INTERSECT는 양쪽 조건을 모두 만족하는 대상을 찾는다#

부서 10:

{1, 2}

급여 5000 이상:

{1, 5}

교집합은:

{1}

입니다.

SQL:

SELECT emp_id
FROM employee
WHERE dept_id = 10

INTERSECT

SELECT emp_id
FROM employee
WHERE salary >= 5000;

가람만 남습니다.


45장. EXCEPT는 왼쪽에만 있는 대상을 찾는다#

다음 SQL을 보겠습니다.

SELECT emp_id
FROM employee
WHERE dept_id = 10

EXCEPT

SELECT emp_id
FROM employee
WHERE salary >= 5000;

첫 번째 집합은:

{1, 2}

두 번째는:

{1, 5}

차집합 결과는:

{2}

따라서 나래만 남습니다.

Oracle에서는 유사한 차집합 연산에 MINUS를 사용해 온 환경도 있습니다.


46장. 집합 연산 결과의 순서를 기대하면 안 된다#

UNION이나 EXCEPT 결과가 현재 정렬된 것처럼 보이더라도 출력 순서가 자동으로 보장되는 것은 아닙니다.

최종 순서가 중요하다면 ORDER BY를 명시해야 합니다.

다음처럼 생각하면 안 됩니다.

UNION
→ 항상 정렬 결과

UNION ALL
→ 항상 입력 순서

실행 방식과 최종 출력 순서는 별개의 문제입니다.


47장. NULL 급여 직원 한 명을 추가하면 평균이 어떻게 바뀔까#

개발 부서에 보라라는 직원을 추가해 보겠습니다.

BEGIN;

INSERT INTO employee (
    emp_id,
    emp_name,
    dept_id,
    salary,
    manager_id
)
VALUES (
    6,
    '보라',
    10,
    NULL,
    1
);

집계합니다.

SELECT
    dept_id,
    COUNT(*) AS staff_count,
    COUNT(salary) AS known_salary_count,
    AVG(salary) AS avg_salary
FROM employee
WHERE dept_id = 10
GROUP BY dept_id;

결과는:

부서ID 직원 수 급여 입력 수 평균
10 3 2 4500

직원은 세 명입니다.

하지만 급여가 입력된 직원은 가람과 나래 두 명입니다.

따라서 평균은:

(5000 + 4000) / 2
=
4500

입니다.


48장. COALESCE를 넣으면 평균의 분모까지 바뀐다#

다음처럼 계산해 보겠습니다.

SELECT
    AVG(COALESCE(salary, 0))
FROM employee
WHERE dept_id = 10;

보라의 NULL이 0으로 바뀝니다.

5000
4000
0

평균:

9000 / 3
=
3000

이전 평균 4500과 완전히 달라졌습니다.

즉 COALESCE는 단순히 NULL을 보기 좋게 만드는 함수가 아닙니다.

집계 전에 사용하면 계산의 의미 자체를 변경할 수 있습니다.

실습 후에는 원래 데이터를 유지하기 위해:

ROLLBACK;

합니다.


49장. 보고서 한 건을 SQL 흐름으로 만들어 보기#

다음 요구사항이 있다고 하겠습니다.

급여가 4000 이상인 직원이 두 명 이상 존재하는 부서의 평균 급여를 보여 주세요.

먼저 결과 한 행의 단위를 정합니다.

부서 한 곳

개별 직원 조건은:

salary >= 4000

입니다.

따라서 WHERE에 둡니다.

부서별로 그룹을 만듭니다.

그다음 직원 수가 두 명 이상인지 HAVING으로 확인합니다.

SELECT
    d.dept_id,
    d.dept_name,
    COUNT(e.emp_id) AS staff_count,
    AVG(e.salary) AS avg_salary
FROM employee AS e
JOIN department AS d
    ON d.dept_id = e.dept_id
WHERE e.salary >= 4000
GROUP BY
    d.dept_id,
    d.dept_name
HAVING COUNT(e.emp_id) >= 2
ORDER BY
    avg_salary DESC,
    d.dept_id;

결과:

부서ID 부서 직원 수 평균
20 운영 2 5000
10 개발 2 4500

50장. SELECT를 단계별로 보면 결과 오류를 찾기 쉽다#

논리적인 의미를 단순화해 보면 다음 흐름으로 생각할 수 있습니다.

flowchart TD
    F["FROM / JOIN<br/>어떤 행을 연결할 것인가"] --> W["WHERE<br/>어떤 개별 행을 남길 것인가"]
    W --> G["GROUP BY<br/>어떤 단위로 묶을 것인가"]
    G --> H["HAVING<br/>어떤 그룹을 남길 것인가"]
    H --> S["SELECT<br/>무엇을 출력할 것인가"]
    S --> D["DISTINCT<br/>결과 중복을 제거할 것인가"]
    D --> O["ORDER BY<br/>어떤 순서로 보여줄 것인가"]
    O --> L["LIMIT<br/>몇 행을 보여줄 것인가"]

이 그림은 SQL의 의미를 분석하기 위한 흐름입니다.

DBMS가 실제 물리적으로 항상 이 순서 그대로 실행한다는 뜻은 아닙니다.

옵티마이저는 실행 계획을 다양하게 바꿀 수 있습니다.


51장. 결과가 틀렸을 때는 최종 숫자보다 행부터 확인하자#

급여 합계가 틀렸다고 하겠습니다.

바로 SUM을 고치려고 하지 말고 먼저 집계 전 데이터를 봅니다.

SELECT
    e.emp_id,
    e.salary,
    p.project_id
FROM ...

같은 직원이 몇 번 나타나는지 확인합니다.

직원 없는 부서가 사라졌다면 GROUP BY부터 보지 말고 JOIN 직후 결과를 확인합니다.

NOT IN 결과가 비었다면 서브쿼리부터 실행해 NULL이 있는지 봅니다.

즉 오류가 생긴 첫 번째 단계를 찾는 것이 중요합니다.


52장. SQL 결과 오류의 대표적인 다섯 패턴#

증상 먼저 확인할 것
직원 없는 부서가 사라짐 LEFT JOIN 뒤 WHERE 조건
직원 없는 부서가 1명으로 표시됨 COUNT 별표 사용 여부
평균이 갑자기 낮아짐 NULL을 0으로 변환했는지
NOT IN 결과가 비어 있음 오른쪽 집합의 NULL
JOIN 후 합계가 커짐 1:N 조인으로 행이 증식했는지

이 다섯 가지는 실제 보고서 오류에서도 자주 반복되는 유형입니다.


53장. 결과 숫자보다 식별자 목록을 먼저 보는 것이 좋다#

부서 급여 합계가 14,000이라는 숫자만 보면 문제가 어디에 있는지 알기 어렵습니다.

하지만 집계 전 직원ID를 보면:

1
1
2

처럼 가람이 두 번 있다는 사실이 바로 보입니다.

따라서 복잡한 보고서를 검증할 때는 다음 순서가 유용합니다.

식별자 목록 확인
↓
행 수 확인
↓
중복 횟수 확인
↓
그다음 SUM·AVG 계산

숫자 오류의 원인이 집계 함수가 아니라 조인 이전 단계에 있는 경우가 많습니다.


54장. “모든 부서”라는 자연어도 정확하게 정의해야 한다#

다음 요구를 받았다고 하겠습니다.

모든 부서의 평균 급여를 보여 주세요.

여기서 질문이 생깁니다.

영업 부서처럼 직원이 0명인 부서도 포함합니까?

포함한다면 평균은 NULL로 표시합니까?

0으로 표시합니까?

미배정 직원은 어디에 포함합니까?

“모든 부서”라는 한 문장에도 여러 업무 정의가 숨어 있습니다.

SQL을 작성하기 전에 이런 의미를 먼저 확정해야 합니다.


55장. “관리자가 아닌 직원”도 관계 정의가 먼저다#

관리자가 아닌 직원을 찾는다는 말은 다음 의미일 수 있습니다.

다른 직원의 manager_id에 한 번도 등장하지 않는 직원.

이 경우 NOT EXISTS로 표현할 수 있습니다.

하지만 조직도에 별도의 관리자 직책 컬럼이 있다면 의미가 달라질 수 있습니다.

즉 SQL 표현식을 먼저 고르는 것이 아니라 업무에서 무엇을 관리자라고 정의하는지 먼저 확인해야 합니다.


56장. NULL을 포함할지 여부도 보고서 정의에 포함해야 한다#

다음 요구가 있습니다.

급여 4000이 아닌 직원.

여기에는 세 가지 해석이 가능합니다.

급여가 입력되어 있고 4000이 아닌 직원

급여가 4000이 아니거나 아직 미입력인 직원

급여가 확인되지 않은 직원은 별도 표시

각각 SQL이 다릅니다.

첫 번째:

WHERE salary <> 4000

두 번째:

WHERE salary <> 4000
   OR salary IS NULL

세 번째는 별도 상태 컬럼이나 출력 분리가 필요할 수 있습니다.


57장. DISTINCT는 문제를 숨길 수도 있다#

조인 결과에 직원이 중복됐습니다.

다음처럼 처리하면 중복이 없어지는 것처럼 보일 수 있습니다.

SELECT DISTINCT
    emp_id,
    emp_name
FROM ...

최종 직원 목록만 필요하다면 맞는 해결일 수도 있습니다.

하지만 급여 합계가 필요한 상황이라면 중복 원인이 그대로 남아 있습니다.

DISTINCT는 증상을 가릴 수 있지만 데이터 관계의 원인을 해결하지는 않습니다.


58장. COALESCE도 같은 방식으로 문제를 숨길 수 있다#

NULL 합계가 보기 싫어서 다음처럼 작성합니다.

COALESCE(SUM(salary), 0)

표시에 0이 필요한 경우에는 적절합니다.

하지만:

AVG(COALESCE(salary, 0))

처럼 집계 전에 NULL을 바꾸면 계산 의미 자체가 달라집니다.

따라서 COALESCE를 어디에 사용하는지가 중요합니다.

집계 후 COALESCE
→ 표시 규칙

집계 전 COALESCE
→ 계산 데이터 자체 변경

59장. 정확한 SELECT를 만드는 실전 순서#

복잡한 SQL을 작성할 때는 다음 순서로 접근할 수 있습니다.

  1. 결과 한 행이 무엇을 의미하는지 적습니다.
  2. 기준이 되는 테이블을 결정합니다.
  3. 반드시 남아야 하는 행을 정합니다.
  4. NULL을 포함할지 결정합니다.
  5. JOIN의 1:1·1:N 관계를 확인합니다.
  6. JOIN 뒤 행 수가 늘어나는지 확인합니다.
  7. WHERE에서 개별 행 조건을 적용합니다.
  8. GROUP BY의 단위를 확인합니다.
  9. COUNT 별표와 COUNT 컬럼 중 무엇이 맞는지 결정합니다.
  10. HAVING으로 그룹 조건을 적용합니다.
  11. NOT IN 사용 시 NULL 가능성을 확인합니다.
  12. DISTINCT나 COALESCE를 붙이기 전에 왜 필요한지 설명합니다.
  13. 예상 식별자와 실제 식별자를 비교합니다.
  14. 마지막에 합계와 평균을 검증합니다.

이 순서대로 보면 SQL이 길어져도 문제 지점을 찾기 쉬워집니다.


60장. 핵심 정리#

SELECT에서 가장 위험한 오류는 문법 오류가 아닙니다.

문법은 맞지만 의미가 틀린 결과입니다.

대표적인 원인은 다음과 같습니다.

NULL

JOIN으로 인한 행 증식

LEFT JOIN의 ON과 WHERE 차이

GROUP BY 이전·이후 조건 혼동

COUNT 별표와 COUNT 컬럼 차이

NOT IN 내부의 NULL

DISTINCT의 오용

COALESCE의 잘못된 적용

LEFT JOIN에서는 어느 쪽 행을 보존하려는지 먼저 정해야 합니다.

오른쪽 테이블 조건을 WHERE에 두면 NULL 확장 행이 제거되어 의도하지 않게 행이 사라질 수 있습니다.

집계에서는 다음 차이가 중요합니다.

COUNT(*)
→ 결과 행 수

COUNT(column)
→ NULL이 아닌 값 수

AVG 역시 NULL을 제외하므로 미입력 값을 0으로 바꾸면 평균의 분모와 의미가 달라집니다.

NOT IN은 오른쪽 결과에 NULL이 포함되면 예상과 완전히 다른 결과를 만들 수 있습니다.

존재하지 않는 관계를 찾는 문제에서는 NOT EXISTS가 의미를 더 명확하게 표현하는 경우가 많습니다.

그리고 JOIN을 추가한 뒤 합계가 커졌다면 가장 먼저 해야 할 일은 SUM을 수정하는 것이 아닙니다.

집계 직전 같은 식별자가 몇 번 나타나는지 확인해야 합니다.

좋은 SELECT는 단순히 SQL이 실행되는 조회가 아닙니다.

결과 한 행이 무엇을 뜻하고, 어떤 행이 왜 포함되거나 제외되었으며, 어떤 값이 몇 번 계산되었는지를 설명할 수 있는 조회입니다.

이 페이지의 목차