icon

안동민 개발노트

6장 : SQL 심화 — 집계와 윈도우 함수

윈도우 함수 심화


윈도우 함수의 진가는 순위 매기기를 넘어, 이전 행 참조, 누적합, 이동 평균 등 시계열 분석에서 발휘됩니다. 윈도우 함수는 GROUP BY처럼 행을 축약하지 않으면서 집계 결과를 사용할 수 있는 SQL의 핵심 고급 기능입니다.


LAG, LEAD — 이전/다음 행 참조

현재 행을 기준으로 이전(LAG) 또는 다음(LEAD) 행의 값을 참조합니다. 별도의 셀프 조인 없이 행 간 비교가 가능합니다.

LAG 기본 문법
LAG(컬럼, N, 기본값) OVER (
    [PARTITION BY 그룹컬럼]
    ORDER BY 정렬컬럼
)
-- N: 몇 행 전인지 (기본값 1)
-- 기본값: NULL 대신 사용할 값 (선택)
전월 매출 비교
SELECT month, revenue,
       LAG(revenue, 1) OVER (ORDER BY month) AS prev_month,
       revenue - LAG(revenue, 1) OVER (ORDER BY month) AS growth
FROM monthly_sales;

-- 결과:
-- 1월 | 1000 | NULL | NULL
-- 2월 | 1200 | 1000 | 200
-- 3월 | 900  | 1200 | -300
LEAD — 다음 행 참조
SELECT name, hire_date,
       LEAD(hire_date, 1) OVER (ORDER BY hire_date) AS next_hire,
       LEAD(hire_date, 1) OVER (ORDER BY hire_date) - hire_date AS gap_days
FROM employees;

-- 다음 입사까지 며칠 걸렸는지 계산
LAG/LEAD 실무 활용
-- 기본값 지정으로 NULL 방지
SELECT month, revenue,
       LAG(revenue, 1, 0) OVER (ORDER BY month) AS prev_month
FROM monthly_sales;

-- 2행 전 참조
SELECT date, price,
       LAG(price, 2) OVER (ORDER BY date) AS two_days_ago
FROM stock_prices;

-- 부서별 이전 사원 급여 비교
SELECT dept, name, salary,
       salary - LAG(salary) OVER (PARTITION BY dept ORDER BY salary DESC)
           AS diff_from_higher
FROM employees;
LAG/LEAD vs 셀프 조인 비교
셀프 조인 방식 (비효율)
  SELECT a.month, a.revenue, b.revenue AS prev
  FROM monthly_sales a
  LEFT JOIN monthly_sales b ON a.month = b.month + 1
  → 조인 비용 발생, 복잡한 조건

윈도우 함수 방식 (효율)
  SELECT month, revenue,
         LAG(revenue) OVER (ORDER BY month) AS prev
  FROM monthly_sales
  → 단일 테이블 스캔, 깔끔한 구문

SUM/AVG OVER — 누적합, 이동 평균

ORDER BY를 포함한 윈도우 함수에서 SUM, AVG 등 집계 함수를 사용하면, 기본적으로 처음부터 현재 행까지의 범위에 대해 집계합니다.

누적합
SELECT month, revenue,
       SUM(revenue) OVER (ORDER BY month) AS cumulative
FROM monthly_sales;

-- 결과:
-- 1월 | 1000 | 1000
-- 2월 | 1200 | 2200
-- 3월 | 900  | 3100
-- 4월 | 1500 | 4600
부서별 누적합
SELECT dept, month, revenue,
       SUM(revenue) OVER (
           PARTITION BY dept ORDER BY month
       ) AS dept_cumulative
FROM dept_sales;
-- 부서별로 독립적인 누적합 계산
이동 평균 (3개월)
SELECT month, revenue,
       AVG(revenue) OVER (
           ORDER BY month
           ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ) AS moving_avg_3
FROM monthly_sales;

-- 결과:
-- 1월 | 1000 | 1000.0       ← 1개만 (이전 행 없음)
-- 2월 | 1200 | 1100.0       ← 2개 평균
-- 3월 | 900  | 1033.3       ← 3개 평균 (1000+1200+900)/3
-- 4월 | 1500 | 1200.0       ← 3개 평균 (1200+900+1500)/3
전체 합 대비 비율
SELECT dept, revenue,
       SUM(revenue) OVER () AS total,
       ROUND(revenue * 100.0 / SUM(revenue) OVER (), 1) AS pct
FROM dept_revenue;

-- SUM() OVER ()는 ORDER BY 없이 전체 합계
-- 결과: 영업 | 5000 | 12000 | 41.7%
누적 카운트와 러닝 토탈
-- 부서별 입사 순서별 인원 누적
SELECT dept, name, hire_date,
       COUNT(*) OVER (PARTITION BY dept ORDER BY hire_date) AS running_count,
       SUM(salary) OVER (PARTITION BY dept ORDER BY hire_date) AS running_total
FROM employees;

ROWS BETWEEN 프레임 지정

윈도우 프레임은 현재 행을 기준으로 집계에 포함할 행의 범위를 지정합니다. 기본값은 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW입니다.

윈도우 프레임 범위
ROWS BETWEEN 시작 AND 끝

시작/끝 옵션:
  UNBOUNDED PRECEDING  — 파티션의 첫 행
  N PRECEDING          — N행 전
  CURRENT ROW          — 현재 행
  N FOLLOWING          — N행 후
  UNBOUNDED FOLLOWING  — 파티션의 마지막 행

시각화:
  행1  행2  행3  [행4]  행5  행6  행7
                  ↑ 현재 행
  ←──────── PRECEDING ──── FOLLOWING ────────→
  UNBOUNDED                          UNBOUNDED
  PRECEDING                          FOLLOWING
프레임 예시
-- 앞뒤 1행을 포함한 이동 평균 (3행 윈도우)
AVG(revenue) OVER (
    ORDER BY month
    ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
)

-- 처음부터 현재까지 누적합 (기본 프레임)
SUM(revenue) OVER (
    ORDER BY month
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)

-- 현재부터 끝까지의 합
SUM(revenue) OVER (
    ORDER BY month
    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
)
ROWS vs RANGE
ROWS: 물리적 행 수 기준
  → ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
  → 정확히 이전 2행 + 현재 행

RANGE: 논리적 값 범위 기준
  → RANGE BETWEEN 2 PRECEDING AND CURRENT ROW
  → ORDER BY 컬럼 값이 현재 - 2 이상인 행

차이가 나는 경우:
  ORDER BY 컬럼에 동일 값이 있을 때
  ROWS: 물리적 순서에 따라 정확히 N행
  RANGE: 같은 값의 행들을 모두 포함

Oracle, PostgreSQL은 RANGE 지원
MySQL 8.0+도 RANGE 지원
ROWS vs RANGE 차이 예시
-- 데이터: 1월=100, 2월=200, 2월=200, 3월=300

-- ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
-- 1행: 100 → SUM=100
-- 2행: 200 → SUM=300 (100+200)
-- 3행: 200 → SUM=400 (200+200)   ← 물리적 이전 1행
-- 4행: 300 → SUM=500 (200+300)

-- RANGE BETWEEN 1 PRECEDING AND CURRENT ROW  (ORDER BY month)
-- 1월: 100 → SUM=100
-- 2월: 200 → SUM=500 (100+200+200) ← 1월~2월 범위
-- 2월: 200 → SUM=500 (100+200+200) ← 같은 결과
-- 3월: 300 → SUM=700 (200+200+300) ← 2월~3월 범위

NTILE — N등분

NTILE(N)은 정렬된 결과를 N개의 동일 크기 그룹으로 분할합니다. 행 수가 N으로 정확히 나누어지지 않으면 앞 그룹부터 1행씩 더 배분합니다.

NTILE 기본
-- 급여 기준 4분위
SELECT name, salary,
       NTILE(4) OVER (ORDER BY salary DESC) AS quartile
FROM employees;

-- 결과 (8명 기준):
-- 김과장 | 9000 | 1 (상위 25%)
-- 이과장 | 8000 | 1
-- 박대리 | 7000 | 2
-- 최대리 | 6000 | 2
-- 정사원 | 5000 | 3
-- 한사원 | 4000 | 3
-- 오인턴 | 3000 | 4
-- 서인턴 | 2000 | 4 (하위 25%)
NTILE 활용
-- 10분위 (Decile) 분류
SELECT name, score,
       NTILE(10) OVER (ORDER BY score DESC) AS decile
FROM test_results;

-- 성과 등급 자동 부여
SELECT name, sales,
       CASE NTILE(5) OVER (ORDER BY sales DESC)
           WHEN 1 THEN 'S'
           WHEN 2 THEN 'A'
           WHEN 3 THEN 'B'
           WHEN 4 THEN 'C'
           WHEN 5 THEN 'D'
       END AS grade
FROM employee_sales;

FIRST_VALUE, LAST_VALUE

파티션 내 첫 번째 또는 마지막 행의 값을 가져옵니다.

FIRST_VALUE, LAST_VALUE
SELECT dept, name, salary,
       FIRST_VALUE(name) OVER (
           PARTITION BY dept ORDER BY salary DESC
       ) AS highest_earner,
       LAST_VALUE(name) OVER (
           PARTITION BY dept ORDER BY salary DESC
           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
       ) AS lowest_earner
FROM employees;

-- LAST_VALUE 주의: 기본 프레임이 CURRENT ROW까지이므로
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 명시 필수!
NTH_VALUE
-- Oracle, PostgreSQL, MySQL 8.0+ 지원
SELECT dept, name, salary,
       NTH_VALUE(name, 2) OVER (
           PARTITION BY dept ORDER BY salary DESC
           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
       ) AS second_highest
FROM employees;
-- 각 부서에서 두 번째로 높은 급여를 받는 사람

PERCENT_RANK, CUME_DIST

백분위 순위를 계산하는 함수입니다.

PERCENT_RANK
-- 0~1 사이 값: (순위 - 1) / (총 행 - 1)
SELECT name, salary,
       RANK() OVER (ORDER BY salary) AS rnk,
       ROUND(PERCENT_RANK() OVER (ORDER BY salary), 2) AS pct_rank
FROM employees;

-- 결과 (5명):
-- 최인턴 | 2000 | 1 | 0.00
-- 한사원 | 4000 | 2 | 0.25
-- 박대리 | 6000 | 3 | 0.50
-- 이과장 | 8000 | 4 | 0.75
-- 김부장 | 10000| 5 | 1.00
CUME_DIST
-- 누적 분포: 현재 행 이하의 비율
SELECT name, salary,
       ROUND(CUME_DIST() OVER (ORDER BY salary), 2) AS cum_dist
FROM employees;

-- 결과 (5명):
-- 최인턴 | 2000 | 0.20  ← 1/5
-- 한사원 | 4000 | 0.40  ← 2/5
-- 박대리 | 6000 | 0.60  ← 3/5
-- 이과장 | 8000 | 0.80  ← 4/5
-- 김부장 | 10000| 1.00  ← 5/5

윈도우 함수 실행 순서

윈도우 함수는 SQL 실행 순서에서 SELECT 절에서 실행되며, WHERE/GROUP BY/HAVING 이후에 처리됩니다. 따라서 WHERE에서 윈도우 함수 결과를 사용할 수 없습니다.

SQL 실행 순서와 윈도우 함수
1. FROM / JOIN     → 테이블 결합
2. WHERE           → 행 필터링
3. GROUP BY        → 그룹화
4. HAVING          → 그룹 필터링
5. SELECT          → 컬럼 선택 + 윈도우 함수 실행 ★
6. ORDER BY        → 정렬
7. LIMIT           → 행 수 제한

→ 윈도우 함수는 5단계에서 실행
→ WHERE에서 윈도우 함수 사용 불가
→ 필터링이 필요하면 서브쿼리나 CTE 사용
윈도우 함수 결과 필터링
-- 오류! WHERE에서 윈도우 함수 사용 불가
SELECT name, salary, RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
WHERE rnk <= 3;  -- ERROR!

-- 해결: 서브쿼리 또는 CTE 사용
WITH ranked AS (
    SELECT name, salary,
           RANK() OVER (ORDER BY salary DESC) AS rnk
    FROM employees
)
SELECT name, salary, rnk
FROM ranked
WHERE rnk <= 3;

복합 윈도우 함수 패턴

연속 증가 구간 찾기
-- 매출이 전월 대비 연속 증가한 구간
WITH growth AS (
    SELECT month, revenue,
           CASE WHEN revenue > LAG(revenue) OVER (ORDER BY month)
                THEN 1 ELSE 0 END AS is_growth
    FROM monthly_sales
),
grouped AS (
    SELECT month, revenue, is_growth,
           SUM(CASE WHEN is_growth = 0 THEN 1 ELSE 0 END)
               OVER (ORDER BY month) AS grp
    FROM growth
)
SELECT MIN(month) AS start_month, MAX(month) AS end_month,
       COUNT(*) AS streak_length
FROM grouped
WHERE is_growth = 1
GROUP BY grp;
행 간 차이를 이용한 이상치 탐지
SELECT date, value,
       value - AVG(value) OVER (
           ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS deviation,
       CASE WHEN ABS(value - AVG(value) OVER (
           ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       )) > 2 * STDDEV(value) OVER (
           ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) THEN 'OUTLIER' ELSE 'NORMAL' END AS status
FROM sensor_data;
누적 비율 (파레토 분석)
WITH product_sales AS (
    SELECT product, SUM(amount) AS total
    FROM sales
    GROUP BY product
)
SELECT product, total,
       SUM(total) OVER (ORDER BY total DESC) AS running_total,
       ROUND(SUM(total) OVER (ORDER BY total DESC) * 100.0
           / SUM(total) OVER (), 1) AS cumulative_pct
FROM product_sales
ORDER BY total DESC;
-- 상위 20% 상품이 매출의 80%를 차지하는지 확인 (파레토 법칙)

실습: 종합 매출 분석

종합 분석 쿼리
SELECT month, revenue,
       -- 전월 대비 성장률
       ROUND((revenue - LAG(revenue) OVER (ORDER BY month))
             / LAG(revenue) OVER (ORDER BY month) * 100, 1) AS growth_pct,
       -- 누적 매출
       SUM(revenue) OVER (ORDER BY month) AS cumulative,
       -- 3개월 이동 평균
       ROUND(AVG(revenue) OVER (
           ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       )) AS ma3,
       -- 전체 비중
       ROUND(revenue / SUM(revenue) OVER () * 100, 1) AS pct_of_total,
       -- 4분위
       NTILE(4) OVER (ORDER BY revenue) AS quartile
FROM monthly_sales;

DBMS별 윈도우 함수 지원

기능OracleMySQL 8.0+PostgreSQLSQL Server
ROW_NUMBER
RANK / DENSE_RANK
LAG / LEAD
NTILE
FIRST_VALUE / LAST_VALUE
NTH_VALUE
PERCENT_RANK
CUME_DIST
RANGE 프레임
GROUPS 프레임
주의사항
MySQL 5.7 이하: 윈도우 함수 미지원 → 서브쿼리로 대체
MySQL 8.0+: 대부분의 윈도우 함수 지원

Oracle: KEEP (DENSE_RANK FIRST/LAST) 등 추가 분석 함수 지원
PostgreSQL: FILTER 절 등 확장 기능 지원

성능 고려사항

윈도우 함수 성능 최적화
1. PARTITION BY에 인덱스 활용
   → PARTITION BY dept ORDER BY salary
   → dept 컬럼에 인덱스가 있으면 파티션 나누기가 빠름

2. ORDER BY 컬럼 인덱스
   → 정렬 비용 감소: ORDER BY 컬럼에 인덱스 존재 시 성능 향상

3. 불필요한 윈도우 함수 중복 제거
   → 동일 OVER 절을 여러 함수에서 사용하면
   → DBMS가 내부적으로 윈도우를 1회만 계산 (윈도우 공유)
   → 명시적으로 WINDOW 절을 사용하면 가독성도 향상

4. WINDOW 절 활용 (PostgreSQL, MySQL 8.0+)
   SELECT name, salary,
          SUM(salary) OVER w AS running_total,
          AVG(salary) OVER w AS running_avg
   FROM employees
   WINDOW w AS (ORDER BY hire_date);
   → 동일 윈도우 정의를 재사용

5. 대규모 데이터셋에서 주의
   → 파티션 크기가 매우 크면 메모리/정렬 비용 증가
   → 필요 시 WHERE로 범위를 먼저 제한

정리

윈도우 함수를 활용하면 셀프 조인이나 상관 서브쿼리 없이 행 간 비교, 누적 계산, 순위 분석을 효율적으로 처리할 수 있습니다. 핵심 포인트는 다음과 같습니다.

  • LAG/LEAD는 이전/다음 행 참조로 시계열 비교에 필수
  • SUM/AVG OVER로 누적합과 이동 평균을 한 번의 쿼리로 계산
  • ROWS BETWEEN으로 정확한 윈도우 프레임 제어
  • NTILE로 등급, PERCENT_RANK로 백분위 순위 계산
  • 윈도우 함수는 SELECT 단계에서 실행되므로 WHERE에서 사용 불가
  • LAST_VALUE 사용 시 프레임을 UNBOUNDED FOLLOWING까지 명시해야 정확한 결과
  • ROWS는 물리적 행, RANGE는 논리적 값 범위 기준으로 프레임을 결정

다음 장에서는 테이블의 구조를 올바르게 설계하는 데이터 모델링과 ERD를 다루겠습니다.

목차