[SQL] Window Function / WITH CTE

ungnam·5일 전

윈도우 함수

윈도우 함수는 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;

처럼 윈도우 함수를 사용하면 직원별 행은 그대로 유지하면서 부서 평균을 각 행에 붙일 수 있다.

0. 기본 구조

함수(...) OVER (
    PARTITION BY 그룹 기준
    ORDER BY 정렬 기준
)

PARTITION BY

GROUP BY와 비슷하게 어떤 기준으로 그룹을 나눌 것인지 지정한다.

ORDER BY

각 Window 안에서 어떤 순서로 계산할지 결정한다.

1. 그룹별 한 행 뽑기 → ROW_NUMBER() ⭐

회원별 가장 최근 주문 1건을 출력하라.

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처럼 동률을 결정할 추가 정렬 기준을 함께 주는 것이 안전하다.

2.

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()를 먼저 의심하자.

2. 순위 → 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

3. 누적합 → 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()를 떠올리자.

4. 그룹 평균/최대/최소를 각 행과 비교

자기 부서 평균보다 연봉이 높은 직원을 구하라.

윈도우 함수로 부서 평균을 각 직원 행에 붙인다.

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

를 고려할 수 있다.

5. 이전 / 다음 행 비교 → 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를 떠올리자.

6. 주의점

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를 사용한다.


WITH CTE

CTE는 Common Table Expression의 약자로, 쿼리의 중간 결과에 이름을 붙여 이후 일반 테이블처럼 참조할 수 있게 하는 문법이다.

쉽게 말하면 복잡한 서브쿼리를 밖으로 빼서 이름을 붙인다고 생각하면 된다.

0. 기본 구조

WITH 이름 AS (
    SELECT ...
)

SELECT ...
FROM 이름;

1. 사용 예시

회원별 총 구매금액을 계산한 뒤 회원 이름과 함께 출력하라.

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;

2. CTE + Window Function ⭐

사용자별 가장 최근 주문 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처럼 본인이 읽기 편한 방식으로 사용하면 된다.

profile
꾸준함을 잃지 말자.

0개의 댓글