윈도우 함수 기초
GROUP BY로 집계하면 개별 행이 사라지고 그룹당 하나의 행만 남습니다. 하지만 부서별 급여 순위처럼 집계 결과와 개별 행을 동시에 보고 싶을 때가 있습니다. 이것이 윈도우 함수의 존재 이유입니다.
윈도우 함수는 SQL:2003 표준에 도입되었으며, 현재 PostgreSQL, MySQL 8.0+, Oracle, SQL Server, SQLite 3.25+ 등 대부분의 주요 DBMS에서 지원합니다. 윈도우 함수를 잘 활용하면 복잡한 분석 계산을 더 간결하게 표현할 수 있습니다.
윈도우 함수란
윈도우 함수(Window Function)는 각 행에 대해 관련된 행들의 집합(윈도우)을 기반으로 계산을 수행하는 함수입니다. GROUP BY와 달리 행을 축소하지 않고 원래 행을 그대로 유지한 채 결과를 추가합니다.
OVER 절
윈도우 함수의 핵심은 OVER() 절입니다. OVER 절은 윈도우(행의 범위)를 정의하며, 그 안에서 PARTITION BY와 ORDER BY를 사용합니다.
함수(인자) OVER (
PARTITION BY 그룹_컬럼 -- 윈도우를 나누는 기준 (생략 시 전체)
ORDER BY 정렬_컬럼 -- 윈도우 내 정렬 순서
ROWS/RANGE BETWEEN ... -- 프레임 범위 (고급, 다음 절에서)
)PARTITION BY
PARTITION BY는 데이터를 그룹으로 나눕니다. GROUP BY와 비슷하지만 행을 축소하지 않습니다.
SELECT name, department, salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_avg
FROM employees;PARTITION BY를 생략하면 WHERE/GROUP BY/HAVING 등을 거친 결과 집합 전체가 하나의 윈도우가 됩니다.
SELECT name, salary,
AVG(salary) OVER () AS total_avg,
salary - AVG(salary) OVER () AS diff_from_total
FROM employees;ORDER BY
OVER 절 안의 ORDER BY는 윈도우 내에서 행의 순서를 지정합니다. 의미 있고 재현 가능한 순위에는 ORDER BY가 사실상 필수이며, 집계 함수에서는 누적 계산의 기준이 됩니다.
-- ORDER BY 없음: 전체 합계
SELECT name, salary,
SUM(salary) OVER (PARTITION BY department) AS dept_total
FROM employees;
-- ORDER BY 있음: 누적 합계 (Running Total)
SELECT name, salary,
SUM(salary) OVER (PARTITION BY department ORDER BY salary) AS running_total
FROM employees;이 차이는 매우 중요합니다. ORDER BY를 넣으면 많은 DBMS에서 기본 프레임이 현재 행까지의 누적 범위처럼 동작합니다. 특히 RANGE 기본값은 같은 정렬값(peer)을 함께 묶을 수 있으므로, 정확히 행 단위 누적을 원하면 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW를 명시하는 것이 안전합니다.
다중 PARTITION BY
여러 컬럼으로 파티션을 나눌 수 있습니다.
-- 부서 + 직급별 평균 급여
SELECT name, department, position, salary,
AVG(salary) OVER (
PARTITION BY department, position
) AS dept_pos_avg
FROM employees;윈도우 함수의 종류
윈도우 함수는 크게 세 가지로 분류됩니다.
ROW_NUMBER, RANK, DENSE_RANK
세 함수 모두 순위를 매기지만, 동일한 값(타이)이 있을 때 처리 방식이 다릅니다.
| 함수 | 동일 값 처리 | 100, 90, 90, 80 | 특징 |
|---|---|---|---|
| ROW_NUMBER | 고유 번호 | 1, 2, 3, 4 | 항상 연속, 타이 없음 |
| RANK | 건너뜀 | 1, 2, 2, 4 | 타이 후 순위 건너뜀 |
| DENSE_RANK | 안 건너뜀 | 1, 2, 2, 3 | 타이 후 다음 순위 |
SELECT name, department, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS drnk
FROM employees;위 예시는 동률 비교를 보여주기 위해 salary만 정렬 기준으로 사용했습니다. 실제 TOP-N처럼 정확히 어떤 행을 고를지 중요하다면 ROW_NUMBER()에는 ORDER BY salary DESC, employee_id처럼 고유한 보조 정렬 기준을 추가해야 결과가 재현 가능합니다.
언제 어떤 함수를 사용할까
NTILE
NTILE(N)은 행을 N개의 동일한 크기의 그룹(버킷)으로 나누고, 각 행에 그룹 번호를 부여합니다.
SELECT name, salary,
NTILE(4) OVER (ORDER BY salary) AS quartile
FROM employees;행 수가 N으로 나누어떨어지지 않으면 앞쪽 그룹에 1개씩 더 배분됩니다. 예를 들어 10개 행을 NTILE(3)으로 나누면 4, 3, 3개씩 배분됩니다.
또한 ORDER BY 방향에 따라 1번 버킷의 의미가 달라집니다. ORDER BY score DESC라면 높은 점수가 1번 버킷에 들어가고, 오름차순이면 낮은 값이 먼저 들어갑니다.
NTILE은 값의 범위를 같은 폭으로 나누는 함수가 아니라 정렬된 행의 위치를 나누는 함수입니다. 동점 행도 정렬 안정성이 부족하면 서로 다른 버킷에 들어갈 수 있습니다.
SELECT student_name, score,
CASE NTILE(5) OVER (ORDER BY score DESC)
WHEN 1 THEN 'A'
WHEN 2 THEN 'B'
WHEN 3 THEN 'C'
WHEN 4 THEN 'D'
WHEN 5 THEN 'F'
END AS grade
FROM exam_results;집계 함수를 윈도우 함수로 사용
SUM, AVG, COUNT, MAX, MIN 등 일반 집계 함수도 OVER 절과 함께 윈도우 함수로 사용할 수 있습니다.
SELECT name, department, salary,
-- 부서별 통계 (개별 행 유지)
COUNT(*) OVER (PARTITION BY department) AS dept_count,
SUM(salary) OVER (PARTITION BY department) AS dept_total,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
MAX(salary) OVER (PARTITION BY department) AS dept_max,
MIN(salary) OVER (PARTITION BY department) AS dept_min
FROM employees;비율 계산
윈도우 함수로 부서 내 비율, 전체 비율 등을 쉽게 계산할 수 있습니다.
SELECT name, department, salary,
-- 부서 내 급여 비율
ROUND(COALESCE(salary, 0) * 100.0 /
NULLIF(SUM(COALESCE(salary, 0)) OVER (PARTITION BY department), 0), 1)
AS dept_pct,
-- 전체 급여 비율
ROUND(COALESCE(salary, 0) * 100.0 /
NULLIF(SUM(COALESCE(salary, 0)) OVER (), 0), 1)
AS total_pct
FROM employees;비율 계산에서는 분모가 0이 될 수 있고, 급여가 NULL인 행을 제외할지 0으로 볼지도 업무 규칙입니다. 위 예시는 NULL 급여를 0으로 간주하고, 분모가 0이면 비율을 NULL로 남깁니다.
누적 합계 (Running Total)
ORDER BY를 추가하면 윈도우 시작부터 현재 행까지의 누적 계산이 됩니다.
SELECT order_date, order_id, amount,
SUM(amount) OVER (
ORDER BY order_date, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
AVG(amount) OVER (
ORDER BY order_date, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_avg
FROM daily_sales;동일한 order_date가 여러 행이면 기본 프레임에 따라 같은 날짜 행이 한꺼번에 묶일 수 있습니다. 그래서 날짜별 행 단위 누적이 필요하면 고유한 보조 정렬 기준과 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW를 함께 명시합니다.
실무 패턴: 그룹별 TOP-N
카테고리별 가장 비싼 상품 3개를 구하는 전형적인 패턴입니다.
SELECT * FROM (
SELECT p.*,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY price DESC, product_id
) AS rn
FROM products p
) ranked
WHERE rn <= 3;동점 가격이 있을 수 있는 컬럼만으로 정렬하면 어떤 행이 먼저 선택될지 DBMS 실행 계획에 따라 달라질 수 있습니다. 그래서 TOP-N 패턴에서는 고유한 보조 정렬 기준을 함께 두는 것이 좋습니다.
이 패턴은 다양한 비즈니스 시나리오에 적용됩니다.
-- 부서별 최근 입사 3명
SELECT * FROM (
SELECT emp.*,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY hire_date DESC, employee_id
) AS rn
FROM employees emp
) t WHERE rn <= 3;
-- 고객별 최대 금액 주문 1건
SELECT * FROM (
SELECT o.*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY total_amount DESC, order_id
) AS rn
FROM orders o
) t WHERE rn = 1;ROW_NUMBER vs RANK의 차이 (TOP-N에서)
동일한 값이 있을 때 결과가 달라질 수 있습니다.
-- ROW_NUMBER: 정확히 2개만 반환 (타이가 있어도)
SELECT * FROM (
SELECT p.*, ROW_NUMBER() OVER (ORDER BY price DESC, product_id) AS rn
FROM products p
) t WHERE rn <= 2;
-- 결과: 2행 (product_id로 동률 내 순서를 고정)
-- RANK: 동점자 모두 포함 (3개 이상 반환 가능)
SELECT * FROM (
SELECT p.*, RANK() OVER (ORDER BY price DESC) AS rnk
FROM products p
) t WHERE rnk <= 2;
-- 결과: 공동 2위가 있으면 3행 이상정확히 N개가 필요하면 ROW_NUMBER, 동점자를 모두 포함하려면 RANK를 사용합니다.
윈도우 함수의 실행 순서
논리적 처리 순서상 윈도우 함수는 WHERE/GROUP BY/HAVING 이후 SELECT 결과를 계산하는 단계에서 평가됩니다. 그래서 WHERE에서 윈도우 함수 결과나 SELECT 별칭을 직접 참조할 수 없습니다.
이 때문에 윈도우 함수의 결과를 WHERE 절에서 직접 사용할 수 없습니다.
-- 오류! WHERE에서 윈도우 함수 사용 불가
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
FROM employees
WHERE rn <= 3; -- ERROR!
-- 해결: 서브쿼리 또는 CTE 사용
SELECT * FROM (
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
FROM employees
) t
WHERE rn <= 3; -- 정상 동작!
-- CTE 사용
WITH ranked AS (
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
FROM employees
)
SELECT * FROM ranked WHERE rn <= 3;일부 DBMS는 QUALIFY처럼 윈도우 함수 결과를 바로 필터링하는 문법을 제공하지만, 범용적인 패턴은 서브쿼리나 CTE입니다.
반대로 SELECT 이후 단계인 ORDER BY에서는 윈도우 함수나 SELECT 별칭을 사용할 수 있는 DBMS가 많습니다. 하지만 WHERE, GROUP BY, HAVING에서 바로 쓰는 것은 일반적으로 허용되지 않습니다.
같은 윈도우를 재사용: WINDOW 절
같은 OVER 절을 여러 번 반복하면 코드가 길어집니다. 일부 DBMS(PostgreSQL, MySQL 8.0+, SQLite 등)는 WINDOW 절로 이름을 붙여 재사용할 수 있습니다. SQL Server처럼 지원하지 않거나, Oracle처럼 버전별 지원 범위를 확인해야 하는 DBMS도 있으므로 사용 전 확인이 필요합니다.
-- 중복되는 OVER 절
SELECT name, department, salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk,
SUM(salary) OVER (PARTITION BY department ORDER BY salary DESC) AS running
FROM employees;
-- WINDOW 절로 정리
SELECT name, department, salary,
ROW_NUMBER() OVER w AS rn,
RANK() OVER w AS rnk,
SUM(salary) OVER w AS running
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC);흔한 실수와 주의사항
실수 1: ORDER BY 누락으로 비결정적 결과
-- 위험: ORDER BY가 없으면 번호가 매번 달라질 수 있음
SELECT name, ROW_NUMBER() OVER () AS rn FROM employees;
-- 올바른 사용: 반드시 ORDER BY 지정
SELECT name, ROW_NUMBER() OVER (ORDER BY name) AS rn FROM employees;실수 2: PARTITION BY와 GROUP BY 혼동
-- GROUP BY 먼저 실행되고, 그 결과에 윈도우 함수 적용
SELECT department, COUNT(*) AS cnt,
RANK() OVER (ORDER BY COUNT(*) DESC) AS popularity_rank
FROM employees
GROUP BY department;
-- 윈도우 함수는 GROUP BY 이후의 결과 행에 대해 동작실수 3: 집계 함수 + ORDER BY의 의미 오해
-- SUM(...) OVER (PARTITION BY dept)
-- → 부서별 총합 (모든 행에 같은 값)
-- SUM(...) OVER (PARTITION BY dept ORDER BY salary)
-- → 부서 내에서 salary 순으로 누적합 (행마다 다른 값!)
-- 누적합이 아닌 전체합을 원하면 ORDER BY를 제거하거나
-- 프레임을 명시적으로 지정
SELECT name, salary,
SUM(salary) OVER (
PARTITION BY department
ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS dept_total
FROM employees;실수 4: NULL 정렬 순서
ORDER BY에서 NULL이 있으면 DBMS마다 기본 위치가 다릅니다. NULLS FIRST/LAST를 지원하지 않는 DBMS에서는 CASE나 COALESCE로 NULL의 정렬 우선순위를 직접 만드는 방식도 자주 사용합니다.
예를 들어 NULL을 항상 뒤로 보내려면 ORDER BY CASE WHEN salary IS NULL THEN 1 ELSE 0 END, salary DESC처럼 명시할 수 있습니다. 단, COALESCE(salary, 0)처럼 실제 값으로 치환하면 0과 NULL의 의미가 섞일 수 있으므로 보고 목적에 맞게 선택해야 합니다.
윈도우 함수와 성능
윈도우 함수는 PARTITION BY와 ORDER BY 조합에 따라 정렬이나 파티션 처리가 필요할 수 있어 대량 데이터에서 성능에 영향을 줄 수 있습니다.
-- 윈도우 함수 최적화를 위한 인덱스
CREATE INDEX idx_emp_dept_salary
ON employees (department, salary DESC);
-- 이 인덱스는 아래 쿼리에서 정렬 비용을 줄이는 데 도움될 수 있음
SELECT name, department, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk
FROM employees;인덱스가 있어도 DBMS가 항상 정렬을 생략하는 것은 아닙니다. WHERE로 대상 행을 먼저 줄일 수 있는지, PARTITION BY와 ORDER BY 순서가 인덱스와 맞는지, SELECT에 필요한 컬럼을 추가 조회해야 하는지까지 실행 계획으로 확인해야 합니다.
핵심 정리
다음 절에서는 이전/다음 행을 참조하는 LAG/LEAD, 프레임 절을 활용한 이동 평균 등 윈도우 함수 심화를 다루겠습니다.