윈도우 함수 — OVER·PARTITION·ROW_NUMBER
"GROUP BY 안 하고 옆 행이랑 비교" — 분석 SQL 의 핵심 도구.
GROUP BY 는 행을 합쳐 통계를 냅니다. 하지만 "각 행의 데이터는 그대로 두고 옆 그룹 통계나 순위를 한 컬럼으로 붙이고 싶다" 면? 그게 윈도우 함수입니다. SQL 의 가장 강력한 도구 중 하나 — 11편은 매일 만나는 6개를 정리합니다.
OVER() — 윈도우 함수의 핵심
-- 일반 집계 — 행이 줄어듦
SELECT category_id, COUNT(*) FROM products GROUP BY category_id;
-- 윈도우 — 모든 행이 살아남음 + 그룹 통계가 한 컬럼으로
SELECT id, name, category_id,
COUNT(*) OVER (PARTITION BY category_id) AS category_count
FROM products;
-- 결과: 모든 상품 + 각자 속한 카테고리의 총 개수
핵심 한 줄. 일반 집계 = "행을 합친다". 윈도우 = "행을 합치지 않고, 그룹 통계를 옆에 붙인다". OVER () 가 윈도우의 트리거.
ROW_NUMBER · RANK · DENSE_RANK — 순위 3종
-- 카테고리별 가격 순위
SELECT name, category_id, price,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY price DESC) AS rn,
RANK() OVER (PARTITION BY category_id ORDER BY price DESC) AS rk,
DENSE_RANK() OVER (PARTITION BY category_id ORDER BY price DESC) AS drk
FROM products;
-- 동점 처리 차이 (가격 10000, 10000, 8000, 5000)
-- ROW_NUMBER : 1, 2, 3, 4 ← 동점도 다른 번호
-- RANK : 1, 1, 3, 4 ← 동점은 같은 순위, 다음은 건너뜀
-- DENSE_RANK : 1, 1, 2, 3 ← 동점은 같은 순위, 다음은 안 건너뜀
실전 — 카테고리별 1위만 뽑기
-- "각 카테고리에서 가격 1위 상품"
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY price DESC) AS rn
FROM products
)
SELECT * FROM ranked WHERE rn = 1;
가장 자주 쓰는 패턴입니다. JOIN + GROUP BY 로 만들면 복잡해지는데, ROW_NUMBER + PARTITION 으로 깔끔하게.
LAG · LEAD — 이전/다음 행과 비교
-- 일별 매출의 전일 대비 증감
SELECT day,
revenue,
LAG(revenue, 1) OVER (ORDER BY day) AS prev,
revenue - LAG(revenue, 1) OVER (ORDER BY day) AS diff,
LEAD(revenue, 1) OVER (ORDER BY day) AS next
FROM daily_revenue
ORDER BY day;
-- 사용자별 이전 주문 시간차
SELECT user_id, created_at,
LAG(created_at) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_at,
created_at - LAG(created_at) OVER (PARTITION BY user_id ORDER BY created_at) AS gap
FROM orders;
LAG/LEAD 의 일상. "전일 대비", "이전 행과의 시간차", "다음 단계까지 남은 시간" — 분석 쿼리의 핵심 패턴. 옛날엔 self-join 으로 했지만 LAG 가 훨씬 깔끔합니다.
SUM/AVG OVER — 누적·이동 평균
-- 누적 매출
SELECT day, revenue,
SUM(revenue) OVER (ORDER BY day) AS running_total
FROM daily_revenue;
-- 7일 이동 평균
SELECT day, revenue,
AVG(revenue) OVER (
ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_7d_avg
FROM daily_revenue;
FIRST_VALUE · LAST_VALUE · NTH_VALUE
-- 각 카테고리의 최고 가격을 모든 행에 붙이기
SELECT name, price, category_id,
FIRST_VALUE(price) OVER (
PARTITION BY category_id ORDER BY price DESC
) AS category_top_price,
price * 100.0 / FIRST_VALUE(price) OVER (
PARTITION BY category_id ORDER BY price DESC
) AS pct_of_top
FROM products;
frame (RANGE/ROWS) — 윈도우의 범위 미세 조정
| 구문 | 의미 |
|---|---|
| ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | 처음부터 현재까지 (기본값 with ORDER BY) |
| ROWS BETWEEN 6 PRECEDING AND CURRENT ROW | 최근 7행 (이동 평균) |
| ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | 전체 윈도우 (= ORDER BY 없는 효과) |
| RANGE BETWEEN '1 day' PRECEDING AND CURRENT ROW | 값 기준(시간 등) |
흔한 함정 — LAST_VALUE. LAST_VALUE() OVER (ORDER BY x) 기본 프레임은 "처음~현재" 라서 LAST_VALUE 가 매번 현재 행 자신을 반환합니다. 실제 "전체 마지막" 을 원하면 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 명시.
WINDOW 절 — 재사용
-- 같은 윈도우를 여러 함수에 쓸 때
SELECT id, name, price,
ROW_NUMBER() OVER w AS rn,
SUM(price) OVER w AS running
FROM products
WINDOW w AS (PARTITION BY category_id ORDER BY price DESC);
윈도우 함수 vs GROUP BY — 한 표
| GROUP BY | 윈도우 함수 | |
|---|---|---|
| 행 수 | 줄어듦 | 그대로 |
| 통계 위치 | 결과 행 | 각 원본 행 옆에 |
| 순위·이전/다음 | 불가 | RANK/LAG/LEAD |
| 대표 용도 | 통계 집계 | 순위·누적·전일 대비 |
12편 — JSON·jsonb 다루기
->·->>·@> 연산자, jsonb_set, GIN 인덱스, 언제 jsonb 가 적절한가.
📚 PostgreSQL 배우기 교재
이전: 10편 서브쿼리·CTE · 현재: 11편 (SQL 기초) · 다음 → 12편 JSON·jsonb · 진행: 11/24
이전: 10편 서브쿼리·CTE · 현재: 11편 (SQL 기초) · 다음 → 12편 JSON·jsonb · 진행: 11/24