월말 정산 보고서를 만들던 개발자 민수 씨는 요청 하나를 받았습니다. "지점별 매출 순위, 그리고 전월 대비 증감까지 한 번에 뽑아 주세요." 예전 같으면 쿼리 결과를 파이썬이나 엑셀로 옮겨 순위를 매기고 뺄셈을 했을 겁니다. 하지만 MySQL 8.0부터는 윈도우 함수 덕분에 SQL 한 번으로 끝낼 수 있습니다. 오늘은 그 핵심만 차근차근 정리해 봅니다.
윈도우 함수가 GROUP BY와 다른 점
GROUP BY는 행을 묶어서 줄 수를 줄입니다. 지점별 합계를 구하면 지점 수만큼의 행만 남죠. 반면 윈도우 함수는 원래 행을 그대로 둔 채 옆에 계산 결과를 붙여 줍니다. 그래서 "내 매출"과 "지점 전체 평균"을 한 줄에서 나란히 볼 수 있습니다.
윈도우 함수는 행을 줄이지 않고, 각 행에 문맥을 붙여 주는 도구입니다.
핵심 문법은 함수() OVER (PARTITION BY ... ORDER BY ...) 하나입니다. PARTITION BY는 "어느 그룹 안에서"이고, ORDER BY는 "어떤 순서로"입니다. 이 두 칸만 이해하면 나머지는 응용입니다.
예제 테이블 준비
아래는 가상의 월별 지점 매출 테이블입니다.
CREATE TABLE monthly_sales (
branch VARCHAR(20) NOT NULL,
ym CHAR(7) NOT NULL, -- 예: 2026-09
amount INT NOT NULL,
PRIMARY KEY (branch, ym)
);
INSERT INTO monthly_sales VALUES
('서울','2026-08',1200),('서울','2026-09',1500),
('부산','2026-08',900), ('부산','2026-09',800),
('대구','2026-08',700), ('대구','2026-09',950);순위 매기기: ROW_NUMBER, RANK, DENSE_RANK
가장 많이 쓰는 용도가 순위입니다. 세 함수는 동점 처리에서 갈립니다.
SELECT branch, amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS rn,
RANK() OVER (ORDER BY amount DESC) AS rk,
DENSE_RANK() OVER (ORDER BY amount DESC) AS drk
FROM monthly_sales
WHERE ym = '2026-09';점수가 1500, 950, 800이면 모두 같은 결과지만, 동점이 생기면 달라집니다. ROW_NUMBER는 동점이어도 1,2,3으로 끊어 번호를 주고, RANK는 1,1,3처럼 건너뛰며, DENSE_RANK는 1,1,2처럼 건너뛰지 않습니다. "그룹별 상위 3건"처럼 정확히 개수를 맞춰야 한다면 ROW_NUMBER, 공동 순위를 인정하려면 RANK 계열을 고르면 됩니다.
그룹별 상위 N건 뽑기
실무에서 가장 자주 만나는 패턴입니다. 윈도우 함수는 WHERE 절에서 바로 쓸 수 없으므로 서브쿼리나 CTE로 감쌉니다.
WITH ranked AS (
SELECT branch, ym, amount,
ROW_NUMBER() OVER (PARTITION BY branch ORDER BY amount DESC) AS rn
FROM monthly_sales
)
SELECT branch, ym, amount
FROM ranked
WHERE rn <= 1;지점마다 가장 매출이 높았던 달 하나씩만 남습니다. 예전에 상관 서브쿼리로 낑낑대던 코드가 훨씬 읽기 쉬워지죠.
전월 대비 증감: LAG와 LEAD
민수 씨의 두 번째 요청이 바로 이것입니다. LAG(컬럼, n)은 n행 이전 값을, LEAD는 n행 이후 값을 가져옵니다.
SELECT branch, ym, amount,
amount - LAG(amount) OVER (PARTITION BY branch ORDER BY ym) AS diff,
ROUND(
(amount - LAG(amount) OVER (PARTITION BY branch ORDER BY ym))
/ LAG(amount) OVER (PARTITION BY branch ORDER BY ym) * 100, 1
) AS pct
FROM monthly_sales
ORDER BY branch, ym;첫 달에는 이전 행이 없으니 결과가 NULL입니다. 필요하면 LAG(amount, 1, 0)처럼 세 번째 인자로 기본값을 줄 수 있습니다. 부산의 9월처럼 마이너스 증감도 한눈에 보이니, 보고서 용도로 아주 좋습니다.
누적합과 이동평균: 프레임 지정
SUM() OVER (ORDER BY ...)는 기본적으로 처음부터 현재 행까지의 누적합이 됩니다. 범위를 직접 정하고 싶다면 프레임 절을 씁니다.
SELECT branch, ym, amount,
SUM(amount) OVER (PARTITION BY branch ORDER BY ym) AS running_total,
AVG(amount) OVER (
PARTITION BY branch ORDER BY ym
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM monthly_sales;ROWS BETWEEN 2 PRECEDING AND CURRENT ROW는 "현재 행과 앞의 두 행", 즉 3개월 이동평균입니다. 참고로 ORDER BY만 쓰고 프레임을 생략하면 기본값이 RANGE라서, 정렬 값이 같은 행끼리 한꺼번에 묶여 계산됩니다. 숫자가 이상하게 나온다면 이 부분부터 의심해 보세요.
쓰기 전에 알아 둘 점
- 버전 확인: 윈도우 함수는 MySQL 8.0 이상에서만 동작합니다. 5.7에서는 문법 오류가 납니다.
SELECT VERSION();으로 먼저 확인하세요. - 성능: 정렬이 필요하므로 PARTITION BY·ORDER BY 컬럼에 맞는 인덱스가 있으면 훨씬 빠릅니다. 큰 테이블이라면
EXPLAIN으로 filesort 여부를 살펴보세요. - 위치 제한: SELECT 목록과 ORDER BY에서만 쓸 수 있고, WHERE·GROUP BY에는 직접 못 씁니다. CTE로 감싸는 습관이 편합니다.
마무리
정리하면, 순위는 ROW_NUMBER·RANK, 전후 비교는 LAG·LEAD, 누적과 이동평균은 SUM·AVG + 프레임 절입니다. 모두 행을 줄이지 않고 옆에 계산을 붙인다는 한 가지 원리에서 나옵니다. 민수 씨는 결국 쿼리 두 개로 보고서를 끝내고 일찍 퇴근했다고 합니다. 오늘 여러분의 데이터에서도 가장 지루한 집계 하나를 윈도우 함수로 바꿔 보세요. 작은 시도가 퇴근 시간을 꽤 당겨 줄 거예요.


