학습 목표
- 파티셔닝이 실제로 푸는 문제(대량 삭제, 넓은 범위 스캔)와 못 푸는 문제(단일 행 조회 성능)를 구분한다
- RANGE / LIST / HASH / KEY / RANGE COLUMNS 를 목적에 맞게 고른다
EXPLAIN의partitions컬럼으로 파티션 프루닝이 걸렸는지 눈으로 확인한다DROP PARTITION으로 오래된 로그를 즉시 버리는 실무 패턴을 익힌다 (DELETE대비 30배 이상)- 파티션의 제약(유니크 키 규칙, FK 불가)을 미리 알고 설계 단계에서 피한다
선행 스텝: Step 15 — 인덱스, Step 16 — EXPLAIN 예상 소요: 60분
파티셔닝(Partitioning)은 하나의 논리 테이블을 여러 개의 물리 조각(파티션)으로 나누어 저장하는 기능입니다. 애플리케이션 입장에서는 여전히 테이블 하나입니다. SQL 도 그대로입니다.
가장 흔한 오해부터 깨고 시작합시다.
| 흔한 기대 | 진실 |
|---|---|
| "테이블이 커서 느리니 파티션 나누면 빨라지겠지" | 아니다. 인덱스로 1행 찾는 쿼리는 파티션해도 빨라지지 않는다. B-트리 높이는 100만 행이든 1억 행이든 3~4단계다. |
| "파티션이 인덱스를 대신한다" | 아니다. 파티션은 인덱스가 아니다. 파티션 키가 없는 조건은 모든 파티션을 다 뒤진다(= 오히려 느려진다). |
| "일단 나눠두면 손해는 없다" | 손해가 있다. 파티션 개수만큼 파일 핸들/메타데이터가 늘고, 프루닝이 안 되는 쿼리는 파티션 수만큼 스캔을 반복한다. |
파티셔닝이 진짜로 잘 푸는 문제는 딱 세 가지입니다.
DROP PARTITION 은 파일을 통째로 버린다. 행 단위 DELETE 와는 차원이 다르다. (← 이게 90%의 도입 이유)OPTIMIZE/ANALYZE 를 파티션 단위로 돌릴 수 있다.⚠️ 함정 — "테이블이 크다"가 파티셔닝의 도입 근거가 될 수 없습니다. 도입 근거는 "오래된 데이터를 주기적으로 버려야 한다" 또는 "조회가 항상 날짜 범위로 들어온다" 입니다. 이 둘 중 어느 것도 해당하지 않으면 파티셔닝하지 마세요.
100만 행짜리 access_logs(logged_at 은 2024-01-01 ~ 2024-12-01) 구조를 복제해서
logged_at 기준 월 단위 RANGE COLUMNS 파티션 테이블을 만듭니다.
파티션이 실제로 나뉘었는지는 information_schema.PARTITIONS 로 확인합니다.
결과
💡 실무 팁 —
TABLE_ROWS는 InnoDB에서 추정치입니다(89,818 vs 실제 92,358). 정확한 행수가 필요하면SELECT COUNT(*) FROM s21_access_logs PARTITION (p2024_09);처럼 파티션을 직접 지목하세요.
partitions 컬럼파티션 프루닝(Partition Pruning) = 옵티마이저가 WHERE 조건을 보고 "이 파티션은 볼 필요조차 없다"고 잘라내는 것.
이게 안 걸리면 파티셔닝은 아무 의미가 없습니다. 확인 방법은 딱 하나, EXPLAIN 의 partitions 컬럼입니다.
결과
partitions = p2024_09 — 13개 중 1개만 열었습니다. 성공.
결과
전 파티션(13개) 스캔. 인덱스가 함수 때문에 안 먹는 것과 정확히 같은 원리입니다.
결과
⚠️ 함정 — 파티션 키를
logged_at으로 잡았는데 정작 API 쿼리가WHERE customer_id = ?라면, 파티셔닝은 이득이 0이고 손해만 남습니다. 파티션 키는 반드시 가장 흔한 WHERE 조건을 보고 정하세요.
비교를 공정하게 하기 위해, 같은 데이터 + 같은 logged_at 인덱스를 가진 비파티션 테이블 s21_access_logs_np 를 만들어 놓고 EXPLAIN ANALYZE(8.0.18+, 쿼리를 실제로 실행하고 시간을 측정)로 비교합니다.
결과
17.7ms vs 41.8ms. 파티션 쪽이 조금 빠르지만, 비파티션도 인덱스로 충분히 잘 처리합니다. (파티션 쪽은 "1개 파티션 풀스캔"이 되어 인덱스 랜덤 액세스를 피한 것이 이득의 정체입니다.)
결과
90ms vs 156ms. 핵심은 시간이 아니라 스캔한 행수입니다: rows=457766 vs rows=1e+6.
인덱스가 포기되는 넓은 범위에서, 파티션은 읽어야 할 데이터 자체를 절반 이하로 줄였습니다.
결과
125ms vs 105ms — 파티션 테이블이 더 느립니다. 같은 100만 행을 읽는데 파티션은 13번 나눠 읽었기 때문입니다. 이것이 "파티셔닝은 공짜가 아니다"의 실측 증거입니다.
TO_DAYS() 등으로 정수화)결과
WHERE TO_DAYS(logged_at) = ... 이라고 쓰지 않았는데도 프루닝됐습니다.
옵티마이저는 TO_DAYS/YEAR/TO_SECONDS 세 함수에 한해 파티션 식을 역산해 줍니다. (그 외 함수는 안 됩니다.)
결과
⚠️ 함정 — LIST 에는 RANGE 의
MAXVALUE같은 안전망이 없습니다. 정의되지 않은 값이 들어오면INSERT가 통째로 실패합니다. 새 지역 코드가 추가될 수 있다면 LIST 는 위험합니다. (PARTITION p_etc VALUES IN (...)에 미리 넉넉히 넣거나, RANGE 를 쓰세요.)
결과 (고객 30명이 4개 파티션에 고르게 분산)
프루닝은 등치(=, IN) 조건에서만 걸립니다.
⚠️ 함정 — HASH 파티션의 개수를 바꾸면(
COALESCE/ADD PARTITION) 전체 데이터를 재분배합니다. 테이블 전체 재작성이라 운영 중에는 사실상 불가능합니다. 처음부터 넉넉히 잡으세요.
결과
HASH 는 파티션 식이 정수를 반환해야 하지만, KEY 는 MySQL 이 내부 해시 함수를 써서 문자열/날짜도 받습니다.
RANGE COLUMNS 는 함수를 쓰지 않고 컬럼 값을 그대로 비교합니다.
DATE/DATETIME/문자열/여러 컬럼(튜플 비교)을 지원해서, 날짜 파티션이라면 RANGE(TO_DAYS(...)) 보다 RANGE COLUMNS(날짜컬럼) 가 더 읽기 쉽고 안전합니다. 이 스텝의 s21_access_logs 도 그렇게 만들었습니다.
결과
이유는 단순합니다. MySQL 의 파티션은 로컬 인덱스(파티션마다 자기 인덱스를 따로 가짐)만 지원합니다. "글로벌 유니크"를 보장하려면 INSERT 마다 모든 파티션의 인덱스를 뒤져야 하는데, 그러면 파티셔닝의 이점이 사라집니다. 그래서 MySQL 은 아예 금지합니다.
이 제약의 진짜 무서운 점: 유니크 인덱스도 마찬가지입니다.
⚠️ 함정 —
users테이블을created_at으로 파티션하려면UNIQUE(email)을UNIQUE(email, created_at)으로 바꿔야 합니다. 그런데 그건 "이메일 중복 방지"가 깨진다는 뜻입니다. (같은 이메일이 다른 날짜로 여러 번 들어올 수 있게 됨.) 비즈니스 유니크 제약이 있는 테이블은 애초에 파티셔닝 후보가 아닙니다. 로그/이벤트/이력처럼 유니크 제약이 PK 하나뿐인 테이블이 파티셔닝의 자리입니다.
결과
파티션 테이블은 FK 를 걸 수도 없고, 남이 자기를 참조하게 할 수도 없습니다. 양방향 모두 금지입니다.
"최근 30일" 같은 움직이는 기준의 파티션은 원리상 만들 수 없습니다. 시간이 흐르면 행이 파티션을 옮겨다녀야 하니까요.
날짜 파티션은 항상 고정된 경계값('2024-02-01')으로 정의하고, 새 파티션을 주기적으로 추가하는 방식이어야 합니다.
| 5.7 까지 | 8.0 |
|---|---|
| 서버가 제공하는 범용 파티셔닝 핸들러로 MyISAM 등 아무 엔진이나 파티션 가능 | 삭제됨. 스토리지 엔진이 네이티브 파티셔닝을 지원해야만 가능 → 사실상 InnoDB 전용 |
ALTER TABLE ... ANALYZE/CHECK/OPTIMIZE/REPAIR PARTITION 이 비InnoDB에서도 동작 | InnoDB 네이티브 파티셔닝 기준으로 재구현 |
(참고) query_cache 존재 | 8.0 에서 완전 제거 — 파티션 테이블의 쿼리 캐시 관련 이슈 자체가 사라짐 |
💡 실무 팁 — 5.7 에서 8.0 으로 업그레이드할 때 MyISAM 파티션 테이블이 하나라도 있으면 업그레이드가 막힙니다. 업그레이드 전에
ALTER TABLE ... ENGINE=InnoDB로 바꾸거나 파티션을 제거해야 합니다.
결과
ADD PARTITION 은 맨 뒤에만 붙일 수 있는데, 맨 뒤가 MAXVALUE 라서 그보다 큰 값을 만들 수 없습니다.
결과
pmax 가 비어 있으므로 데이터 이동이 없어 순식간에 끝납니다.
pmax 를 항상 비워두는 것(= 미래 파티션을 미리 만들어 두는 것)이 운영의 핵심입니다.
파티셔닝을 도입하는 가장 큰 이유입니다. 완전히 같은 92,358행을 지워봅시다.
결과
0.244초 vs 0.007초 — 약 35배. 그런데 진짜 차이는 시간이 아닙니다.
DELETE | DROP PARTITION | |
|---|---|---|
| 소요 시간 | 0.244초 (92k행 기준. 1억 행이면 수 시간) | 0.007초 (행수와 거의 무관) |
| 언두/리두 로그 | 지운 행 전부만큼 생성 | 거의 없음 (DDL) |
| 복제 지연 | 레플리카에서 그대로 재실행 → 지연 폭발 | 거의 없음 |
| 잠금 | 행 잠금 대량 발생 → 다른 트랜잭션 대기 | 짧은 메타데이터 락 |
| 디스크 반환 | 안 줄어든다 (DATA_FREE 로 남음) | 즉시 OS 에 반환 |
디스크 확인:
결과
⚠️ 함정 — 대량
DELETE는 디스크를 되돌려주지 않습니다. 빈 공간(DATA_FREE)으로 남아 재사용될 뿐입니다. 실제로 파일을 줄이려면OPTIMIZE TABLE(= 테이블 전체 재작성)을 돌려야 하는데, 이건 더 무겁습니다.DROP PARTITION은 이 문제 자체가 없습니다.
💡 실무 팁 — 로그 테이블 운영 루틴
- 매월 1일: 다음 달 파티션을
REORGANIZE PARTITION pmax INTO (...)로 미리 만든다- 같은 날: 보관 기간(예: 13개월)이 지난 파티션을
DROP PARTITION한다- 이 두 줄을 Step 20 의
EVENT로 자동화한다파티션 이름을
p2024_01처럼 정렬 가능한 규칙으로 지으면information_schema.PARTITIONS에서 오래된 파티션 목록을 SQL 로 뽑아 동적 DDL 을 만들 수 있습니다.
법적 보관 의무 때문에 "지우기 전에 따로 보관"해야 할 때가 있습니다.
EXCHANGE PARTITION 은 파티션 하나와 일반 테이블 하나를 통째로 맞바꿉니다. (데이터 복사 없음, 메타데이터만 교환)
결과
이제 빈 파티션을 안심하고 버립니다.
| 항목 | 내용 |
|---|---|
| 도입 이유 1순위 | DROP PARTITION 으로 오래된 데이터를 즉시 버리기 (DELETE 대비 35배+, 디스크 즉시 반환) |
| 도입 이유 2순위 | 넓은 날짜 범위 조회에서 스캔량 자체를 줄이기 (프루닝) |
| 도입하면 안 되는 경우 | 단일 행 조회가 주력 / 파티션 키가 WHERE 에 안 들어옴 / 비즈니스 유니크 제약이 있음 / FK 가 필요함 |
| RANGE | 정수 파티션 식. 날짜는 TO_DAYS() 등으로 정수화. MAXVALUE 안전망 필수 |
| RANGE COLUMNS | 컬럼을 그대로 비교. 날짜 파티션의 1순위 선택 |
| LIST | 유한한 열거값. MAXVALUE 가 없어 미정의 값은 INSERT 실패(ERROR 1526) |
| HASH / KEY | 균등 분산. 등치 조건에서만 프루닝. 파티션 수 변경 = 전체 재분배 |
| 최대 제약 1 | 모든 유니크 키/PK 는 파티션 키를 전부 포함해야 함 (ERROR 1503) |
| 최대 제약 2 | FK 불가 (ERROR 1506), 양방향 모두 |
| 8.0 변경점 | 네이티브 파티셔닝만 지원 → InnoDB 전용. MyISAM 파티션은 ERROR 1178 |
| 확인 방법 | EXPLAIN 의 partitions 컬럼. information_schema.PARTITIONS |
| 관리 DDL | REORGANIZE(pmax 쪼개기) → DROP PARTITION(버리기) → EXCHANGE PARTITION(아카이빙) |
exercise.sql 을 열어 직접 풀어 보세요. 정답은 solution.sql 에 있습니다.
s21_access_logs 에서 2024년 5월 데이터만 세는 쿼리를 쓰고, EXPLAIN 으로 파티션 1개만 열리는지 확인하라.WHERE DATE(logged_at) = '2024-05-05' 는 프루닝이 되는가? 안 된다면 되게 고쳐라.status_code 를 LIST 파티션 키로 하는 테이블 s21_ex_status 를 만들어라 (성공 2xx/3xx, 클라이언트 4xx, 서버 5xx 3개 파티션). status_code = 200 조회 시 프루닝을 확인하라.s21_access_logs 의 pmax 를 쪼개서 2025년 1분기 파티션 3개(p2025_01~p2025_03)와 pmax 를 만들어라.customers 테이블을 created_at 으로 파티셔닝하려고 한다. 왜 불가능한가(혹은 무엇을 포기해야 하는가)? SQL 로 증명하라.이 스텝은 SQL 스크립트 세 개로 구성됩니다. 먼저 practice.sql 을 실행해 파티션 실습 테이블(s21_access_logs)과 비교군(s21_access_logs_np)을 만들고 21-1 ~ 21-9 의 모든 예제를 직접 재현합니다. 그다음 exercise.sql 의 TODO 를 채워 연습문제를 풀고, 마지막으로 solution.sql 로 답을 맞춰 봅니다. 세 파일 모두 practice.sql → exercise.sql → solution.sql 순서에 의존하므로 순서를 지켜 주세요.
강의 본문 21-1 ~ 21-9 를 그대로 따라가는 메인 실습 스크립트입니다.
mysql ... -t --force < practice.sql 로 실행합니다. --force 를 붙이는 이유는 이 스크립트가 일부러 실패하는 SQL(21-6 의 제약 위반 5종)을 포함하고 있어서, 에러가 나도 멈추지 않고 끝까지 진행하게 하기 위함입니다. 에러 메시지를 눈으로 보는 것 자체가 학습 포인트입니다.access_logs(100만 행)를 s21_access_logs 로 복제하는데, PK 가 PRIMARY KEY (log_id, logged_at) 입니다. log_id 단독이 아닌 이유는 모든 유니크 키가 파티션 키(logged_at)를 포함해야 한다는 제약 때문입니다. 여기서 어기면 뒤에 나오는 ERROR 1503 을 자기 테이블에서 만나게 됩니다.s21_access_logs_np 는 CREATE TABLE ... LIKE access_logs + ADD KEY idx_np_logged_at (logged_at) 로 만듭니다. 같은 데이터, 같은 인덱스, 파티션만 없음 — 21-4 의 실측 비교를 공정하게 하기 위한 장치입니다.s21_bad_uk, s21_bad_uk2, s21_bad_fk, s21_bad_expr, s21_bad_myisam)은 전부 의도적으로 실패하는 DDL 입니다. 각각 ERROR 1503(PK 에 파티션 키 누락) / ERROR 1503(UNIQUE 에 파티션 키 누락) / ERROR 1506(FK 불가) / ERROR 1064(DATEDIFF(NOW(), d) 는 비결정적) / ERROR 1178(MyISAM 은 네이티브 파티셔닝 미지원)이 납니다. 테이블이 만들어지지 않는 것이 정상입니다.ADD PARTITION (a) 도 일부러 실패하는 문장이라 ERROR 1493 이 납니다. 맨 뒤가 pmax(MAXVALUE)라 그보다 큰 경계를 붙일 수 없기 때문이며, 바로 아래 (b) REORGANIZE PARTITION pmax INTO (...) 가 정석 해법으로 이어집니다. (c) 의 첫 번째 REORGANIZE 역시 전체 범위를 늘리려 해서 ERROR 1520 이 나고, 상한을 '2025-03-01' 로 유지한 두 번째 문장만 성공합니다.ALTER TABLE s21_access_logs DROP PARTITION p2024_01, 21-9 는 EXCHANGE PARTITION p2024_03 후 DROP PARTITION p2024_03 을 실행합니다. 즉 스크립트를 한 번 돌리고 나면 p2024_01 과 p2024_03 파티션이 사라진 상태가 되고, s21_access_logs_np 에서는 2024-02-01 이전 92,358행이 DELETE 됩니다. exercise.sql 이 이 상태를 전제로 하므로 그대로 두면 됩니다. 맨 끝 21-10 의 뒷정리 DROP TABLE 은 주석 처리돼 있으니, 다시 처음부터 하고 싶을 때만 풀어서 쓰세요.access_logs, customers)은 SELECT 만 하고, 실습 테이블은 전부 s21_ 접두사를 씁니다. 다른 스텝의 데이터를 건드리지 않는 안전 장치입니다.위 연습문제 6문항의 실습 틀입니다. practice.sql 을 먼저 실행한 뒤에 열어야 합니다.
EXPLAIN SELECT COUNT(*) FROM s21_access_logs WHERE DATE(logged_at) = '2024-05-05'; 는 일부러 프루닝이 깨지는 쿼리로 미리 실행되게 두었습니다. partitions 컬럼에 전 파티션이 나열되는 것을 눈으로 확인한 뒤, 반열린 구간(>= '2024-05-05' AND < '2024-05-06')으로 고쳐 쓰는 것이 과제입니다.SELECT DISTINCT status_code FROM access_logs ORDER BY status_code; 를 먼저 돌려보게 해 둔 이유는, LIST 에는 MAXVALUE 안전망이 없어서 열거하지 않은 값이 하나라도 들어오면 INSERT 전체가 ERROR 1526 으로 죽기 때문입니다.ADD PARTITION 은 pmax 때문에 ERROR 1493 이 나고, 게다가 practice.sql 이 p2025_q1(< '2025-03-01')을 남겨 뒀기 때문에 그냥 REORGANIZE 만 하면 경계가 역행해 또 ERROR 1493 이 납니다. 먼저 p2025_q1 을 치워야 한다는 것이 이 문제의 함정입니다.EXCHANGE PARTITION → DROP PARTITION 2단 콤보를, 문제 6 은 customers 의 uk_customers_email 때문에 created_at 파티셔닝이 불가능함을 SQL 로 증명하는 문제입니다. 문제 6 은 반드시 s21_ex_customers 로 복제해서 시도하세요 — 공용 customers 테이블은 건드리면 안 됩니다.--force 를 붙입니다. 문제 2·6 처럼 실패를 관찰하는 문장이 있기 때문입니다.exercise.sql 6문항의 정답과 해설입니다. 스스로 풀어본 뒤에 열어 보세요.
TO_DAYS / YEAR / TO_SECONDS 뿐이며 그것도 파티션 식이 같은 함수로 정의됐을 때만이라는 단서를 못 박습니다.access_logs 의 실제 status_code 가 200/304/400/404/500/503 여섯 종류임을 밝히고, 그럼에도 VALUES IN (200, 201, 204, 301, 302, 304) 처럼 여유 있게 열거합니다. 보너스로 WHERE status_code >= 400 이 p_client,p_server 두 파티션으로 프루닝되는 것을 보여줍니다 — LIST 도 범위 조건에서 프루닝이 됩니다.ALTER TABLE s21_access_logs DROP PARTITION p2025_q1; 을 먼저 실행한 뒤 REORGANIZE PARTITION pmax INTO (...) 로 2025년 1~3월 + pmax 를 만듭니다. 순서가 바뀌면 ERROR 1493 입니다.s21_ex_archive_04 는 s21_access_logs 와 컬럼·인덱스가 완전히 동일하되 PARTITION BY 절만 없습니다. EXCHANGE PARTITION 의 필수 조건이라 한 컬럼이라도 다르면 교환이 거부됩니다. 검산은 before_rows - after_rows = archived 로 합니다.UNIQUE KEY uk_email (email) 로 ERROR 1503 을 재현한 뒤, UNIQUE KEY uk_email (email, created_at) 로 "포기하면 되긴 된다"를 보여주고, 곧바로 같은 이메일 dup@example.com 을 다른 날짜로 두 번 넣어 중복 가입이 성공해 버리는 것을 실증합니다. 비즈니스 유니크 제약이 있는 테이블은 애초에 파티셔닝 후보가 아니라는 결론의 근거입니다.