모든 글
약 13분 분량 이론/데이터베이스

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콘텐츠 저장규모특이사항
WordPressMySQLwp_posts.post_content 직접 저장수천~수백만 건리비전도 같은 테이블에 저장
DiscoursePostgreSQLposts.raw 직접 저장월 4M+ 신규 포스트TOAST가 자동 압축 처리
Stack OverflowSQL Server직접 저장200M+ 요청/일384GB RAM + 4TB PCIe SSD 2대
RedditPostgreSQL직접 저장100K+ 읽기/초Aurora PostgreSQL + 샤딩
NotionPostgreSQL블록 단위 직접 저장2,000억+ 블록480 논리 샤드 / 96 물리 인스턴스
ConfluenceDBVertical Partitioning수백만 건CONTENT + BODYCONTENT 분리
WikipediaMySQL별도 text 테이블 + External StorageTB급 리비전 이력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 cores
SQL Server Cluster 2: Dell R730xd — 768GB RAM, 6TB PCIe SSD, 2x8 cores

200M+ 요청/일을 SQL Server 2대로 처리합니다. Elastic과 Redis는 읽기 캐시 역할이지, 콘텐츠의 원천은 SQL Server입니다. 전체 DB에 stored procedure는 1개뿐이고, Dapper(Micro-ORM)로 직접 쿼리합니다.

교훈: 잘 튜닝된 RDBMS + 충분한 RAM + SSD면 콘텐츠를 DB 밖으로 뺄 필요가 없습니다.

출처: Stack Overflow Hardware 2016

Notion, 샤딩으로 2,000억 블록 처리:

시점물리 인스턴스논리 샤드총 블록 수
202132대-수십억
202396대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가 정당화됩니다.

출처: External Storage — Wikitech


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

출처: AWS RDS Pricing, Cloudflare R2 Pricing

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
DiscoursePostgreSQL 직접안 함
XenForoMySQL 직접안 함
WordPressMySQL 직접안 함
Stack OverflowSQL Server 직접안 함

출처: Database Workload Read-Write Ratio — Benchant


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’)이나 외부 저장소 분리를 검토하는 것이 바람직하다.

출처: On MySQL InnoDB Row Formats and Compression — Carson Ip

5-2. 동작 원리

InnoDB 내부에서 zlib으로 16KB 페이지를 더 작은 크기로 압축하여 디스크에 저장합니다.

InnoDB ROW_FORMAT=COMPRESSED 동작 원리

적용은 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없음압축 안 함 (기본 페이지와 동일)
850%일반적 선택, 텍스트 데이터에 적합
475%공격적 압축, 실패율 높아질 수 있음
2, 187~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_rate
FROM 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 TOASTMySQL InnoDB COMPRESSED
동작행이 ~2KB 초과 시 자동 압축 + out-of-line 저장ALTER TABLE명시적 활성화
알고리즘pglz (기본), LZ4 (PG 14+)zlib
투명성완전 투명완전 투명
압축 조건TOAST 임계값(약 2KB) 이하로 줄일 수 있거나 의미 있게 크기가 줄어들 때항상 시도 (실패 시 이중 저장)

핵심 차이: PostgreSQL은 별도 설정 없이 TOAST가 자동으로 작동합니다. MySQL은 명시적으로 적용해야 합니다.

출처: PostgreSQL TOAST Documentation


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까지 읽을 수 있음

Vertical Partitioning: 테이블 분리

분리하면 메타데이터 테이블만 스캔하므로, 한 페이지에 더 많은 행이 들어가고 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 사례입니다.

출처: Confluence Data Model — Atlassian

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마다 포함
NOBLOBBLOB/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
블로그/CMS100:1+RDBMS 직접 저장 + CDN
위키/협업10:1~50:1RDBMS + 리비전 테이블
채팅/메시징5:1~20:1ScyllaDB, Cassandra
E-commerce 상품1000:1+RDBMS + Redis/CDN 캐시

출처: Database Workload Read-Write Ratio — Benchant, Data Store Choice Criteria — Azure Architecture Center


8. 종합 결론

의사결정 매트릭스

기준RDBMS 직접 저장Vertical PartitioningObject Storage 이동
데이터 규모<100GB10GB~10TB>1TB
검색 필요O (FULLTEXT 가능)OX (별도 인덱스 필요)
트랜잭션필요필요불필요
복잡도낮음낮음~중간높음

검토했으나 현 시점에서 불필요한 것들

방안결론이유
Object Storage 이동제외트랜잭션 깨짐, 핵심 문제(인덱스 크기) 해결 안 됨, 월 $11 절감
페이지 압축 (COMPRESSION=)제외펀치 홀 의존, 프로덕션 비추천
앱 레벨 gzip 압축제외FULLTEXT 검색 불가, ORM 투명성 깨짐
NoSQL 전환제외스키마 고정적, 트랜잭션/JOIN 필요, 현 규모에서 RDBMS 충분
InnoDB 압축보류핵심 문제(인덱스 크기)에 영향 없으나, 데이터 절감이 필요할 때 재검토
Vertical Partitioning보류binlog_row_image=NOBLOB로 복제 부담 해결 가능, 목록 쿼리 비율 확인 후 결정

결론

콘텐츠 122GB → DB 직접 저장 유지 콘텐츠 저장 방식 변경 불필요
FULLTEXT ngram 인덱스 100GB+ 검색 인덱스를 외부로 분리
디스크 여유 부족 디스크 확장이 가장 비용 효율적

디스크를 많이 먹는 건 콘텐츠가 아니라 ngram 인덱스입니다. 콘텐츠를 압축하거나 옮기는 게 아니라, 검색 인덱스를 외부 검색엔진으로 분리하는 것이 근본적인 해결입니다.


참고 자료

플랫폼 아키텍처:

MySQL/PostgreSQL 기술:

비용/클라우드:

기타:

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.