본문 바로가기
TYLER SONGBlog
← Blog index
CS7 min read

Window Function: 행을 합치지 않고 파티션 단위로 계산하는 SQL 분석 함수

윈도우 함수(Window Function)는 GROUP BY처럼 행을 하나로 합치지 않고, 각 행을 유지한 채 관련된 행 집합(윈도우)을 대상으로 집계·순위·이전/다음 값 참조를 수행하는 SQL 기능이다. PARTITION BY, ORDER BY, 프레임(frame) 절로 계산 범위를 정의하며, 순위 산정, 누적합, 이동 평균, 전후 행 비교를 서브쿼리 없

By 송민성

1. 개념

윈도우 함수(Window Function)는 현재 행(current row)과 관련된 행들의 집합을 대상으로 값을 계산하되, 결과 행의 수를 줄이지 않는 SQL 함수다. 기본 형태는 다음과 같다.

sql
함수() OVER ( PARTITION BY 컬럼 ORDER BY 컬럼 ROWS | RANGE | GROUPS BETWEEN ... AND ... )
  • OVER 절: 이 함수가 윈도우 함수임을 나타내며 윈도우를 정의한다.
  • PARTITION BY: 행을 그룹으로 나눈다. 생략하면 전체 결과가 하나의 파티션이다.
  • ORDER BY: 파티션 내부의 정렬 순서를 정한다.
  • 프레임(frame): 현재 행을 기준으로 실제 계산에 쓰는 행의 범위다.

윈도우 함수는 세 부류로 나뉜다.

분류대표 함수
순위(ranking)ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK
값 참조(value)LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE
집계(aggregate)SUM, AVG, COUNT, MIN, MAX (OVER와 함께 사용)

2. 왜 사용하는가

GROUP BY는 그룹당 한 행만 남기므로 "각 주문과 그 고객의 총 주문액을 함께 보기" 같은 요구를 한 번에 처리할 수 없다. 이를 기존 방식으로 풀면 집계 서브쿼리를 만들어 다시 조인(self join)해야 한다.

윈도우 함수를 쓰면 다음이 가능하다.

  • 원본 행을 유지하면서 그룹 집계값을 같은 행에 붙인다.
  • 그룹별 상위 N개(Top-N per group)를 간단히 뽑는다.
  • 누적합, 이동 평균 같은 순서 의존 계산을 표현한다.
  • 이전 행·다음 행과의 차이를 자기 조인 없이 계산한다.

3. 동작 원리

논리적 실행 순서에서 윈도우 함수는 FROM, WHERE, GROUP BY, HAVING 이후에 계산되고, SELECT 목록 평가 단계에서 결과에 붙은 뒤 ORDER BY, LIMIT이 적용된다. 이 때문에 다음 규칙이 생긴다.

  • WHERE, GROUP BY, HAVING에서는 윈도우 함수를 직접 쓸 수 없다. 결과로 필터링하려면 서브쿼리나 CTE로 감싼다.
  • GROUP BY와 함께 쓰면 윈도우 함수는 그룹화가 끝난 결과 행을 대상으로 동작한다.

계산 과정은 개념적으로 다음과 같다.

  1. PARTITION BY 값이 같은 행끼리 묶는다.
  2. 각 파티션을 ORDER BY 기준으로 정렬한다.
  3. 각 행마다 프레임 범위를 정한다.
  4. 프레임 안의 행으로 함수를 계산해 현재 행에 붙인다.

프레임

프레임은 ROWS, RANGE, GROUPS 모드로 지정한다.

  • ROWS: 물리적 행 개수로 범위를 정한다.
  • RANGE: ORDER BY 값의 범위로 정한다. 같은 값(peer)을 가진 행은 모두 프레임에 포함된다.
  • GROUPS: 같은 값을 가진 peer 그룹 단위로 정한다. PostgreSQL 11부터 지원한다.

프레임을 명시하지 않았을 때의 기본값은 중요하다. OVER에 ORDER BY가 있으면 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, 없으면 파티션 전체가 프레임이다. ORDER BY가 있는 상태의 SUM이 누적합이 되는 이유다.

순위 함수 차이

점수가 100, 90, 90, 80일 때:

함수결과
ROW_NUMBER1, 2, 3, 4
RANK1, 2, 2, 4
DENSE_RANK1, 2, 2, 3

4. 코드 예제

실행 가능한 샘플 데이터부터 만든다.

sql
CREATE TABLE orders ( id serial PRIMARY KEY, customer_id int NOT NULL, ordered_at date NOT NULL, amount numeric(10, 2) NOT NULL ); INSERT INTO orders (customer_id, ordered_at, amount) VALUES (1, '2026-01-05', 100), (1, '2026-01-20', 250), (1, '2026-02-03', 250), (2, '2026-01-10', 80), (2, '2026-02-14', 120), (3, '2026-01-31', 300);

4-1. 원본 행 유지 + 그룹 집계

sql
SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total, ROUND(amount / SUM(amount) OVER (PARTITION BY customer_id) * 100, 1) AS pct_of_customer FROM orders ORDER BY customer_id, id;

4-2. 순위와 그룹별 Top-N

고객별로 금액이 큰 주문 2건만 뽑는다. 윈도우 함수 결과는 WHERE에서 쓸 수 없으므로 CTE로 감싼다.

sql
WITH ranked AS ( SELECT id, customer_id, amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC, id) AS rn, RANK() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rnk, DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS drnk FROM orders ) SELECT * FROM ranked WHERE rn <= 2 ORDER BY customer_id, rn;

4-3. 누적합과 이동 평균

sql
SELECT id, customer_id, ordered_at, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY ordered_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total, AVG(amount) OVER ( PARTITION BY customer_id ORDER BY ordered_at, id ROWS BETWEEN 1 PRECEDING AND CURRENT ROW ) AS moving_avg_2 FROM orders ORDER BY customer_id, ordered_at, id;

4-4. LAG/LEAD로 이전 행과 비교

sql
SELECT id, customer_id, ordered_at, amount, LAG(amount) OVER w AS prev_amount, amount - LAG(amount) OVER w AS diff, ordered_at - LAG(ordered_at) OVER w AS days_since_prev FROM orders WINDOW w AS (PARTITION BY customer_id ORDER BY ordered_at, id) ORDER BY customer_id, ordered_at, id;

WINDOW 절로 윈도우에 이름을 붙이면 같은 정의를 반복하지 않아도 된다.

4-5. LAST_VALUE의 프레임 함정

sql
SELECT id, customer_id, amount, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY ordered_at, id ) AS last_default_frame, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY ordered_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_full_frame FROM orders ORDER BY customer_id, ordered_at, id;

기본 프레임은 현재 행까지이므로 last_default_frame은 현재 행의 값과 같아진다. 파티션의 진짜 마지막 값은 프레임을 명시해야 얻는다.

5. 시간 복잡도 또는 성능 특성

윈도우 함수의 비용은 대부분 파티션 분할과 정렬에서 나온다.

  • 실행기는 PARTITION BY, ORDER BY 컬럼 기준으로 입력을 정렬하고, 이를 처리하는 WindowAgg 노드를 실행 계획에 둔다. 정렬 비용은 일반적으로 O(n log n)이다.
  • 이미 해당 순서로 정렬된 인덱스를 사용할 수 있으면 별도 정렬을 생략할 수 있다. 예를 들어 (customer_id, ordered_at) 인덱스가 도움이 될 수 있다.
  • 정렬 키가 다른 윈도우 정의가 여러 개이면 정렬이 그만큼 반복될 수 있다. 가능하면 같은 PARTITION BY/ORDER BY를 공유하도록 맞춘다.
  • 프레임 크기가 큰 집계는 PostgreSQL이 이동 프레임 집계를 증분 방식으로 처리하는 경우가 많지만, 함수에 따라 달라진다. 실제 비용은 EXPLAIN (ANALYZE, BUFFERS)로 확인한다.
  • 정렬 데이터가 work_mem을 넘으면 디스크로 넘어가 느려진다.
sql
EXPLAIN (ANALYZE, BUFFERS) SELECT id, SUM(amount) OVER (PARTITION BY customer_id ORDER BY ordered_at, id) FROM orders;

6. 실무 사용 사례

  • 그룹별 최신 1건 조회: ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)로 순번을 매기고 rn = 1만 선택한다. 중복 제거(dedup)에도 같은 패턴을 쓴다.
  • 매출 리포트: 월별 누적 매출, 전월 대비 증감(LAG), 전체 대비 비중.
  • 랭킹 화면: 리더보드, 카테고리별 인기 상품 Top-N.
  • 세션화(sessionization): 이벤트 간 시간 차를 LAG로 구하고, 일정 시간 이상 벌어지면 새 세션으로 간주한다.
  • 이동 평균: 최근 7일 평균 지표 등 시계열 평활화.
  • 연속 구간 탐지: 날짜 - ROW_NUMBER()가 같으면 연속된 날짜라는 성질로 연속 로그인 일수를 계산한다.

7. 주의할 점

  • WHERE에서 직접 쓸 수 없다. CTE나 서브쿼리로 감싸야 한다. (참고로 QUALIFY 절은 Snowflake 등 일부 DBMS의 기능이며 PostgreSQL에는 없다.)
  • 동점 처리: ROW_NUMBER는 동점이어도 순번이 다르게 붙지만, 어떤 행이 먼저인지는 보장되지 않는다. 결과를 결정적(deterministic)으로 만들려면 ORDER BY에 유니크한 컬럼(예: id)을 추가한다.
  • 기본 프레임 때문에 LAST_VALUE, NTH_VALUE가 기대와 다르게 나온다. 필요하면 프레임을 명시한다.
  • RANGE 기본 프레임은 같은 값의 행을 한꺼번에 포함한다. ORDER BY가 유니크하지 않으면 누적합에서 동점 행들이 같은 값을 갖는다. 행 단위 누적이 필요하면 ROWS를 쓴다.
  • ORDER BY 정렬 방향과 NULL 위치(NULLS FIRST/LAST)에 따라 LAG, 순위 결과가 달라진다.
  • 윈도우 함수가 GROUP BY 결과 위에서 계산된다는 점을 혼동하지 않는다. SUM(SUM(x)) OVER (...)처럼 집계 위에 윈도우 집계를 쓸 수 있다.
  • 대용량 테이블에서는 정렬 비용이 커지므로 WHERE로 먼저 범위를 줄이고, 계획을 확인한다.

8. 핵심 정리

  • 윈도우 함수는 행을 합치지 않고 OVER 절로 정의한 윈도우 위에서 계산한다.
  • PARTITION BY는 그룹 나누기, ORDER BY는 파티션 내 정렬, 프레임은 계산 범위를 정한다.
  • 순위 함수는 ROW_NUMBER, RANK, DENSE_RANK의 동점 처리 방식이 다르다.
  • ORDER BY가 있을 때 기본 프레임은 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW이며, LAST_VALUE 오류의 원인이 된다.
  • 윈도우 함수 결과로 필터링하려면 CTE나 서브쿼리로 감싼다.
  • 비용은 주로 정렬에서 발생하므로 인덱스, 윈도우 정의 공유, EXPLAIN으로 관리한다.