SQL에서 GROUP BY를 쓰면 여러 row가 하나의 그룹으로 접힌다. 예를 들어 고객별 매출 합계를 구하면 고객마다 하나의 row만 나온다.

반면 window function은 원래 결과 row를 그대로 유지한다. 그리고 각 row에 대해 “이 row가 속한 문맥에서 계산한 값”을 옆에 붙여준다.

Oracle 문서는 window function을 다음처럼 설명한다.

A window function combines aggregate, ranking, or analytic functions with an over-clause for windowed calculations.

즉 window function은 크게 보면 다음 구조다. (Oracle Docs)

function(...) OVER (
  PARTITION BY ...
  ORDER BY ...
  ROWS/RANGE ...
)

window function을 이해할 때 중요한 질문은 세 가지다.

1. 어떤 row들과 함께 계산할 것인가?
2. 그 row들을 어떤 순서로 볼 것인가?
3. 현재 row를 기준으로 그중 어느 범위까지 볼 것인가?

각각 PARTITION BY, ORDER BY, window frame에 대응한다.


1. Window function은 row를 줄이지 않는다

일반적인 aggregate query는 row를 줄인다.

SELECT cust_id, AVG(amount_sold)
FROM sales
GROUP BY cust_id;

이 쿼리는 cust_id별 평균을 계산한다. 결과는 고객별로 하나의 row만 남는다. 원래 sales 테이블에 있던 개별 판매 row들은 결과에서 사라진다.

하지만 같은 aggregate function을 window function으로 쓰면 다르다.

SELECT
  cust_id,
  amount_sold,
  AVG(amount_sold) OVER (
    PARTITION BY cust_id
  ) AS avg_amount_by_customer
FROM sales;

이 경우 각 판매 row는 그대로 남는다. 대신 같은 cust_id를 가진 row들의 평균값이 각 row에 반복해서 붙는다.

즉 이렇게 볼 수 있다.

GROUP BY
  → 여러 row를 하나의 row로 접는다.

Aggregate window function
  → row를 접지 않고, 접었을 때의 값을 각 row에 붙인다.

이 차이가 window function의 출발점이다.


2. Oracle 문서의 세 가지 분류

Oracle Analytics 문서는 window function을 크게 세 가지로 나눈다. (Oracle Docs)

1. aggregate function
2. ranking function
3. analytic function

이 분류가 SQL 전체에서 언제나 보편적인 용어라고 보기는 어렵다. 특히 “analytic function”이라는 말은 넓게는 window function 전체를 가리키는 말로도 쓰인다. 하지만 이 Oracle 문서에서는 LAG, LEAD처럼 이전/다음 row를 참조하는 함수를 좁은 의미의 analytic function으로 분류한다.

따라서 여기서는 Oracle 문서의 분류를 기준으로 정리한다.


3. Aggregate function: 그룹 계산 결과를 각 row에 붙인다

aggregate function은 SUM, AVG, MIN, MAX, COUNT처럼 여러 row를 모아 하나의 값을 계산하는 함수다.

일반적으로는 GROUP BY와 함께 쓴다.

SELECT
  cust_id,
  SUM(amount_sold) AS total_amount
FROM sales
GROUP BY cust_id;

하지만 window function으로 쓰면 row가 줄어들지 않는다.

SELECT
  cust_id,
  time_id,
  amount_sold,
  SUM(amount_sold) OVER (
    PARTITION BY cust_id
  ) AS total_amount_by_customer
FROM sales;

이 결과는 각 판매 row를 유지하면서, 같은 고객의 전체 매출 합계를 옆에 붙인다.

개념적으로는 이런 느낌이다.

cust_id | amount | total_by_customer
--------+--------+------------------
A       | 100    | 600
A       | 200    | 600
A       | 300    | 600
B       |  50    | 120
B       |  70    | 120

GROUP BY였다면 A는 한 row, B도 한 row만 나왔을 것이다. 하지만 window function은 원래 row를 그대로 유지한다.


4. Ranking function: partition 안에서 순위를 매긴다

ranking function은 partition 안에서 row의 순위를 계산한다.

Oracle 문서 기준으로 ranking function에는 다음 함수들이 포함된다. (Oracle Docs)

ROW_NUMBER()
RANK()
DENSE_RANK()
PERCENT_RANK()

예를 들어 고객별로 매출액이 큰 순서대로 번호를 붙이고 싶다면 다음처럼 쓸 수 있다.

SELECT
  cust_id,
  amount_sold,
  ROW_NUMBER() OVER (
    PARTITION BY cust_id
    ORDER BY amount_sold DESC
  ) AS row_no
FROM sales;

여기서 중요한 점은 ranking function에는 ORDER BY가 사실상 필수라는 것이다.

왜냐하면 순위는 순서가 있어야 계산할 수 있기 때문이다. “몇 번째인가?”라는 질문은 “무엇을 기준으로 정렬했을 때 몇 번째인가?”라는 질문을 포함한다.

RANK()DENSE_RANK()는 동점 처리 방식이 다르다.

amount | RANK | DENSE_RANK
-------+------+------------
100    | 1    | 1
90     | 2    | 2
90     | 2    | 2
80     | 4    | 3

RANK()는 동점 뒤에 gap이 생긴다. DENSE_RANK()는 gap 없이 다음 순위를 붙인다. ROW_NUMBER()는 동점 여부와 상관없이 각 row에 고유한 번호를 붙인다.


5. Analytic function: 이전 row, 다음 row를 참조한다

Oracle 문서에서 analytic function으로 분류한 대표적인 함수는 LAGLEAD다. (Oracle Docs)

LAG()
LEAD()

LAG는 현재 row보다 앞에 있는 row의 값을 가져온다. LEAD는 현재 row보다 뒤에 있는 row의 값을 가져온다.

예를 들어 고객별 매출 row를 시간순으로 정렬한 뒤, 이전 매출액을 함께 보고 싶다면 다음처럼 쓸 수 있다.

SELECT
  cust_id,
  time_id,
  amount_sold,
  LAG(amount_sold, 1, 0) OVER (
    PARTITION BY cust_id
    ORDER BY time_id
  ) AS prev_amount
FROM sales;

이 함수는 현재 row를 기준으로 바로 이전 row의 amount_sold 값을 가져온다. 이전 row가 없으면 기본값으로 0을 사용한다.

결과는 이런 느낌이다.

cust_id | time_id    | amount | prev_amount
--------+------------+--------+------------
A       | 2026-01-01 | 100    | 0
A       | 2026-01-02 | 150    | 100
A       | 2026-01-03 | 130    | 150

이 함수 역시 ORDER BY가 중요하다. 이전 row와 다음 row는 정렬 순서가 있어야 정의될 수 있기 때문이다.


6. OVER clause: window function의 진짜 핵심

window function에서 진짜 중요한 부분은 함수 이름보다 OVER 절이다.

Oracle 문서의 OVER 절은 다음 요소를 가진다. (Oracle Docs)

OVER (
  [PARTITION BY ...]
  [ORDER BY ...]
  [frame_clause]
)

각 요소의 역할은 다르다.

PARTITION BY
  → row들을 큰 그룹으로 나눈다.

ORDER BY
  → partition 안에서 row들의 순서를 정한다.

frame_clause
  → 현재 row를 기준으로 실제 계산에 사용할 범위를 정한다.

여기서 PARTITION BYframe을 구분하는 것이 중요하다.

PARTITION BY는 큰 울타리다. frame은 그 울타리 안에서 현재 row를 기준으로 실제 계산에 쓰는 부분집합이다.


7. Window frame: partition 안의 실제 계산 범위

예를 들어 고객 A의 판매 row가 100개 있다고 하자.

PARTITION BY cust_id

이렇게 하면 고객 A의 row 100개가 하나의 partition이 된다.

그런데 매번 100개 전체를 계산에 쓰고 싶은 것은 아닐 수 있다. 누적합을 구하고 싶다면 “처음부터 현재 row까지”만 보면 된다.

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

이동 평균을 구하고 싶다면 “현재 row 앞뒤 한 row씩”만 보면 된다.

ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING

이처럼 frame은 partition 안에서 현재 row를 기준으로 실제 계산 대상이 되는 범위를 정한다.

Oracle 문서의 frame clause는 크게 다음 구조다. (Oracle Docs)

ROWS frame_start

ROWS BETWEEN frame_start AND frame_end

RANGE frame_start

RANGE BETWEEN frame_start AND frame_end

즉 먼저 ROWS 또는 RANGE를 고르고, 그다음 시작점만 지정하거나 시작점과 끝점을 모두 지정한다.


8. Frame start와 frame end

frame의 시작점은 다음 중 하나가 될 수 있다.

UNBOUNDED PRECEDING
n PRECEDING
CURRENT ROW

frame의 끝점은 다음 중 하나가 될 수 있다.

CURRENT ROW
n FOLLOWING
UNBOUNDED FOLLOWING

예를 들어 다음 frame은 partition의 첫 row부터 현재 row까지를 의미한다.

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

누적합에 자주 쓰인다.

SELECT
  cust_id,
  time_id,
  amount_sold,
  SUM(amount_sold) OVER (
    PARTITION BY cust_id
    ORDER BY time_id
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS running_total
FROM sales;

다음 frame은 현재 row부터 partition의 마지막 row까지다.

ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING

다음 frame은 현재 row 주변 3개 row다.

ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING

그리고 다음 frame은 partition 전체다.

ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING

9. ROWS와 RANGE의 차이

frame을 이해할 때 가장 헷갈리는 지점이 ROWSRANGE의 차이다.

간단히 말하면 다음과 같다.

ROWS
  → 물리적인 row 개수 기준

RANGE
  → ORDER BY 값의 범위 기준

예를 들어 다음은 현재 row와 바로 앞 row 하나를 포함한다.

ROWS BETWEEN 1 PRECEDING AND CURRENT ROW

반면 다음은 현재 row의 날짜를 기준으로 하루 전부터 현재 날짜까지를 포함한다.

RANGE BETWEEN INTERVAL '1' DAY PRECEDING AND CURRENT ROW

예제로 보면 차이가 더 분명하다.

WITH sales(dt, amount) AS (
  SELECT DATE '2026-01-01', 100 FROM dual UNION ALL
  SELECT DATE '2026-01-02', 200 FROM dual UNION ALL
  SELECT DATE '2026-01-03', 300 FROM dual UNION ALL
  SELECT DATE '2026-01-10', 400 FROM dual
)
SELECT
  dt,
  amount,
  SUM(amount) OVER (
    ORDER BY dt
    ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
  ) AS sum_by_rows,
  SUM(amount) OVER (
    ORDER BY dt
    RANGE BETWEEN INTERVAL '1' DAY PRECEDING AND CURRENT ROW
  ) AS sum_by_range
FROM sales
ORDER BY dt;

개념적인 결과는 다음과 같다.

dt          amount   sum_by_rows   sum_by_range
----------  ------   -----------   ------------
2026-01-01     100           100            100
2026-01-02     200           300            300
2026-01-03     300           500            500
2026-01-10     400           700            400

마지막 row를 보면 차이가 드러난다.

ROWS BETWEEN 1 PRECEDING AND CURRENT ROW는 물리적으로 바로 앞 row 하나를 포함한다. 그래서 2026-01-10에서는 바로 앞 row인 2026-01-03과 현재 row인 2026-01-10을 더한다. 결과는 300 + 400 = 700이다.

반면 RANGE BETWEEN INTERVAL '1' DAY PRECEDING AND CURRENT ROW는 날짜 값 기준이다. 2026-01-10 기준 하루 전은 2026-01-09다. 그런데 2026-01-09부터 2026-01-10 사이에는 현재 row밖에 없다. 그래서 결과는 400이다.

따라서 다음처럼 구분할 수 있다.

ROWS
  → 앞의 N개 row, 뒤의 N개 row처럼 물리적 위치가 중요할 때

RANGE
  → 최근 N일, 현재 값 기준 ±10처럼 ORDER BY 값의 범위가 중요할 때

10. RANGE CURRENT ROW의 함정: 같은 ORDER BY 값은 함께 묶일 수 있다

RANGE는 row 개수가 아니라 값의 범위를 기준으로 한다. 그래서 같은 ORDER BY 값을 가진 row들이 있으면 함께 frame에 들어올 수 있다.

예를 들어 다음과 같은 데이터가 있다고 하자.

dt          amount
----------  ------
2026-01-01  100
2026-01-02  200
2026-01-02  300
2026-01-03  400

다음 frame을 사용하면:

RANGE BETWEEN CURRENT ROW AND CURRENT ROW

현재 row 하나만 보는 것처럼 보이지만, 실제로는 같은 ORDER BY dt 값을 가진 row들이 함께 포함될 수 있다.

2026-01-02 row에서는 같은 날짜를 가진 두 row가 모두 frame에 들어올 수 있다.

반면 다음은 물리적인 현재 row 하나만 본다.

ROWS BETWEEN CURRENT ROW AND CURRENT ROW

그래서 ROWS CURRENT ROWRANGE CURRENT ROW는 같은 의미가 아니다.

이 차이는 중복된 sort key가 있을 때 중요하다.


11. Frame을 생략하면 항상 partition 전체일까?

주의할 점이 있다.

직관적으로는 frame을 생략하면 partition 전체가 계산 대상일 것 같지만, 실제 SQL에서는 ORDER BY가 있느냐 없느냐에 따라 기본 frame이 달라질 수 있다.

예를 들어 다음처럼 ORDER BY 없이 aggregate window function을 쓰면 partition 전체를 대상으로 계산하는 것으로 이해하기 쉽다.

SUM(amount_sold) OVER (
  PARTITION BY cust_id
)

이 경우 같은 고객의 전체 합계가 각 row에 붙는다.

하지만 ORDER BY가 들어가면 이야기가 달라질 수 있다.

SUM(amount_sold) OVER (
  PARTITION BY cust_id
  ORDER BY time_id
)

이런 쿼리는 많은 DB에서 partition 전체 합계가 아니라 누적합처럼 동작할 수 있다. 기본 frame이 “처음부터 현재 row까지”로 잡히는 경우가 있기 때문이다.

그래서 window function을 쓸 때는 frame을 명시하는 습관이 좋다.

전체 partition을 보고 싶다면:

ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING

누적값을 보고 싶다면:

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

이동 범위를 보고 싶다면:

ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING

이렇게 의도를 코드에 직접 드러내는 것이 안전하다.


12. Window function을 한 문장으로 정리하면

window function은 현재 row를 중심으로 주변 문맥을 계산하는 기능이다.

하지만 여기서 중요한 점은 현재 row가 사라지지 않는다는 것이다.

GROUP BY
  → row들을 그룹으로 접어서 결과 row를 만든다.

Window function
  → row를 유지한 채, 각 row에 계산 결과를 붙인다.

그리고 그 “주변 문맥”은 다음 세 가지로 결정된다.

PARTITION BY
  → 어떤 row들과 같은 그룹인가?

ORDER BY
  → 그 그룹 안에서 어떤 순서인가?

FRAME
  → 현재 row 기준으로 어디부터 어디까지 볼 것인가?

13. MATCH_RECOGNIZE와 비교하면 더 선명해지는 지점

이 관점은 MATCH_RECOGNIZE를 이해할 때도 도움이 된다.

일반적인 window function은 항상 현재 output row가 있다. 그리고 그 row에 연결된 window를 계산한다.

즉 기본 구조는 다음과 같다.

현재 row 하나
  + 그 row가 속한 partition
  + 그 row 기준의 frame
  → 계산 결과를 현재 row에 붙임

반면 row pattern recognition은 여러 row가 하나의 match를 만들 수 있다.

여러 input row
  → 하나의 pattern match
  → 그 match를 어떻게 output으로 만들 것인가?

그래서 MATCH_RECOGNIZE에서는 ONE ROW PER MATCH, ALL ROWS PER MATCH 같은 개념이 중요해진다. match 하나를 결과 row 하나로 낼 것인지, match에 포함된 row들을 모두 낼 것인지가 별도의 문제가 되기 때문이다.

반대로 일반 window function에서는 이런 개념이 자연스럽지 않다. 이미 기준이 되는 output row가 있고, window function은 그 row에 값을 붙이는 방식이기 때문이다.


마무리

window function을 어렵게 만드는 이유는 함수 자체보다 OVER 절 때문이다.

SUM, AVG, RANK, LAG 같은 함수 이름만 보면 각각은 어렵지 않다. 하지만 PARTITION BY, ORDER BY, ROWS, RANGE, UNBOUNDED PRECEDING, CURRENT ROW, UNBOUNDED FOLLOWING이 함께 나오면 갑자기 복잡해진다.

그래도 핵심은 단순하다.

Window function은 row를 줄이지 않는다.
각 row에 대해, 그 row가 속한 문맥의 값을 계산해서 붙인다.

그리고 그 문맥은 다음 세 단계로 정해진다.

PARTITION BY  : 누구와 함께 볼 것인가
ORDER BY      : 어떤 순서로 볼 것인가
FRAME         : 현재 row 기준 어디까지 볼 것인가

이 구조만 잡고 나면 window function은 훨씬 덜 헷갈린다. 특히 ROWSRANGE의 차이를 이해하면 window frame을 훨씬 명확하게 사용할 수 있다.