Devin.KR

윈도 함수 응용 - 순위·누적·이동 평균

개발자KR 조회 13

이 장에서 배우는 것

집계 함수는 여러 행을 하나로 뭉치지만, 윈도 함수(window function)는 행을 그대로 둔 채로 옆에 순위·누적값·이전 행 값을 붙여준다. 앞 장에서 ROLLUP·CUBE·GROUPING SETS 로 소계·총계 표를 읽는 법을 다뤘다면, 이 장은 행 단위로 붙는 계산값을 다룬다. RANK·DENSE_RANK·ROW_NUMBER 의 차이, PARTITION BY 와 ORDER BY 조합, ROWS 와 RANGE 프레임의 차이, LAG·LEAD 를 이용한 이전 행 비교, 누적 합계와 이동 평균을 실습한다.

  • RANK, DENSE_RANK, ROW_NUMBER 가 동점을 처리하는 방식의 차이를 구분한다
  • PARTITION BY 와 OVER() 절 안의 ORDER BY 가 각각 무엇을 바꾸는지 이해한다
  • ROWS 프레임과 RANGE 프레임이 다른 결과를 내는 상황을 직접 계산으로 확인한다
  • LAG·LEAD 로 이전·다음 행 값을 끌어와 증감을 계산한다
  • 누적 합계와 이동 평균 쿼리를 스스로 작성하고 검증한다

문제 상황

온라인 서점 마케팅팀이 다음 세 가지를 요청했다. 첫째, 회원 등급(GRADE)별로 주문 금액 순위를 매겨 상위 구매자를 뽑고 싶다. 둘째, 회원별로 날짜순 누적 구매액과 최근 2건 이동 평균을 보고 싶다. 셋째, 직전 주문 대비 이번 주문 금액이 얼마나 늘거나 줄었는지 보고 싶다. 이 세 요청은 모두 GROUP BY 로는 풀리지 않는다. GROUP BY 는 여러 행을 한 행으로 줄이지만, 마케팅팀은 원래 행 개수를 유지한 채 옆에 순위·누적값·증감을 붙이고 싶어 하기 때문이다. 이럴 때 쓰는 것이 윈도 함수다.

순위를 매기는 세 함수

이 장에서는 아래 네 테이블을 사용한다. 주문상세의 AMOUNT 는 QTY × PRICE 로, 표본 데이터에서는 한 주문에 도서 한 종만 담아 계산을 단순하게 유지했다.

회원(MEMBER) 표본
MEMBER_IDMEMBER_NAMEGRADE
M01김도윤GOLD
M02이서연SILVER
M03박준호GOLD
M04최유리SILVER
주문(ORDERS)·주문상세(ORDER_DETAIL) 표본
ORDER_IDMEMBER_IDORDER_DATEAMOUNT
O101M012026-01-0522000
O102M022026-01-0830000
O103M012026-02-0228000
O104M032026-02-1022000
O105M022026-02-1516000
O106M042026-03-0139000
O107M012026-03-0415000
O108M032026-03-2028000

GOLD 등급(M01, M03)의 주문을 금액 내림차순으로 보면 28,000원이 두 건(O103, O108), 22,000원이 두 건(O101, O104) 있다. 이 동점이 RANK, DENSE_RANK, ROW_NUMBER 를 구분하는 좋은 재료가 된다.

세 순위 함수의 차이
함수동점 처리다음 순위대표 용도
RANK동점에 같은 순위 부여동점 개수만큼 건너뜀공동 순위 표시
DENSE_RANK동점에 같은 순위 부여건너뛰지 않고 이어짐등급 구간 나누기
ROW_NUMBER동점이어도 고유 번호항상 1씩 증가Top-N 한 건 추출, 페이지네이션
RANK는 동점 뒤 순위를 건너뛰고 DENSE_RANK는 건너뛰지 않으며 ROW_NUMBER는 동점이어도 고유 번호를 매긴다

ROW_NUMBER 는 동점을 아예 구분하지 않고 고유 번호를 매기기 때문에, 어떤 행이 1번이 되는지는 ORDER BY 에 동점 처리용 열을 추가하지 않으면 보장되지 않는다. 이 문제는 뒤의 자주 틀리는 것 절에서 다시 다룬다.

프레임 지정 - ROWS 와 RANGE

기본 프레임의 함정

OVER() 안에 ORDER BY 만 쓰고 프레임을 생략하면, 표준 SQL 은 기본값으로 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 를 적용한다. 즉 ORDER BY 가 있는 순간 SUM 이나 AVG 는 파티션 전체 합계가 아니라 누적값을 돌려준다. 파티션 전체 총합을 원한다면 ORDER BY 를 빼거나, 프레임을 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 으로 명시해야 한다. 이 차이는 MySQL 8 매뉴얼의 프레임 설명에도 나와 있다.

ROWS BETWEEN 과 RANGE BETWEEN

ROWS 는 물리적인 행 위치를 기준으로 프레임을 정한다. RANGE 는 ORDER BY 에 쓴 값 자체를 기준으로, 같은 값을 가진 행을 하나의 피어 그룹(peer group)으로 묶어 같은 결과를 준다. GOLD 등급 주문을 금액 내림차순으로 누적 합계를 구해보면 이 차이가 드러난다.

ROWS 와 RANGE 프레임 비교
구분기준동점 처리사용 시점
ROWS물리적 행 위치동점이라도 행마다 다른 값정확히 몇 번째 행까지로 범위를 고정할 때(이동 평균 등)
RANGEORDER BY 값(논리적 범위)같은 값을 가진 행은 모두 같은 결과정렬 기준 값 자체로 묶어야 할 때
RANGE는 동점 그룹 전체에 같은 누적값을 주고 ROWS는 물리적 행 순서로 값을 누적한다

이전 행과 비교하기 - LAG 와 LEAD

LAG 는 현재 행보다 앞선 행의 값을, LEAD 는 뒤따르는 행의 값을 가져온다. 두 함수 모두 PARTITION BY 를 빠뜨리면 다른 회원의 데이터끼리 비교하게 되므로, "회원별 직전 주문"처럼 파티션 단위 비교가 필요할 때는 반드시 PARTITION BY 를 같이 써야 한다. 기본 두 번째 인자는 오프셋(몇 행 앞/뒤)이고, 세 번째 인자로 이전 행이 없을 때 쓸 기본값을 지정할 수 있다.

MySQL 8 과 Oracle 차이

윈도 함수 관련 문법 차이
항목MySQL 8Oracle영향
NULLS FIRST/LAST지원 안 함(CASE 식으로 우회)ORDER BY 뒤에 직접 지정 가능NULL 이 섞인 정렬 기준일 때 결과 순서가 달라질 수 있음
LAG/LEAD 의 IGNORE NULLS지원 안 함LAG(col) IGNORE NULLS 지원MySQL 에서는 NULL 건너뛰기를 서브쿼리로 직접 구현해야 함
WINDOW 절 재사용SELECT ... WINDOW w AS (...) 지원해당 구문 없음Oracle 은 OVER() 를 매번 반복 작성해야 함

완성 코드

-- 스키마와 표본 데이터
CREATE TABLE MEMBER (
    MEMBER_ID   VARCHAR(10) PRIMARY KEY,
    MEMBER_NAME VARCHAR(20) NOT NULL,
    GRADE       VARCHAR(10) NOT NULL
);

CREATE TABLE BOOK (
    BOOK_ID   VARCHAR(10) PRIMARY KEY,
    BOOK_NAME VARCHAR(40) NOT NULL,
    CATEGORY  VARCHAR(20) NOT NULL,
    PRICE     INT NOT NULL
);

CREATE TABLE ORDERS (
    ORDER_ID   VARCHAR(10) PRIMARY KEY,
    MEMBER_ID  VARCHAR(10) NOT NULL,
    ORDER_DATE DATE NOT NULL,
    FOREIGN KEY (MEMBER_ID) REFERENCES MEMBER(MEMBER_ID)
);

CREATE TABLE ORDER_DETAIL (
    ORDER_ID VARCHAR(10) NOT NULL,
    BOOK_ID  VARCHAR(10) NOT NULL,
    QTY      INT NOT NULL,
    AMOUNT   INT NOT NULL,
    PRIMARY KEY (ORDER_ID, BOOK_ID),
    FOREIGN KEY (ORDER_ID) REFERENCES ORDERS(ORDER_ID),
    FOREIGN KEY (BOOK_ID) REFERENCES BOOK(BOOK_ID)
);

INSERT INTO MEMBER VALUES
    ('M01', '김도윤', 'GOLD'),
    ('M02', '이서연', 'SILVER'),
    ('M03', '박준호', 'GOLD'),
    ('M04', '최유리', 'SILVER');

INSERT INTO BOOK VALUES
    ('B01', 'SQL 기초', 'IT', 22000),
    ('B02', '추리소설 A', '소설', 15000),
    ('B03', '데이터베이스 실무', 'IT', 28000),
    ('B04', '에세이 모음', '에세이', 13000),
    ('B05', '추리소설 B', '소설', 16000);

INSERT INTO ORDERS VALUES
    ('O101', 'M01', '2026-01-05'),
    ('O102', 'M02', '2026-01-08'),
    ('O103', 'M01', '2026-02-02'),
    ('O104', 'M03', '2026-02-10'),
    ('O105', 'M02', '2026-02-15'),
    ('O106', 'M04', '2026-03-01'),
    ('O107', 'M01', '2026-03-04'),
    ('O108', 'M03', '2026-03-20');

INSERT INTO ORDER_DETAIL VALUES
    ('O101', 'B01', 1, 22000),
    ('O102', 'B02', 2, 30000),
    ('O103', 'B03', 1, 28000),
    ('O104', 'B01', 1, 22000),
    ('O105', 'B05', 1, 16000),
    ('O106', 'B04', 3, 39000),
    ('O107', 'B02', 1, 15000),
    ('O108', 'B03', 1, 28000);

-- 1) 등급별 주문 금액 순위 - 세 함수 비교
SELECT
    m.GRADE,
    o.ORDER_ID,
    m.MEMBER_ID,
    od.AMOUNT,
    RANK()       OVER (PARTITION BY m.GRADE ORDER BY od.AMOUNT DESC)            AS RANK_NO,
    DENSE_RANK() OVER (PARTITION BY m.GRADE ORDER BY od.AMOUNT DESC)            AS DENSE_NO,
    ROW_NUMBER() OVER (PARTITION BY m.GRADE ORDER BY od.AMOUNT DESC, o.ORDER_ID) AS ROW_NO
FROM ORDERS o
JOIN MEMBER m       ON m.MEMBER_ID = o.MEMBER_ID
JOIN ORDER_DETAIL od ON od.ORDER_ID = o.ORDER_ID
ORDER BY m.GRADE, RANK_NO, o.ORDER_ID;

-- 2) 회원별 누적 합계, 최근 2건 이동 평균, 직전 주문 대비 증감
SELECT
    o.MEMBER_ID,
    o.ORDER_ID,
    o.ORDER_DATE,
    od.AMOUNT,
    SUM(od.AMOUNT) OVER (
        PARTITION BY o.MEMBER_ID ORDER BY o.ORDER_DATE
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS CUM_AMOUNT,
    ROUND(AVG(od.AMOUNT) OVER (
        PARTITION BY o.MEMBER_ID ORDER BY o.ORDER_DATE
        ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
    ), 0) AS MOVING_AVG2,
    LAG(od.AMOUNT) OVER (PARTITION BY o.MEMBER_ID ORDER BY o.ORDER_DATE) AS PREV_AMOUNT,
    od.AMOUNT - LAG(od.AMOUNT) OVER (PARTITION BY o.MEMBER_ID ORDER BY o.ORDER_DATE) AS DIFF_AMOUNT
FROM ORDERS o
JOIN ORDER_DETAIL od ON od.ORDER_ID = o.ORDER_ID
WHERE o.MEMBER_ID = 'M01'
ORDER BY o.ORDER_DATE;

-- 3) ROWS 프레임과 RANGE 프레임의 차이 - GOLD 등급, 금액 내림차순
SELECT
    m.MEMBER_ID,
    o.ORDER_ID,
    od.AMOUNT,
    SUM(od.AMOUNT) OVER (
        PARTITION BY m.GRADE ORDER BY od.AMOUNT DESC, o.ORDER_ID
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS CUM_ROWS,
    SUM(od.AMOUNT) OVER (
        PARTITION BY m.GRADE ORDER BY od.AMOUNT DESC
        RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS CUM_RANGE
FROM ORDERS o
JOIN MEMBER m       ON m.MEMBER_ID = o.MEMBER_ID
JOIN ORDER_DETAIL od ON od.ORDER_ID = o.ORDER_ID
WHERE m.GRADE = 'GOLD'
ORDER BY od.AMOUNT DESC, o.ORDER_ID;

줄별 해설

첫 번째 쿼리는 PARTITION BY m.GRADE 로 등급마다 순위를 따로 매기고, ORDER BY od.AMOUNT DESC 로 금액이 큰 순서를 기준으로 삼는다. RANK_NO, DENSE_NO 는 od.AMOUNT DESC 하나만 기준으로 두어 동점을 그대로 드러내고, ROW_NO 는 뒤에 o.ORDER_ID 를 추가해 동점이어도 결과가 실행마다 흔들리지 않도록 했다.

두 번째 쿼리의 CUM_AMOUNT 는 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 로 "파티션 시작부터 현재 행까지"를 명시해 누적 합계를 만든다. MOVING_AVG2 는 ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 로 프레임을 현재 행과 바로 앞 1건으로 좁혀 최근 2건 평균을 낸다. LAG(od.AMOUNT) 는 같은 회원의 직전 주문 금액을 가져오고, 첫 주문에서는 이전 행이 없으므로 NULL 이 된다.

세 번째 쿼리는 같은 데이터를 두 가지 프레임으로 계산해 차이를 대조한다. CUM_ROWS 는 ORDER BY 에 o.ORDER_ID 를 추가해 동점이어도 정해진 물리적 순서대로 하나씩 누적한다. CUM_RANGE 는 ORDER BY 에 od.AMOUNT DESC 하나만 남겨, 28,000원 두 건과 22,000원 두 건을 각각 하나의 피어 그룹으로 묶어 같은 누적값을 부여한다.

실행 결과

mysql> -- 쿼리 1 실행 결과
GRADE   ORDER_ID  MEMBER_ID  AMOUNT  RANK_NO  DENSE_NO  ROW_NO
GOLD    O103      M01        28000   1        1         1
GOLD    O108      M03        28000   1        1         2
GOLD    O101      M01        22000   3        2         3
GOLD    O104      M03        22000   3        2         4
GOLD    O107      M01        15000   5        3         5
SILVER  O106      M04        39000   1        1         1
SILVER  O102      M02        30000   2        2         2
SILVER  O105      M02        16000   3        3         3
mysql> -- 쿼리 2 실행 결과 (M01)
MEMBER_ID  ORDER_ID  ORDER_DATE  AMOUNT  CUM_AMOUNT  MOVING_AVG2  PREV_AMOUNT  DIFF_AMOUNT
M01        O101      2026-01-05  22000   22000       22000        NULL         NULL
M01        O103      2026-02-02  28000   50000       25000        22000        6000
M01        O107      2026-03-04  15000   65000       21500        28000        -13000
mysql> -- 쿼리 3 실행 결과 (GOLD)
MEMBER_ID  ORDER_ID  AMOUNT  CUM_ROWS  CUM_RANGE
M01        O103      28000   28000     56000
M03        O108      28000   56000     56000
M01        O101      22000   78000     100000
M03        O104      22000   100000    100000
M01        O107      15000   115000    115000

실무에서 자주 틀리는 것

기본 프레임을 총합으로 착각한다

-- 틀린 코드: 회원별 총 구매액을 매 행에 표시하려 했는데
SELECT MEMBER_ID, ORDER_ID, AMOUNT,
       SUM(AMOUNT) OVER (PARTITION BY MEMBER_ID ORDER BY ORDER_DATE) AS TOTAL_AMOUNT
FROM ORDER_JOINED;
-- ORDER BY 가 있어 기본 프레임이 RANGE UNBOUNDED PRECEDING TO CURRENT ROW 가 되어
-- TOTAL_AMOUNT 가 누적합으로 나온다
-- 고친 코드: ORDER BY 를 빼거나 프레임을 명시한다
SELECT MEMBER_ID, ORDER_ID, AMOUNT,
       SUM(AMOUNT) OVER (PARTITION BY MEMBER_ID) AS TOTAL_AMOUNT
FROM ORDER_JOINED;

WHERE 절에 윈도 함수를 직접 쓴다

-- 틀린 코드: WHERE 절에서 윈도 함수의 별칭을 바로 쓸 수 없다
SELECT ORDER_ID, AMOUNT,
       RANK() OVER (PARTITION BY GRADE ORDER BY AMOUNT DESC) AS RANK_NO
FROM ORDER_JOINED
WHERE RANK_NO = 1;
-- 고친 코드: 서브쿼리나 CTE 로 한 번 감싼 뒤 바깥에서 필터링한다
WITH RANKED AS (
    SELECT ORDER_ID, AMOUNT,
           RANK() OVER (PARTITION BY GRADE ORDER BY AMOUNT DESC) AS RANK_NO
    FROM ORDER_JOINED
)
SELECT * FROM RANKED WHERE RANK_NO = 1;

LAG·LEAD 에 PARTITION BY 를 빠뜨린다

-- 틀린 코드: 전체 주문을 날짜순으로 한 줄로 보고 비교해버린다
SELECT MEMBER_ID, ORDER_DATE, AMOUNT,
       LAG(AMOUNT) OVER (ORDER BY ORDER_DATE) AS PREV_AMOUNT
FROM ORDER_JOINED;
-- 결과: 바로 앞 날짜가 다른 회원 주문이면 엉뚱한 값과 비교하게 된다
-- 고친 코드: PARTITION BY 로 같은 회원 안에서만 비교한다
SELECT MEMBER_ID, ORDER_DATE, AMOUNT,
       LAG(AMOUNT) OVER (PARTITION BY MEMBER_ID ORDER BY ORDER_DATE) AS PREV_AMOUNT
FROM ORDER_JOINED;

동점 처리용 정렬 키를 빠뜨린다

-- 틀린 코드: AMOUNT 가 같은 행이 여러 개면 순서가 보장되지 않는다
SELECT ORDER_ID, AMOUNT,
       ROW_NUMBER() OVER (PARTITION BY GRADE ORDER BY AMOUNT DESC) AS ROW_NO
FROM ORDER_JOINED;
-- 고친 코드: 동점을 깨는 열(ORDER_ID 등)을 추가해 결과를 고정한다
SELECT ORDER_ID, AMOUNT,
       ROW_NUMBER() OVER (PARTITION BY GRADE ORDER BY AMOUNT DESC, ORDER_ID) AS ROW_NO
FROM ORDER_JOINED;

한눈에 보기

이 장에서 쓴 윈도 함수 요소
키워드역할주의점
PARTITION BY그룹을 나눠 그룹 안에서만 계산을 독립시킨다빠뜨리면 전체 테이블을 한 그룹으로 계산한다
OVER() 안 ORDER BY순위·누적 계산의 정렬 기준을 정한다있는 순간 기본 프레임이 누적형으로 바뀐다
ROWS / RANGE프레임을 물리적 행 또는 값 기준으로 정한다동점이 있으면 두 결과가 달라진다
LAG / LEAD이전·다음 행 값을 가져와 증감을 계산한다PARTITION BY 없이 쓰면 다른 그룹과 섞인다

연습 문제

  1. GOLD 등급에서 28,000원 주문이 두 건(O103, O108) 있을 때, RANK, DENSE_RANK, ROW_NUMBER 값이 각 함수마다 어떻게 다른지 설명하라.
  2. SILVER 등급 회원(M02, M04)의 주문 중 금액이 가장 큰 1건만 뽑고 싶다. WHERE 절에 윈도 함수를 바로 쓸 수 없는 이유를 설명하고, 올바른 쿼리를 작성하라.
  3. M03 의 주문은 O104(2026-02-10, 22,000원), O108(2026-03-20, 28,000원) 두 건이다. 날짜순으로 CUM_AMOUNT(누적 합계)와 LAG 를 이용한 DIFF_AMOUNT(직전 주문과의 차이)를 계산하라.
  4. SILVER 등급 주문 금액은 39,000원, 30,000원, 16,000원으로 동점이 없다. 이 경우 ROWS 프레임과 RANGE 프레임으로 각각 누적 합계를 구하면 두 결과가 같아지는 이유를 설명하라.

정답과 해설

1번 RANK 는 두 건 모두 1위를 주고 다음 행인 22,000원 두 건에는 3위를 준다(2위를 건너뜀). DENSE_RANK 는 두 건 모두 1위를 주고 다음 22,000원 두 건에는 2위를 준다(건너뛰지 않음). ROW_NUMBER 는 동점이어도 1, 2 처럼 서로 다른 고유 번호를 매기며, 어느 행이 1번이 되는지는 ORDER BY 에 동점 처리용 열(예: ORDER_ID)을 추가해야 정해진다.

2번 RANK, DENSE_RANK, ROW_NUMBER 같은 윈도 함수는 SELECT 목록에서만 계산되고 WHERE 절이 실행되는 단계에는 아직 값이 없어 직접 참조할 수 없다. CTE 나 서브쿼리로 한 번 감싼 뒤 바깥 쿼리의 WHERE 절에서 걸러야 한다.

WITH RANKED AS (
    SELECT o.ORDER_ID, m.MEMBER_ID, od.AMOUNT,
           ROW_NUMBER() OVER (ORDER BY od.AMOUNT DESC, o.ORDER_ID) AS ROW_NO
    FROM ORDERS o
    JOIN MEMBER m ON m.MEMBER_ID = o.MEMBER_ID
    JOIN ORDER_DETAIL od ON od.ORDER_ID = o.ORDER_ID
    WHERE m.GRADE = 'SILVER'
)
SELECT * FROM RANKED WHERE ROW_NO = 1;
-- 결과: O106, M04, 39000, 1

3번 CUM_AMOUNT 는 O104 에서 22,000, O108 에서 22,000+28,000=50,000 이다. DIFF_AMOUNT 는 O104 에서 이전 주문이 없어 NULL, O108 에서 28,000-22,000=6,000 이다.

4번 RANGE 는 동점(같은 ORDER BY 값)이 있을 때만 여러 행을 하나의 피어 그룹으로 묶어 ROWS 와 다른 결과를 만든다. SILVER 주문 금액은 39,000, 30,000, 16,000 으로 모두 값이 달라 각 행이 자기 혼자만의 피어 그룹이 되므로, RANGE 도 ROWS 와 마찬가지로 한 행씩 누적하게 되어 두 결과가 39,000 / 69,000 / 85,000 으로 같아진다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.