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 때문에 결과 행 하나가 만들어집니다.
영업 | NULLCOUNT 별표는 이 행을 셉니다.
따라서 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
NULLNULL이 포함되어 있습니다.
그 결과 전체 쿼리가 예상과 달리 0행을 반환할 수 있습니다.
38장. 왜 NOT IN에 NULL이 하나만 있어도 문제가 될까#
직원ID 2를 검사해 보겠습니다.
논리적으로 NOT IN은 다음과 비슷합니다.
2 <> NULL
AND
2 <> 1
AND
2 <> 1
AND
2 <> 1
AND
2 <> NULL2 <> 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 | 500042장. 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을 작성할 때는 다음 순서로 접근할 수 있습니다.
- 결과 한 행이 무엇을 의미하는지 적습니다.
- 기준이 되는 테이블을 결정합니다.
- 반드시 남아야 하는 행을 정합니다.
- NULL을 포함할지 결정합니다.
- JOIN의 1:1·1:N 관계를 확인합니다.
- JOIN 뒤 행 수가 늘어나는지 확인합니다.
- WHERE에서 개별 행 조건을 적용합니다.
- GROUP BY의 단위를 확인합니다.
- COUNT 별표와 COUNT 컬럼 중 무엇이 맞는지 결정합니다.
- HAVING으로 그룹 조건을 적용합니다.
- NOT IN 사용 시 NULL 가능성을 확인합니다.
- DISTINCT나 COALESCE를 붙이기 전에 왜 필요한지 설명합니다.
- 예상 식별자와 실제 식별자를 비교합니다.
- 마지막에 합계와 평균을 검증합니다.
이 순서대로 보면 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이 실행되는 조회가 아닙니다.
결과 한 행이 무엇을 뜻하고, 어떤 행이 왜 포함되거나 제외되었으며, 어떤 값이 몇 번 계산되었는지를 설명할 수 있는 조회입니다.