MySQL 용량이 부족할 때: 콘텐츠 저장 아키텍처 탐구
목차
1. 들어가며: 디스크가 꽉 찰 뻔한 이야기
1,477만 건, 122GB의 위키 데이터를 MySQL에 넣고 검색 인덱스를 만들려고 했습니다.
로컬 디스크: 994GB 중 960GB 사용 (34GB 여유)MySQL data 볼륨: 122GB → 인덱스 생성 중 287.8GB (165GB 증가, 아직 진행 중)CREATE FULLTEXT INDEX ft_title_content ON posts(title, content) WITH PARSER ngram;을 실행한 결과:
- 600초 후 MySQL Workbench 연결 끊김 (Error 2013: Lost connection)
SHOW PROCESSLIST로 확인 → State:altering table(진행 중)- 디스크 꽉 찰 위험 →
KILL로 강제 종료 - 종료 후에도 볼륨: 249.6GB (부분 정리만 된 상태)
디스크를 많이 먹는 건 콘텐츠(122GB)가 아니라 FULLTEXT ngram 인덱스(100GB+)였습니다. 이 사실에서 “그렇다면 콘텐츠 저장 방식을 바꿔야 하나? 현업은 어떻게 하나?”라는 의문이 생겼고, 이 글은 그 탐구의 결과입니다.
2. 뭐가 용량을 먹는 건가: 콘텐츠 vs 인덱스
| 대상 | 문서당 평균 토큰 수 | 총 토큰 수 | 인덱스 추정 크기 |
|---|---|---|---|
| title만 | 26개 | ~3.8억 개 | 1~3 GB |
| content만 | 6,585개 | ~973억 개 | 50~150 GB+ |
| title + content | ~6,611개 | ~976억 개 | 100~200 GB+ |
content가 전체 토큰의 99.6%를 차지합니다. ngram 파서는 텍스트를 n글자 단위로 분할하므로, 원본 텍스트 대비 토큰 수가 폭발적으로 증가합니다. 예를 들어 ‘안녕하세요’라는 5글자 텍스트는 ngram_token_size=2 기준으로 4개의 토큰(‘안녕’, ‘녕하’, ‘하세’, ‘세요’)이 생성됩니다. 이것이 FULLTEXT ngram 인덱스 크기가 원본 데이터보다 커지는 근본적인 이유입니다.
여기서 핵심적인 구분이 필요합니다:
- 콘텐츠 데이터 자체: 122GB. 원본 텍스트입니다.
- FULLTEXT 인덱스: 100GB+. 검색을 위한 역색인 자료구조입니다.
콘텐츠를 압축하거나 Object Storage로 옮겨도 인덱스 크기는 그대로입니다. 핵심 문제가 해결되지 않는다는 뜻입니다. 그래도 콘텐츠 저장 방식 자체가 궁금했기에, 현업이 어떻게 하는지 알아봤습니다.
3. 현업은 콘텐츠를 어디에 저장하나
3-1. 주요 플랫폼의 콘텐츠 저장 방식
| 서비스 | DB | 콘텐츠 저장 | 규모 | 특이사항 |
|---|---|---|---|---|
| WordPress | MySQL | wp_posts.post_content 직접 저장 | 수천~수백만 건 | 리비전도 같은 테이블에 저장 |
| Discourse | PostgreSQL | posts.raw 직접 저장 | 월 4M+ 신규 포스트 | TOAST가 자동 압축 처리 |
| Stack Overflow | SQL Server | 직접 저장 | 200M+ 요청/일 | 384GB RAM + 4TB PCIe SSD 2대 |
| PostgreSQL | 직접 저장 | 100K+ 읽기/초 | Aurora PostgreSQL + 샤딩 | |
| Notion | PostgreSQL | 블록 단위 직접 저장 | 2,000억+ 블록 | 480 논리 샤드 / 96 물리 인스턴스 |
| Confluence | DB | Vertical Partitioning | 수백만 건 | CONTENT + BODYCONTENT 분리 |
| Wikipedia | MySQL | 별도 text 테이블 + External Storage | TB급 리비전 이력 | delta 압축 → 원본의 2% 이하 |
출처: WordPress DB Structure, Discourse PostgreSQL, Stack Overflow Architecture 2016, Notion Sharding, Wikipedia External Storage
거의 모든 플랫폼이 콘텐츠를 DB에 직접 저장합니다. Object Storage로 이동하는 건 Wikipedia처럼 리비전 이력이 TB급일 때만 발생하는 예외적 패턴입니다.
3-2. 각 플랫폼에서 배울 점
Stack Overflow, 하드웨어로 해결:
SQL Server Cluster 1: Dell R720xd — 384GB RAM, 4TB PCIe SSD, 2x12 coresSQL Server Cluster 2: Dell R730xd — 768GB RAM, 6TB PCIe SSD, 2x8 cores200M+ 요청/일을 SQL Server 2대로 처리합니다. Elastic과 Redis는 읽기 캐시 역할이지, 콘텐츠의 원천은 SQL Server입니다. 전체 DB에 stored procedure는 1개뿐이고, Dapper(Micro-ORM)로 직접 쿼리합니다.
교훈: 잘 튜닝된 RDBMS + 충분한 RAM + SSD면 콘텐츠를 DB 밖으로 뺄 필요가 없습니다.
Notion, 샤딩으로 2,000억 블록 처리:
| 시점 | 물리 인스턴스 | 논리 샤드 | 총 블록 수 |
|---|---|---|---|
| 2021 | 32대 | - | 수십억 |
| 2023 | 96대 | 480개 | 2,000억+ |
workspace_id 기준 샤딩으로 수백 TB급 텍스트 데이터를 PostgreSQL에서 처리합니다. Object Storage로 빼지 않습니다.
출처: Notion Sharding, Storing 200 Billion Entities — ByteByteGo
Wikipedia, 유일한 External Storage 사례:
Wikipedia만이 텍스트를 DB 밖으로 뺐습니다. 이유는 리비전 이력이 TB급이기 때문입니다.
text 테이블 → 포인터 ("DB://cluster1/12345") ↓External Storage 클러스터 (별도 MySQL DB의 blobs 테이블) ↓delta 압축: 첫 리비전=전문, 이후=차분만, 배치 gzip → 전체 이력이 원본의 2% 이하비압축 덤프 3TB+를 초과하는 규모에서만 External Storage가 정당화됩니다.
4. Object Storage(R2/S3)로 빼면 해결될까?
4-1. 비용 분석
| 스토리지 유형 | $/GB/월 | 100GB 비용 |
|---|---|---|
| AWS RDS (gp3) | $0.115 | $11.50 |
| AWS EBS (gp3) | $0.08 | $8.00 |
| AWS S3 Standard | $0.023 | $2.30 |
| Cloudflare R2 | $0.015 | $1.50 |
| S3 Glacier | $0.004 | $0.40 |
RDS 스토리지는 S3 대비 5배 비쌉니다. 하지만 122GB 규모에서 차액은 월 $11 수준입니다. 이게 아키텍처 변경을 정당화할 만큼의 차이인지 생각해봐야 합니다.
4-2. 숨겨진 비용: 스토리지 비용만이 전부가 아니다
| 문제 | 설명 |
|---|---|
| 트랜잭션 일관성 | DB INSERT 성공 + S3 PUT 실패 시 데이터 불일치 |
| JOIN 불가 | DB 행과 S3 오브젝트를 JOIN할 수 없음 |
| ORM 투명성 깨짐 | post.getContent()가 S3 HTTP 호출로 변질 |
| FULLTEXT 검색 불가 | S3 오브젝트에 MATCH...AGAINST를 실행할 수 없음 |
| 레이턴시 증가 | S3 GET: 50~200ms (Standard S3 같은 리전 기준. S3 Express One Zone은 single-digit ms 가능) vs DB Buffer Pool: sub-ms |
| Atomic UPDATE 불가 | 콘텐츠 수정 + 포인터 업데이트가 원자적이지 않음 |
월 $11을 절감하려고 트랜잭션 일관성, JOIN, ORM 투명성을 포기하는 건 합리적이지 않습니다. 현업 커뮤니티 플랫폼(Discourse, WordPress, Stack Overflow) 중 콘텐츠를 Object Storage로 뺀 곳은 없습니다.
| 플랫폼 | 콘텐츠 저장 | Object Storage |
|---|---|---|
| Discourse | PostgreSQL 직접 | 안 함 |
| XenForo | MySQL 직접 | 안 함 |
| WordPress | MySQL 직접 | 안 함 |
| Stack Overflow | SQL Server 직접 | 안 함 |
5. InnoDB 압축: ROW_FORMAT=COMPRESSED
콘텐츠를 DB에 유지하면서 용량을 줄이는 방법이 있습니다. InnoDB 테이블 압축입니다.
5-1. MySQL 압축 두 가지 방식
이름이 비슷하지만 완전히 다른 두 가지 압축이 있습니다.
| ROW_FORMAT=COMPRESSED (테이블 압축) | COMPRESSION= (페이지 압축) | |
|---|---|---|
| 도입 | MySQL 5.1 InnoDB Plugin (MySQL 5.5+ 기본 내장) | MySQL 5.7.8+ |
| 동작 | InnoDB 내부에서 zlib으로 작은 페이지 생성 | OS 파일시스템의 sparse file + hole punching |
| 펀치 홀 필요 | 아니오 | 예 (OS + 하드웨어 지원 필수) |
| 파일 복사 | 정상 동작 | cp 시 hole이 채워져 원본 크기로 복원됨 |
| Buffer Pool | 압축본 + 원본 이중 저장 | 원본만 저장 (메모리 효율 좋음) |
| 프로덕션 | 성숙, 안정적 | Percona: “프로덕션에 추천하기 어렵다” |
사용할 방식은 ROW_FORMAT=COMPRESSED입니다. 펀치 홀과 무관하고, InnoDB 내부에서 완결되는 전통적 압축입니다.
참고: MySQL 8.0에서 InnoDB 압축 관련 설정에 대해 “향후 MySQL 릴리스에서 제거될 수 있습니다”라는 경고가 있다. 새 프로젝트에서는 페이지 압축(COMPRESSION=‘zlib’)이나 외부 저장소 분리를 검토하는 것이 바람직하다.
5-2. 동작 원리
InnoDB 내부에서 zlib으로 16KB 페이지를 더 작은 크기로 압축하여 디스크에 저장합니다.
적용은 ALTER TABLE 한 줄입니다:
ALTER TABLE post_contents ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8;주의: 대용량 테이블에 ALTER TABLE을 실행하면 테이블 전체가 재구축되며, 디스크 공간이 임시로 2배 필요하고 장시간 락이 걸릴 수 있다. 프로덕션에서는 pt-online-schema-change나 gh-ost 같은 온라인 DDL 도구 사용을 권장한다.
애플리케이션 코드 변경은 전혀 필요 없습니다. SELECT content FROM post_contents가 자동으로 압축 해제된 원본을 반환합니다.
5-3. KEY_BLOCK_SIZE 선택
KEY_BLOCK_SIZE는 압축된 페이지의 목표 크기(KB)입니다. InnoDB 기본 페이지 16KB를 얼마나 줄일지 결정합니다.
| KEY_BLOCK_SIZE | 목표 압축률 | 특징 |
|---|---|---|
| 16 | 없음 | 압축 안 함 (기본 페이지와 동일) |
| 8 | 50% | 일반적 선택, 텍스트 데이터에 적합 |
| 4 | 75% | 공격적 압축, 실패율 높아질 수 있음 |
| 2, 1 | 87~94% | 대부분 실패 → 이중 저장으로 오히려 손해 |
압축 실패가 중요한 이유: 16KB를 8KB로 압축하는데 실패하면, 페이지 스플릿이 발생하고 Buffer Pool에 압축본 + 원본 둘 다 저장됩니다. 실패율이 높으면 오히려 메모리를 더 씁니다.
최적 값을 찾으려면 인덱스별 압축 통계를 확인해야 합니다:
-- 인덱스별 압축 통계 활성화 (테스트 시에만 ON)SET GLOBAL innodb_cmp_per_index_enabled = ON;
-- KEY_BLOCK_SIZE별 테스트 테이블 생성CREATE TABLE test_compress_8 LIKE post_contents;ALTER TABLE test_compress_8 ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8;
-- 샘플 데이터 삽입INSERT INTO test_compress_8 SELECT * FROM post_contents LIMIT 10000;
-- 압축 성공률 확인SELECT database_name, table_name, index_name, compress_ops, -- 압축 시도 횟수 compress_ops_ok, -- 압축 성공 횟수 ROUND(compress_ops_ok / compress_ops * 100, 1) AS success_rateFROM INFORMATION_SCHEMA.INNODB_CMP_PER_INDEX;| 성공률 | 판단 |
|---|---|
| 90%+ | 해당 KEY_BLOCK_SIZE 적합 |
| 70~90% | 사용 가능하지만 모니터링 필요 |
| 70% 미만 | 한 단계 큰 값으로 올려야 함 |
5-4. CRUD 패턴별 압축 적합도
핵심 원칙: 읽기마다 해제, 쓰기마다 재압축이 반복되면 CPU 병목이 됩니다. 따라서 데이터의 사용 패턴이 압축 적합도를 결정합니다.
| CRUD 패턴 | 압축 적합도 | 이유 |
|---|---|---|
| INSERT-only (로그/감사) | 최적 | 한번 쓰면 변경 없음. 재압축 없음 |
| Write-once, Read-many (블로그/CMS) | 적합 | 쓰기 적어 재압축 빈도 낮음 |
| 빈번한 UPDATE (카운터) | 부적합 | 매 UPDATE마다 재압축 + page split 위험 |
| 위키/협업 편집 | 조건부 | 현재 버전: 주의 필요, 리비전 이력: 최적 |
Basecamp 사례 (프로덕션 검증):
- 가장 큰 테이블: ~430GB → ROW_FORMAT=COMPRESSED 적용 후: 172GB (60% 절감)
- 새 레코드 평균 40% 축소
- 슬로우 쿼리가 “거의 제거됨” (I/O 감소 + 메모리 압박 해소)
출처: Scaling Your Database via InnoDB Table Compression — Signal v. Noise (Basecamp)
5-5. PostgreSQL TOAST와 비교
PostgreSQL을 쓰는 Discourse나 Reddit이 별도 압축 없이도 되는 이유는 TOAST 메커니즘 때문입니다.
| 항목 | PostgreSQL TOAST | MySQL InnoDB COMPRESSED |
|---|---|---|
| 동작 | 행이 ~2KB 초과 시 자동 압축 + out-of-line 저장 | ALTER TABLE로 명시적 활성화 |
| 알고리즘 | pglz (기본), LZ4 (PG 14+) | zlib |
| 투명성 | 완전 투명 | 완전 투명 |
| 압축 조건 | TOAST 임계값(약 2KB) 이하로 줄일 수 있거나 의미 있게 크기가 줄어들 때 | 항상 시도 (실패 시 이중 저장) |
핵심 차이: PostgreSQL은 별도 설정 없이 TOAST가 자동으로 작동합니다. MySQL은 명시적으로 적용해야 합니다.
6. Vertical Partitioning: 무거운 TEXT 분리
6-1. 왜 분리하나
MySQL의 TEXT/BLOB은 overflow page(16KB 청크)에 저장됩니다. 이로 인해:
- 1MB TEXT를 읽으려면 64개 overflow page × 16KB = 64 read IOPs 필요
- TEXT가 결과에 포함되면 디스크 기반 임시 테이블 강제 (MEMORY 엔진이 TEXT 미지원)
- 목록 조회에서 10,000행 스캔 시 불필요한 overflow page까지 읽을 수 있음
분리하면 메타데이터 테이블만 스캔하므로, 한 페이지에 더 많은 행이 들어가고 Buffer Pool 효율이 올라갑니다.
출처: Why Everyone Avoids TEXT Fields in MySQL — Leapcell, How InnoDB Handles TEXT/BLOB — Percona
6-2. 분리하지 않아도 되는 경우
- 단건 상세 조회가 대부분이고, 목록 조회가 적은 경우
- 데이터 크기가 수GB 이하인 경우
- 경험 법칙: TEXT/BLOB 평균 >4KB이고, 목록:상세 비율이 5:1 이상이면 분리가 이득
Confluence 사례: CONTENT 테이블(메타데이터) + BODYCONTENT 테이블(본문)로 분리. 엔터프라이즈 위키의 대표적 Vertical Partitioning 사례입니다.
6-3. binlog_row_image=NOBLOB: 테이블 분리 없이 복제 최적화
Master-Slave 구성에서 LONGTEXT가 복제에 부담을 줄 수 있습니다.
view_count UPDATE (+1) → binlog_row_image=FULL (기본값) → binlog에 content(LONGTEXT) 포함 전체 행 기록 → 바뀐 건 view_count 하나뿐인데 LONGTEXT가 매번 Slave로 전송해결은 설정 한 줄입니다:
SET GLOBAL binlog_row_image = 'NOBLOB';| 설정 | binlog 기록 |
|---|---|
FULL (기본) | 모든 컬럼, content가 매 UPDATE마다 포함 |
NOBLOB | BLOB/TEXT는 변경된 경우만 포함 |
MINIMAL | 변경된 컬럼 + PK만 |
복제(replication) 트래픽 측면에서는 대형 컬럼 데이터 전송을 줄이는 유사한 효과가 있습니다. 단, Vertical Partitioning의 핵심 이점인 목록 조회 시 Buffer Pool 효율 향상이나 임시 테이블 최적화는 해결되지 않습니다.
7. 데이터가 계속 커지면? 현업의 대응 패턴
디스크를 무한정 늘릴 수는 없습니다. 현업에서는 분리 전략을 씁니다.
| 전략 | 설명 | 적용 시점 |
|---|---|---|
| 검색 엔진 분리 | DB에서 FULLTEXT 인덱스 제거, 외부 검색엔진 담당 | 인덱스 크기가 부담될 때 |
| 테이블 파티셔닝 | 시간 기준 물리적 분리 | 행 수가 수천만 이상 |
| 콜드 데이터 아카이빙 | 오래된 데이터를 아카이브로 이동 | 활성/비활성 구분 가능할 때 |
| Object Storage 분리 | content를 S3/R2로 이동 | TB급 + 리비전 이력 관리 필요 시 |
| 샤딩 | tenant 기준 DB 분할 | 단일 DB 성능 한계 도달 시 |
핵심은 압축이 아니라 “분리”입니다. 검색은 검색엔진으로, 오래된 데이터는 아카이브로, 첨부파일은 Object Storage로 보냅니다.
CRUD 패턴별 최적 저장소
데이터의 읽기:쓰기 비율이 저장소 선택의 핵심 기준입니다.
| 워크로드 | 읽기:쓰기 | 최적 저장소 |
|---|---|---|
| 로그/감사 | 1:100+ | S3/R2 + Parquet, 시계열 DB |
| 블로그/CMS | 100:1+ | RDBMS 직접 저장 + CDN |
| 위키/협업 | 10:1~50:1 | RDBMS + 리비전 테이블 |
| 채팅/메시징 | 5:1~20:1 | ScyllaDB, Cassandra |
| E-commerce 상품 | 1000:1+ | RDBMS + Redis/CDN 캐시 |
출처: Database Workload Read-Write Ratio — Benchant, Data Store Choice Criteria — Azure Architecture Center
8. 종합 결론
의사결정 매트릭스
| 기준 | RDBMS 직접 저장 | Vertical Partitioning | Object Storage 이동 |
|---|---|---|---|
| 데이터 규모 | <100GB | 10GB~10TB | >1TB |
| 검색 필요 | O (FULLTEXT 가능) | O | X (별도 인덱스 필요) |
| 트랜잭션 | 필요 | 필요 | 불필요 |
| 복잡도 | 낮음 | 낮음~중간 | 높음 |
검토했으나 현 시점에서 불필요한 것들
| 방안 | 결론 | 이유 |
|---|---|---|
| Object Storage 이동 | 제외 | 트랜잭션 깨짐, 핵심 문제(인덱스 크기) 해결 안 됨, 월 $11 절감 |
| 페이지 압축 (COMPRESSION=) | 제외 | 펀치 홀 의존, 프로덕션 비추천 |
| 앱 레벨 gzip 압축 | 제외 | FULLTEXT 검색 불가, ORM 투명성 깨짐 |
| NoSQL 전환 | 제외 | 스키마 고정적, 트랜잭션/JOIN 필요, 현 규모에서 RDBMS 충분 |
| InnoDB 압축 | 보류 | 핵심 문제(인덱스 크기)에 영향 없으나, 데이터 절감이 필요할 때 재검토 |
| Vertical Partitioning | 보류 | binlog_row_image=NOBLOB로 복제 부담 해결 가능, 목록 쿼리 비율 확인 후 결정 |
결론
콘텐츠 122GB → DB 직접 저장 유지 콘텐츠 저장 방식 변경 불필요FULLTEXT ngram 인덱스 100GB+ 검색 인덱스를 외부로 분리디스크 여유 부족 디스크 확장이 가장 비용 효율적디스크를 많이 먹는 건 콘텐츠가 아니라 ngram 인덱스입니다. 콘텐츠를 압축하거나 옮기는 게 아니라, 검색 인덱스를 외부 검색엔진으로 분리하는 것이 근본적인 해결입니다.
참고 자료
플랫폼 아키텍처:
- WordPress Database Structure — WP STAGING
- Stack Overflow Architecture 2016 — Nick Craver
- Discourse PostgreSQL — Discourse Blog
- Notion Sharding
- Wikipedia External Storage — Wikitech
- Confluence Data Model — Atlassian
MySQL/PostgreSQL 기술:
- On MySQL InnoDB Row Formats and Compression — Carson Ip
- Scaling via InnoDB Table Compression — Basecamp
- How InnoDB Handles TEXT/BLOB — Percona
- PostgreSQL TOAST Documentation
비용/클라우드:
기타:
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.