SQL 윈도우 함수와 CTE: 동점 순위·누적합·재귀 조직도 실습
1장. 같은 매출인데 왜 공동 1위가 사라졌을까#
팀 A의 판매 실적이 다음과 같다고 하겠습니다.
| 판매ID | 날짜 | 금액 |
|---|---|---|
| 1 | 1월 | 100 |
| 2 | 2월 | 120 |
| 3 | 3월 | 120 |
| 4 | 4월 | 80 |
2월과 3월 매출은 둘 다 120입니다.
따라서 금액 기준으로는 공동 1위라고 볼 수 있습니다.
그런데 다음처럼 순번을 계산하면:
ROW_NUMBER() OVER (
ORDER BY amount DESC, sale_id
)결과는 다음과 같습니다.
120 → 1
120 → 2
100 → 3
80 → 4공동 1위가 사라졌습니다.
SQL이 틀린 것은 아닙니다.
우리가 순번을 구한 것인지 공동 순위를 구한 것인지가 달랐던 것입니다.
윈도우 함수에서는 문법보다 먼저 다음 세 가지를 정해야 합니다.
누구와 비교할 것인가?
어떤 순서로 계산할 것인가?
현재 행에서 어디까지 계산할 것인가?이 세 질문이 각각 PARTITION BY, ORDER BY, 프레임과 연결됩니다.
2장. 윈도우 함수는 행을 없애지 않고 계산 결과를 붙인다#
일반 집계 함수는 여러 행을 하나로 줄입니다.
다음 SQL을 보겠습니다.
SELECT
team,
AVG(amount)
FROM sale
GROUP BY team;결과는 팀별 한 행입니다.
A | 105
B | 100개별 판매행은 사라졌습니다.
반면 윈도우 함수를 사용하면:
SELECT
sale_id,
team,
amount,
AVG(amount) OVER (
PARTITION BY team
) AS team_avg
FROM sale;원래 행을 그대로 유지하면서 평균을 옆에 붙일 수 있습니다.
A | 100 | 105
A | 120 | 105
A | 120 | 105
A | 80 | 105
B | 90 | 100
B | 110 | 100즉:
GROUP BY
→ 행을 묶고 줄인다
윈도우 함수
→ 행을 유지하고 계산값을 붙인다라고 이해하면 쉽습니다.
3장. 실습 데이터 만들기#
다음 테이블을 사용하겠습니다.
CREATE TABLE sale (
sale_id integer PRIMARY KEY,
team varchar(10) NOT NULL,
sale_day date NOT NULL,
amount integer NOT NULL
);데이터를 입력합니다.
INSERT INTO sale VALUES
(1, 'A', DATE '2026-01-01', 100),
(2, 'A', DATE '2026-02-01', 120),
(3, 'A', DATE '2026-03-01', 120),
(4, 'A', DATE '2026-04-01', 80),
(5, 'B', DATE '2026-01-01', 90),
(6, 'B', DATE '2026-02-01', 110);데이터는 다음과 같습니다.
| 판매ID | 팀 | 판매일 | 금액 |
|---|---|---|---|
| 1 | A | 2026-01-01 | 100 |
| 2 | A | 2026-02-01 | 120 |
| 3 | A | 2026-03-01 | 120 |
| 4 | A | 2026-04-01 | 80 |
| 5 | B | 2026-01-01 | 90 |
| 6 | B | 2026-02-01 | 110 |
4장. OVER가 윈도우 함수의 시작이다#
윈도우 함수의 기본 형태는 다음과 같습니다.
함수(...) OVER (...)예를 들어:
AVG(amount) OVER (
PARTITION BY team
)은 각 행이 속한 팀의 평균을 계산합니다.
OVER 안에는 대표적으로 다음 요소가 들어갑니다.
PARTITION BY
ORDER BY
ROWS 또는 RANGE역할을 나누면 다음과 같습니다.
| 요소 | 역할 |
|---|---|
| PARTITION BY | 계산 집단을 나눔 |
| ORDER BY | 집단 안 계산 순서를 정함 |
| ROWS·RANGE | 현재 행에서 계산할 범위를 정함 |
5장. PARTITION BY는 비교 집단을 나눈다#
팀별 평균을 계산해 보겠습니다.
SELECT
sale_id,
team,
amount,
AVG(amount) OVER (
PARTITION BY team
) AS team_avg
FROM sale
ORDER BY sale_id;팀 A의 합계는:
100 + 120 + 120 + 80
=
420입니다.
행은 네 개이므로 평균은:
420 / 4
=
105입니다.
팀 B는:
90 + 110
=
200이므로 평균은 100입니다.
결과:
| 판매ID | 팀 | 금액 | 팀 평균 |
|---|---|---|---|
| 1 | A | 100 | 105 |
| 2 | A | 120 | 105 |
| 3 | A | 120 | 105 |
| 4 | A | 80 | 105 |
| 5 | B | 90 | 100 |
| 6 | B | 110 | 100 |
6장. PARTITION BY가 없으면 전체가 하나의 집단이 된다#
다음처럼 작성하면:
AVG(amount) OVER ()A와 B를 구분하지 않습니다.
전체 판매액 합계는:
420 + 200
=
620행은 6개입니다.
전체 평균은 약:
103.33입니다.
모든 행에 같은 전체 평균이 붙습니다.
따라서 PARTITION BY는 다음 질문에 답합니다.
이 계산에서 서로 비교할 사람이나 데이터의 범위는 어디까지인가?
7장. 윈도우 안의 ORDER BY는 계산 순서를 결정한다#
다음 두 질문은 서로 다릅니다.
팀에서 금액이 높은 순서는?
날짜가 흐르면서 누적 매출은?
첫 번째는 금액 순서가 필요합니다.
ORDER BY amount DESC두 번째는 날짜 순서가 필요합니다.
ORDER BY sale_day같은 데이터를 사용해도 계산 목적에 따라 윈도우 안의 ORDER BY가 달라집니다.
8장. 최종 ORDER BY와 윈도우 내부 ORDER BY는 서로 다르다#
다음 SQL을 보겠습니다.
SELECT
sale_id,
amount,
RANK() OVER (
ORDER BY amount DESC
) AS rank_no
FROM sale
ORDER BY sale_id;윈도우 내부:
ORDER BY amount DESC는 순위를 계산합니다.
질의 마지막:
ORDER BY sale_id는 최종 화면 표시 순서를 정합니다.
따라서 다음 두 개는 서로 대체되지 않습니다.
계산 순서
출력 순서9장. ROW_NUMBER는 모든 행에 서로 다른 번호를 준다#
팀 A의 금액 순서를 계산합니다.
SELECT
sale_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY team
ORDER BY amount DESC, sale_id
) AS row_no
FROM sale
WHERE team = 'A'
ORDER BY row_no;결과:
| 판매ID | 금액 | ROW_NUMBER |
|---|---|---|
| 2 | 120 | 1 |
| 3 | 120 | 2 |
| 1 | 100 | 3 |
| 4 | 80 | 4 |
두 행이 모두 120이지만 번호는 다릅니다.
ROW_NUMBER는 동점을 공동 순위로 처리하지 않습니다.
10장. 동점에서 안정적인 ROW_NUMBER를 만들려면 추가 기준이 필요하다#
다음처럼 금액만 정렬했다고 하겠습니다.
ROW_NUMBER() OVER (
ORDER BY amount DESC
)120인 두 행 가운데 어느 것이 먼저인지 업무 규칙이 없습니다.
DBMS가 항상 같은 순서를 보여준다고 기대해서는 안 됩니다.
따라서 순번이 반드시 안정적이어야 한다면 다음처럼 고유한 기준을 추가할 수 있습니다.
ORDER BY
amount DESC,
sale_id하지만 이 추가 기준을 RANK에도 그대로 넣으면 동점의 의미가 바뀔 수 있습니다.
11장. RANK는 동점 뒤 순위를 건너뛴다#
SELECT
sale_id,
amount,
RANK() OVER (
PARTITION BY team
ORDER BY amount DESC
) AS rank_no
FROM sale
WHERE team = 'A';결과는 다음과 같습니다.
120 → 1
120 → 1
100 → 3
80 → 4공동 1위가 두 명이므로 다음 순위는 3위입니다.
12장. DENSE_RANK는 동점 뒤 순위를 이어 간다#
같은 데이터에서:
DENSE_RANK() OVER (
PARTITION BY team
ORDER BY amount DESC
)를 사용하면:
120 → 1
120 → 1
100 → 2
80 → 3이 됩니다.
차이를 정리하면 다음과 같습니다.
| 금액 | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 120 | 1 | 1 | 1 |
| 120 | 2 | 1 | 1 |
| 100 | 3 | 3 | 2 |
| 80 | 4 | 4 | 3 |
13장. 순위 함수는 요구사항을 먼저 구분해야 한다#
다음 세 문장은 서로 다른 요구입니다.
정확히 네 명에게 1, 2, 3, 4 번호를 붙여라.→ ROW_NUMBER
동점은 공동 순위로 하고 다음 순위는 건너뛰어라.→ RANK
동점은 공동 순위로 하고 다음 순위는 이어서 붙여라.→ DENSE_RANK
순위라고 모두 같은 함수가 아닙니다.
14장. RANK에 고유 ID를 추가하면 공동 순위가 사라질 수 있다#
다음처럼 작성했다고 하겠습니다.
RANK() OVER (
ORDER BY amount DESC, sale_id
)120 두 행은 amount는 같지만 sale_id가 다릅니다.
따라서 정렬값 전체 조합이 다릅니다.
결과적으로 공동 순위가 깨질 수 있습니다.
즉 다음 두 요구를 분리해야 합니다.
순위 계산 기준
최종 표시 순서순위는 amount만으로 계산하고, 화면은 바깥 ORDER BY에서 sale_id를 추가할 수 있습니다.
15장. 누적합은 현재 행까지 얼마가 쌓였는지를 계산한다#
팀 A의 월별 누적 매출을 구해 보겠습니다.
SELECT
sale_id,
sale_day,
amount,
SUM(amount) OVER (
PARTITION BY team
ORDER BY sale_day, sale_id
ROWS BETWEEN
UNBOUNDED PRECEDING
AND CURRENT ROW
) AS running_total
FROM sale
WHERE team = 'A'
ORDER BY sale_day, sale_id;결과:
| 판매ID | 금액 | 누적합 |
|---|---|---|
| 1 | 100 | 100 |
| 2 | 120 | 220 |
| 3 | 120 | 340 |
| 4 | 80 | 420 |
16장. ROWS는 실제 행 개수를 기준으로 범위를 잡는다#
다음 프레임을 보겠습니다.
ROWS BETWEEN
1 PRECEDING
AND CURRENT ROW뜻은 다음과 같습니다.
현재 행과 바로 이전 한 행을 계산에 포함한다.
팀 A에서 평균을 계산하면:
SELECT
sale_id,
amount,
AVG(amount) OVER (
PARTITION BY team
ORDER BY sale_day, sale_id
ROWS BETWEEN
1 PRECEDING
AND CURRENT ROW
) AS moving_avg
FROM sale
WHERE team = 'A'
ORDER BY sale_day, sale_id;결과는:
| 판매ID | 금액 | 현재+이전 평균 |
|---|---|---|
| 1 | 100 | 100 |
| 2 | 120 | 110 |
| 3 | 120 | 120 |
| 4 | 80 | 100 |
입니다.
17장. 같은 날짜가 여러 건이면 날짜만으로 행 단위 누적을 정의하기 어렵다#
다음 판매가 있다고 하겠습니다.
9월 1일 | 100
9월 2일 | 50
9월 2일 | 30
9월 3일 | 20날짜만 사용해:
ORDER BY sale_day라고 하면 9월 2일의 두 행 가운데 어떤 행이 먼저인지 명확하지 않습니다.
행마다 잔액처럼 누적값을 표시해야 한다면 다음처럼 추가 기준이 필요합니다.
ORDER BY
sale_day,
sale_id그러면 한 행씩 계산할 순서를 정할 수 있습니다.
18장. 거래별 누적과 일별 누적은 다른 질문이다#
앞의 데이터를 거래별로 누적하면:
100
150
180
200이 될 수 있습니다.
하지만 일별 합계를 먼저 만들면:
9월 1일
100
9월 2일
80
9월 3일
20이 됩니다.
그리고 날짜별 누적은:
100
180
200입니다.
최종 합계는 같지만 결과 한 행의 의미가 다릅니다.
거래 한 건
하루 한 건무엇을 원하는지 먼저 정해야 합니다.
19장. RANGE는 같은 정렬값을 가진 행을 함께 다룰 수 있다#
ROWS는 실제 행 위치를 기준으로 합니다.
반면 RANGE는 정렬값을 기준으로 동점 행을 함께 다룰 수 있습니다.
예를 들어 팀 A 금액을 오름차순으로 정렬합니다.
80
100
120
120기본적인 RANGE 성격의 프레임에서는 120이라는 같은 정렬값을 가진 두 행이 peer로 함께 계산될 수 있습니다.
따라서 두 120 행의 누적합이 둘 다 420으로 나타날 수 있습니다.
20장. 기본 프레임에 기대지 않고 필요한 범위를 직접 쓰는 것이 안전하다#
다음처럼 쓰면:
SUM(amount) OVER (
ORDER BY amount
)DBMS의 기본 프레임 규칙이 적용됩니다.
동점 데이터가 있을 때 예상과 다른 결과가 나올 수 있습니다.
한 행씩 확실하게 누적하려면 다음처럼 명시하는 편이 이해하기 쉽습니다.
SUM(amount) OVER (
ORDER BY amount, sale_id
ROWS BETWEEN
UNBOUNDED PRECEDING
AND CURRENT ROW
)21장. LAG는 이전 행의 값을 가져온다#
전월 대비 매출 변화를 계산하고 싶다고 하겠습니다.
SELECT
sale_id,
sale_day,
amount,
LAG(amount) OVER (
PARTITION BY team
ORDER BY sale_day, sale_id
) AS previous_amount
FROM sale
WHERE team = 'A'
ORDER BY sale_day, sale_id;결과:
| 판매ID | 현재 금액 | 이전 금액 |
|---|---|---|
| 1 | 100 | NULL |
| 2 | 120 | 100 |
| 3 | 120 | 120 |
| 4 | 80 | 120 |
첫 번째 행은 이전 행이 없으므로 NULL입니다.
22장. 전월 대비 증감액도 바로 계산할 수 있다#
SELECT
sale_day,
amount,
amount
- LAG(amount) OVER (
PARTITION BY team
ORDER BY sale_day, sale_id
) AS difference
FROM sale
WHERE team = 'A'
ORDER BY sale_day, sale_id;결과:
1월 | 100 | NULL
2월 | 120 | +20
3월 | 120 | 0
4월 | 80 | -40첫 번째 행의 비교 대상이 없다는 사실을 NULL이 표현합니다.
23장. 첫 행의 NULL을 0으로 바꾸면 의미가 바뀔 수 있다#
다음처럼 작성할 수 있습니다.
COALESCE(
LAG(amount) OVER (...),
0
)하지만 이것은:
이전 판매가 존재하지 않음을:
이전 판매액이 0으로 바꿉니다.
표시 목적에는 쓸 수 있지만 두 의미가 같은지는 확인해야 합니다.
24장. LEAD는 다음 행의 값을 가져온다#
SELECT
sale_id,
amount,
LEAD(amount) OVER (
PARTITION BY team
ORDER BY sale_day, sale_id
) AS next_amount
FROM sale
WHERE team = 'A'
ORDER BY sale_day, sale_id;결과:
100 → 다음 120
120 → 다음 120
120 → 다음 80
80 → 다음 없음마지막 행에는 다음 행이 없으므로 NULL입니다.
25장. LAST_VALUE는 이름만 보고 쓰면 틀리기 쉽다#
팀의 마지막 판매액을 모든 행에 붙이고 싶다고 하겠습니다.
다음처럼 작성할 수 있습니다.
LAST_VALUE(amount) OVER (
PARTITION BY team
ORDER BY sale_day
)그런데 예상과 달리 각 행에서 현재 프레임의 마지막 값이 나올 수 있습니다.
즉 모든 행에서 팀의 최종값 80이 나오지 않을 수 있습니다.
26장. 전체 파티션의 마지막 값을 원하면 프레임 끝을 명시한다#
SELECT
sale_id,
amount,
LAST_VALUE(amount) OVER (
PARTITION BY team
ORDER BY sale_day, sale_id
ROWS BETWEEN
UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING
) AS last_team_amount
FROM sale
WHERE team = 'A'
ORDER BY sale_id;모든 행의 결과는:
80입니다.
전체 팀 범위 끝까지 프레임에 포함했기 때문입니다.
27장. FIRST_VALUE도 프레임과 정렬 기준을 함께 봐야 한다#
같은 프레임에서:
FIRST_VALUE(amount)을 사용하면 팀 A의 첫 판매액 100을 모든 행에 붙일 수 있습니다.
중요한 것은 FIRST_VALUE, LAST_VALUE라는 함수 이름만 보는 것이 아닙니다.
어떤 순서에서 첫 값인가?
어떤 프레임 안에서 마지막 값인가?를 확인해야 합니다.
28장. CTE는 긴 SQL을 의미 있는 단계로 나눈다#
CTE는 Common Table Expression의 약자입니다.
기본 형태는 다음과 같습니다.
WITH 이름 AS (
SELECT ...
)
SELECT ...
FROM 이름;예를 들어 팀별 합계를 먼저 계산합니다.
WITH team_total AS (
SELECT
team,
SUM(amount) AS total
FROM sale
GROUP BY team
)
SELECT *
FROM team_total;결과:
A | 420
B | 20029장. CTE는 결과 단위를 명확하게 만드는 데 특히 유용하다#
팀별 비중을 계산한다고 하겠습니다.
먼저 팀별 합계를 만듭니다.
WITH team_total AS (
SELECT
team,
SUM(amount) AS total
FROM sale
GROUP BY team
)이 시점에서 결과 한 행은:
팀 하나입니다.
그다음 전체 합계를 계산합니다.
grand_total AS (
SELECT SUM(total) AS total
FROM team_total
)마지막으로 비율을 계산합니다.
WITH team_total AS (
SELECT
team,
SUM(amount) AS total
FROM sale
GROUP BY team
),
grand_total AS (
SELECT SUM(total) AS total
FROM team_total
)
SELECT
t.team,
t.total,
ROUND(
t.total * 100.0 / g.total,
1
) AS pct
FROM team_total AS t
CROSS JOIN grand_total AS g
ORDER BY t.team;결과:
A | 420 | 67.7
B | 200 | 32.330장. CTE는 무조건 임시 테이블로 저장되는 것이 아니다#
다음처럼 생각하면 안 됩니다.
CTE
=
반드시 임시 결과를 디스크나 메모리에 저장현대 DBMS는 CTE를 상위 질의와 합쳐 최적화하거나 별도로 계산할 수 있습니다.
제품과 질의 구조에 따라 달라집니다.
따라서 CTE는 우선 논리적인 질의 단계에 이름을 붙이는 표현으로 이해하는 것이 좋습니다.
성능은 실행 계획으로 확인합니다.
31장. 월별 매출 순위는 먼저 월별 합계를 만들어야 한다#
원천 데이터가 거래 단위라고 하겠습니다.
한 부서가 한 달 동안 다음처럼 거래했습니다.
20
30
50원천 행에서 바로 순위를 매기면 20, 30, 50 각각이 순위 대상이 됩니다.
하지만 요구가:
월별 부서 매출 순위
라면 먼저 합계를 만들어야 합니다.
20 + 30 + 50
=
100그 뒤 이 100이라는 월 합계에 순위를 매겨야 합니다.
32장. CTE로 집계와 순위 단계를 분리할 수 있다#
WITH monthly_sales AS (
SELECT
team,
DATE_TRUNC('month', sale_day) AS month,
SUM(amount) AS monthly_amount
FROM sale
GROUP BY
team,
DATE_TRUNC('month', sale_day)
)
SELECT
team,
month,
monthly_amount,
RANK() OVER (
PARTITION BY month
ORDER BY monthly_amount DESC
) AS rank_no
FROM monthly_sales;중요한 것은 순위 계산 전에 한 행의 단위를 월별 팀 합계로 바꾸었다는 점입니다.
33장. 상위 두 등급과 최대 두 행은 다른 요구다#
A, B, C 팀의 월매출이 다음과 같다고 하겠습니다.
A = 100
B = 100
C = 80RANK 결과는:
A → 1
B → 1
C → 3DENSE_RANK는:
A → 1
B → 1
C → 2따라서:
상위 두 매출 등급
과:
최대 두 팀
은 다릅니다.
동점 처리 방식을 먼저 정해야 합니다.
34장. 재귀 CTE는 자기 자신을 반복 참조하는 계층 조회에 사용한다#
조직도를 생각해 보겠습니다.
대표
├─ 개발팀장
│ └─ 개발자
└─ 운영팀장테이블을 만듭니다.
CREATE TABLE org_employee (
emp_id integer PRIMARY KEY,
emp_name varchar(30) NOT NULL,
manager_id integer
REFERENCES org_employee(emp_id)
);데이터:
INSERT INTO org_employee VALUES
(1, '대표', NULL),
(2, '개발팀장', 1),
(3, '개발자', 2),
(4, '운영팀장', 1);35장. 조직 관계를 ERD로 보면 자기 참조 관계다#
erDiagram
EMPLOYEE o|--o{ EMPLOYEE : manages
EMPLOYEE {
int emp_id PK
string emp_name
int manager_id FK
}한 직원의 manager_id가 같은 테이블의 다른 직원 emp_id를 가리킵니다.
재귀 CTE는 이런 계층을 반복적으로 따라가는 데 사용할 수 있습니다.
36장. 재귀 CTE는 앵커와 재귀 항으로 구성된다#
구조를 단순화하면 다음과 같습니다.
WITH RECURSIVE org AS (
-- 앵커
SELECT ...
UNION ALL
-- 재귀 항
SELECT ...
FROM ...
JOIN org ...
)
SELECT ...
FROM org;앵커는 시작점을 찾습니다.
재귀 항은 현재 결과를 이용해 다음 단계로 내려갑니다.
37장. 루트부터 조직도를 내려가 보자#
WITH RECURSIVE org (
emp_id,
emp_name,
manager_id,
depth,
path
) AS (
SELECT
emp_id,
emp_name,
manager_id,
1,
emp_name::text
FROM org_employee
WHERE manager_id IS NULL
UNION ALL
SELECT
e.emp_id,
e.emp_name,
e.manager_id,
o.depth + 1,
o.path || ' → ' || e.emp_name
FROM org_employee AS e
JOIN org AS o
ON e.manager_id = o.emp_id
)
SELECT
emp_id,
depth,
path
FROM org
ORDER BY path;결과는 개념적으로 다음과 같습니다.
대표
대표 → 개발팀장
대표 → 개발팀장 → 개발자
대표 → 운영팀장38장. depth는 조직도 깊이를 계산한다#
앵커에서:
depth = 1로 시작합니다.
재귀 항에서:
o.depth + 1을 사용합니다.
따라서 결과는:
| 직원 | 깊이 |
|---|---|
| 대표 | 1 |
| 개발팀장 | 2 |
| 운영팀장 | 2 |
| 개발자 | 3 |
처럼 계산됩니다.
39장. path를 함께 만들면 계층 경로를 확인할 수 있다#
다음 부분이 경로를 만듭니다.
o.path || ' → ' || e.emp_name개발자는:
대표
↓
개발팀장
↓
개발자이므로:
대표 → 개발팀장 → 개발자라는 경로를 얻습니다.
재귀 쿼리 디버깅에서도 경로 정보는 매우 유용합니다.
40장. 조직 데이터에 순환이 생기면 재귀가 끝나지 않을 수 있다#
잘못된 데이터가 다음처럼 들어갔다고 하겠습니다.
직원 2의 관리자 → 3
직원 3의 관리자 → 2그러면:
2 → 3 → 2 → 3 → ...처럼 반복될 수 있습니다.
따라서 실제 계층 데이터에서는 순환 방지 전략이 필요합니다.
41장. 방문한 ID를 경로에 기록해 순환을 막을 수 있다#
개념적으로는 다음처럼 생각할 수 있습니다.
이미 방문한 직원ID 목록다음 직원이 이미 목록에 있다면 재귀를 중단합니다.
PostgreSQL에서는 배열을 이용하거나 SQL 표준의 재귀 관련 기능을 활용할 수 있습니다.
중요한 것은 재귀 쿼리를 작성할 때 다음 질문입니다.
데이터가 항상 완벽한 트리라고 확신할 수 있는가?
확신할 수 없다면 순환을 고려해야 합니다.
42장. Oracle의 계층형 질의와 재귀 CTE는 개념적으로 연결된다#
Oracle에서는 오래전부터 CONNECT BY 계열의 계층 질의를 사용해 왔습니다.
예를 들어 개념적으로:
CONNECT BY PRIOR emp_id = manager_id와 같은 부모·자식 관계를 표현할 수 있습니다.
재귀 CTE에서는:
e.manager_id = o.emp_id처럼 부모와 자식을 직접 조인합니다.
문법은 다르지만 모두 계층 관계를 반복적으로 따라간다는 목적은 같습니다.
43장. PIVOT은 행을 열로 펼치는 문제다#
현재 판매 데이터는 다음처럼 세로 형태입니다.
A | 1월 | 100
A | 2월 | 120
B | 1월 | 90
B | 2월 | 110보고서에서 다음처럼 보고 싶을 수 있습니다.
| 팀 | 1월 | 2월 |
|---|---|---|
| A | 100 | 120 |
| B | 90 | 110 |
이것이 피벗 형태입니다.
44장. PostgreSQL에서는 조건부 집계로 고정 월 피벗을 만들 수 있다#
SELECT
team,
SUM(amount)
FILTER (
WHERE sale_day = DATE '2026-01-01'
) AS jan,
SUM(amount)
FILTER (
WHERE sale_day = DATE '2026-02-01'
) AS feb
FROM sale
GROUP BY team
ORDER BY team;결과:
| 팀 | 1월 | 2월 |
|---|---|---|
| A | 100 | 120 |
| B | 90 | 110 |
45장. 피벗은 정규화와 같은 개념이 아니다#
행을 열로 바꾸었다고 데이터 모델 자체가 정규화되거나 비정규화된 것은 아닙니다.
피벗은 주로 표현 형태를 바꾸는 작업입니다.
행 중심 결과
↔
열 중심 보고서데이터베이스 테이블의 정규형과는 별개의 문제입니다.
46장. UNPIVOT은 열을 다시 행으로 펼친다#
다음 결과가 있다고 하겠습니다.
A | 100 | 120이를:
A | 1월 | 100
A | 2월 | 120처럼 되돌리는 것이 UNPIVOT 계열의 작업입니다.
DBMS마다 지원 구문이 다릅니다.
PostgreSQL에서는 상황에 따라 VALUES, UNION ALL, LATERAL 등을 사용할 수 있습니다.
47장. VIEW는 쿼리에 이름을 붙여 재사용한다#
팀별 합계를 자주 조회한다고 하겠습니다.
CREATE VIEW team_sales AS
SELECT
team,
SUM(amount) AS total
FROM sale
GROUP BY team;이제:
SELECT *
FROM team_sales;로 사용할 수 있습니다.
현재 원본 데이터 기준 결과는:
A | 420
B | 200입니다.
일반 뷰는 기본적으로 쿼리 정의를 이용해 원본 데이터를 조회하는 논리적 객체로 이해할 수 있습니다.
48장. 원본이 바뀌면 일반 뷰 결과도 바뀐다#
A팀에 매출 50을 추가한다고 하겠습니다.
INSERT INTO sale
VALUES (
7,
'A',
DATE '2026-05-01',
50
);이제 일반 뷰를 조회하면:
A | 470
B | 200이 될 수 있습니다.
원본 데이터가 변경됐기 때문입니다.
49장. 물질화 뷰는 결과 자체를 저장한다#
PostgreSQL에서는 다음처럼 물질화 뷰를 만들 수 있습니다.
CREATE MATERIALIZED VIEW team_sales_cached AS
SELECT
team,
SUM(amount) AS total
FROM sale
GROUP BY team;이 객체는 계산 결과를 저장합니다.
따라서 원본에 새로운 매출이 추가되어도 자동으로 항상 최신이 되는 것은 아닙니다.
50장. 물질화 뷰는 새로 고침 전까지 과거 결과를 보여줄 수 있다#
물질화 뷰 생성 시 A팀이 420이었다고 하겠습니다.
그 뒤 원본에 50을 추가했습니다.
일반 뷰:
A = 470물질화 뷰:
A = 420일 수 있습니다.
새로 고침을 실행합니다.
REFRESH MATERIALIZED VIEW team_sales_cached;그러면:
A = 470으로 갱신됩니다.
51장. 물질화 뷰에서는 최신성이 설계 조건이 된다#
물질화 뷰는 읽기 성능을 높이는 대신 데이터 최신성 관리가 필요합니다.
다음 질문을 정해야 합니다.
매 요청마다 최신이어야 하는가?
5분 지연을 허용하는가?
하루 한 번 갱신해도 되는가?
갱신 중 조회는 어떻게 처리할 것인가?따라서 단순히:
물질화 뷰
→ 빠르다로 끝나는 개념이 아닙니다.
52장. 한 화면에 순위와 누적합을 같이 표시할 수도 있다#
판매 목록에서 다음 두 값을 함께 보여주고 싶다고 하겠습니다.
팀 내 판매액 순위
날짜순 누적 매출두 계산은 서로 다른 ORDER BY를 사용합니다.
SELECT
sale_id,
team,
sale_day,
amount,
RANK() OVER (
PARTITION BY team
ORDER BY amount DESC
) AS amount_rank,
SUM(amount) OVER (
PARTITION BY team
ORDER BY sale_day, sale_id
ROWS BETWEEN
UNBOUNDED PRECEDING
AND CURRENT ROW
) AS running_total
FROM sale
ORDER BY
team,
sale_day,
sale_id;같은 행에 숫자 두 개가 붙어 있지만 계산 원리는 서로 다릅니다.
53장. 윈도우 함수 하나마다 세 가지를 따로 확인해야 한다#
예를 들어:
SUM(amount) OVER (
PARTITION BY team
ORDER BY sale_day
ROWS BETWEEN
UNBOUNDED PRECEDING
AND CURRENT ROW
)을 보면 다음처럼 읽을 수 있습니다.
누구와?
→ 같은 팀
어떤 순서로?
→ 날짜순
어디까지?
→ 첫 행부터 현재 행까지이 세 질문으로 읽으면 긴 윈도우 구문도 쉽게 해석할 수 있습니다.
54장. 결과가 틀렸다면 PARTITION·ORDER·FRAME 중 어디가 다른지 찾는다#
순위가 전체 회사 기준으로 나와버렸다면:
PARTITION BY 누락일 수 있습니다.
전월 대비 값이 엉뚱하다면:
ORDER BY 기준 오류일 수 있습니다.
누적합이 동점 행에서 예상보다 한꺼번에 증가했다면:
프레임 또는 peer 처리를 확인해야 합니다.
윈도우 함수 오류를 한 덩어리로 보지 않고 세 요소로 나누면 원인을 찾기 쉽습니다.
55장. CTE는 행의 단위가 바뀌는 지점을 보여주는 도구이기도 하다#
다음 흐름을 생각해 보겠습니다.
flowchart TD
A["원천 판매<br/>한 행 = 거래"] --> B["월별 집계 CTE<br/>한 행 = 팀·월"]
B --> C["순위 계산<br/>한 행 = 팀·월 + 순위"]
C --> D["최종 보고서"]CTE를 쓰는 목적은 SQL을 단순히 여러 줄로 나누는 것이 아닙니다.
각 단계에서:
한 행이 무엇을 의미하는가?
를 명확하게 만드는 데 도움이 됩니다.
56장. 윈도우 함수와 GROUP BY를 함께 사용할 수 있다#
먼저 GROUP BY로 월별 매출을 만들고 그 결과에 윈도우 함수를 적용할 수 있습니다.
WITH monthly AS (
SELECT
team,
DATE_TRUNC('month', sale_day) AS month,
SUM(amount) AS amount
FROM sale
GROUP BY
team,
DATE_TRUNC('month', sale_day)
)
SELECT
team,
month,
amount,
SUM(amount) OVER (
PARTITION BY team
ORDER BY month
ROWS BETWEEN
UNBOUNDED PRECEDING
AND CURRENT ROW
) AS cumulative_amount
FROM monthly;즉 GROUP BY와 윈도우 함수는 경쟁 관계가 아닙니다.
서로 다른 단계에서 함께 사용할 수 있습니다.
57장. 가장 흔한 윈도우 함수 실수 정리#
| 증상 | 먼저 확인할 것 |
|---|---|
| 동점인데 순위가 다름 | ROW_NUMBER 사용 여부 또는 정렬 열 |
| RANK 뒤 순위가 건너뜀 | RANK 고유 동작 |
| 누적합이 동점에서 한꺼번에 증가 | RANGE·기본 프레임 |
| 첫 행 전월값이 NULL | LAG에 이전 행이 없음 |
| LAST_VALUE가 최종값이 아님 | 프레임 끝 |
| 팀별 순위인데 전체 순위가 나옴 | PARTITION BY |
| 화면 순서가 매번 달라짐 | 최종 ORDER BY |
| 월 순위가 거래 단위로 계산됨 | 집계 전 순위를 매겼는지 |
58장. 재귀 CTE에서 가장 흔한 오류#
계층형 조회에서는 다음을 확인해야 합니다.
시작점이 정확한가?
부모와 자식 조인 방향이 맞는가?
depth 증가가 맞는가?
순환 데이터가 가능한가?
같은 노드를 여러 번 방문할 수 있는가?조인 방향을 반대로 쓰면 상위로 올라가거나 아무 결과도 나오지 않을 수 있습니다.
59장. 윈도우 함수·CTE·뷰의 역할을 구분하면#
| 기능 | 주된 역할 |
|---|---|
| 윈도우 함수 | 원래 행을 유지하며 순위·누적·비교값 계산 |
| 일반 CTE | 복잡한 SQL의 단계를 이름으로 분리 |
| 재귀 CTE | 계층·그래프 형태 관계 반복 탐색 |
| VIEW | 쿼리 정의를 재사용 |
| MATERIALIZED VIEW | 쿼리 결과 자체를 저장해 재사용 |
모두 SQL 결과를 다루지만 해결하는 문제가 다릅니다.
60장. 핵심 정리#
윈도우 함수의 핵심은 원래 행을 유지하면서 다른 행과의 관계를 계산한다는 데 있습니다.
다음 세 요소를 반드시 구분해야 합니다.
PARTITION BY
→ 누구와 함께 계산할 것인가
ORDER BY
→ 어떤 순서로 계산할 것인가
ROWS·RANGE
→ 현재 행에서 어디까지 계산할 것인가순위 함수도 목적에 따라 달라집니다.
ROW_NUMBER
→ 모든 행에 서로 다른 순번
RANK
→ 공동 순위 후 번호 건너뜀
DENSE_RANK
→ 공동 순위 후 번호 이어짐누적합에서는 같은 정렬값을 가진 행이 있을 때 프레임이 중요합니다.
한 행씩 확실하게 누적하려면 동점을 해결할 정렬 열과 ROWS 프레임을 명시하는 것이 이해하기 쉽습니다.
LAG와 LEAD는 이전·다음 행과 비교할 때 유용하고, FIRST_VALUE와 LAST_VALUE는 프레임 범위를 반드시 함께 확인해야 합니다.
CTE는 복잡한 쿼리를 단순히 예쁘게 나누는 문법이 아닙니다.
각 단계에서 결과 한 행의 의미가 어떻게 바뀌는지를 드러내는 도구로 사용할 수 있습니다.
재귀 CTE는 조직도처럼 부모와 자식 관계를 반복적으로 따라가며 깊이와 경로를 계산할 수 있습니다. 이때 실제 데이터에 순환이 존재할 가능성까지 고려해야 합니다.
일반 뷰와 물질화 뷰도 구분해야 합니다.
일반 뷰
→ 원본을 기준으로 조회
물질화 뷰
→ 계산 결과를 저장물질화 뷰는 조회를 빠르게 만들 수 있지만 갱신 전까지 이전 값을 보여줄 수 있으므로 최신성 요구를 함께 설계해야 합니다.
윈도우 함수가 복잡해 보일 때는 하나의 문장으로 바꾸면 됩니다.
누구와 비교하고, 어떤 순서로, 어디까지 계산하는가?
이 세 가지를 설명할 수 있다면 동점 순위, 이동평균, 누적합, 전월 비교처럼 복잡해 보이는 분석 SQL도 훨씬 쉽게 검증할 수 있습니다.