윈도우 함수는 GROUP BY처럼 그룹별 계산을 하지만, 원본 행을 없애지 않고 계산 결과를 옆 컬럼으로 붙인다는 점이 핵심이다.
예를 들어 부서별 평균 연봉을 구할 때
SELECT dept_id, AVG(salary)
FROM employee
GROUP BY dept_id;
를 사용하면 결과가 부서별 1행으로 줄어든다.
반면
SELECT
employee_id,
dept_id,
salary,
AVG(salary) OVER (
PARTITION BY dept_id
) AS dept_avg
FROM employee;
처럼 윈도우 함수를 사용하면 직원별 행은 그대로 유지하면서 부서 평균을 각 행에 붙일 수 있다.
함수(...) OVER (
PARTITION BY 그룹 기준
ORDER BY 정렬 기준
)
PARTITION BYGROUP BY와 비슷하게 어떤 기준으로 그룹을 나눌 것인지 지정한다.
ORDER BY각 Window 안에서 어떤 순서로 계산할지 결정한다.
ROW_NUMBER() ⭐회원별 가장 최근 주문 1건을 출력하라.
회원마다 주문을 최신순으로 정렬하고 순번을 붙인다.
SELECT
o.*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date DESC, order_id DESC
) AS rn
FROM orders o;
각 회원별 결과가 대략 다음과 같이 나온다.
user_id order_date rn
1 2026-10-03 1
1 2026-09-20 2
1 2026-08-10 3
2 2026-10-01 1
2 2026-09-01 2
order_date가 같은 행이 존재할 수 있다면order_id처럼 동률을 결정할 추가 정렬 기준을 함께 주는 것이 안전하다.
rn = 1만 가져오면 된다.
SELECT *
FROM (
SELECT
o.*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date DESC, order_id DESC
) AS rn
FROM orders o
) x
WHERE rn = 1;
문장에서 보이는 표현이
회원별 최신 1건
카테고리별 최고가 상품 하나
그룹별 첫 번째 / 마지막 데이터
그룹마다 하나씩 선택
이라면 ROW_NUMBER()를 먼저 의심하자.
RANK() / DENSE_RANK()ROW_NUMBER()동점이어도 무조건 서로 다른 번호를 준다.
score rank
100 1
100 2
90 3
RANK()동점은 같은 순위이며, 다음 순위를 건너뛴다.
score rank
100 1
100 1
90 3
DENSE_RANK()동점은 같은 순위이지만, 다음 순위를 건너뛰지 않는다.
score rank
100 1
100 1
90 2
SUM() OVER()회원별 누적 구매 금액을 구하라.
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS cumulative_amount
FROM orders;
결과:
amount cumulative_amount
100 100
200 300
50 350
문장에서
누적 합계
지금까지의 총합
날짜순 누적 금액
같은 표현이 보이면 SUM() OVER()를 떠올리자.
자기 부서 평균보다 연봉이 높은 직원을 구하라.
윈도우 함수로 부서 평균을 각 직원 행에 붙인다.
SELECT *
FROM (
SELECT
e.*,
AVG(salary) OVER (
PARTITION BY dept_id
) AS dept_avg
FROM employee e
) x
WHERE salary > dept_avg;
상관 서브쿼리로도 풀 수 있다.
SELECT *
FROM employee e
WHERE salary > (
SELECT AVG(e2.salary)
FROM employee e2
WHERE e2.dept_id = e.dept_id
);
즉
행마다 자기 그룹 평균/최대/최소가 필요
→ Window Function 또는 Correlated Subquery
를 고려할 수 있다.
LAG() / LEAD()LAG(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
)
LEAD(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
)
이전 주문과 현재 주문 금액의 차이를 구하라.
SELECT
user_id,
order_date,
amount,
amount - LAG(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS diff
FROM orders;
문장에서
이전 거래와 비교
직전 값과의 차이
다음 데이터와 비교
같은 표현이 나오면 LAG / LEAD를 떠올리자.
Window Function 결과는 보통 같은 SELECT문의 WHERE에서 바로 사용할 수 없다.
다음은 안 된다.
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date DESC
) AS rn
FROM orders
WHERE rn = 1;
WHERE가 Window Function 계산보다 먼저 처리되기 때문이다.
그래서
SELECT *
FROM (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date DESC
) AS rn
FROM orders
) x
WHERE rn = 1;
처럼 서브쿼리로 감싸거나, WITH CTE를 사용한다.
CTE는 Common Table Expression의 약자로, 쿼리의 중간 결과에 이름을 붙여 이후 일반 테이블처럼 참조할 수 있게 하는 문법이다.
쉽게 말하면 복잡한 서브쿼리를 밖으로 빼서 이름을 붙인다고 생각하면 된다.
WITH 이름 AS (
SELECT ...
)
SELECT ...
FROM 이름;
회원별 총 구매금액을 계산한 뒤 회원 이름과 함께 출력하라.
WITH user_total AS (
SELECT
user_id,
SUM(amount) AS total
FROM orders
GROUP BY user_id
)
SELECT
u.user_id,
u.name,
t.total
FROM users u
JOIN user_total t
ON u.user_id = t.user_id;
머릿속으로
user_total = 회원별 총 구매금액 테이블
이라고 이름 붙이고 나면 이후에는 일반 테이블처럼 JOIN하면 된다.
참고로 CTE는 여러 개 정의할 수도 있다.
WITH
user_total AS (
SELECT
user_id,
SUM(amount) AS total
FROM orders
GROUP BY user_id
),
high_users AS (
SELECT user_id
FROM user_total
WHERE total >= 100000
)
SELECT u.*
FROM users u
JOIN high_users h
ON u.user_id = h.user_id;
사용자별 가장 최근 주문 1건을 구하라.
WITH ranked AS (
SELECT
o.*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date DESC, order_id DESC
) AS rn
FROM orders o
)
SELECT *
FROM ranked
WHERE rn = 1;
참고로 서브쿼리 버전은 다음과 같다.
SELECT *
FROM (
SELECT
o.*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_date DESC, order_id DESC
) AS rn
FROM orders o
) x
WHERE rn = 1;
결과는 같기 때문에 단순하면 서브쿼리, 복잡해지면 CTE처럼 본인이 읽기 편한 방식으로 사용하면 된다.