문서 읽는 데 53분 · D5

D-5: 윈도우 함수 ② — 차곡차곡 쌓고, 소계와 총계까지 한 번에

목차 21
전체 24강 중 17강 · 데이터베이스
난이도 · 입문

ℹ️Oracle SQL로 SQLD 자격증을 대비하며 실전 SQL을 쌓는 트랙이에요. 프로그래밍 경험이 없어도 시작할 수 있고, 부트캠프에서는 자바 기초 다음·스프링 부트 직전에 배치하길 권해요.

안녕하세요, 홍순구 튜터입니다. 지난 시간엔 데이터를 가로로 줄 세워 등수를 매기고(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
-- 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월부터 그달까지의 평균 팔로워" 를 구하는 거죠. 매달 들쭉날쭉한 값을 평탄하게 펴서 추세를 보고 싶을 때 써요.

SQL
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)을 만들어 볼게요.

SQL
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)과 나란히 비교할게요.

SQL
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월) 있어요.

SQL
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 는 그룹별 개수만 줬어요. 소계나 총계 행은 따로 쿼리를 또 날려서 합쳐야 했죠. ROLLUPGROUP BY 를 살짝 확장해서, 그룹별 집계에 소계 행과 총계 행을 자동으로 덧붙여 줘요. 지난 시간 예고한 그 기능이에요.

오늘은 게시물에 새로 붙인 카테고리 컬럼(category)을 써요. 게시물 50개에 여행·맛집·일상·음악·패션 다섯 카테고리가 붙어 있어요. 월별·카테고리별 게시물 수를 소계·총계와 함께 뽑아 볼게요.

SQL
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 는 가능한 모든 조합의 소계를 만들어 줘요.

SQL
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 는 "내가 원하는 소계 조합만 콕 집어" 만들어요. 상세도 총계도 필요 없고 월 소계와 카테고리 소계만 보고 싶다면 이렇게 써요.

SQL
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 는 내가 직접 적은 조합만 만드는 가장 자유로운 방식이에요. 사실 ROLLUPCUBEGROUPING 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번)로 좁히고, SUMAVG 를 같은 OVER 절로 나란히 붙여요. 한 회원만 보니 PARTITION BY 는 없어도 되지만, 있어도 결과는 같아요.

SQL
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 만 쓴 기본 프레임)을 나란히 붙여 비교해요.

SQL
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 프레임 유무

SQL
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) 는 컬럼이 하나뿐이라, 카테고리별 상세 + 전체 총계만 나와요.

SQL
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 에 걸어요.

SQL
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 나 동점이 끼는 곳은 프레임을 명시해요. 명시한 프레임은 버그를 막을 뿐 아니라, 동료가 쿼리를 읽을 때 "이 계산이 어디부터 어디까지인지" 를 바로 알려 주는 문서 역할도 해요.

전체 목록 데이터베이스