안동민 개발노트 아이콘

안동민 개발노트

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

윈도우 함수 심화

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


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

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

LAG 기본 문법
LAG(컬럼, N, 기본값) OVER (
    [PARTITION BY 그룹컬럼]
    ORDER BY 정렬컬럼
)
-- N: 몇 행 전인지 (기본값 1)
-- 기본값: NULL 대신 사용할 값 (선택)

LAG/LEAD의 이전/다음은 프레임보다 PARTITION BYORDER BY가 만든 순서에 의해 결정됩니다. ORDER BY 컬럼이 유일하지 않으면 같은 값 사이의 순서가 흔들릴 수 있으므로 필요하면 고유한 보조 정렬 기준을 함께 둡니다.

전월 매출 비교
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;

-- 다음 입사까지 며칠 걸렸는지 계산

날짜 차이 계산 문법은 DBMS마다 다릅니다. PostgreSQL/Oracle은 날짜 뺄셈을 활용할 수 있지만, MySQL이나 SQL Server에서는 DATEDIFF 계열 함수를 사용하는 경우가 많습니다.

LAG/LEAD 실무 활용
-- 경계 행 기본값 지정
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(revenue, 1, 0)의 기본값은 "이전 행이 없을 때" 쓰입니다. 이전 행은 존재하지만 그 행의 revenue 자체가 NULL이면 일반적으로 NULL이 그대로 반환됩니다. NULL 값을 건너뛰는 IGNORE NULLS 옵션은 DBMS와 버전에 따라 지원 범위가 다릅니다.

윈도우 함수 안의 ORDER BY는 함수 계산 순서를 정할 뿐 최종 출력 순서를 보장하지 않습니다. 결과 표시 순서가 중요하면 쿼리 바깥의 ORDER BY도 별도로 작성합니다.


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

ORDER BY를 포함한 윈도우 집계 함수는 많은 DBMS에서 처음부터 현재 행까지의 누적 범위처럼 동작합니다. 다만 기본 프레임은 ROWS가 아니라 RANGE ... CURRENT ROW 계열인 경우가 많아, 같은 정렬값(peer)이 함께 묶일 수 있습니다. 행 단위 누적을 원하면 프레임을 명시하는 것이 안전합니다.

누적합
SELECT month, revenue,
       SUM(revenue) OVER (
           ORDER BY month
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) 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
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) 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%

비율이나 성장률 계산에서는 정수 나눗셈을 피하기 위해 100.0처럼 소수 연산을 유도하고, 분모가 0이 될 수 있으면 NULLIF로 방어합니다.

누적 카운트와 러닝 토탈
-- 부서별 입사 순서별 인원 누적
SELECT dept, name, hire_date,
       COUNT(*) OVER (
           PARTITION BY dept ORDER BY hire_date, employee_id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_count,
       SUM(salary) OVER (
           PARTITION BY dept ORDER BY hire_date, employee_id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_total
FROM employees;

ROWS BETWEEN 프레임 지정

윈도우 프레임은 현재 행을 기준으로 집계에 포함할 행의 범위를 지정합니다. ORDER BY가 있는 집계 윈도우는 기본적으로 현재 행까지의 누적 범위처럼 보이지만, DBMS별 기본값은 대개 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 계열입니다. 정확히 물리적 행 수 기준으로 계산하려면 ROWS 프레임을 직접 써야 합니다.

프레임 예시
-- 앞뒤 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: 논리적 값 범위 또는 peer 기준
  → RANGE BETWEEN 2 PRECEDING AND CURRENT ROW
  → ORDER BY 값이 현재 값 - 2 이상인 행

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

RANGE의 숫자/날짜 offset 지원 범위는 DBMS마다 다름
같은 정렬값 peer 처리까지 포함해 실행 결과를 확인해야 함

GROUPS: 같은 ORDER BY 값을 가진 peer group 단위
  → GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW
  → 직전 peer group + 현재 peer group 포함
  → 지원 DBMS와 버전이 제한적이므로 확인 필요
ROWS vs RANGE 차이 예시
-- 데이터: day_no=1 amount=100, day_no=2 amount=200,
--       day_no=2 amount=200, day_no=3 amount=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 day_no)
-- day_no=1: 100 → SUM=100
-- day_no=2: 200 → SUM=500 (100+200+200) ← 1~2 범위
-- day_no=2: 200 → SUM=500 (100+200+200) ← 같은 결과
-- day_no=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, employee_id
       ) AS highest_earner,
       LAST_VALUE(name) OVER (
           PARTITION BY dept ORDER BY salary DESC, employee_id
           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
       ) AS lowest_earner
FROM employees;

-- LAST_VALUE 주의: 기본 프레임이 CURRENT ROW까지로 끝나는 경우가 많으므로
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 명시 필수!

FIRST_VALUELAST_VALUE도 정렬값이 동률이면 어떤 행을 첫 번째/마지막으로 볼지 불안정할 수 있습니다. 급여가 같은 사람이 있을 수 있다면 ORDER BY salary DESC, employee_id처럼 고유한 보조 정렬 기준을 추가합니다.

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;
-- 각 부서에서 두 번째로 높은 급여를 받는 사람
-- NTH_VALUE 지원 여부와 NULL 처리 옵션은 DBMS별로 다름

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

PERCENT_RANK는 파티션에 행이 하나뿐이면 일반적으로 0에 가까운 특수 결과가 나오며, 동점은 RANK 기준으로 같은 값을 가집니다.

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

윈도우 함수 실행 순서

논리적 처리 순서상 윈도우 함수는 WHERE/GROUP BY/HAVING 이후 SELECT 결과를 계산하는 단계에서 평가됩니다. 따라서 WHERE에서 윈도우 함수 결과나 SELECT 별칭을 직접 사용할 수 없습니다.

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

→ 윈도우 함수는 5단계에서 실행
→ WHERE에서 윈도우 함수 사용 불가
→ 필터링이 필요하면 서브쿼리나 CTE 사용
→ 일부 DBMS는 QUALIFY 문법 제공
윈도우 함수 결과 필터링
-- 오류! 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
                   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
               ) 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;

표준편차 함수 이름과 표본/모집단 기준은 DBMS마다 다릅니다. PostgreSQL은 STDDEV_SAMP, STDDEV_POP, SQL Server는 STDEV, STDEVP처럼 나뉘므로 이상치 기준을 먼저 정해야 합니다.

누적 비율 (파레토 분석)
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, product
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_total,
       ROUND(SUM(total) OVER (
           ORDER BY total DESC, product
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) * 100.0 / NULLIF(SUM(total) OVER (), 0), 1) AS cumulative_pct
FROM product_sales
ORDER BY total DESC;
-- 상위 20% 상품이 매출의 80%를 차지하는지 확인 (파레토 법칙)

동일 매출 상품이 여러 개 있으면 기본 RANGE 프레임이 동률 행을 함께 묶을 수 있습니다. 행 단위 누적 비율을 보고 싶다면 위처럼 보조 정렬 기준과 ROWS 프레임을 명시합니다.


실습: 종합 매출 분석

종합 분석 쿼리
WITH base AS (
    SELECT month, revenue,
           LAG(revenue) OVER (ORDER BY month) AS prev_revenue
    FROM monthly_sales
)
SELECT month, revenue,
       -- 전월 대비 성장률
       ROUND((revenue - prev_revenue) * 100.0
             / NULLIF(prev_revenue, 0), 1) AS growth_pct,
       -- 누적 매출
       SUM(revenue) OVER (
           ORDER BY month
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS cumulative,
       -- 3개월 이동 평균
       ROUND(AVG(revenue) OVER (
           ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       )) AS ma3,
       -- 전체 비중
       ROUND(revenue * 100.0 / NULLIF(SUM(revenue) OVER (), 0), 1) AS pct_of_total,
       -- 4분위: 1번 버킷이 높은 매출 구간
       NTILE(4) OVER (ORDER BY revenue DESC, month) AS quartile
FROM base;

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 프레임

위 표는 핵심 기능 중심의 요약입니다. 는 버전이나 문법 제약에 따라 사용 가능 범위가 달라진다는 뜻입니다. RANGE offset, GROUPS, EXCLUDE, IGNORE NULLS 같은 고급 옵션은 DBMS와 버전에 따라 지원 범위가 달라지므로 실제 사용 전 공식 문서를 확인해야 합니다.


성능 고려사항


정리

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

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

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