D-5: 윈도우 함수 ② — 차곡차곡 쌓고, 소계와 총계까지 한 번에
목차 21
안녕하세요, 홍순구 튜터입니다. 지난 시간엔 데이터를 가로로 줄 세워 등수를 매기고(RANK·ROW_NUMBER), 앞뒤 행을 끌어와 비교하는(LAG·LEAD) 윈도우 함수를 배웠어요. 행을 그대로 둔 채 옆 칸에 순위를 붙이는 OVER() 의 정체를 잡은 거죠.
오늘은 같은 OVER() 를 한 단계 더 밀어붙여요. "1월부터 이번 달까지 팔로워를 차곡차곡 더한 누적 합계" 처럼, 처음부터 지금까지를 쌓아 가는 누적을 만들고, 창(window)이 들여다보는 범위를 직접 정하는 윈도우 프레임을 배웁니다. 지난 시간에 풀다 만 LAST_VALUE 의 숙제도 이 프레임으로 깔끔히 마무리하고요.
거기에 더해, 보고서의 끝판왕인 소계와 총계를 한 번에 뽑아 주는 ROLLUP·CUBE 까지 갑니다. "5월 게시물 몇 개, 6월 게시물 몇 개, 그리고 전체 몇 개" 를 쿼리 한 방에 내는 거예요.
오늘의 여정
① 누적이란 — 집계는 한 값으로 뭉치고, 누적은 차곡차곡 쌓아 간다
② 누적 SUM — 1월부터 이번 달까지 팔로워 합치기
③ 누적 AVG — 그달까지의 평균
④ 윈도우 프레임 ROWS BETWEEN — 창의 범위를 직접 정한다 (이동 평균)
⑤ LAST_VALUE 숙제 풀기 — 기본 프레임의 함정
⑥ ROWS vs RANGE — 행으로 셀까, 값으로 셀까
⑦ ROLLUP — 소계와 총계를 한 번에
⑧ CUBE · GROUPING SETS — 원하는 소계만 골라서
💡 오늘 수업의 핵심 — "ORDER BY 를 붙인 OVER() 는 처음부터 이 행까지를 쌓아 누적을 만든다. 그 쌓는 범위를 직접 정하는 게 프레임(ROWS BETWEEN)이고, 소계·총계를 한 번에 뽑는 게 ROLLUP·CUBE 다"
🎯 학습 목표
- 누적
SUM·AVG OVER (ORDER BY ...)로 월별 누적 팔로워·누적 평균을 계산하고,ORDER BY가 누적을 만드는 원리를 이해한다. (SQLD 2과목 'SQL 활용 — 윈도우 함수', ★빈출) - 윈도우 프레임(
ROWS/RANGE BETWEEN)으로 창의 범위를 직접 정하고, 둘의 차이와LAST_VALUE기본 프레임 함정을 구분한다. ROLLUP·CUBE·GROUPING SETS로 소계·총계를 한 번에 뽑고,GROUPING()함수로 소계 행을 가려낸다. (SQLD 2과목 '그룹 함수', ★★★ 빈출)
Step 1: "누적이란 무엇인가 — 집계는 뭉치고, 누적은 쌓아 간다"
지난 시간 윈도우 함수의 핵심은 "행을 뭉치지 않고 그대로 둔 채 옆에 값을 붙인다" 였어요. 오늘 배울 누적도 그 연장선이에요. 다만 옆에 붙이는 값이 순위가 아니라 "여기까지의 합계" 라는 점이 달라요.
은행 통장을 떠올려 보세요. 입금 내역이 한 줄씩 있고, 그 옆에 "잔액" 칸이 있죠? 잔액은 그 줄까지의 입금을 전부 더한 값이에요. 줄이 내려갈수록 잔액이 차곡차곡 불어나고요. 이게 바로 누적(running total)이에요.
집계 (C-4) — 여러 달을 "한 값으로 뭉친다"
100 108 120 ... ──SUM()──► 713 (결과 한 줄)
누적 (오늘) — 각 달을 "그대로 두고 그 줄까지의 합을 옆에 붙인다"
월 팔로워 │ 누적
1월 100 │ 100 통장 잔액처럼
2월 108 │ 208 한 줄 내려갈 때마다
3월 120 │ 328 차곡차곡 불어난다
... ... │ ...
C-4 의 SUM 은 6개월 팔로워를 더해 713 이라는 한 값으로 뭉쳤어요. 오늘의 누적은 6줄을 6줄 그대로 두고, 각 줄 옆에 "그달까지의 합" 을 붙여요. 1월은 100, 2월은 100+108=208, 3월은 208+120=328… 이런 식이죠.
이걸 가능하게 하는 게 지난 시간 배운 OVER() 에 ORDER BY 를 더하는 거예요. 오늘 데이터는 지난 시간에 만든 월별 팔로워 스냅샷 표(account_monthly_stat)를 그대로 써요. 회원 4명(김재훈·강지수·김유나·박지훈)의 2026년 1월부터 6월까지 팔로워 수가 담겨 있죠.
💡 한 줄 정리 — 집계(
SUM)는 여러 행을 한 값으로 뭉치고, 누적은 행을 그대로 둔 채 "그 행까지의 합" 을 옆에 붙인다. 통장 잔액을 떠올리면 쉽다.
🙋 학생 질문 — "튜터님, 누적도 결국 SUM 인데 C-4 집계랑 뭐가 달라요?"
쓰는 함수는 똑같은 SUM 이에요. 차이는 OVER() 가 붙느냐예요. SUM(follower_count) 만 쓰고 GROUP BY 로 묶으면 6개월이 한 줄(713)로 뭉쳐요. 그런데 SUM(follower_count) OVER (ORDER BY stat_month) 처럼 OVER() 와 ORDER BY 를 붙이면, 행은 6줄 그대로 두고 각 줄 옆에 "그달까지의 누적" 을 붙여 줘요. 같은 SUM 이라도 GROUP BY 는 뭉치기, OVER (ORDER BY) 는 쌓기예요.
Step 2: "1월부터 이번 달까지 더하기 — 누적 SUM OVER" ★빈출
바로 누적 팔로워를 구해 볼게요. 회원별로 1월부터 그달까지 팔로워를 차곡차곡 더합니다.
-- sql/queries/D5_window_frame_rollup.sql
SELECT m.nickname,
TO_CHAR(s.stat_month, 'YYYY-MM') AS mon,
s.follower_count,
SUM(s.follower_count) OVER (PARTITION BY s.member_id ORDER BY s.stat_month) AS cum_follower
FROM account_monthly_stat s
JOIN member m ON m.member_id = s.member_id
ORDER BY s.member_id, s.stat_month;
두 가지가 눈에 들어와야 해요. ORDER BY s.stat_month 는 "1월 → 2월 → … 순서로 쌓아라" 라는 뜻이고, PARTITION BY s.member_id 는 지난 시간 배운 그 그룹 나누기예요. 회원이 바뀌면 누적을 0부터 다시 시작하라는 거죠. 김재훈과 박지훈의 결과를 떼어 보면 이래요.
| nickname | mon | follower_count | cum_follower |
|---|---|---|---|
| 김재훈 | 2026-01 | 100 | 100 |
| 김재훈 | 2026-02 | 108 | 208 |
| 김재훈 | 2026-03 | 120 | 328 |
| 김재훈 | 2026-04 | 110 | 438 |
| 김재훈 | 2026-05 | 130 | 568 |
| 김재훈 | 2026-06 | 145 | 713 |
| 박지훈 | 2026-01 | 90 | 90 |
| 박지훈 | 2026-02 | 95 | 185 |
| 박지훈 | 2026-03 | 100 | 285 |
| 박지훈 | 2026-04 | 130 | 415 |
| 박지훈 | 2026-05 | 160 | 575 |
| 박지훈 | 2026-06 | 175 | 750 |
김재훈을 보면 100 → 208(100+108) → 328(208+120) … 통장 잔액처럼 차곡차곡 불어나죠. 그리고 김재훈의 6월(713)에서 박지훈으로 넘어가는 순간, 누적이 713 을 이어받지 않고 박지훈의 1월값 90 부터 다시 시작해요. PARTITION BY 가 회원 경계에서 누적을 끊어 준 거예요. 강지수는 6월 누적이 1460, 김유나는 1260 까지 쌓여요.
ORDER BY 를 빼면 어떻게 될까요? 그러면 "그 행까지" 라는 순서 자체가 없어져서, 누적이 아니라 그 회원의 6개월 전체 합계(713)가 모든 행에 똑같이 붙어요. ⚠️ 누적을 만들려면 OVER() 안에 ORDER BY 가 반드시 있어야 한다는 점, 이게 시험 단골이에요.
💡 한 줄 정리 —
SUM(x) OVER (PARTITION BY 그룹 ORDER BY 정렬)은 그룹 안에서 정렬 순서대로 "그 행까지의 합" 을 쌓는다.ORDER BY가 없으면 누적이 아니라 그룹 전체 합이 된다.
🙋 학생 질문 — "튜터님, PARTITION BY 를 빼면 어떻게 돼요?"
PARTITION BY 를 빼면 회원 구분 없이 24행 전체를 하나로 보고 쌓아요. 김재훈 6월까지 쌓인 누적에 강지수 1월이 그대로 이어 붙는 식이죠. "회원 한 명의 통장" 이 아니라 "네 명 통장을 한 줄로 이어 붙인 장부" 가 되는 거예요. 회원별로 따로 누적하고 싶으면 PARTITION BY member_id 로 칸막이를 쳐 줘야 해요. 지난 시간 순위를 회원별로 다시 매길 때 쓴 그 PARTITION BY 가 누적에서도 똑같이 동작해요.
Step 3: "그달까지의 평균 — 누적 AVG OVER"
함수만 SUM 에서 AVG 로 바꾸면 누적 합계가 누적 평균이 돼요. "1월부터 그달까지의 평균 팔로워" 를 구하는 거죠. 매달 들쭉날쭉한 값을 평탄하게 펴서 추세를 보고 싶을 때 써요.
SELECT m.nickname,
TO_CHAR(s.stat_month, 'YYYY-MM') AS mon,
s.follower_count,
ROUND(AVG(s.follower_count) OVER (PARTITION BY s.member_id ORDER BY s.stat_month), 1) AS cum_avg
FROM account_monthly_stat s
JOIN member m ON m.member_id = s.member_id
ORDER BY s.member_id, s.stat_month;
김재훈의 결과만 보면 누적 평균이 어떻게 움직이는지 한눈에 들어와요.
| nickname | mon | follower_count | cum_avg |
|---|---|---|---|
| 김재훈 | 2026-01 | 100 | 100 |
| 김재훈 | 2026-02 | 108 | 104 |
| 김재훈 | 2026-03 | 120 | 109.3 |
| 김재훈 | 2026-04 | 110 | 109.5 |
| 김재훈 | 2026-05 | 130 | 113.6 |
| 김재훈 | 2026-06 | 145 | 118.8 |
1월은 100 한 개뿐이니 평균도 100 이에요. 2월은 (100+108)/2 = 104, 3월은 (100+108+120)/3 = 109.3 … 이렇게 그달까지의 값을 다 모아 평균을 내요. 4월에 팔로워가 110 으로 한 번 줄었지만, 누적 평균(109.5)은 거의 안 흔들렸죠? 누적 평균은 한 달의 출렁임을 부드럽게 눌러서 "전체적으로 우상향인가" 같은 큰 흐름을 보여줘요.
누적 합계와 누적 평균은 OVER() 절이 완전히 똑같아요. 함수 이름만 다를 뿐이죠. 한 번 누적의 구조를 익혀 두면 SUM·AVG·COUNT·MAX 어디에든 그대로 붙일 수 있어요.
💡 한 줄 정리 —
AVG(x) OVER (... ORDER BY ...)는 "그 행까지의 평균" 을 누적으로 낸다. 한 달의 출렁임을 눌러 큰 추세를 보여 주며, 누적 합계와OVER()구조는 동일하다.
🙋 학생 질문 — "튜터님, 누적 평균이랑 그냥 전체 평균이랑 뭐가 달라요?"
전체 평균은 6개월을 다 모아 하나의 값(김재훈이면 (100+108+120+110+130+145)/6 ≈ 118.8)을 내요. 결과가 한 줄이죠. 누적 평균은 각 달마다 "여기까지의 평균" 을 따로 내서 6줄이 그대로 남아요. 그래서 1월엔 100, 2월엔 104… 처럼 평균이 점점 변해 가는 모습을 볼 수 있어요. 마지막 달(6월)의 누적 평균은 전체 평균과 똑같아져요. 그 시점엔 "그달까지" 가 곧 "전체" 니까요.
Step 4: "창의 범위를 직접 정하기 — 윈도우 프레임 ROWS BETWEEN"
지금까지 누적은 "처음부터 이 행까지" 를 더했어요. 그런데 사실 우리는 한 번도 "처음부터 이 행까지" 라고 직접 적은 적이 없어요. ORDER BY 만 붙였더니 데이터베이스가 알아서 그 범위로 더해 준 거죠. 이 "더하는 범위" 를 윈도우 프레임(frame)이라고 불러요. 그리고 이 프레임은 우리가 직접 정할 수 있어요.
프레임을 적는 문법이 ROWS BETWEEN 시작 AND 끝 이에요. 시작과 끝에 들어갈 수 있는 표현은 다섯 가지예요.
ROWS BETWEEN 시작 AND 끝 — 다섯 가지 경계
UNBOUNDED PRECEDING 맨 처음 행부터
n PRECEDING 현재 행에서 n 줄 위
CURRENT ROW 현재 행
n FOLLOWING 현재 행에서 n 줄 아래
UNBOUNDED FOLLOWING 맨 끝 행까지
예를 들어 누적은 사실 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(맨 처음부터 현재 행까지)예요. 우리가 Step 2 에서 생략한 그 기본값이죠. 이제 이 범위를 좁혀서 "최근 3개월" 만 보는 이동 평균(moving average)을 만들어 볼게요.
SELECT m.nickname,
TO_CHAR(s.stat_month, 'YYYY-MM') AS mon,
s.follower_count,
ROUND(AVG(s.follower_count) OVER (PARTITION BY s.member_id ORDER BY s.stat_month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 1) AS mov_avg_3m
FROM account_monthly_stat s
JOIN member m ON m.member_id = s.member_id
WHERE s.member_id IN (1, 12)
ORDER BY s.member_id, s.stat_month;
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW 는 "내 위로 2줄 + 현재 줄 = 최근 3개월" 을 평균 내라는 뜻이에요. 누적 평균(처음부터 다 봄)과 어떻게 다른지 김재훈으로 비교해 보세요.
| nickname | mon | follower_count | mov_avg_3m |
|---|---|---|---|
| 김재훈 | 2026-01 | 100 | 100 |
| 김재훈 | 2026-02 | 108 | 104 |
| 김재훈 | 2026-03 | 120 | 109.3 |
| 김재훈 | 2026-04 | 110 | 112.7 |
| 김재훈 | 2026-05 | 130 | 120 |
| 김재훈 | 2026-06 | 145 | 128.3 |
4월부터 차이가 드러나요. 누적 평균(Step 3)은 4월에 109.5 였는데, 3개월 이동 평균은 112.7 이에요. 이동 평균은 1·2월의 낮은 값을 더 이상 보지 않고 최근 3개월(2·3·4월 = 108·120·110)만 평균 내거든요. 6월을 보면 더 확실해요. 누적 평균은 118.8 인데 이동 평균은 128.3 이에요. 최근 석 달(4·5·6월 = 110·130·145)의 상승세만 잡아내서 더 높게 나오는 거죠.
이동 평균은 "오래된 과거는 잊고 최근 흐름만" 보고 싶을 때 써요. 주가 차트의 5일선·20일선이 바로 이 이동 평균이에요.
💡 한 줄 정리 — 윈도우 프레임
ROWS BETWEEN 시작 AND 끝은 계산할 행의 범위를 직접 정한다.2 PRECEDING AND CURRENT ROW는 최근 3개월 이동 평균이 되고, 생략하면 누적(처음부터 현재까지)이 기본이다.
🙋 학생 질문 — "튜터님, 그럼 지금까지 ORDER BY 만 썼는데 프레임은 왜 안 적었어요?"
OVER() 안에 ORDER BY 만 적고 프레임을 생략하면, 데이터베이스가 기본 프레임을 자동으로 채워 넣어요. 그 기본값이 "맨 처음부터 현재 행까지" 라서 누적이 됐던 거예요. 그러니까 Step 2 의 누적 SUM 은 사실 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 를 줄여 쓴 셈이죠. 프레임을 직접 적으면 이 기본 범위를 내 마음대로 바꿀 수 있어요. 그리고 이 "기본 프레임" 이 다음 Step 에서 함정의 원인이 돼요.
Step 5: "지난 시간에 풀다 만 숙제 — LAST_VALUE 의 함정" ★빈출
지난 시간 끝에 LAST_VALUE 가 이상하게 동작하는 걸 보고 숙제로 남겼던 거 기억나세요? "구간의 마지막 값" 을 가져와야 하는데 자꾸 현재 행의 값만 돌려줬죠. 오늘 배운 프레임으로 그 정체를 밝히고 해결해요.
각 회원의 "최종(6월) 팔로워" 를 모든 행에 붙이고 싶어요. 먼저 프레임 없이 LAST_VALUE 를 그냥 써 보고(lv_default), 프레임을 끝까지 늘린 버전(lv_fixed)과 나란히 비교할게요.
SELECT m.nickname,
TO_CHAR(s.stat_month, 'YYYY-MM') AS mon,
s.follower_count,
LAST_VALUE(s.follower_count) OVER (PARTITION BY s.member_id ORDER BY s.stat_month) AS lv_default,
LAST_VALUE(s.follower_count) OVER (PARTITION BY s.member_id ORDER BY s.stat_month
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS lv_fixed
FROM account_monthly_stat s
JOIN member m ON m.member_id = s.member_id
WHERE s.member_id IN (1, 12)
ORDER BY s.member_id, s.stat_month;
| nickname | mon | follower_count | lv_default | lv_fixed |
|---|---|---|---|---|
| 김재훈 | 2026-01 | 100 | 100 | 145 |
| 김재훈 | 2026-02 | 108 | 108 | 145 |
| 김재훈 | 2026-03 | 120 | 120 | 145 |
| 김재훈 | 2026-04 | 110 | 110 | 145 |
| 김재훈 | 2026-05 | 130 | 130 | 145 |
| 김재훈 | 2026-06 | 145 | 145 | 145 |
lv_default 를 보세요. "마지막 값" 을 달라고 했는데 매 줄 자기 자신의 값(100·108·120…)이 나와요. 이게 함정이에요. 원인은 Step 4 에서 본 기본 프레임이에요. ORDER BY 만 쓰면 프레임이 "처음부터 현재 행까지" 로 잡혀요. 그러니 1월 행에서 LAST_VALUE 가 보는 범위는 1월 한 줄뿐이고, 그 안의 마지막은 당연히 1월 자기 자신이죠. 4월 행에선 1~4월까지가 범위라 마지막이 4월 자신이고요.
lv_fixed 는 프레임을 ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING(현재 행부터 맨 끝까지)으로 늘렸어요. 이제 어느 행에서 보든 범위의 끝이 진짜 마지막 행(6월)이라, 모든 줄에 145(김재훈의 6월값)가 제대로 붙어요. 박지훈도 마찬가지로 전부 175(6월값)가 나오고요.
⚠️ 함정 —
LAST_VALUE를 프레임 없이ORDER BY만 쓰면 기본 프레임(현재 행까지) 탓에 "마지막 값" 이 아니라 "현재 행 값" 이 나온다. 진짜 마지막을 보려면ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING(또는UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)으로 프레임을 끝까지 늘려야 한다. SQLD 단골 함정이다.
💡 한 줄 정리 —
FIRST_VALUE는 기본 프레임에서도 잘 동작하지만,LAST_VALUE는 기본 프레임이 "현재 행까지" 라 함정이 생긴다. 프레임을 맨 끝까지 늘려야 진짜 마지막 값이 나온다.
🙋 학생 질문 — "튜터님, 그럼 FIRST_VALUE 는 왜 함정이 없었어요?"
기본 프레임이 "처음부터 현재 행까지" 라서 그래요. FIRST_VALUE(범위의 첫 값)가 보는 "처음" 은 어느 행에서 보든 항상 진짜 첫 행(1월)이에요. 그래서 프레임을 안 적어도 1월값이 잘 나왔죠. 반대로 LAST_VALUE(범위의 마지막 값)가 보는 "마지막" 은 기본 프레임에선 현재 행이라 함정이 생겨요. 지난 시간 FIRST_VALUE 로 1월값을 끌어올 땐 멀쩡했고, LAST_VALUE 만 이상했던 이유가 바로 이거예요. LAST_VALUE 를 만나면 프레임을 꼭 챙기세요.
Step 6: "행으로 셀까, 값으로 셀까 — ROWS vs RANGE" ★빈출
프레임을 적을 때 ROWS 말고 RANGE 라고 쓸 수도 있어요. 둘은 비슷해 보이지만 동점(같은 정렬값)을 만나면 결과가 갈려요. 이 차이가 시험에 자주 나와요.
ROWS는 물리적인 행을 세요. "위로 2줄" 하면 정확히 2줄이에요.RANGE는 정렬값(value)을 봐요. 정렬 기준이 같은 값을 가진 행(peer)들을 같은 값끼리 묶어서 취급해요.
차이를 보려면 정렬 기준에 동점이 있어야 해요. 그래서 이번엔 회원 구분 없이 24행 전체를 팔로워 수(follower_count)로 줄 세워 누적해 볼게요. 마침 팔로워 100 인 행이 둘(김재훈 1월·박지훈 3월), 130 인 행이 둘(김재훈 5월·박지훈 4월) 있어요.
SELECT TO_CHAR(stat_month, 'YYYY-MM') AS mon,
member_id,
follower_count,
SUM(follower_count) OVER (ORDER BY follower_count
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_sum,
SUM(follower_count) OVER (ORDER BY follower_count
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS range_sum
FROM account_monthly_stat
ORDER BY follower_count, member_id;
팔로워가 낮은 쪽 여덟 행만 떼어 보면 차이가 또렷해요.
| mon | member_id | follower_count | rows_sum | range_sum |
|---|---|---|---|---|
| 2026-01 | 12 | 90 | 90 | 90 |
| 2026-02 | 12 | 95 | 185 | 185 |
| 2026-01 | 1 | 100 | 285 | 385 |
| 2026-03 | 12 | 100 | 385 | 385 |
| 2026-02 | 1 | 108 | 493 | 493 |
| 2026-04 | 1 | 110 | 603 | 603 |
| 2026-05 | 1 | 130 | 733 | 863 |
| 2026-04 | 12 | 130 | 863 | 863 |
팔로워 100 인 두 행을 보세요. ROWS 는 첫 100 행에서 285(90+95+100), 둘째 100 행에서 385(90+95+100+100)로 한 줄씩 차곡차곡 더해요. 반면 RANGE 는 두 100 행 모두 385 예요. 정렬값이 같은 행(peer)을 하나로 봐서, 두 100 을 한꺼번에 포함한 합을 똑같이 주는 거죠. 130 인 두 행에서도 똑같아요. ROWS 는 733·863 으로 갈리고, RANGE 는 둘 다 863 이에요.
정렬값이 같은 행(동점)을 만났을 때
ROWS : 물리적 행을 한 줄씩 센다 → 285, 385 (다르다)
RANGE : 같은 값을 함께 묶어서 본다 → 385, 385 (같다)
여기서 Step 4·5 의 기본 프레임이 다시 등장해요. ORDER BY 만 쓸 때의 기본 프레임은 사실 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 예요. ROWS 가 아니라 RANGE 죠. 그래서 동점이 있는 데이터에서 누적을 낼 땐, 의도와 다르게 동점이 하나로 합쳐질 수 있어요. "한 줄씩 정확히" 누적하고 싶으면 ROWS 를 명시하는 게 안전해요.
⚠️ 함정 —
OVER (ORDER BY ...)의 기본 프레임은ROWS가 아니라RANGE다. 정렬 기준에 동점이 있으면 누적값이 동점끼리 같아진다. 행 단위로 또박또박 누적하려면ROWS를 직접 적는다.
💡 한 줄 정리 —
ROWS는 물리적 행을 세고,RANGE는 같은 정렬값을 하나로 본다. 동점이 없으면 결과가 같지만, 동점이 있으면RANGE는 peer 를 한꺼번에 포함해 값이 같아진다.
🙋 학생 질문 — "튜터님, 그럼 실무에선 ROWS 랑 RANGE 중에 뭘 써요?"
대부분은 ROWS 를 써요. "최근 3개월", "직전 7일" 처럼 행(또는 기간) 개수로 범위를 정하는 경우가 많고, 동점이 끼어도 또박또박 한 줄씩 세는 게 직관적이거든요. RANGE 는 "같은 날짜의 거래를 하나로 본다" 처럼 정렬값이 같은 행을 의도적으로 함께 묶고 싶을 때 써요. 헷갈리면 "행 개수로 자르면 ROWS, 값이 같으면 한 덩어리로 보고 싶으면 RANGE" 로 기억하세요. 그리고 기본값이 RANGE 라는 것만은 시험을 위해 꼭 외워 두고요.
Step 7: "소계와 총계를 한 번에 — ROLLUP" ★★★ 빈출
이제 윈도우에서 잠깐 내려와, 보고서의 단골인 소계(subtotal)와 총계(grand total)를 다뤄요. "5월 게시물 몇 개, 6월 게시물 몇 개, 그리고 전체 몇 개" 를 한 쿼리로 뽑는 거예요.
C-4 에서 배운 GROUP BY 는 그룹별 개수만 줬어요. 소계나 총계 행은 따로 쿼리를 또 날려서 합쳐야 했죠. ROLLUP 은 GROUP BY 를 살짝 확장해서, 그룹별 집계에 소계 행과 총계 행을 자동으로 덧붙여 줘요. 지난 시간 예고한 그 기능이에요.
오늘은 게시물에 새로 붙인 카테고리 컬럼(category)을 써요. 게시물 50개에 여행·맛집·일상·음악·패션 다섯 카테고리가 붙어 있어요. 월별·카테고리별 게시물 수를 소계·총계와 함께 뽑아 볼게요.
SELECT TO_CHAR(created_at, 'YYYY-MM') AS mon,
category,
COUNT(*) AS n,
GROUPING(TO_CHAR(created_at, 'YYYY-MM')) AS g_mon,
GROUPING(category) AS g_cat
FROM post
GROUP BY ROLLUP(TO_CHAR(created_at, 'YYYY-MM'), category)
ORDER BY g_mon, mon, g_cat, category;
| mon | category | n | g_mon | g_cat |
|---|---|---|---|---|
| 2026-05 | 맛집 | 5 | 0 | 0 |
| 2026-05 | 여행 | 6 | 0 | 0 |
| 2026-05 | 음악 | 3 | 0 | 0 |
| 2026-05 | 일상 | 6 | 0 | 0 |
| 2026-05 | 패션 | 3 | 0 | 0 |
| 2026-05 | (소계) | 23 | 0 | 1 |
| 2026-06 | 맛집 | 7 | 0 | 0 |
| 2026-06 | 여행 | 8 | 0 | 0 |
| 2026-06 | 음악 | 5 | 0 | 0 |
| 2026-06 | 일상 | 7 | 0 | 0 |
| 2026-06 | (소계) | 27 | 0 | 1 |
| (총계) | (총계) | 50 | 1 | 1 |
상세 행(카테고리별 개수) 사이사이에 소계와 총계가 끼어든 게 보이죠? 5월 다섯 카테고리(맛집5·여행6·음악3·일상6·패션3) 다음에 5월 소계 23 이 붙고, 6월 다음에 6월 소계 27, 맨 끝에 전체 총계 50 이 붙어요. GROUP BY 였다면 9줄(상세)만 나왔을 텐데, ROLLUP 이 소계 2줄 + 총계 1줄을 더해 12줄을 만든 거예요.
그런데 소계·총계 행은 category 칸이 비어 있어요(NULL). 상세 행의 NULL 과 헷갈리지 않을까요? 이걸 가려 주는 게 GROUPING() 함수예요. GROUPING(category) 는 그 행에서 카테고리가 소계로 뭉쳐졌으면 1, 실제 값이면 0 을 줘요. 표의 g_cat 칸을 보세요. 소계·총계 행만 1 이죠. 그래서 "소계 행만 골라 줘" 하려면 GROUPING(category) = 1 같은 조건을 쓰면 돼요.
GROUP BY ROLLUP(월, 카테고리) — 오른쪽 컬럼부터 단계적으로 뭉친다
(월, 카테고리) → 상세 9줄 g_mon=0 g_cat=0
(월) → 월 소계 2줄 g_mon=0 g_cat=1
() → 전체 총계 1줄 g_mon=1 g_cat=1
💡 한 줄 정리 —
GROUP BY ROLLUP(a, b)는 상세 + (a별 소계) + 총계를 한 번에 낸다. 오른쪽 컬럼부터 단계적으로 뭉치며, 소계·총계 행은GROUPING()함수가 1 로 표시해 가려낼 수 있다.
🙋 학생 질문 — "튜터님, ROLLUP(월, 카테고리) 랑 ROLLUP(카테고리, 월) 은 결과가 같아요?"
아니요, 달라요. ROLLUP 은 오른쪽 컬럼부터 하나씩 떼어 내며 뭉쳐요. ROLLUP(월, 카테고리) 는 "월별 소계" 와 "총계" 가 나오고, ROLLUP(카테고리, 월) 은 "카테고리별 소계" 와 "총계" 가 나와요. 즉 어떤 기준의 소계를 보고 싶은지에 따라 순서를 정해요. "월마다 합계를 보고 싶다" 면 월을 앞에, "카테고리마다 합계를 보고 싶다" 면 카테고리를 앞에 두세요. 양쪽 소계를 다 보고 싶으면 다음 Step 의 CUBE 를 쓰면 되고요.
Step 8: "모든 조합의 소계, 그리고 원하는 것만 — CUBE · GROUPING SETS"
ROLLUP(월, 카테고리) 은 월별 소계만 줬어요. 그런데 "월별 소계도 보고 카테고리별 소계도 보고 싶다" 면요? 이때 CUBE 를 써요. CUBE 는 가능한 모든 조합의 소계를 만들어 줘요.
SELECT CASE WHEN GROUPING(TO_CHAR(created_at, 'YYYY-MM'))=1 AND GROUPING(category)=1 THEN '총계'
WHEN GROUPING(TO_CHAR(created_at, 'YYYY-MM'))=1 THEN '카테고리 소계'
WHEN GROUPING(category)=1 THEN '월 소계'
ELSE '상세' END AS kind,
TO_CHAR(created_at, 'YYYY-MM') AS mon,
category,
COUNT(*) AS n
FROM post
GROUP BY CUBE(TO_CHAR(created_at, 'YYYY-MM'), category)
ORDER BY GROUPING(TO_CHAR(created_at, 'YYYY-MM')), mon,
GROUPING(category), category;
GROUPING() 을 CASE 와 엮어서 각 행이 어떤 종류인지(상세/월 소계/카테고리 소계/총계) 이름표를 붙였어요.
| kind | mon | category | n |
|---|---|---|---|
| 상세 | 2026-05 | 맛집 | 5 |
| 상세 | 2026-05 | 여행 | 6 |
| 상세 | 2026-05 | 음악 | 3 |
| 상세 | 2026-05 | 일상 | 6 |
| 상세 | 2026-05 | 패션 | 3 |
| 월 소계 | 2026-05 | 23 | |
| 상세 | 2026-06 | 맛집 | 7 |
| 상세 | 2026-06 | 여행 | 8 |
| 상세 | 2026-06 | 음악 | 5 |
| 상세 | 2026-06 | 일상 | 7 |
| 월 소계 | 2026-06 | 27 | |
| 카테고리 소계 | 맛집 | 12 | |
| 카테고리 소계 | 여행 | 14 | |
| 카테고리 소계 | 음악 | 8 | |
| 카테고리 소계 | 일상 | 13 | |
| 카테고리 소계 | 패션 | 3 | |
| 총계 | 50 |
ROLLUP 은 12줄이었는데 CUBE 는 17줄이에요. 월 소계 2줄과 총계 1줄에 더해, ROLLUP 에는 없던 카테고리 소계 5줄(맛집12·여행14·음악8·일상13·패션3)이 추가됐거든요. "월로도 잘라 보고, 카테고리로도 잘라 보고, 둘 다 무시한 총계까지" 한 번에 나오는 거죠.
여기서 패션을 눈여겨보세요. 카테고리 소계에 패션 3 이 분명히 있어요. 그런데 상세 행을 보면 5월 패션(3)은 있는데 6월 패션 행이 아예 없죠? 패션 게시물은 5월에만 있고 6월엔 한 개도 없거든요. ⚠️ CUBE 는 실제로 존재하는 조합만 상세 행으로 만들어요. 그래서 (2026-06, 패션) 같은 빈 조합은 0 짜리 행조차 만들지 않아요. 데이터에 없는 조합이 결과에서 통째로 빠진다는 점, 보고서를 읽을 때 꼭 기억하세요.
마지막으로 GROUPING SETS 예요. CUBE 가 모든 조합을 다 만든다면, GROUPING SETS 는 "내가 원하는 소계 조합만 콕 집어" 만들어요. 상세도 총계도 필요 없고 월 소계와 카테고리 소계만 보고 싶다면 이렇게 써요.
SELECT TO_CHAR(created_at, 'YYYY-MM') AS mon,
category,
COUNT(*) AS n
FROM post
GROUP BY GROUPING SETS ((TO_CHAR(created_at, 'YYYY-MM')), (category))
ORDER BY mon, category;
GROUPING SETS ((월), (카테고리)) 는 "월로 묶은 소계 한 벌, 카테고리로 묶은 소계 한 벌만 줘" 라는 뜻이에요. 결과는 딱 7줄(월 소계 5월 23·6월 27 + 카테고리 소계 5줄)이에요. 상세 9줄도, 총계 1줄도 없어요. 필요한 소계만 골라 가벼운 보고서를 만들 때 좋아요.
세 형제 비교 (월 × 카테고리 기준)
ROLLUP(월, 카테고리) → 상세 + 월 소계 + 총계 (12줄)
CUBE(월, 카테고리) → 상세 + 월 소계 + 카테고리 소계 + 총계 (17줄)
GROUPING SETS(...) → 내가 적은 조합만 (여기선 7줄)
💡 한 줄 정리 —
CUBE는 가능한 모든 조합의 소계(+총계)를 내고,GROUPING SETS는 원하는 조합만 골라 낸다.CUBE는 데이터에 없는 조합은 행을 만들지 않으니, 빠진 조합에 주의한다.
🙋 학생 질문 — "튜터님, ROLLUP·CUBE·GROUPING SETS 셋이 헷갈려요. 한 번에 정리해 주세요."
소계를 얼마나 만들지로 갈라요. ROLLUP 은 계층적으로 한쪽 방향 소계만(월별 소계 → 총계). CUBE 는 가능한 모든 방향 소계(월별 + 카테고리별 + 총계). GROUPING SETS 는 내가 직접 적은 조합만 만드는 가장 자유로운 방식이에요. 사실 ROLLUP 과 CUBE 는 GROUPING SETS 로 똑같이 풀어쓸 수 있어요. 자주 쓰는 패턴을 짧게 줄여 놓은 게 ROLLUP·CUBE 라고 보면 돼요. 시험에선 "공동 소계가 몇 줄 나오는가" 를 묻는 경우가 많으니, 셋이 만드는 소계 조합을 구분하는 게 핵심이에요.
마무리
오늘은 윈도우 함수를 누적과 프레임까지 밀어붙이고, 보고서용 소계·총계를 한 번에 뽑는 ROLLUP·CUBE 까지 봤어요. 지난 시간의 "줄 세우기" 에서 오늘의 "쌓기와 묶기" 로, 분석 쿼리의 큰 줄기를 거의 다 잡은 셈이에요.
오늘 배운 핵심 세 가지
- 💡 누적 —
OVER()에ORDER BY를 붙이면 "그 행까지" 를 쌓는 누적이 된다.PARTITION BY로 그룹마다 0부터 다시 쌓고,SUM·AVG어디에든 같은 구조로 붙는다. - 💡 프레임 —
ROWS BETWEEN으로 계산 범위를 직접 정한다. 기본 프레임은 "현재 행까지" 라LAST_VALUE가 함정이 되고, 기본값이RANGE라 동점에서ROWS와 갈린다. - 💡 소계 —
ROLLUP은 계층 소계 + 총계,CUBE는 모든 조합 소계,GROUPING SETS는 원하는 조합만.GROUPING()함수가 소계 행을 1 로 표시해 가려낸다.
다음 시간 예고
오늘 우리는 데이터를 세로로 쌓아 소계를 냈어요. 다음 시간엔 방향을 틀어서, 데이터를 가로로 펼쳐요. "월" 을 행이 아니라 열(컬럼)로 바꿔서 5월·6월을 나란히 놓는 PIVOT, 한 게시물에 달린 해시태그 여러 개를 #여행 #제주 #바다 처럼 한 줄 문자열로 모아 주는 LISTAGG, 그리고 인기 게시물 상위 N 개만 똑 떼어 내는 Top-N 까지 배웁니다. 오늘 만든 카테고리가 다음 시간 가로 교차표에서 다시 등장해요.
과제
오늘 배운 누적·프레임·소계를 직접 써 보는 과제예요. 결과가 몇 줄 나올지, 누적값이 얼마가 될지 먼저 머릿속으로 그려 본 뒤 실행해서 맞는지 확인해 보세요.
[기초] 누적 SUM 과 누적 AVG
account_monthly_stat 표로 강지수(회원 6번)의 월별 누적 팔로워와 누적 평균을 함께 구해 보세요. (가) SUM(...) OVER (... ORDER BY stat_month) 로 1월부터 그달까지의 누적 팔로워를, 같은 줄에 AVG(...) OVER (...) 로 누적 평균을 붙여 6줄을 출력하세요. (나) 6월 행의 누적 팔로워가 강지수의 6개월 전체 합계와 같은지, 6월 누적 평균이 6개월 전체 평균과 같은지 확인하고 왜 그런지 한 줄로 설명해 보세요.
[응용] 이동 평균과 LAST_VALUE
account_monthly_stat 표로 다음을 해 보세요. (가) 김유나(회원 9번)의 팔로워에 대해 ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 로 최근 2개월 이동 평균을 구하고, 누적 평균(ORDER BY 만 쓴 버전)과 한 줄에 나란히 놓아 어떤 달에 둘이 가장 크게 벌어지는지 찾아보세요. (나) LAST_VALUE 로 각 회원의 6월 팔로워를 모든 행에 붙이되, 프레임을 안 쓴 버전과 ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING 을 쓴 버전이 어떻게 다른지 직접 확인해 보세요.
[심화] ROLLUP·CUBE 와 GROUPING
post 표(카테고리 컬럼 포함)로 다음을 해 보세요. (가) ROLLUP(category) 로 카테고리별 게시물 수와 전체 총계를 구해, 총 몇 줄이 나오는지 확인하세요. (나) CUBE(TO_CHAR(created_at,'YYYY-MM'), category) 결과에서 GROUPING() 을 조건으로 써서 "카테고리 소계 행" 만 골라내 보세요. 그리고 그 결과에 6월 패션이 왜 보이지 않는지(또는 보이는지) 설명해 보세요.
생각해볼 주제
1. 누적을 데이터베이스에서 계산할까, 애플리케이션에서 계산할까
누적 팔로워 그래프를 그릴 때, 오늘처럼 데이터베이스가 SUM OVER 로 누적을 계산해 줄 수도 있고, 월별 원본 값만 받아 와 애플리케이션 코드가 반복문으로 더해 갈 수도 있어요. 두 방식은 각각 어떤 상황에서 유리할까요? 데이터 양, 같은 누적을 여러 화면에서 재사용하는지, 누적 기준(월별·주별)이 자주 바뀌는지를 기준으로 견줘 보세요.
2. 소계·총계를 SQL 의 ROLLUP 으로 낼까, 화면단에서 합칠까
월별·카테고리별 게시물 수 보고서에서 소계와 총계가 필요할 때, ROLLUP·CUBE 로 데이터베이스가 한 번에 내려줄 수도 있고, 상세 데이터만 받아 와 화면(또는 엑셀)에서 합계를 낼 수도 있어요. 소계 행과 상세 행이 한 결과에 섞여 내려올 때 생기는 장단점은 무엇일까요? GROUPING() 으로 소계 행을 구분해야 하는 부담과, 그럼에도 데이터베이스에서 집계할 때의 이점을 함께 따져 보세요.
3. 윈도우 프레임의 기본값을 외워 둬야 하는 이유
오늘 LAST_VALUE 함정과 ROWS/RANGE 차이는 모두 "기본 프레임이 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW" 라는 한 가지 사실에서 나왔어요. 문법을 줄여 쓸 수 있게 해 주는 이런 "똑똑한 기본값" 이, 어떤 경우엔 오히려 함정이 되는 이유는 무엇일까요? 기본값에 의존할 때와 프레임을 항상 명시할 때의 트레이드오프를, 가독성과 버그 예방 관점에서 생각해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 정답을 그대로 베끼기보다, "이 값은 처음부터 쌓는 누적인가, 최근 몇 개만 보는 이동인가", "이 소계는 어느 기준으로 묶은 것인가", "프레임을 안 적으면 기본값이 무엇인가" 를 떠올리는 감각을 기르는 게 목표예요.
🎯 [과제 1 예시답안] 누적 SUM 과 누적 AVG
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
| (가) 누적 SUM + 누적 AVG 한 줄에 | 60% | OVER (ORDER BY stat_month) · 강지수 6줄 |
| (나) 6월 = 전체와 같은 이유 | 40% | "그달까지" 가 6월엔 곧 "전체" |
풀이 예시
(가) 강지수의 누적 팔로워와 누적 평균
회원을 강지수(6번)로 좁히고, SUM 과 AVG 를 같은 OVER 절로 나란히 붙여요. 한 회원만 보니 PARTITION BY 는 없어도 되지만, 있어도 결과는 같아요.
SELECT TO_CHAR(stat_month, 'YYYY-MM') AS mon,
follower_count,
SUM(follower_count) OVER (ORDER BY stat_month) AS cum_sum,
ROUND(AVG(follower_count) OVER (ORDER BY stat_month), 1) AS cum_avg
FROM account_monthly_stat
WHERE member_id = 6
ORDER BY stat_month;
| mon | follower_count | cum_sum | cum_avg |
|---|---|---|---|
| 2026-01 | 200 | 200 | 200 |
| 2026-02 | 215 | 415 | 207.5 |
| 2026-03 | 240 | 655 | 218.3 |
| 2026-04 | 260 | 915 | 228.8 |
| 2026-05 | 255 | 1170 | 234 |
| 2026-06 | 290 | 1460 | 243.3 |
누적 합계는 200 → 415(200+215) → 655(415+240) … 차곡차곡 쌓여 6월에 1460 까지 가요. 누적 평균은 그달까지의 값을 모아 평균을 내니 200 → 207.5 → 218.3 … 으로 천천히 올라가고요.
(나) 6월의 누적이 전체와 같은 이유
강지수의 6개월 팔로워를 다 더하면 200+215+240+260+255+290 = 1460 이고, 6으로 나눈 전체 평균은 243.3 이에요. 6월 행의 누적 합계(1460), 누적 평균(243.3)과 똑같죠. 누적은 "그 행까지" 를 보는데, 마지막 행인 6월에선 "그달까지" 가 곧 "1월부터 6월 전체" 라서 전체 집계와 일치하는 거예요.
💡 튜터의 한마디 — 누적의 마지막 행은 언제나 전체 집계와 같아요. 그래서 "전체 합계만 있으면 됐지 왜 누적이 필요한가" 싶을 때, 답은 "중간 과정(1월·2월…)까지 한 줄씩 보고 싶어서" 예요. 결과 한 줄이면 GROUP BY, 쌓이는 과정을 다 보려면 누적 OVER (ORDER BY) 라고 기억하세요.
🎯 [과제 2 예시답안] 이동 평균과 LAST_VALUE
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
| (가) 2개월 이동 평균 vs 누적 평균 | 55% | 가장 벌어지는 달 = 6월(240 vs 210) |
(나) LAST_VALUE 프레임 유무 차이 |
45% | 없으면 현재 행 값, 늘리면 6월값(250) |
풀이 예시
(가) 최근 2개월 이동 평균과 누적 평균 비교
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 는 "직전 1개월 + 현재 = 최근 2개월" 이에요. 같은 줄에 누적 평균(ORDER BY 만 쓴 기본 프레임)을 나란히 붙여 비교해요.
SELECT TO_CHAR(stat_month, 'YYYY-MM') AS mon,
follower_count,
ROUND(AVG(follower_count) OVER (ORDER BY stat_month
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 1) AS mov_avg_2m,
ROUND(AVG(follower_count) OVER (ORDER BY stat_month), 1) AS cum_avg
FROM account_monthly_stat
WHERE member_id = 9
ORDER BY stat_month;
| mon | follower_count | mov_avg_2m | cum_avg |
|---|---|---|---|
| 2026-01 | 180 | 180 | 180 |
| 2026-02 | 210 | 195 | 195 |
| 2026-03 | 185 | 197.5 | 191.7 |
| 2026-04 | 205 | 195 | 195 |
| 2026-05 | 230 | 217.5 | 202 |
| 2026-06 | 250 | 240 | 210 |
6월에 가장 크게 벌어져요. 이동 평균은 240(최근 5·6월 = 230·250의 평균)인데, 누적 평균은 210(1~6월 전체 평균)이에요. 30 이나 차이가 나죠. 김유나가 후반에 팔로워를 끌어올렸는데, 이동 평균은 최근만 보니 그 상승을 빠르게 반영하고, 누적 평균은 초반의 낮은 값까지 끌고 가서 천천히 움직이거든요.
(나) LAST_VALUE 프레임 유무
SELECT TO_CHAR(stat_month, 'YYYY-MM') AS mon,
follower_count,
LAST_VALUE(follower_count) OVER (ORDER BY stat_month) AS lv_default,
LAST_VALUE(follower_count) OVER (ORDER BY stat_month
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS lv_fixed
FROM account_monthly_stat
WHERE member_id = 9
ORDER BY stat_month;
| mon | follower_count | lv_default | lv_fixed |
|---|---|---|---|
| 2026-01 | 180 | 180 | 250 |
| 2026-02 | 210 | 210 | 250 |
| 2026-03 | 185 | 185 | 250 |
| 2026-04 | 205 | 205 | 250 |
| 2026-05 | 230 | 230 | 250 |
| 2026-06 | 250 | 250 | 250 |
프레임을 안 쓴 lv_default 는 매 줄 자기 값(180·210·185…)을 줘요. 기본 프레임이 "처음부터 현재 행까지" 라, 각 행에서 보는 마지막이 자기 자신이거든요. 프레임을 맨 끝까지 늘린 lv_fixed 는 어느 행에서 보든 마지막이 6월이라 250(김유나의 6월값)이 전부 붙어요.
💡 튜터의 한마디 — LAST_VALUE 가 이상하면 십중팔구 프레임 때문이에요. "마지막 값" 을 원하면 ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING 으로 끝까지 늘리거나, 정렬을 뒤집어 FIRST_VALUE 로 받는 우회법도 있어요. 둘 중 익숙한 쪽을 쓰면 돼요.
🎯 [과제 3 예시답안] ROLLUP·CUBE 와 GROUPING
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
(가) ROLLUP(category) 행 수 |
45% | 카테고리 5 + 총계 1 = 6줄 |
(나) GROUPING 으로 카테고리 소계만 |
55% | 5줄 · 6월 패션이 빠진 이유 설명 |
풀이 예시
(가) 카테고리별 게시물 수와 총계
ROLLUP(category) 는 컬럼이 하나뿐이라, 카테고리별 상세 + 전체 총계만 나와요.
SELECT NVL(category, '(총계)') AS category,
COUNT(*) AS n,
GROUPING(category) AS g
FROM post
GROUP BY ROLLUP(category)
ORDER BY g, category;
| category | n | g |
|---|---|---|
| 맛집 | 12 | 0 |
| 여행 | 14 | 0 |
| 음악 | 8 | 0 |
| 일상 | 13 | 0 |
| 패션 | 3 | 0 |
| (총계) | 50 | 1 |
6줄이에요. 카테고리 5줄(맛집12·여행14·음악8·일상13·패션3)에 전체 총계 1줄(50)이 더해졌죠. 총계 행은 category 가 NULL 이라 NVL 로 '(총계)' 라고 표시하고, GROUPING(category) 가 1 인 걸로도 구분할 수 있어요.
(나) CUBE 에서 카테고리 소계만 골라내기
CUBE(월, category) 결과 중 카테고리 소계는 "월은 뭉치고(GROUPING(월)=1) 카테고리는 살아 있는(GROUPING(category)=0)" 행이에요. 이 조건을 HAVING 에 걸어요.
SELECT category, COUNT(*) AS n
FROM post
GROUP BY CUBE(TO_CHAR(created_at, 'YYYY-MM'), category)
HAVING GROUPING(TO_CHAR(created_at, 'YYYY-MM')) = 1
AND GROUPING(category) = 0
ORDER BY category;
| category | n |
|---|---|
| 맛집 | 12 |
| 여행 | 14 |
| 음악 | 8 |
| 일상 | 13 |
| 패션 | 3 |
5줄이고, 패션도 3 으로 잘 보여요. 이 결과엔 "월" 이라는 구분이 없어요(월을 통째로 뭉친 소계니까요). 그래서 "6월 패션" 같은 건 애초에 나올 수 없고, 패션은 모든 달을 합친 3(전부 5월 게시물)으로만 나와요. "6월 패션이 안 보인다" 는 건 상세 행 이야기예요. 패션 게시물이 5월에만 있어서, CUBE 의 상세 행에는 (2026-06, 패션) 조합이 아예 만들어지지 않거든요. 데이터에 없는 조합은 0 짜리 행조차 생기지 않는다는 게 핵심이에요.
💡 튜터의 한마디 — GROUPING() 은 소계·총계 행을 가려내는 신호등이에요. 어떤 컬럼이 뭉쳐졌으면 1, 살아 있으면 0. 이걸 HAVING 이나 CASE 에 걸면 "월 소계만", "카테고리 소계만", "총계만" 을 자유롭게 추려낼 수 있어요.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] 누적을 데이터베이스에서 계산할까, 애플리케이션에서 계산할까
문제 상황 요약
누적 팔로워 그래프를 그릴 때, 데이터베이스가 SUM OVER 로 누적을 내려줄 수도 있고, 월별 원본만 받아 와 애플리케이션이 반복문으로 더해 갈 수도 있어요. 같은 그래프를 그리는 두 갈래 중 무엇을 기준으로 고를까요.
튜터의 가이드 및 해설
데이터 양과 재사용 빈도, 그리고 누적 기준이 바뀌는지를 봐요.
데이터베이스에서 누적을 내면, 정렬과 합산을 데이터에 가장 가까운 곳에서 한 번에 끝내고 화면은 결과만 받아요. 같은 누적을 여러 화면(웹·앱·관리자)에서 똑같이 써야 한다면, 쿼리 한 곳에 로직을 두는 편이 일관성도 좋고 각 화면이 따로 더하다 틀리는 일도 없죠. 수천·수만 행을 애플리케이션으로 전부 끌어와 더하는 것보다 네트워크와 메모리도 아끼고요.
반대로 애플리케이션에서 더하는 게 나을 때도 있어요. 이미 화면에 월별 원본을 다 받아 와 그래프를 그리는 중이라면, 누적은 그 배열을 한 번 훑으며 더하면 그만이라 굳이 쿼리를 다시 날릴 이유가 없어요. 누적 기준이 사용자 조작으로 자주 바뀌는 경우(월별 ↔ 주별 ↔ 분기별 토글)도, 매번 새 쿼리를 날리기보다 받아 둔 데이터를 클라이언트에서 다시 묶는 게 반응이 빠를 수 있고요.
🎯 SQLD는 이렇게 나온다
누적을 직접 계산하는 문제보다, "이 SUM(x) OVER (ORDER BY ...) 의 결과가 몇 행이고 각 행 값이 얼마인가" 를 묻는 게 단골이에요. ORDER BY 가 있으면 누적, 없으면 전체 합이 모든 행에 붙는다는 차이, 그리고 PARTITION BY 로 그룹마다 다시 쌓인다는 점을 결과 표로 읽어낼 수 있어야 해요.
💡 실무에선
"한 번 계산해 여러 곳에서 재사용" 이면 데이터베이스, "이미 받아 둔 데이터를 화면에서 살짝 가공" 이면 애플리케이션이 출발점이에요. 대량 데이터를 누적해야 하면 데이터베이스에 맡기고 필요한 구간만 받아 오는 게 보통 유리해요.
🤔 [생각해볼 주제 2] 소계·총계를 SQL 의 ROLLUP 으로 낼까, 화면단에서 합칠까
문제 상황 요약
월별·카테고리별 게시물 수 보고서에 소계와 총계가 필요해요. ROLLUP·CUBE 로 데이터베이스가 소계 행을 섞어 내려줄 수도 있고, 상세만 받아 와 화면이나 엑셀에서 합계를 낼 수도 있어요. 소계 행이 상세와 한 결과에 섞여 오는 게 득일까 실일까요.
튜터의 가이드 및 해설
ROLLUP 으로 소계를 내려주면, 모든 화면이 똑같은 규칙으로 계산한 소계를 보게 돼요. 한 화면은 합계를 반올림하고 다른 화면은 안 하는 식의 불일치가 안 생기죠. 보고서를 그대로 내보내거나 인쇄할 때도, 소계·총계가 이미 행으로 들어 있으니 따로 더할 필요가 없어요.
대신 비용이 있어요. 소계 행은 category 가 NULL 로 비어 있어서, 화면이 "이게 상세인지 소계인지" 를 구분해야 해요. 그래서 GROUPING() 으로 소계 행을 표시하고, 화면은 그 신호를 보고 굵게 칠하거나 들여쓰기를 다르게 하는 처리가 필요하죠. 이 구분을 빠뜨리면 소계 50 이 상세 한 줄처럼 섞여 보이는 사고가 나요.
반대로 상세만 받아 화면에서 합치면, 결과가 단순(상세만)해서 다루기 쉬워요. 다만 합계 로직이 화면마다 흩어지면, 같은 보고서인데 웹과 엑셀의 총계가 미묘하게 다른 일이 생길 수 있어요.
🎯 SQLD는 이렇게 나온다
ROLLUP(a, b) 와 CUBE(a, b) 가 각각 몇 행을 내는지, 그 차이(카테고리 소계의 유무)를 묻는 문제가 빈출이에요. GROUPING(컬럼) 이 소계 행에서 1 을 낸다는 점, ROLLUP(a,b) 와 ROLLUP(b,a) 의 소계가 다르다는 점도 자주 나와요. 결과 행 수를 세는 연습을 해 두세요.
💡 실무에선
여러 채널이 같은 보고서를 봐야 하면 ROLLUP 으로 소계를 통일하고 GROUPING() 으로 표시해요. 화면 하나에서만 가볍게 쓰는 합계라면 상세만 받아 화면에서 더하는 것도 충분하고요. 기준은 "이 소계를 한 곳에서 정의해야 하는가" 예요.
🤔 [생각해볼 주제 3] 윈도우 프레임의 기본값을 외워 둬야 하는 이유
문제 상황 요약
오늘 본 LAST_VALUE 함정과 ROWS/RANGE 차이는 모두 "기본 프레임이 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW" 라는 한 가지에서 나왔어요. 문법을 짧게 줄여 주는 똑똑한 기본값이 왜 함정이 되는 걸까요.
튜터의 가이드 및 해설
기본값은 "가장 흔한 경우" 를 가정해 코드를 줄여 줘요. 누적 합계는 대부분 "처음부터 현재까지" 라서, 프레임을 생략하면 그렇게 동작하도록 기본값이 잡혀 있죠. 덕분에 SUM(x) OVER (ORDER BY ...) 만 써도 누적이 돼요.
문제는 기본값이 모든 함수에 똑같이 적용된다는 점이에요. 누적 SUM 에는 "현재 행까지" 가 자연스럽지만, LAST_VALUE 에는 그게 "마지막이 아니라 현재 값" 이라는 함정이 돼요. 함수의 의도와 기본 프레임이 어긋나는 순간, 생략된 기본값이 조용히 엉뚱한 답을 만들어요. 게다가 기본값이 ROWS 가 아니라 RANGE 라, 동점이 있는 데이터에선 누적이 의도와 다르게 동점끼리 합쳐지기도 하고요.
그래서 "기본값을 알되, 의심스러우면 명시한다" 가 안전해요. LAST_VALUE 처럼 함정이 알려진 곳이나 동점이 있는 데이터에선 프레임을 직접 적어, 코드를 읽는 사람도 범위를 한눈에 알 수 있게 하는 거죠.
🎯 SQLD는 이렇게 나온다
"프레임을 생략한 SUM OVER (ORDER BY ...) 의 기본 프레임은 무엇인가" 를 직접 묻거나, LAST_VALUE 가 현재 행 값을 내는 결과를 주고 "왜 마지막 값이 안 나오는가" 를 묻는 식이에요. 기본값이 RANGE ... CURRENT ROW 라는 한 줄이 정답의 열쇠예요.
💡 실무에선
누적처럼 기본값이 의도와 맞는 곳은 생략해 짧게 쓰고, LAST_VALUE 나 동점이 끼는 곳은 프레임을 명시해요. 명시한 프레임은 버그를 막을 뿐 아니라, 동료가 쿼리를 읽을 때 "이 계산이 어디부터 어디까지인지" 를 바로 알려 주는 문서 역할도 해요.