SQLD · 기본
데이터 모델링과 SQL 기본
SELECT WHERE 결과 예측 - 연산자 우선순위 NOT IN NULL LIKE CASE ORDER BY (SQLD 기본 7장)
AND·OR 우선순위, NULL 이 섞인 IN·NOT IN, LIKE 이스케이프, 단순 CASE 와 NULL, 단일행 함수와 NULL 정렬 위치를 결과를 먼저 손으로 예측한 뒤 실행해 확인한다.
개발자 · 원고 갱신
이 장에서 배우는 것
6장에서 NULL 과 3값 논리를 정리했다. 이제 2과목 SQL 이다. SELECT·WHERE 의 문법 자체는 『SQL 실전 기초』의 2단원 WHERE와 3단원 정렬에서 다뤘다. 이 장은 문법을 다시 설명하지 않고, 결과를 실행 전에 손으로 맞히는 연습에 집중한다. 7~12장은 모두 같은 예제 스키마를 쓴다.
- AND·OR 우선순위, NULL 과 비교, NULL 이 섞인 IN·NOT IN 의 결과 행 수를 예측한다.
- LIKE 의 와일드카드와 ESCAPE, 단순 CASE 가 NULL 을 못 잡는 이유, 자주 나오는 단일행 함수를 확인한다.
- ORDER BY 에서 NULL 이 어디에 오는지가 제품마다 다르다는 점과, 별칭·위치 번호 정렬을 정리한다.
핵심 개념
"개발팀이거나 영업팀 중 연봉 4천 넘는 사람"이라는 요청을 받은 개발자가 괄호 없이 조건을 이어 썼다. 결과에 연봉 3,900 인 개발팀원이 섞여 나왔다. SQL 이 틀린 것이 아니라 요청을 SQL 로 옮길 때 우선순위를 놓쳤다. 시험의 결과 예측 문제는 대부분 이런 지점을 노린다.
논리 연산자 우선순위
괄호 → 비교 연산자 → NOT → AND → OR 순이다. A OR B AND C 는 A OR (B AND C) 다. 헷갈리면 괄호를 쓴다. 괄호는 성능에 아무 영향이 없다.
NULL 과 비교 연산
=, <>, >, BETWEEN, LIKE 모두 한쪽이 NULL 이면 UNKNOWN 이고, WHERE 에서 버려진다. 그래서 bonus <> 0 은 보너스가 NULL 인 사람을 포함하지 않는다. NOT (bonus > 100) 도 NULL 인 사람을 포함하지 않는다. NOT 은 UNKNOWN 을 참으로 바꾸지 못한다.
SELECT 문의 처리 순서
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY 순으로 처리된다. 그래서 SELECT 에서 만든 별칭은 WHERE 에서 쓸 수 없고, ORDER BY 에서는 쓸 수 있다. 처리 순서의 자세한 설명은 『SQL 실전 기초』 1단원에 있다.
그림 · SELECT 문이 처리되는 순서와 별칭을 쓸 수 있는 곳 — SQL 은 FROM 에서 시작해 WHERE, GROUP BY, HAVING 을 거쳐 SELECT 에서 별칭을 만들고 마지막에 ORDER BY 를 한다. 그래서 별칭은 ORDER BY 에서만 쓸 수 있다.
자주 나오는 단일행 함수
| 분류 | 함수 | 기억할 점 |
|---|---|---|
| 문자 | SUBSTR, LENGTH, UPPER, LOWER, TRIM, LTRIM, RTRIM, REPLACE | SUBSTR 의 시작 위치는 1부터 센다 |
| 숫자 | ROUND, TRUNC, CEIL, FLOOR, MOD, ABS, SIGN | ROUND(값, -1) 은 일의 자리에서 반올림(Oracle) |
| NULL 관련 | COALESCE, NULLIF, NVL, NVL2, ISNULL | NULLIF(a, b) 는 a = b 면 NULL, 아니면 a |
| 조건 | CASE, DECODE | 단순 CASE 는 = 로 비교하므로 NULL 을 못 잡는다 |
예제 스키마
작은 회사의 팀·직원·판매·급여 등급이다. 7~12장이 모두 이 스키마를 쓴다. 일부러 뚫어 둔 구멍이 있다. 강예린은 팀이 없고(team_id NULL), 법무팀(40)은 직원이 없으며 도시도 NULL 이다. 보너스는 NULL, 0, 양수가 섞여 있고, 급여 4,300 과 3,900 이 두 명씩 같다.
-- 7~12장 공통 예제 스키마: 작은 회사의 팀·직원·판매·급여 등급 (가상 데이터)
CREATE TABLE team (
team_id INTEGER PRIMARY KEY,
team_name TEXT NOT NULL,
city TEXT
);
CREATE TABLE staff (
staff_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
team_id INTEGER REFERENCES team(team_id),
manager_id INTEGER REFERENCES staff(staff_id),
salary INTEGER NOT NULL,
bonus INTEGER,
hired TEXT NOT NULL
);
CREATE TABLE sale (
sale_id INTEGER PRIMARY KEY,
staff_id INTEGER NOT NULL REFERENCES staff(staff_id),
sale_month TEXT NOT NULL,
region TEXT NOT NULL,
product TEXT NOT NULL,
amount INTEGER
);
CREATE TABLE grade (
grade INTEGER PRIMARY KEY,
low INTEGER NOT NULL,
high INTEGER NOT NULL
);
INSERT INTO team VALUES
(10, '개발', '서울'), (20, '영업', '부산'), (30, '디자인', '서울'), (40, '법무', NULL);
INSERT INTO staff VALUES
(101, '한지수', 10, NULL, 5200, NULL, '2019-03-02'),
(102, '오민재', 10, 101, 4300, 300, '2021-07-15'),
(103, '박서윤', 10, 102, 3900, NULL, '2023-01-09'),
(104, '이도현', 20, 101, 4300, 500, '2020-11-30'),
(105, '최하늘', 20, 104, 3600, 0, '2024-02-19'),
(106, '정우진', 30, 101, 4800, NULL, '2018-05-21'),
(107, '강예린', NULL, 104, 3100, 200, '2025-06-02'),
(108, '윤태오', 30, 106, 3900, NULL, '2022-09-12');
INSERT INTO sale VALUES
(1, 102, '2026-07', '서울', 'A', 100),
(2, 103, '2026-07', '서울', 'B', 50),
(3, 104, '2026-07', '부산', 'A', 70),
(4, 105, '2026-07', '부산', 'A', 30),
(5, 102, '2026-08', '서울', 'A', 120),
(6, 103, '2026-08', '서울', 'B', NULL),
(7, 104, '2026-08', '부산', 'B', 80),
(8, 107, '2026-08', '부산', 'A', 40);
INSERT INTO grade VALUES (1, 0, 3499), (2, 3500, 4199), (3, 4200, 4999), (4, 5000, 99999);
| staff_id | name | team_id | manager_id | salary | bonus | hired |
|---|---|---|---|---|---|---|
| 101 | 한지수 | 10 | NULL | 5200 | NULL | 2019-03-02 |
| 102 | 오민재 | 10 | 101 | 4300 | 300 | 2021-07-15 |
| 103 | 박서윤 | 10 | 102 | 3900 | NULL | 2023-01-09 |
| 104 | 이도현 | 20 | 101 | 4300 | 500 | 2020-11-30 |
| 105 | 최하늘 | 20 | 104 | 3600 | 0 | 2024-02-19 |
| 106 | 정우진 | 30 | 101 | 4800 | NULL | 2018-05-21 |
| 107 | 강예린 | NULL | 104 | 3100 | 200 | 2025-06-02 |
| 108 | 윤태오 | 30 | 106 | 3900 | NULL | 2022-09-12 |
| team_id | team_name | city |
|---|---|---|
| 10 | 개발 | 서울 |
| 20 | 영업 | 부산 |
| 30 | 디자인 | 서울 |
| 40 | 법무 | NULL |
SQL과 실행 결과
각 예제를 읽기 전에 결과 행 수를 먼저 적어 보고 비교하는 것을 권한다.
AND 가 OR 보다 먼저
SELECT name, team_id, salary FROM staff
WHERE team_id = 10 OR team_id = 20 AND salary > 4000
ORDER BY staff_id;
SELECT name, team_id, salary FROM staff
WHERE (team_id = 10 OR team_id = 20) AND salary > 4000
ORDER BY staff_id;
실행 결과:
name team_id salary
------ ------- ------
한지수 10 5200
오민재 10 4300
박서윤 10 3900
이도현 20 4300
name team_id salary
------ ------- ------
한지수 10 5200
오민재 10 4300
이도현 20 4300
첫 쿼리는 "개발팀 전원" 또는 "영업팀이면서 4,000 초과"이므로 박서윤(3,900)이 들어간다. 괄호를 친 두 번째 쿼리가 요청의 뜻이다.
NULL 과 비교하면 행이 빠진다
SELECT (SELECT COUNT(*) FROM staff WHERE bonus = NULL) AS eq_null,
(SELECT COUNT(*) FROM staff WHERE bonus IS NULL) AS is_null,
(SELECT COUNT(*) FROM staff WHERE bonus <> 0) AS not_zero,
(SELECT COUNT(*) FROM staff WHERE NOT (bonus > 100)) AS not_over_100;
실행 결과:
eq_null is_null not_zero not_over_100
------- ------- -------- ------------
0 4 3 1
보너스가 NULL 인 4명은 <> 0 에도, NOT (> 100) 에도 들어가지 않는다. 마지막 1명은 보너스 0 인 최하늘뿐이다.
IN, NOT IN 과 NULL
SELECT (SELECT COUNT(*) FROM staff WHERE team_id IN (10, NULL)) AS in_10_null,
(SELECT COUNT(*) FROM staff WHERE team_id NOT IN (20)) AS not_in_20,
(SELECT COUNT(*) FROM staff WHERE team_id NOT IN (20, NULL)) AS not_in_20_null;
실행 결과:
in_10_null not_in_20 not_in_20_null
---------- --------- --------------
3 5 0
IN (10, NULL) 은 10 인 3명을 찾는다. OR 로 풀면 참 OR UNKNOWN = 참이기 때문이다. NOT IN (20) 은 5명이다. 팀이 NULL 인 강예린은 NULL <> 20 이 UNKNOWN 이라 빠진다. NOT IN (20, NULL) 은 0명이다. AND 로 풀면 모든 행에 UNKNOWN 이 하나씩 붙는다.
그림 · NOT IN 목록에 NULL 이 하나라도 있으면 — team_id NOT IN (20, NULL) 은 team_id <> 20 AND team_id <> NULL 이다. 뒤 조건이 늘 UNKNOWN 이라 어떤 행도 참이 될 수 없어 0행이 된다. NULL 을 뺀 NOT IN (20) 은 5행이었다(07-in-null).
표 · NULL 이 섞인 조건의 실제 행 수 (staff 8명, 보너스 NULL 4명, 팀 NULL 1명)
| 조건 | 행 수 | 왜 |
|---|---|---|
bonus = NULL | 0 | NULL 과의 비교는 UNKNOWN |
bonus IS NULL | 4 | NULL 검사는 IS NULL 로만 |
bonus <> 0 | 3 | NULL 4명은 빠진다 |
NOT (bonus > 100) | 1 | NOT UNKNOWN 도 UNKNOWN |
team_id IN (10, NULL) | 3 | 10 과 같은 행은 참 |
team_id NOT IN (20) | 5 | 팀 NULL 1명은 빠진다 |
team_id NOT IN (20, NULL) | 0 | 참이 되는 행이 없다 |
LIKE 와 ESCAPE
WITH code(c) AS (VALUES ('A_01'), ('AB01'), ('A%02'), ('a_03'))
SELECT c,
c LIKE 'A_%' AS plain_underscore,
c LIKE 'A\_%' ESCAPE '\' AS escaped,
c GLOB 'A_*' AS glob_case_sensitive
FROM code;
실행 결과:
c plain_underscore escaped glob_case_sensitive
---- ---------------- ------- -------------------
A_01 1 1 1
AB01 1 0 0
A%02 1 0 0
a_03 1 1 0
_ 는 "아무 글자 하나"라서 'A_%' 는 A 로 시작하고 두 글자 이상인 모든 값에 맞는다. 밑줄 문자 자체를 찾으려면 ESCAPE 로 지정한 문자를 앞에 붙인다. 네 번째 행 a_03 이 LIKE 에 걸린 이유는 아래 "표준과 구현의 차이"에서 설명한다.
단일행 함수
SELECT SUBSTR('SQLD시험대비', 5, 2) AS sub,
LENGTH('SQLD시험') AS len,
UPPER('sqld') AS up,
'[' || TRIM(' a b ') || ']' AS trimmed,
REPLACE('2026-09-24', '-', '') AS replaced;
SELECT ROUND(45.678, 1) AS r1,
ROUND(45.678) AS r0,
ROUND(45.678, -1) AS r_neg,
7 / 2 AS int_div,
7 / 2.0 AS real_div,
7 % 3 AS modulo;
실행 결과:
sub len up trimmed replaced
---- --- ---- ------- --------
시험 6 SQLD [a b] 20260924
r1 r0 r_neg int_div real_div modulo
---- ---- ----- ------- -------- ------
45.7 46.0 46.0 3 3.5 1
SUBSTR 은 5번째 글자부터 2글자를 자른다. LENGTH 는 바이트가 아니라 글자 수를 센다. ROUND(45.678, -1) 이 50 이 아니라 46.0 인 것은 SQLite 가 음수 자릿수를 0 으로 다루기 때문이다. 정수끼리 나누면 정수 몫만 남는다(7 / 2 = 3). 이 동작은 제품마다 다르다.
단순 CASE 는 NULL 을 못 잡는다
SELECT name, bonus,
CASE WHEN salary >= 4500 THEN '상'
WHEN salary >= 4000 THEN '중'
ELSE '하' END AS band,
CASE bonus WHEN NULL THEN '없음' ELSE '있음' END AS simple_case,
CASE WHEN bonus IS NULL THEN '없음' ELSE '있음' END AS searched_case
FROM staff WHERE team_id = 10 ORDER BY staff_id;
실행 결과:
name bonus band simple_case searched_case
------ ----- ---- ----------- -------------
한지수 NULL 상 있음 없음
오민재 300 중 있음 있음
박서윤 NULL 하 있음 없음
CASE bonus WHEN NULL 은 bonus = NULL 로 비교하므로 절대 참이 되지 않는다. 그래서 보너스가 NULL 인 한지수·박서윤도 '있음'이 됐다. NULL 을 가르려면 검색 CASE(CASE WHEN bonus IS NULL)를 쓴다.
COALESCE 와 NULLIF
SELECT name, salary, bonus,
salary + bonus AS raw_sum,
salary + COALESCE(bonus, 0) AS safe_sum,
NULLIF(bonus, 0) AS zero_as_null
FROM staff WHERE team_id IN (10, 20) ORDER BY staff_id;
실행 결과:
name salary bonus raw_sum safe_sum zero_as_null
------ ------ ----- ------- -------- ------------
한지수 5200 NULL NULL 5200 NULL
오민재 4300 300 4600 4600 300
박서윤 3900 NULL NULL 3900 NULL
이도현 4300 500 4800 4800 500
최하늘 3600 0 3600 3600 NULL
NULL 의 정렬 위치
SELECT name, bonus FROM staff ORDER BY bonus, staff_id;
실행 결과:
name bonus
------ -----
한지수 NULL
박서윤 NULL
정우진 NULL
윤태오 NULL
최하늘 0
강예린 200
오민재 300
이도현 500
SELECT name, bonus FROM staff ORDER BY bonus DESC, staff_id;
실행 결과:
name bonus
------ -----
이도현 500
오민재 300
강예린 200
최하늘 0
한지수 NULL
박서윤 NULL
정우진 NULL
윤태오 NULL
SQLite 는 NULL 을 가장 작은 값으로 보고 오름차순 맨 앞, 내림차순 맨 뒤에 둔다. 위치를 직접 정하려면 NULLS FIRST·NULLS LAST 를 쓴다.
SELECT name, bonus FROM staff ORDER BY bonus NULLS LAST, staff_id;
실행 결과:
name bonus
------ -----
최하늘 0
강예린 200
오민재 300
이도현 500
한지수 NULL
박서윤 NULL
정우진 NULL
윤태오 NULL
별칭, 위치 번호, SELECT 에 없는 컬럼으로 정렬
SELECT name, salary * 12 AS annual
FROM staff WHERE team_id = 10
ORDER BY annual DESC;
SELECT name, salary * 12 AS annual
FROM staff WHERE team_id = 10
ORDER BY 2;
SELECT name FROM staff WHERE team_id = 10 ORDER BY hired;
실행 결과:
name annual
------ ------
한지수 62400
오민재 51600
박서윤 46800
name annual
------ ------
박서윤 46800
오민재 51600
한지수 62400
name
------
한지수
오민재
박서윤
세 번째 쿼리처럼 SELECT 목록에 없는 hired 로도 정렬할 수 있다. 단, SELECT DISTINCT 나 GROUP BY 가 있으면 제약이 생긴다(8장).
표준과 구현의 차이
아래 Oracle·SQL Server 동작은 비교 설명이며 실행하지 않았다.
| 항목 | SQLite(실행) | Oracle | SQL Server |
|---|---|---|---|
| NULL 정렬(오름차순) | 맨 앞 | 맨 뒤(NULL 을 가장 큰 값으로 봄) | 맨 앞 |
| NULLS FIRST / LAST | 지원 | 지원 | 없음(CASE 로 우회) |
| LIKE 대소문자 | ASCII 영문은 구분 안 함 | 구분함 | 데이터베이스 정렬 규칙(collation)에 따름. 기본 설정은 대개 구분 안 함 |
| 정수 나눗셈 7 / 2 | 3 | 3.5 | 3 |
| 문자열 연결 | || | ||, CONCAT | +, CONCAT |
| NULL 대체 | COALESCE, IFNULL | NVL, NVL2, COALESCE | ISNULL, COALESCE |
| 나머지 | % | MOD(7, 3) | % |
| ROUND(45.678, -1) | 음수 자릿수를 0 으로 취급 | 50 | 50.000 |
[구현 차이] 위 LIKE 예제의
a_03은 SQLite 가 영문 대소문자를 구분하지 않아서 걸렸다. Oracle 에서는 걸리지 않는다. 대소문자를 구분하는 비교가 필요하면 SQLite 에서는GLOB(와일드카드가*,?)을 쓴다. 예제의 마지막 열이 그 결과다.
[구현 차이] 시험은 NULL 정렬 위치를 Oracle 기준으로 묻는 경우가 많다. "Oracle 에서 NULL 은 오름차순 맨 뒤, 내림차순 맨 앞"을 기억하고, SQL Server 와 SQLite 는 반대라는 것까지 함께 기억한다.
시험에서 헷갈리는 지점
판단 1. "WHERE NOT team_id = 10 은 팀이 10 이 아닌 모든 직원(팀이 없는 직원 포함)을 돌려준다"
틀렸다. 팀이 NULL 인 직원은 team_id = 10 이 UNKNOWN 이고, NOT UNKNOWN 도 UNKNOWN 이라 빠진다. 포함하려면 OR team_id IS NULL 을 더한다.
판단 2. "BETWEEN 0 AND 300 은 0 과 300 을 포함한다"
맞다. 양 끝을 포함한다. BETWEEN 300 AND 0 처럼 작은 값을 뒤에 쓰면 0행이 된다.
판단 3. "ORDER BY 에서 SELECT 절의 별칭을 쓸 수 있으므로 WHERE 에서도 쓸 수 있다"
틀렸다. WHERE 는 SELECT 보다 먼저 처리되므로 별칭이 아직 없다. ORDER BY 는 SELECT 뒤에 처리되므로 쓸 수 있다.
연습 문제
모두 예제 스키마 기준이다. 실행하기 전에 결과를 먼저 적는다.
SELECT COUNT(*) FROM staff WHERE NOT team_id = 10의 결과는?WHERE bonus BETWEEN 0 AND 300에 해당하는 직원 이름을 직원 번호 순으로 쓰라.- 직원 101, 104, 105 에 대해
COALESCE(NULLIF(bonus, 0), -1)의 값을 쓰라. WHERE team_id = 30 OR bonus > 250 AND salary < 4500을 만족하는 직원 이름을 직원 번호 순으로 쓰라.ORDER BY bonus DESC, name으로 정렬했을 때 첫 3명을 SQLite 기준과 Oracle 기준으로 각각 쓰라.
정답과 해설
1. 4. 팀 20 의 2명과 팀 30 의 2명이다. 팀이 NULL 인 강예린은 빠진다.
SELECT COUNT(*) AS n FROM staff WHERE NOT team_id = 10;
실행 결과:
n
-
4
2. 오민재(300), 최하늘(0), 강예린(200). NULL 인 4명은 BETWEEN 에서 UNKNOWN 이다.
SELECT name FROM staff WHERE bonus BETWEEN 0 AND 300 ORDER BY staff_id;
실행 결과:
name
------
오민재
최하늘
강예린
3. 101 은 NULL → NULLIF 도 NULL → -1. 104 는 500. 105 는 0 → NULLIF 가 NULL 로 바꿈 → -1.
SELECT staff_id, COALESCE(NULLIF(bonus, 0), -1) AS b
FROM staff WHERE staff_id IN (101, 104, 105) ORDER BY staff_id;
실행 결과:
staff_id b
-------- ---
101 -1
104 500
105 -1
4. team_id = 30 OR (bonus > 250 AND salary < 4500) 이다. 팀 30 의 정우진·윤태오, 그리고 보너스 300·500 에 급여 4,300 인 오민재·이도현. 직원 번호 순으로 오민재, 이도현, 정우진, 윤태오.
SELECT name FROM staff
WHERE team_id = 30 OR bonus > 250 AND salary < 4500
ORDER BY staff_id;
실행 결과:
name
------
오민재
이도현
정우진
윤태오
5. SQLite 는 내림차순에서 NULL 이 맨 뒤라 이도현, 오민재, 강예린이다. Oracle 은 내림차순에서 NULL 이 맨 앞이라 NULL 4명 중 이름순 첫 3명인 박서윤, 윤태오, 정우진이다. Oracle 결과는 SQLite 에서 NULLS FIRST 를 붙여 재현했다(Oracle 자체를 실행한 것은 아니다).
SELECT name, bonus FROM staff ORDER BY bonus DESC, name LIMIT 3;
SELECT name, bonus FROM staff ORDER BY bonus DESC NULLS FIRST, name LIMIT 3;
실행 결과:
name bonus
------ -----
이도현 500
오민재 300
강예린 200
name bonus
------ -----
박서윤 NULL
윤태오 NULL
정우진 NULL
다음 장에서는 여러 행을 하나로 줄이는 집계와 GROUP BY 의 결과를 예측한다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.