명령어/DB

[PostgreSQL] 월간 매출 리포트 한 방에: ROLLUP + FILTER + GROUPING()

jykim23 2026. 7. 21. 21:53
반응형

설치·접속: PostgreSQL 설치와 접속

부제: 카테고리별·월별 매출에 소계와 총계, 환불액까지 붙인 리포트를 UNION ALL 없이 단일 SELECT로 뽑을 때

문제 상황

카테고리별 매출, 카테고리+월별 매출, 그리고 전사 총계를 한 표에 뿌려야 한다. 흔한 방식은 GROUP BY가 다른 쿼리를 3~4개 만들어 UNION ALL로 붙이거나, 앱에서 각 레벨을 다시 집계하는 것이다. 여기에 "완료 매출과 환불액을 나란히" 요구가 붙으면 쿼리는 더 늘어난다. 이걸 ROLLUP + FILTER + GROUPING() 조합으로 단일 SELECT에 담는다.

검증용 스크래치 테이블은 주문 200건에 카테고리(electronics/apparel/grocery), 판매월(1~4월), 상태(paid/refunded/canceled), 금액을 붙여 만들었다.

1단계 — ROLLUP으로 소계와 총계를 한 번에

GROUP BY ROLLUP(category, mon)(category, mon)(category)() 세 grouping set을 한 번에 계산한다. 즉 카테고리+월 상세, 카테고리 소계, 전체 총계가 한 결과에 쌓인다.

SELECT category, extract(month FROM sold_at)::int AS mon, sum(amount) AS revenue
FROM sales
GROUP BY ROLLUP(category, extract(month FROM sold_at));

소계 행에서는 mon이 NULL, 총계 행에서는 category까지 NULL로 나온다. 문제는 이 NULL이 "값이 없는 행"인지 "소계 행"인지 구분이 안 된다는 점이다.

2단계 — GROUPING()으로 소계 행을 라벨링

GROUPING(col)은 그 컬럼이 이번 grouping set에서 집계로 뭉개졌으면 1, 살아있으면 0을 준다. 이걸로 NULL이 "진짜 데이터의 NULL"인지 "소계라서 비운 칸"인지 확실히 구분해 라벨을 붙인다.

3단계 — FILTER로 환불액을 같은 줄에

sum(amount) FILTER (WHERE status='refunded')는 환불 건만 골라 합산한다. CASE WHEN ... THEN amount ELSE 0 END를 감싸는 대신 의도가 그대로 드러난다. 세 조각을 합친 최종 리포트:

SELECT
  CASE WHEN GROUPING(category)=1 THEN 'ALL (total)' ELSE category END AS category,
  CASE WHEN GROUPING(mon)=1 THEN 'subtotal' ELSE mon::text END AS mon,
  sum(amount)                                    AS revenue,
  sum(amount) FILTER (WHERE status='refunded')   AS refunded,
  count(*)    FILTER (WHERE status='canceled')   AS cancels,
  GROUPING(category, mon)                        AS grp
FROM (SELECT category, extract(month FROM sold_at)::int AS mon, amount, status
      FROM sales) t
GROUP BY ROLLUP(category, mon)
ORDER BY GROUPING(category), category, GROUPING(mon), mon;
  category   |   mon    | revenue  | refunded | cancels | grp 
-------------+----------+----------+----------+---------+-----
 apparel     | 1        |  1425.53 |   220.63 |       3 |   0
 apparel     | 2        |  2418.79 |    89.18 |       7 |   0
 apparel     | 3        |  2228.70 |   581.14 |       3 |   0
 apparel     | 4        |  2627.08 |  1168.86 |       5 |   0
 apparel     | subtotal |  8700.10 |  2059.81 |      18 |   1
 electronics | 1        |  3139.91 |   557.01 |       4 |   0
 electronics | 2        |  2656.91 |   702.85 |       1 |   0
 electronics | 3        |  2200.97 |    45.45 |       3 |   0
 electronics | 4        |  3065.11 |   967.45 |       3 |   0
 electronics | subtotal | 11062.90 |  2272.76 |      11 |   1
 grocery     | 1        |  4636.73 |  1554.54 |       3 |   0
 grocery     | 2        |  3501.69 |  1275.40 |       2 |   0
 grocery     | 3        |  4134.65 |   906.44 |       4 |   0
 grocery     | 4        |  1842.27 |   229.68 |       2 |   0
 grocery     | subtotal | 14115.34 |  3966.06 |      11 |   1
 ALL (total) | subtotal | 33878.34 |  8298.63 |      40 |   3
(16 rows)

grp=0은 상세 행, grp=1은 카테고리 소계, 마지막 ALL (total) 행이 전사 총계다. 상세 12행 + 소계 3행 + 총계 1행 = 16행이 한 쿼리에서 나왔다. 같은 걸 UNION ALL로 짜면 최소 세 개의 SELECT를 붙여야 하고, 나중에 컬럼 하나 추가할 때마다 세 군데를 똑같이 고쳐야 한다.

결론 — 언제 이 조합을 쓰나

"상세 + 소계 + 총계"를 한 표로 내려보내는 리포트, 그리고 그 안에서 상태별(완료/환불/취소) 수치를 나란히 놓아야 하는 대시보드가 정확히 이 조합의 자리다. ROLLUP이 계층 소계를, GROUPING()이 소계 행 라벨을, FILTER가 조건부 집계 컬럼을 담당한다. 셋 다 core 기능이라 확장도 필요 없다.

이렇게도 쓴다

특정 소계 조합만 필요하면 ROLLUP 대신 GROUPING SETS로 정확히 지정한다. (조합: GROUPING SETS)

SELECT category, extract(month FROM sold_at)::int AS mon, sum(amount) AS revenue
FROM sales
GROUP BY GROUPING SETS ((category), (extract(month FROM sold_at)), ());
  category   | mon | revenue  
-------------+-----+----------
 apparel     |     |  8700.10
 electronics |     | 11062.90
 grocery     |     | 14115.34
             |   1 |  9202.17
             |   2 |  8577.39
             |   3 |  8564.32
             |   4 |  7534.46
             |     | 33878.34
(8 rows)

 

카테고리별 매출 구성비(%)는 윈도우 집계로 붙인다. sum(sum(amount)) OVER ()가 전체 합. (조합: 윈도우 함수)

SELECT category, sum(amount) AS revenue,
       round(100 * sum(amount) / sum(sum(amount)) OVER (), 1) AS pct
FROM sales GROUP BY category ORDER BY revenue DESC;
  category   | revenue  | pct  
-------------+----------+------
 grocery     | 14115.34 | 41.7
 electronics | 11062.90 | 32.7
 apparel     |  8700.10 | 25.7

 

모든 부분집합(카테고리 단독, 월 단독, 교차, 총계)이 다 필요하면 CUBE. (조합: CUBE)

SELECT category, extract(month FROM sold_at)::int AS mon, sum(amount)
FROM sales GROUP BY CUBE(category, extract(month FROM sold_at));

 

정렬을 소계가 맨 아래로 가게 하려면 ORDER BY GROUPING(...)을 앞에 둔다. NULL 정렬에 의존하지 않아 안전하다.

... GROUP BY ROLLUP(category, mon)
ORDER BY GROUPING(category), category, GROUPING(mon), mon;

차원이 3개를 넘어가고 계층이 고정(연-분기-월-일)이면 ROLLUP이 여전히 깔끔하지만, 임의 다차원 슬라이스를 사용자가 직접 돌리는 BI 성격이면 집계를 미리 물질화(MATERIALIZED VIEW)해 두고 그 위에서 조회하는 편으로 갈아탄다.

반응형