SQL 실무 패턴 15가지: 중복 찾기·순위·누적합·페이지 조회
1장. 잘 쓰던 SQL을 복사했는데 이번에는 결과가 틀렸다#
실무에서 SQL을 작성하다 보면 비슷한 요구가 반복됩니다.
중복을 찾아라
급여 3위를 찾아라
누적 매출을 계산하라
이전 달과 비교하라
프로젝트가 없는 직원을 찾아라
조직도를 조회하라
다음 페이지를 가져와라그래서 자주 쓰는 SQL을 복사해서 테이블 이름만 바꾸는 경우가 많습니다.
문제는 같은 패턴처럼 보여도 실제 질문은 조금씩 다르다는 것입니다.
예를 들어:
세 번째 높은 급여라는 문장만 해도 여러 의미가 있습니다.
세 번째 직원
세 번째로 높은 서로 다른 급여
공동 순위 3위 직원
정렬 결과의 세 번째 행각각 SQL이 다릅니다.
SQL 패턴의 핵심은 문장을 외우는 것이 아닙니다.
그 SQL이 어떤 전제에서 맞는지를 함께 기억하는 것입니다.
2장. 이번 글에서는 하나의 작은 데이터로 15개 패턴을 검산한다#
직원 테이블을 만듭니다.
CREATE TABLE p_employee (
emp_id integer PRIMARY KEY,
name text NOT NULL,
dept_id integer,
job text,
salary integer NOT NULL,
mgr_id integer
);데이터를 넣습니다.
INSERT INTO p_employee
VALUES
(1, '가람', 10, '사원', 300, NULL),
(2, '나래', 10, '대리', 500, 1),
(3, '다온', 20, '사원', 500, 1),
(4, '라온', NULL, '사원', 200, 2);부서 테이블도 만듭니다.
CREATE TABLE p_department (
dept_id integer PRIMARY KEY
);INSERT INTO p_department
VALUES
(10),
(20),
(30);3장. 먼저 사람이 데이터를 읽어 보자#
직원 데이터:
| 사번 | 이름 | 부서 | 직급 | 급여 | 관리자 |
|---|---|---|---|---|---|
| 1 | 가람 | 10 | 사원 | 300 | NULL |
| 2 | 나래 | 10 | 대리 | 500 | 1 |
| 3 | 다온 | 20 | 사원 | 500 | 1 |
| 4 | 라온 | NULL | 사원 | 200 | 2 |
부서:
10
20
30입니다.
급여를 내림차순으로 정렬하면:
500
500
300
200입니다.
서로 다른 급여값은:
500
300
200입니다.
이 차이를 기억해 두어야 순위 문제를 정확하게 풀 수 있습니다.
4장. SQL 패턴을 적용하기 전에 세 가지를 먼저 정하자#
각 패턴을 사용할 때 먼저 다음을 적습니다.
무엇을 한 행으로 볼 것인가?
NULL을 어떻게 볼 것인가?
동점·중복을 어떻게 볼 것인가?예:
직원 한 행
부서 NULL은 미배정
같은 급여는 공동 순위처럼 정의하면 SQL 선택이 쉬워집니다.
5장. 패턴 1 — GROUP BY로 중복값 찾기#
같은 급여를 받는 직원이 둘 이상 있는지 찾습니다.
SELECT
salary,
COUNT(*) AS employee_count
FROM p_employee
GROUP BY salary
HAVING COUNT(*) > 1;결과:
| salary | employee_count |
|---|---|
| 500 | 2 |
6장. 이 결과는 직원 행 전체가 중복됐다는 뜻이 아니다#
다음 두 직원:
나래
급여 500
다온
급여 500은 서로 다른 직원입니다.
GROUP BY salary는 단지:
같은 salary 값이 두 번 나타났다는 사실을 찾습니다.
따라서:
중복 급여와:
중복 직원을 혼동하면 안 됩니다.
7장. 어떤 컬럼 조합을 중복으로 볼지가 핵심이다#
이름과 급여까지 같아야 중복이라고 한다면:
GROUP BY
name,
salary
HAVING COUNT(*) > 1;을 사용해야 합니다.
이메일 중복이라면:
GROUP BY lower(email)같은 규칙이 필요할 수 있습니다.
즉:
중복은 SQL 함수가 아니라 업무 식별 기준에서 시작된다.
입니다.
8장. GROUP BY는 NULL도 하나의 그룹으로 묶는다#
예를 들어 dept_id를 그룹화하면:
10
20
NULL세 그룹이 나올 수 있습니다.
NULL 부서 직원이 둘 이상 있다면:
GROUP BY dept_id
HAVING COUNT(*) > 1;에서 NULL 그룹도 중복 그룹처럼 보일 수 있습니다.
하지만 이것이:
같은 부서를 의미하는지는 업무적으로 별개입니다.
9장. 패턴 2 — 세 번째 높은 서로 다른 값 찾기#
질문:
세 번째로 높은 서로 다른 급여를 받는 직원은 누구인가?
급여 종류:
500
300
200입니다.
따라서 세 번째 급여:
200입니다.
직원:
라온입니다.
10장. DENSE_RANK를 사용하면 서로 다른 값 기준 순위를 만들 수 있다#
SELECT
emp_id,
name,
salary
FROM (
SELECT
e.*,
DENSE_RANK() OVER (
ORDER BY salary DESC
) AS salary_rank
FROM p_employee AS e
) AS ranked
WHERE salary_rank = 3;결과:
라온
200입니다.
11장. ROW_NUMBER를 쓰면 질문이 달라진다#
ROW_NUMBER() OVER (
ORDER BY salary DESC, emp_id
)를 사용하면:
나래
1
다온
2
가람
3
라온
4입니다.
세 번째 행:
가람
300이 됩니다.
즉:
세 번째 직원과:
세 번째로 높은 서로 다른 급여는 다릅니다.
12장. RANK와 DENSE_RANK도 결과가 다르다#
급여:
500
500
300
200RANK:
500
1
500
1
300
3
200
4DENSE_RANK:
500
1
500
1
300
2
200
3입니다.
따라서:
3위라는 말이 RANK 기준인지 DENSE_RANK 기준인지 확인해야 합니다.
13장. 패턴 3 — 누적합 계산하기#
사번 순서의 급여:
300
500
500
200입니다.
누적합:
300
800
1300
1500입니다.
14장. 윈도우 SUM으로 누적합을 만든다#
SELECT
emp_id,
name,
salary,
SUM(salary) OVER (
ORDER BY emp_id
ROWS BETWEEN
UNBOUNDED PRECEDING
AND CURRENT ROW
) AS running_total
FROM p_employee
ORDER BY emp_id;15장. 누적합에는 반드시 순서가 필요하다#
다음 질문을 생각해 보겠습니다.
직원 급여 누적합을 구하라.
누적은 순서가 있어야 합니다.
사번 순서?
입사일 순서?
급여 순서?
이름 순서?기준이 다르면 누적값도 달라집니다.
SQL의 ORDER BY는 단순 화면 정렬이 아니라 누적 계산의 의미를 결정합니다.
16장. ROWS 프레임을 명시하면 동점 처리 의미가 더 분명하다#
예:
ROWS BETWEEN
UNBOUNDED PRECEDING
AND CURRENT ROW는 물리적으로 현재 행까지를 누적합니다.
정렬값에 동점이 있을 때 ROWS와 기본 프레임의 의미가 달라질 수 있으므로 누적합에서는 프레임을 명시하는 습관이 좋습니다.
17장. 패턴 4 — 이전 기간과 증감률 계산하기#
월 매출:
1월
100
2월
120
3월
0이라고 하겠습니다.
18장. LAG로 이전 행 값을 가져온다#
WITH monthly(
month_no,
revenue
) AS (
VALUES
(1, 100),
(2, 120),
(3, 0)
)
SELECT
month_no,
revenue,
LAG(revenue) OVER (
ORDER BY month_no
) AS previous_revenue
FROM monthly
ORDER BY month_no;결과:
1월
이전 없음
2월
이전 100
3월
이전 120입니다.
19장. 증감률을 계산한다#
WITH monthly(
month_no,
revenue
) AS (
VALUES
(1, 100),
(2, 120),
(3, 0)
),
compared AS (
SELECT
month_no,
revenue,
LAG(revenue) OVER (
ORDER BY month_no
) AS previous_revenue
FROM monthly
)
SELECT
month_no,
revenue,
ROUND(
100.0
* (revenue - previous_revenue)
/ NULLIF(previous_revenue, 0),
2
) AS growth_pct
FROM compared
ORDER BY month_no;결과:
1월
NULL
2월
20%
3월
-100%입니다.
20장. 이전 값이 0이면 증가율은 일반 공식으로 계산할 수 없다#
4월 매출:
100을 추가한다고 하겠습니다.
3월:
0입니다.
계산:
(100 - 0) / 0은 정의할 수 없습니다.
그래서:
NULLIF(previous_revenue, 0)를 사용해 0 나눗셈을 피합니다.
21장. LAG는 이전 기간이 아니라 이전 행을 가져온다#
2월 데이터가 빠졌다고 하겠습니다.
1월 100
3월 130LAG는 3월의 이전값으로:
1월 100을 가져옵니다.
따라서:
LAG
=
전월이라고 단정하면 안 됩니다.
기간 누락 가능성이 있다면 날짜 키로 직접 연결하는 방식도 검토해야 합니다.
22장. 패턴 5 — 두 테이블의 행 수 차이 계산하기#
직원 수:
4부서 수:
3차이:
1입니다.
PostgreSQL:
SELECT
(
SELECT COUNT(*)
FROM p_employee
)
-
(
SELECT COUNT(*)
FROM p_department
) AS count_diff;결과:
1입니다.
23장. 행 수가 같다고 데이터가 같은 것은 아니다#
직원 테이블:
4행다른 직원 테이블:
4행이라고 해서 데이터가 같은 것은 아닙니다.
행 수 비교는:
건수 차이만 알려줍니다.
내용 비교가 필요하면:
EXCEPT
JOIN
해시
키 비교등 다른 검증이 필요합니다.
24장. Oracle의 DUAL과 PostgreSQL은 다르다#
Oracle에서는 계산용 SELECT에:
FROM dual을 사용하는 경우가 있습니다.
PostgreSQL에서는:
SELECT 1 + 1;처럼 별도 FROM 없이 계산할 수 있습니다.
제품별 문법을 그대로 복사하지 않는 것이 중요합니다.
25장. 패턴 6 — NOT IN과 NULL 처리#
질문:
등록된 부서 목록에 없는 부서를 가진 직원을 찾아라.
단순하게:
SELECT emp_id
FROM p_employee
WHERE dept_id NOT IN (
SELECT dept_id
FROM p_department
);라고 작성할 수 있습니다.
하지만 NULL 때문에 해석이 복잡해집니다.
26장. 라온의 dept_id는 NULL이다#
라온:
dept_id = NULL입니다.
NULL은:
부서 10이 아니다
부서 20이 아니다
부서 30이 아니다라고 참으로 판단되는 값이 아닙니다.
비교 결과는 UNKNOWN이 될 수 있습니다.
그래서 라온이 자동으로:
부서 목록에 없는 직원으로 나오지 않습니다.
27장. 미배정 직원은 별도 조건으로 정의하는 편이 명확하다#
SELECT
emp_id,
name
FROM p_employee
WHERE dept_id IS NULL;결과:
라온입니다.
업무 질문이:
부서가 지정되지 않은 직원
이라면 이 조건이 더 정확합니다.
28장. 존재하지 않는 부서와 미배정도 구분해야 한다#
다음 두 데이터는 다릅니다.
dept_id = NULL의미:
미배정일 수 있습니다.
반면:
dept_id = 999인데 부서 999가 없다면:
참조 오류일 수 있습니다.
둘을 같은 문제로 볼지 업무 정의가 필요합니다.
29장. 패턴 7 — 문자열 집계하기#
부서별 직원 이름을 한 줄로 만들고 싶습니다.
PostgreSQL에서는 string_agg를 사용할 수 있습니다.
SELECT
dept_id,
string_agg(
name,
', '
ORDER BY name
) AS employee_names
FROM p_employee
GROUP BY dept_id
ORDER BY dept_id NULLS LAST;30장. 결과를 보면 NULL 부서도 하나의 그룹이다#
결과 개념:
부서 10
가람, 나래
부서 20
다온
NULL
라온입니다.
부서가 없는 직원을 제외하려면:
WHERE dept_id IS NOT NULL을 추가해야 합니다.
31장. 문자열 집계 안의 ORDER BY도 중요하다#
다음 두 결과:
가람, 나래와:
나래, 가람은 의미상 같은 직원 집합일 수 있습니다.
하지만 화면·CSV·테스트에서는 결과 문자열이 달라집니다.
재현 가능한 결과가 필요하면 집계 내부 정렬 기준을 명시합니다.
32장. Oracle에서는 LISTAGG 계열 문법을 사용한다#
Oracle에서는 대표적으로:
LISTAGG를 사용합니다.
PostgreSQL:
string_agg와 목적은 비슷하지만 문법은 다릅니다.
제품별 SQL을 옮길 때:
함수 이름
정렬 위치
길이 제한
NULL 처리를 확인해야 합니다.
33장. 패턴 8 — CASE로 수동 피벗 만들기#
부서별로:
사원 급여 합계
대리 급여 합계를 한 행에 표시합니다.
SELECT
dept_id,
SUM(
CASE
WHEN job = '사원'
THEN salary
ELSE 0
END
) AS staff_salary,
SUM(
CASE
WHEN job = '대리'
THEN salary
ELSE 0
END
) AS assistant_salary
FROM p_employee
WHERE dept_id IS NOT NULL
GROUP BY dept_id
ORDER BY dept_id;34장. 결과를 직접 계산하면#
부서 10:
가람
사원 300
나래
대리 500따라서:
사원 300
대리 500입니다.
부서 20:
다온
사원 500따라서:
사원 500
대리 0입니다.
35장. ELSE 0과 ELSE NULL은 집계 의미가 다를 수 있다#
현재:
ELSE 0을 사용했습니다.
대리 직원이 없는 부서:
0으로 표시됩니다.
반대로:
ELSE NULL이라면 조건에 맞는 값이 하나도 없을 때 SUM 결과가 NULL이 될 수 있습니다.
업무적으로:
없음
0을 같은 의미로 볼지 결정해야 합니다.
36장. 패턴 9 — 가장 높은 한 행 찾기#
급여가 가장 높은 직원 한 명을 찾습니다.
SELECT
emp_id,
name,
salary
FROM p_employee
ORDER BY
salary DESC,
emp_id
FETCH FIRST 1 ROW ONLY;결과:
나래
500입니다.
37장. 왜 emp_id까지 정렬하는가#
나래와 다온:
500으로 동점입니다.
ORDER BY salary DESC만 쓰면 둘 중 어떤 행이 첫 번째인지 안정적으로 정의되지 않을 수 있습니다.
그래서:
ORDER BY
salary DESC,
emp_id로 동점을 해소합니다.
38장. 최고 급여를 받는 모든 직원을 찾는다면 다른 SQL이 필요하다#
질문:
최고 급여 직원 한 명
과:
최고 급여를 받는 모든 직원
은 다릅니다.
모든 직원을 찾는다면:
SELECT
emp_id,
name,
salary
FROM p_employee
WHERE salary = (
SELECT MAX(salary)
FROM p_employee
);결과:
나래
다온입니다.
39장. 한 행 제한과 최고값의 모든 행을 혼동하지 말자#
FETCH FIRST 1 ROW ONLY은 정확히 한 행을 돌려줍니다.
하지만 최고값 동점자가 여러 명일 수 있습니다.
따라서:
최고값과:
상위 한 행을 구분해야 합니다.
40장. 패턴 10 — EXISTS로 관련 데이터 존재 확인#
질문:
실제 부서 테이블과 연결되는 직원은 누구인가?
SELECT
emp_id,
name
FROM p_employee AS e
WHERE EXISTS (
SELECT 1
FROM p_department AS d
WHERE d.dept_id = e.dept_id
)
ORDER BY emp_id;결과:
가람
나래
다온입니다.
라온은 제외됩니다.
41장. IN으로도 같은 결과를 만들 수 있다#
SELECT
emp_id,
name
FROM p_employee
WHERE dept_id IN (
SELECT dept_id
FROM p_department
)
ORDER BY emp_id;현재 데이터에서는 같은 결과가 나옵니다.
42장. EXISTS가 항상 빠르고 IN이 항상 느리다는 규칙은 없다#
현대 옵티마이저는 상황에 따라 두 쿼리를 비슷한 실행 계획으로 변환할 수 있습니다.
실제 성능은:
통계
인덱스
데이터 분포
서브쿼리 구조에 따라 달라집니다.
따라서:
큰 데이터
→ 무조건 EXISTS같은 단순 규칙은 피하는 것이 좋습니다.
43장. 부정 조건에서는 NULL 문제가 훨씬 중요해진다#
긍정 IN은 일치하는 값이 있으면 참이 될 수 있습니다.
하지만:
NOT IN은 오른쪽 집합에 NULL이 섞일 때 예상하지 못한 UNKNOWN 결과가 생길 수 있습니다.
미존재 여부를 표현할 때는:
NOT EXISTS가 의미를 더 분명하게 만드는 경우가 많습니다.
44장. 패턴 11 — MERGE로 갱신과 삽입을 한 흐름으로 처리#
대상 테이블:
CREATE TABLE p_pay (
emp_id integer PRIMARY KEY,
salary integer NOT NULL
);원천:
CREATE TABLE p_pay_source (
emp_id integer PRIMARY KEY,
salary integer NOT NULL
);데이터:
INSERT INTO p_pay
VALUES
(1, 300),
(2, 500);INSERT INTO p_pay_source
VALUES
(2, 550),
(5, 100);45장. 기대 결과를 먼저 계산하자#
원래 대상:
1 → 300
2 → 500원천:
2 → 550
5 → 100따라서:
1 → 그대로 300
2 → 550으로 수정
5 → 신규 삽입되어야 합니다.
46장. PostgreSQL MERGE 예제#
MERGE INTO p_pay AS target
USING p_pay_source AS source
ON target.emp_id = source.emp_id
WHEN MATCHED THEN
UPDATE SET
salary = source.salary
WHEN NOT MATCHED THEN
INSERT (
emp_id,
salary
)
VALUES (
source.emp_id,
source.salary
);결과:
SELECT *
FROM p_pay
ORDER BY emp_id;| emp_id | salary |
|---|---|
| 1 | 300 |
| 2 | 550 |
| 5 | 100 |
47장. MERGE가 있다고 동시성 문제가 사라지는 것은 아니다#
다른 트랜잭션이 같은 키를 동시에 삽입할 수 있습니다.
또 원천 테이블에 동일 키가 여러 건 존재할 수 있습니다.
따라서:
PRIMARY KEY
UNIQUE
트랜잭션
재시도 정책을 함께 고려해야 합니다.
48장. 원천 데이터의 중복 키도 먼저 검증해야 한다#
예:
emp_id 2
salary 550
emp_id 2
salary 600두 행이 원천에 있다면:
어느 값을 최종값으로 사용할 것인가?라는 문제가 생깁니다.
MERGE가 업무 기준을 대신 정해 주지는 않습니다.
49장. 패턴 12 — 재귀 CTE로 조직도 조회#
현재 관리자 관계:
가람
├─ 나래
│ └─ 라온
└─ 다온입니다.
사번 1부터 조직도를 내려가 보겠습니다.
50장. 재귀 CTE 예제#
WITH RECURSIVE org AS (
SELECT
emp_id,
name,
mgr_id,
1 AS depth,
ARRAY[emp_id] AS path
FROM p_employee
WHERE emp_id = 1
UNION ALL
SELECT
e.emp_id,
e.name,
e.mgr_id,
o.depth + 1,
o.path || e.emp_id
FROM p_employee AS e
JOIN org AS o
ON e.mgr_id = o.emp_id
WHERE NOT e.emp_id = ANY(o.path)
)
SELECT
emp_id,
name,
depth
FROM org
ORDER BY
depth,
emp_id;결과:
가람
depth 1
나래
depth 2
다온
depth 2
라온
depth 3입니다.
51장. path를 저장하는 이유는 순환 방지다#
잘못된 데이터가:
1 → 2 → 4 → 1처럼 순환한다고 하겠습니다.
방문 노드를 검사하지 않으면 재귀가 반복될 수 있습니다.
그래서:
WHERE NOT e.emp_id = ANY(o.path)로 이미 방문한 직원은 다시 따라가지 않습니다.
52장. 단순 깊이 제한은 완전한 순환 검사가 아니다#
WHERE depth < 10처럼 제한하면 무한 반복은 막을 수 있습니다.
하지만 정상 조직이 11단계인 경우도 잘립니다.
따라서:
깊이 제한
→ 보조 안전장치
방문 노드 확인
→ 실제 순환 방지로 구분하는 것이 좋습니다.
53장. Oracle에서는 CONNECT BY 계열 문법도 사용한다#
Oracle에서는 조직도 같은 계층형 조회를:
START WITH
CONNECT BY PRIOR구문으로 표현할 수 있습니다.
PostgreSQL에서는 재귀 CTE가 일반적입니다.
제품을 바꿀 때는 문법뿐 아니라:
시작점
부모·자식 방향
깊이
순환 처리가 같은 결과를 만드는지 검증해야 합니다.
54장. 패턴 13 — 무작위 행 추출#
학습용으로 직원 두 명을 임의 선택합니다.
SELECT
emp_id,
name
FROM p_employee
ORDER BY random()
LIMIT 2;매 실행마다 결과가 달라질 수 있습니다.
55장. 무작위 두 행과 10% 샘플은 다른 요구다#
정확히 두 행과:
전체의 약 10%는 다릅니다.
LIMIT 2는 행 수를 고정합니다.
샘플링 기능은 비율이나 블록 기반 동작을 사용할 수 있습니다.
업무 요구를 먼저 구분해야 합니다.
56장. ORDER BY random은 대규모 테이블에서 비쌀 수 있다#
행이:
1,000만 건이라면 모든 행에 무작위 값을 생성하고 정렬하는 방식은 비용이 클 수 있습니다.
작은 테이블의 교육용 패턴을 대규모 샘플링에 그대로 적용하면 안 됩니다.
57장. 무작위 추출에도 재현성이 필요할 수 있다#
통계 실험이나 테스트에서:
매번 같은 표본이 필요할 수도 있습니다.
그렇다면 단순 random()보다:
시드
해시 기반 표본
샘플 ID 보관등 재현 가능한 방법을 검토할 수 있습니다.
58장. 패턴 14 — OFFSET 기반 페이지 조회#
급여 내림차순, 사번 오름차순 전체 순서는:
나래 500
다온 500
가람 300
라온 200입니다.
페이지당 2명이라고 하겠습니다.
59장. 첫 번째 페이지#
SELECT
emp_id,
name,
salary
FROM p_employee
ORDER BY
salary DESC,
emp_id ASC
LIMIT 2;결과:
나래
다온입니다.
60장. 두 번째 페이지#
SELECT
emp_id,
name,
salary
FROM p_employee
ORDER BY
salary DESC,
emp_id ASC
LIMIT 2
OFFSET 2;결과:
가람
라온입니다.
61장. 동점이 있으면 보조 정렬 키가 필요하다#
다음 정렬만 사용하면:
ORDER BY salary DESC나래와 다온의 순서가 명확하지 않습니다.
페이지 경계에서:
첫 페이지에 나래
두 번째 페이지에 다온처럼 흔들릴 수 있습니다.
그래서:
ORDER BY
salary DESC,
emp_id ASC처럼 유일한 보조 키를 추가하는 것이 좋습니다.
62장. 하지만 안정적인 ORDER BY만으로 페이지 전체가 고정되는 것은 아니다#
첫 페이지를 조회한 뒤 새 직원이 들어왔습니다.
새 직원
급여 700전체 순서는:
700 신규
500 나래
500 다온
300 가람
200 라온으로 바뀝니다.
이 상태에서 OFFSET 2를 사용하면 앞에서 봤던 행이 다시 나오거나 기존 행이 밀릴 수 있습니다.
63장. OFFSET의 위치는 데이터 변화에 따라 움직인다#
첫 요청:
1
나래
2
다온새 직원 삽입 후:
1
신규
2
나래
3
다온이 됩니다.
두 번째 요청에서:
OFFSET 2를 하면 다온부터 시작할 수 있습니다.
이미 봤던 다온이 다시 나타납니다.
64장. 키셋 페이지네이션을 검토할 수 있다#
첫 페이지 마지막 행:
다온
salary = 500
emp_id = 3입니다.
다음 페이지는 이 정렬 위치 뒤를 찾습니다.
SELECT
emp_id,
name,
salary
FROM p_employee
WHERE salary < 500
OR (
salary = 500
AND emp_id > 3
)
ORDER BY
salary DESC,
emp_id ASC
LIMIT 2;결과:
가람
라온입니다.
65장. 정렬 방향이 다르면 비교 방향도 달라진다#
현재 정렬:
salary DESC
emp_id ASC입니다.
따라서 다음 페이지:
급여가 더 작거나
급여가 같다면 사번이 더 커야 함입니다.
모든 컬럼에 같은 >를 쓰면 틀립니다.
66장. 키셋은 앞쪽 신규 삽입에 강하다#
첫 페이지 뒤에 급여 700 직원이 새로 들어와도:
salary < 500
OR
salary = 500 AND emp_id > 3조건은 그대로입니다.
따라서 이미 본 위치 뒤쪽을 비교적 안정적으로 이어갈 수 있습니다.
67장. 하지만 정렬값 자체가 바뀌면 다시 나타날 수 있다#
첫 페이지에서 본 나래의 급여:
500이 다음 요청 전에:
250으로 바뀌었다고 하겠습니다.
나래는 정렬 위치가 뒤로 이동합니다.
그러면 다음 페이지에서 다시 나타날 가능성이 있습니다.
키셋 페이지네이션이 모든 동시 변경을 해결하는 것은 아닙니다.
68장. 보고서라면 같은 스냅샷이 더 중요할 수 있다#
업무가:
최신 목록을 계속 이어 보기
라면 키셋 페이지가 잘 맞을 수 있습니다.
하지만:
동일 시점의 100만 행 보고서를 1000페이지에 걸쳐 조회
라면 페이지 사이 데이터 변화 자체가 문제입니다.
이 경우:
스냅샷
결과 테이블
기준 시각같은 별도 설계가 필요할 수 있습니다.
69장. 패턴 15 — 연결되지 않는 행 찾기#
질문:
부서 테이블과 연결되지 않는 직원을 찾아라.
SELECT
e.emp_id,
e.name,
e.dept_id
FROM p_employee AS e
LEFT JOIN p_department AS d
ON d.dept_id = e.dept_id
WHERE d.dept_id IS NULL
ORDER BY e.emp_id;결과:
라온
dept_id = NULL입니다.
70장. 오른쪽 기본키를 NULL 검사하는 이유#
p_department.dept_id는 기본키입니다.
정상적으로 조인된 행에서는 NULL이 될 수 없습니다.
따라서:
d.dept_id IS NULL이면:
조인 상대가 없었다고 판단할 수 있습니다.
71장. 오른쪽의 nullable 컬럼을 검사하면 잘못된 결과가 나올 수 있다#
부서 테이블에:
description이라는 NULL 허용 컬럼이 있다고 하겠습니다.
WHERE d.description IS NULL을 사용하면:
부서가 실제 존재하지만 description만 NULL인 행까지 잘못 선택할 수 있습니다.
안티 조인 패턴에서는 존재 여부를 판정할 수 있는 비NULL 키를 사용하는 것이 안전합니다.
72장. 같은 문제를 NOT EXISTS로도 표현할 수 있다#
SELECT
e.emp_id,
e.name,
e.dept_id
FROM p_employee AS e
WHERE NOT EXISTS (
SELECT 1
FROM p_department AS d
WHERE d.dept_id = e.dept_id
)
ORDER BY e.emp_id;이 경우 라온도 결과에 들어옵니다.
질문은:
연결되는 부서 행이 존재하지 않는가?
를 직접 표현합니다.
73장. 조인 후 집계는 패턴보다 먼저 행의 단위를 확인해야 한다#
주문 한 건에 품목 세 개가 있다고 하겠습니다.
주문 100
품목 A
품목 B
품목 C주문 테이블과 품목을 조인하면 주문 100이 세 행으로 늘어납니다.
그 상태에서:
COUNT(*)를 사용하면:
3이 나옵니다.
하지만 주문 건수는:
1입니다.
74장. COUNT 별표는 현재 결과 행을 센다#
SQL에서:
COUNT(*)은 우리가 머릿속에서 생각하는:
주문 수
직원 수
고객 수를 자동으로 이해하지 않습니다.
현재 FROM·JOIN 결과의 행 수를 셉니다.
따라서 반환 그레인을 먼저 확인해야 합니다.
75장. DISTINCT를 무조건 붙이는 것도 위험하다#
중복이 생겼다고:
SELECT DISTINCT ...를 붙이면 화면상 중복은 사라질 수 있습니다.
하지만 실제 원인이:
잘못된 조인 조건
1:N 관계
원천 중복중 무엇인지 알 수 없습니다.
DISTINCT는 의미상 동일 행을 합치는 것이 맞을 때 사용해야 합니다.
76장. SUM DISTINCT도 주의해야 한다#
직원 두 명의 급여가 둘 다:
500입니다.
SUM(DISTINCT salary)를 사용하면 500을 한 번만 더합니다.
결과:
500입니다.
하지만 직원 급여 합은:
1000입니다.
DISTINCT salary는 직원 중복 제거가 아니라 값 중복 제거입니다.
77장. 패턴을 복사하기 전에 바꿔야 할 첫 번째 항목 — 반환 단위#
질문:
프로젝트 참여 직원이라면 결과 한 행은:
직원이어야 합니다.
질문:
프로젝트 작업 목록이라면 결과 한 행은:
작업 배정이어야 합니다.
같은 JOIN이라도 집계 기준이 완전히 달라집니다.
78장. 두 번째 항목 — NULL 의미#
NULL은 상황마다 다를 수 있습니다.
미배정
미입력
알 수 없음
해당 없음
연계 실패SQL에서 동일하게 NULL이라고 해서 업무 의미까지 같은 것은 아닙니다.
79장. 세 번째 항목 — 동점 처리#
급여 500이 두 명입니다.
질문에 따라:
둘 다 공동 1위
둘 중 한 명만 첫 번째 행
서로 다른 급여값은 하나로 해석할 수 있습니다.
함수 선택 전에 동점 정책을 정해야 합니다.
80장. 네 번째 항목 — 정렬 안정성#
페이지·TOP N·문자열 집계에서는 정렬이 중요합니다.
salary DESC만으로는 급여 동점 순서가 정해지지 않습니다.
따라서:
salary DESC,
emp_id ASC처럼 안정적인 보조 키를 둘 수 있습니다.
81장. 다만 순위 계산 기준에는 보조 키를 함부로 넣으면 안 된다#
공동 급여 순위가 필요합니다.
RANK() OVER (
ORDER BY salary DESC
)에서는 나래와 다온이 같은 순위입니다.
여기에:
ORDER BY
salary DESC,
emp_id를 넣으면 두 행의 정렬키가 달라져 동점이 깨질 수 있습니다.
즉:
순위 의미의 ORDER BY와:
화면 표시용 ORDER BY를 구분해야 합니다.
82장. 다섯 번째 항목 — 기간 연속성#
LAG를 전월 비교에 사용하려면:
매월 정확히 한 행
누락 월 없음같은 전제가 필요할 수 있습니다.
기간이 누락되면:
이전 행
≠
이전 달입니다.
83장. 여섯 번째 항목 — 데이터 변경 여부#
페이지 조회에서는:
두 요청 사이에 신규 행이 들어오는가?
정렬값이 수정되는가?
삭제가 발생하는가?를 확인해야 합니다.
OFFSET이 맞는 SQL이어도 요청 사이 데이터가 바뀌면 같은 사용자에게 중복·누락이 생길 수 있습니다.
84장. 패턴 15개를 한눈에 정리하면#
| 번호 | 요구 | 대표 SQL |
|---|---|---|
| 1 | 중복값 찾기 | GROUP BY + HAVING |
| 2 | N번째 서로 다른 값 | DENSE_RANK |
| 3 | 누적합 | SUM OVER |
| 4 | 이전 행 비교 | LAG |
| 5 | 건수 차이 | 스칼라 서브쿼리 |
| 6 | 부정 조건 | NOT IN / NULL 주의 |
| 7 | 문자열 집계 | string_agg |
| 8 | 수동 피벗 | CASE + SUM |
| 9 | 상위 한 행 | ORDER BY + FETCH |
| 10 | 존재 여부 | EXISTS |
| 11 | 갱신·삽입 | MERGE |
| 12 | 계층 탐색 | 재귀 CTE |
| 13 | 무작위 추출 | random |
| 14 | 페이지 조회 | OFFSET / Keyset |
| 15 | 미연결 행 | LEFT JOIN / NOT EXISTS |
85장. 패턴 1~4에서 가장 중요한 함정#
중복#
어떤 열이 같은 것을 중복이라 부르는가?N번째#
N번째 행인가?
N번째 서로 다른 값인가?
공동 순위 N위인가?누적#
어떤 순서로 누적하는가?증감#
이전 행이 실제 이전 기간인가?패턴 이름보다 정의가 먼저입니다.
86장. 패턴 5~8에서 가장 중요한 함정#
건수 차이#
건수가 같다고 내용이 같은 것은 아니다.NOT IN#
NULL이 있으면 부정 조건의 의미가 바뀔 수 있다.문자열 집계#
집계 내부 정렬을 지정했는가?피벗#
조건에 해당하는 행이 없을 때
0인가 NULL인가?를 확인해야 합니다.
87장. 패턴 9~12에서 가장 중요한 함정#
상위 한 행#
동점일 때 한 명인가 모두인가?EXISTS#
존재 여부만 필요한가
연결 행 전체가 필요한가?MERGE#
원천 키 중복과 동시성은 어떻게 막는가?재귀#
순환 데이터가 들어올 수 있는가?를 확인합니다.
88장. 패턴 13~15에서 가장 중요한 함정#
랜덤#
정확한 개수인가?
비율 샘플인가?
재현 가능해야 하는가?페이지#
정렬키가 유일한가?
페이지 사이 데이터가 바뀌는가?미연결 행#
검사하는 오른쪽 컬럼이
정상 조인에서 NULL이 될 수 없는가?를 봐야 합니다.
89장. SQL 패턴에는 기대 결과를 함께 보관하는 것이 좋다#
예:
패턴
세 번째 높은 서로 다른 급여
동점
같은 값은 같은 순위
예상 입력
500, 500, 300, 200
예상 결과
200이렇게 보관하면 나중에 다른 테이블로 옮겼을 때 결과가 달라진 이유를 찾기 쉽습니다.
90장. 정상값만으로 패턴을 시험하면 부족하다#
최소한 다음 입력을 추가해야 합니다.
NULL
동점
빈 결과
중복 행
미배정
기간 누락
정렬값 변경경계 데이터에서 SQL의 실제 의미가 드러납니다.
91장. 실행되는 SQL과 올바른 SQL은 다르다#
다음 SQL이 오류 없이 실행됐다고 하겠습니다.
SELECT ...그러나 결과가 업무 요구와 다를 수 있습니다.
SQL 엔진은:
세 번째 직원이 필요한지
세 번째 급여가 필요한지알지 못합니다.
이 판단은 개발자가 해야 합니다.
92장. SQL 패턴 검증 체크리스트#
- 결과 한 행은 무엇을 의미하는가?
- 중복 기준은 어떤 컬럼인가?
- NULL은 어떤 업무 상태인가?
- 동점을 같은 순위로 볼 것인가?
- 정렬의 보조 키가 필요한가?
- 집계 전에 JOIN으로 행이 늘어나는가?
- COUNT 별표가 실제로 세려는 단위와 같은가?
- DISTINCT가 오류를 숨기고 있지 않은가?
- LAG의 이전 행이 실제 이전 기간인가?
- NOT IN에 NULL 가능성이 있는가?
- EXISTS가 더 직접적인 표현은 아닌가?
- MERGE 원천 키가 고유한가?
- 재귀 관계에 순환이 가능한가?
- 무작위 결과가 재현 가능해야 하는가?
- 페이지 사이 데이터 변경이 가능한가?
- OFFSET 대신 키셋이 적합한가?
- 정렬값 자체가 수정될 수 있는가?
- 경계 데이터로 예상 결과를 검산했는가?
93장. 패턴을 재사용할 때 가장 중요한 습관#
SQL을 복사하기 전에 아래 네 줄을 먼저 적어 보는 것이 좋습니다.
대상:
한 행의 의미:
동점·NULL 규칙:
예상 결과:예를 들어:
대상
전체 직원
한 행의 의미
직원 한 명
동점
같은 급여는 공동 순위
예상 결과
급여 500 두 명 모두 1위라고 적어 두면 ROW_NUMBER와 RANK 중 어느 것을 써야 하는지 쉽게 판단할 수 있습니다.
94장. 핵심 정리#
SQL 실무 패턴은 매우 유용합니다.
하지만 패턴을:
복사해서 테이블명만 변경하는 방식으로 사용하면 실행 가능한 오답을 만들기 쉽습니다.
중복 찾기에서는:
어떤 컬럼 조합을 중복으로 보는가?를 먼저 정해야 합니다.
N번째 값에서는:
N번째 행
N번째 서로 다른 값
공동 순위 N위를 구분해야 합니다.
누적합에서는:
어떤 순서로 누적하는가?가 필요합니다.
증감률에서는:
이전 행이 실제 이전 기간인가?를 확인해야 합니다.
NOT IN에서는 NULL 때문에 결과가 사라질 수 있고, 조인 후 집계에서는 1:N 관계 때문에 같은 업무 객체가 여러 행으로 늘어날 수 있습니다.
DISTINCT를 붙여 화면상 중복만 제거하면 원인을 감출 수도 있습니다.
페이지 조회에서도:
ORDER BY가 안정적이다.와:
여러 요청 사이 결과 집합이 고정된다.는 다른 문제입니다.
OFFSET은 중간 삽입으로 위치가 밀릴 수 있고, 키셋 방식은 앞쪽 삽입에는 강하지만 정렬값 자체가 변경되면 같은 행이 다시 나타날 수도 있습니다.
재귀 CTE 역시 조직이 항상 완벽한 트리라고 가정해서는 안 됩니다.
A
→ B
→ C
→ A같은 잘못된 순환 관계가 들어올 수 있으므로 방문 경로 검사 같은 안전장치가 필요합니다.
이번 15개 패턴 전체를 관통하는 원칙은 하나입니다.
SQL 패턴은 문법을 재사용하는 것이 아니라 검증된 사고방식을 재사용하는 것이다.
따라서 좋은 패턴에는 SQL 문장뿐 아니라 다음이 함께 있어야 합니다.
적용 조건
반환 단위
NULL 규칙
동점 규칙
정렬 기준
경계값
기대 결과그리고 새 데이터에 적용한 뒤에는 반드시 묻습니다.
이 SQL이 실행되는가?
에서 끝내지 말고:
왜 정확히 이 행과 이 숫자가 나왔는가?
까지 설명할 수 있어야 합니다.
그 설명이 가능할 때 SQL 패턴은 단순한 복사 코드가 아니라 실제 업무에서 재사용할 수 있는 도구가 됩니다.