[TIL] MySQL에서 피벗테이블 만들기

2025. 9. 30. 01:12·SQL

이번 주에는 MySQL로 피벗을 만드는 방법을 배웠습니다.

 

배운 내용을 복습할 겸, 엑셀·파이썬 없이 순수 SQL만으로

“월 × 카테고리” 매출 표를 만들고 월 합계/연도 합계/전체 합계까지 한 번에 붙이는 과정을 다시 정리해보겠습니다 !

 

특히 MySQL의 ONLY_FULL_GROUP_BY 모드에서도 깨끗하게 동작하도록 서브쿼리에서 연·월을 먼저 계산하는 패턴을 사용합니다.

 

이번 글에서는 다음과 같은 테이블을 MySQL로 만들게 됩니다.

  • "월 × 카테고리(전자/의류/식품)" 교차표를 열 피벗으로 만들기
  • WITH ROLLUP으로 월 합계, 연도 합계, 전체 합계를 자동으로 붙이기
  • GROUPING() 함수로 합계 행을 라벨링
  • ONLY_FULL_GROUP_BY 환경에서도 안전하게 동작

 

 

0. 핵심 아이디어 한눈에 정리

(1) 피벗 (열로 펼치기)

: SUM(CASE WHEN category='전자' THEN amount ELSE 0 END) 형태로 조건부 집계를 사용합니다.

 

(2) 합계 자동화

: GROUP BY ... WITH ROLLUP을 붙이면 소계/총계가 자동 생성됩니다.

 

(3) 합계 식별/라벨링

: GROUPING(col)이 합계 행이면 1, 일반 행이면 0을 반환합니다.

 

(4) ONLY_FULL_GROUP_BY 대응
: DATE_FORMAT(sale_date, '%Y-%m')처럼 원본 컬럼을 SELECT에서 직접 참조하면 에러가 날 수 있습니다.
서브쿼리에서 YEAR(sale_date) AS yr, MONTH(sale_date) AS mo를 먼저 만들어 두고, 바깥 쿼리에서는 yr, mo만 사용합니다.

 

 

왜 서브쿼리(yr, mo)인가?

ONLY_FULL_GROUP_BY가 켜져 있으면, GROUP BY YEAR(sale_date), MONTH(sale_date)를 하면서

SELECT DATE_FORMAT(sale_date, '%Y-%m') 같은 원본 컬럼/함수를 함께 쓰면 함수적 종속성 위반으로 에러가 납니다.


따라서 안쪽 서브쿼리에서 YEAR(sale_date) AS yr, MONTH(sale_date) AS mo를 명시 컬럼으로 뽑아두고,

바깥쪽에선 yr, mo만으로 집계/라벨링을 합니다. 이 패턴이 가장 깔끔합니다.

 

1. [준비] 예제 스키마 & 더미 데이터

-- (옵션) 예제 전용 DB
-- CREATE DATABASE IF NOT EXISTS sales_demo
--   DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- USE sales_demo;

-- 기존 테이블이 있다면 삭제(없어도 경고만 나오고 통과)
DROP TABLE IF EXISTS sales;

-- 매출 테이블
CREATE TABLE sales (
  id         INT PRIMARY KEY AUTO_INCREMENT,
  sale_date  DATE NOT NULL,
  region     ENUM('서울','부산','대전','대구','광주') NOT NULL,
  channel    ENUM('온라인','오프라인') NOT NULL,
  category   ENUM('전자','의류','식품') NOT NULL,
  product    VARCHAR(50) NOT NULL,
  quantity   INT NOT NULL,
  unit_price DECIMAL(12,2) NOT NULL,
  -- 금액 = 수량 * 단가 (생성 컬럼)
  amount     DECIMAL(14,2) AS (quantity * unit_price) STORED,

  KEY idx_date (sale_date),
  KEY idx_region_channel (region, channel),
  KEY idx_category (category),
  KEY idx_date_category (sale_date, category)
) ENGINE=InnoDB;

-- 2025-08, 2025-09 / 서울·부산 / 온·오프 / 전자·의류·식품 더미 데이터
INSERT INTO sales (sale_date, region, channel, category, product, quantity, unit_price) VALUES
('2025-08-05','서울','온라인','전자','노트북A', 5, 300000),
('2025-08-05','서울','온라인','의류','셔츠A',   8,  50000),
('2025-08-05','서울','온라인','식품','과자A',  20,   8000),
('2025-08-06','서울','오프라인','전자','노트북B', 3, 320000),
('2025-08-06','서울','오프라인','의류','셔츠B',   6,  60000),
('2025-08-06','서울','오프라인','식품','과자B',  15,   9000),

('2025-08-07','부산','온라인','전자','노트북C', 4, 280000),
('2025-08-07','부산','온라인','의류','셔츠C',   7,  45000),
('2025-08-07','부산','온라인','식품','과자C',  18,   7000),
('2025-08-08','부산','오프라인','전자','노트북D', 2, 310000),
('2025-08-08','부산','오프라인','의류','셔츠D',   5,  55000),
('2025-08-08','부산','오프라인','식품','과자D',  12,   8500),

('2025-09-10','서울','온라인','전자','노트북E', 6, 310000),
('2025-09-10','서울','온라인','의류','셔츠E',  10,  52000),
('2025-09-10','서울','온라인','식품','과자E',  22,   8200),
('2025-09-11','서울','오프라인','전자','노트북F', 4, 330000),
('2025-09-11','서울','오프라인','의류','셔츠F',   7,  61000),
('2025-09-11','서울','오프라인','식품','과자F',  16,   9200),

('2025-09-12','부산','온라인','전자','노트북G', 5, 285000),
('2025-09-12','부산','온라인','의류','셔츠G',   8,  46000),
('2025-09-12','부산','온라인','식품','과자G',  19,   7300),
('2025-09-13','부산','오프라인','전자','노트북H', 3, 315000),
('2025-09-13','부산','오프라인','의류','셔츠H',   6,  56000),
('2025-09-13','부산','오프라인','식품','과자H',  13,   8700);
상위 20개 출력
SELECT COUNT(*) AS rows, SUM(amount) AS total_amount FROM sales;
-- 기대: rows=24, total_amount=13,853,400

 

 

2. 월 × 카테고리 피벗 + 월합계 + 연도합계 + 전체합계

패턴 요약

1. 서브쿼리에서 YEAR(sale_date) AS yr, MONTH(sale_date) AS mo 뽑기

2. 바깥에서 GROUP BY yr, mo WITH ROLLUP

3. SUM(CASE WHEN category=... THEN amount END)로 열 피벗

4. GROUPING(yr), GROUPING(mo)로 합계 행 라벨링

5. 정렬은 ORDER BY GROUPING(yr), yr, GROUPING(mo), mo

 

SELECT
  CASE
    WHEN GROUPING(yr)=1 AND GROUPING(mo)=1 THEN '전체 합계'   -- grand total
    WHEN GROUPING(mo)=1 THEN CONCAT(yr, '년 합계')           -- year subtotal
    ELSE CONCAT(yr, '-', LPAD(mo, 2, '0'))                   -- e.g. 2025-08
  END AS '월 \\ 카테고리',

  -- 카테고리별 금액(열 피벗)
  SUM(CASE WHEN category='전자' THEN amount ELSE 0 END) AS 전자,
  SUM(CASE WHEN category='의류' THEN amount ELSE 0 END) AS 의류,
  SUM(CASE WHEN category='식품' THEN amount ELSE 0 END) AS 식품,

  -- 월 합계
  SUM(amount) AS '월 매출 합계'
FROM (
  SELECT YEAR(sale_date) AS yr,
         MONTH(sale_date) AS mo,
         category,
         amount
  FROM sales
) s
GROUP BY yr, mo WITH ROLLUP
ORDER BY
  GROUPING(yr), yr,         -- 일반 연도 먼저, 마지막에 전체 합계
  GROUPING(mo), mo;         -- 각 연도 안에서 월들 먼저, 그 다음 연도 합계

연도 합계 행을 숨기고 싶으면 마지막에 아래를 추가합니다.
HAVING NOT (GROUPING(yr)=0 AND GROUPING(mo)=1);

 

 

옵션 : 출력 포맷/필터링/수량 피벗

(1) 숫자 포맷(3자리 콤마) : 시각화를 위해 표시용으로만 FORMAT()을 씁니다(문자형 반환 주의)

SELECT ..., 
    FORMAT(SUM(amount), 0) AS '월 매출 합계(표시용)'
FROM ...

 

 

(2) 특정 연도만 보고 싶을 때 : 인덱스 친화적으로 범위 조건을 권장합니다.

FROM sales 
WHERE sale_date >= '2025-01-01' AND sale_date < '2026-01-01'

 

(3) 수량 기준 피벗 : amount 대신 quantity로 동일 패턴을 사용합니다.

SUM(CASE WHEN category='전자' THEN quantity ELSE 0 END) AS 전자_수량
 

3. [응용 예시 1] 지역 × 채널 피벗 + 지역합계 + 전체합계

SELECT
  CASE WHEN GROUPING(region)=1 THEN '전체 합계' ELSE region END AS '지역 \\ 채널',
  SUM(CASE WHEN channel='온라인'  THEN amount ELSE 0 END) AS 온라인,
  SUM(CASE WHEN channel='오프라인' THEN amount ELSE 0 END) AS 오프라인,
  SUM(amount) AS '지역 매출 합계'
FROM sales
GROUP BY region WITH ROLLUP
ORDER BY GROUPING(region), region;

 

4. [응용 예시 2] 카테고리 × 채널 — 건수와 매출을 동시에

SELECT
  CASE WHEN GROUPING(category)=1 THEN '전체 합계' ELSE category END AS '카테고리 \\ 채널',

  -- 주문 건수(행 수)
  COUNT(CASE WHEN channel='온라인'  THEN 1 END)  AS '온라인 건수',
  COUNT(CASE WHEN channel='오프라인' THEN 1 END) AS '오프라인 건수',

  -- 매출 금액
  SUM(CASE WHEN channel='온라인'  THEN amount ELSE 0 END) AS '온라인 매출',
  SUM(CASE WHEN channel='오프라인' THEN amount ELSE 0 END) AS '오프라인 매출',

  SUM(amount) AS '카테고리 합계 매출'
FROM sales
GROUP BY category WITH ROLLUP
ORDER BY GROUPING(category), category;

 

5. 정렬 & 라벨링 핵심 정리

- 합계 행 인지 : GROUPING(col) -> 합계행이면 1, 일반행이면 0.

- 라벨링 규칙

   - 월 라벨 : CONCAT(yr, '-', LPAD(mo,2,'0'))

   - 연도 합계 : GROUPING(mo)=1

   - 전체 합계 : GROUPING(yr)=1 AND GROUPING(mo)=1

- 정렬 추천 패턴일반 행 -> 연도합계 -> 전체합계 순서로 깔끔히 정렬됩니다.

- ORDER BY GROUPING(yr), yr, GROUPING(mo), mo

 

6. 성능/운영 팁

(1) 인덱스

- 시계열 집계에 sale_date(날짜), 축약 피벗에 region(지역), channel(채널), 카테고리 집계에 sale_date(날짜), category(카테고리) 등이 유리합니다.

-- 월×카테고리 피벗 (월 라벨은 ym 생성 컬럼 사용 시 최적)
CREATE INDEX idx_sales_ym_cat ON sales (/* ym 또는 sale_date */, category);

-- 지역×채널 피벗
CREATE INDEX idx_sales_region_channel_ym ON sales (region, channel, /* ym 또는 sale_date */);

-- 드릴다운/세부 조회
CREATE INDEX idx_sales_date_category ON sales (sale_date, category);

 

- 대량 데이터라면 EXPLAIN으로 type/rows/key/Extra 확인 필수

* type은 range 이상, rows는 기간 제한으로 낮아져야 합니다. key가 의도한 인덱스인지 확인하고, Extra에 Using temporary/Using filesort가 잦으면 인덱스 순서와 GROUP BY/ORDER BY를 재검토합니다.

EXPLAIN
SELECT YEAR(sale_date)*100+MONTH(sale_date) AS ym, category, SUM(amount)
FROM sales
WHERE sale_date >= '2025-08-01' AND sale_date < '2025-10-01'
GROUP BY ym, category;

 

(2) 생성 컬럼(amount)

- 읽기 많은 집계에서 계산 비용을 줄이고 일관성을 확보합니다.
* 읽기 많은 집계에서는 amount = quantity * unit_price를 생성(STORED) 컬럼으로 두면 매 쿼리마다 곱셈을 반복하지 않아 CPU 사용량과 변환 비용을 줄일 수 있습니다.

-- 이미 테이블이 있다면
ALTER TABLE sales
  ADD COLUMN amount DECIMAL(14,2) AS (quantity * unit_price) STORED;

 

(3) 동적 피벗

- 카테고리/채널이 자주 바뀌면 고정 컬럼 피벗은 유지보수 비용이 큽니다.

* 동적 컬럼이 필요하면 애플리케이션 레이어에서 피벗하거나, MySQL에선 동적 SQL(프로시저)로 생성합니다.

 

(4) ROLLUP 과한 합계 숨기기

- ROLLUP은 월 합계 + 연도 합계 + 전체 합계를 모두 만듭니다. 필요 없는 합계는 HAVING으로 정리합니다.

* HAVING NOT (조건)으로 특정 합계 행을 제거해 시각적으로 단순화할 수 있습니다.

-- ex) 연도 합계 행만 제거
HAVING NOT (GROUPING(yr)=0 AND GROUPING(mo)=1);

-- ex) 전체 합계 행까지 제거
HAVING GROUPING(yr)=0 AND GROUPING(mo)=0;

 

 

 

이 글의 핵심은 서브쿼리에서 집계 키(yr/mo)를 먼저 생성해

ONLY_FULL_GROUP_BY 환경에서도 안전하게 ROLLUP + 피벗을 구현하는 패턴입니다.

 

이 패턴 하나면 "월×카테고리", "지역×채널", "상품군×고객등급" 등 대부분의 경영 리포트를

순수 SQL로 재사용 가능하게 만들 수 있습니다.

'SQL' 카테고리의 다른 글

[TIL] MySQL 기본 문법부터 트랜잭션, 락까지 한번에 정리  (1) 2025.10.10
'SQL' 카테고리의 다른 글
  • [TIL] MySQL 기본 문법부터 트랜잭션, 락까지 한번에 정리
uoaheu
uoaheu
uoaheu 님의 블로그 입니다.
  • uoaheu
    uoaheu 님의 블로그
    uoaheu
  • 전체
    오늘
    어제
    • 분류 전체보기 (50)
      • 알고리즘 (7)
      • CS (9)
      • FRONTEND (9)
        • React (12)
        • Kotlin (1)
        • JS (5)
        • HTML (2)
      • SQL (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    useactionstate
    멀티캠퍼스it부트캠프
    혼자서 공부하는 네트워크
    toss 분석
    mysql
    토스 앱 분석
    리액트usestate
    이더넷프레임
    엘지유플러스유레카프론트엔드
    BFS
    mysql로 피벗테이블만들기
    백준1926번
    토스 uiux
    알고리즘
    토스분석
    부트캠프후기
    toss uiux
    유레카3기
    mysql 피벗테이블
    boj25418
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
uoaheu
[TIL] MySQL에서 피벗테이블 만들기
상단으로

티스토리툴바