원문: Scaling YouTube's Backend: The Vitess Trade-offs — @Scale 2014 (Sugu Sougoumarane)
이 글은 위 발표를 재구성한 2부작 중 1편(문제편) 입니다.
- 1편: 단일 MySQL이 무너지는 과정과 각 단계의 트레이드오프 (현재)
- 2편: Vitess 아키텍처와 운영 자동화, 그리고 도입 판단 기준
들어가며: 어느 물류 시스템의 작업 로그 테이블
배송 시스템(TMS)과 물류센터 입고 시스템(WMS Inbound)은 서로 완전히 분리된 시스템입니다. 담당 조직도, 코드베이스도, 데이터베이스도 다릅니다. 배포 주기도 따로 돌아갑니다.
그런데 이 두 시스템에는 묘한 공통점이 있습니다. 각자 자기 도메인의 모든 작업을 로그로 남긴다는 것, 그리고 그 로그 테이블이 각각 독립적으로 같은 한계에 도달한다는 것입니다.
- TMS: 기사가 배송 상태를 바꿀 때마다 배송 작업 로그에 한 행
- WMS Inbound: 작업자가 상품을 스캔할 때마다 입고 작업 로그에 한 행
처음엔 각 시스템에 로그 테이블 하나면 충분합니다. 인덱스 몇 개 잘 걸어두면 조회도 빠릅니다.
그런데 이 테이블은 절대 줄어들지 않습니다. 정산·클레임·감사 때문에 지울 수도 없습니다. 수년이 지나면 이런 순서로 신호가 옵니다.

ALTER TABLE 한 번이 무서워지고, 백업 창이 밤을 넘기고, 슬로우 쿼리 알람이 일상이 됩니다.
ALTER TABLE이 무서워진 순간
숫자로 말하면 이렇습니다. 로그 테이블이 수십억 행, 수백 기가바이트를 넘어가면서 컬럼 하나 추가하는 작업의 성격이 완전히 바뀌었습니다.
- 소요 시간: 몇 분이 아니라 수십 분에서 몇 시간을 잡아야 했습니다. 배포 일정에 "스키마 변경 창"을 따로 확보해야 하는 수준입니다.
- 부수 효과: 진짜 문제는 시간이 아니라 I/O였습니다. Altering 작업이 도는 동안 DB의 I/O 부하가 치솟고, 그 여파로 일반 쿼리의 지연시간이 함께 올라갔습니다.
- 결과: 결국 현장에 준장애 상황이 발생했습니다.
마지막 항목이 핵심입니다. 물류 시스템의 지연은 웹 서비스의 지연과 성격이 다릅니다. 화면이 느려지는 데서 끝나지 않고, 현장 작업자가 스캐너 앞에서 멈춰 서고, 그 라인 전체가 밀립니다. 데이터베이스 유지보수 작업 하나가 물리 세계의 처리량을 그대로 깎아먹습니다.

이 경험 이후로 팀의 기준이 바뀌었습니다. "이 컬럼, 정말 지금 추가해야 하나?" 를 먼저 묻게 됩니다. 스키마 변경이 어려워지면 도메인 모델 개선도 함께 굳습니다. 기술 부채가 구조적으로 갚기 어려워지는 상태가 되는 것입니다.
물론 우회로는 있습니다. pt-online-schema-change나 gh-ost 같은 온라인 DDL 도구는 원본 테이블을 직접 잠그는 대신 섀도 테이블에 복사하며 진행합니다. 다만 복사 자체가 I/O를 쓰기 때문에 근본적으로 같은 압력이 남고, 수백 GB 테이블에서는 작업 시간이 길어져 운영 부담이 커집니다.
결국 이건 한 대의 서버가 감당하는 데이터가 너무 크다는 문제입니다. 도구로 완화할 수는 있어도, 도구로 없앨 수는 없습니다. (2편에서 다룰 Vitess의 Online DDL은 이 문제를 샤딩된 각 인스턴스가 감당할 만한 크기로 만든 뒤에 접근한다는 점에서 다릅니다.)
흥미로운 건 두 팀이 서로 상의한 적도 없는데 결국 같은 회의를 하게 된다는 점입니다. "파티셔닝으로 버틸까, 아니면 샤딩까지 갈까?" 시스템이 아무리 잘 분리되어 있어도, 단일 관계형 DB 위에서 계속 누적되는 데이터는 도메인과 무관하게 같은 형태의 벽을 만납니다.
2014년 @Scale 컨퍼런스에서 YouTube의 Sugu Sougoumarane이 발표한 "Scaling YouTube's Backend: The Vitess Trade-offs"는 정확히 이 회의의 결말에 대한 이야기입니다. 규모는 다르지만 문제의 구조는 똑같습니다.
이 발표가 좋은 이유는 제목에 있습니다. Trade-offs. 무엇을 도입했는가가 아니라 무엇을 포기했는가를 말합니다.
💡 Vitess 한 줄 요약: MySQL을 대체하는 것이 아니라, MySQL 앞에 붙어서 샤딩·라우팅·보호·운영 자동화를 대신해 주는 클러스터링 계층. Go로 작성되었고 현재 CNCF Graduated 프로젝트입니다.
1편에서는 단일 MySQL이 무너지는 과정과 각 단계의 대가를 다룹니다. Vitess의 구조와 도입 판단은 2편에서 이어집니다.
1단계: 읽기 부하 — 레플리카를 붙이고 "최신성"을 포기하다
문제
서비스 초기에는 MySQL 한 대와 애플리케이션 서버 몇 대면 충분합니다. 하지만 트래픽이 늘면 순서대로 이런 문제가 생깁니다.
- 데이터가 커지면서 쿼리가 느려진다
- 백업이나 스키마 변경 중 서비스가 멈춘다
- 서버 한 대가 죽으면 데이터가 날아간다
- 글로벌 사용자에게 서비스하기엔 지연시간이 크다
해법: 읽기를 레플리카로 분산
가장 먼저 쓰는 카드는 복제(replication) 입니다.

MySQL의 기본 복제는 비동기이므로 Primary의 응답 속도에는 영향이 없습니다. 읽기 처리량은 레플리카를 늘리는 만큼 늘어납니다.
포기한 것: 강한 일관성 (데이터 신선도)
비동기이므로 레플리카의 데이터는 몇 초 늦습니다. 사용자가 값을 수정하고 바로 새로고침했는데 예전 값이 보이는, 그 익숙한 버그입니다.
YouTube의 선택은 "모든 읽기가 똑같이 중요하지는 않다" 였습니다.

조회수가 3초 늦게 반영되는 건 아무도 신경 쓰지 않습니다. 하지만 방금 바꾼 설정이 안 바뀌어 보이면 버그로 인식합니다. 일관성을 시스템 전체가 아니라 "쿼리 단위"로 결정한 것이 핵심입니다.
🚚 물류 시스템에 대입하면 (두 시스템이 각자 독립적으로 내려야 하는 판단입니다)
Replica Read로 충분 Primary Read가 필요 TMS (배송) 지난달 배송 이력 조회, 정산용 집계, 관리자 대시보드 배송 상태 전이 시 현재 상태 확인 WMS (입고) 입고 실적 리포트, BI 집계 방금 스캔한 상품의 중복 검증, 재고 차감 직전 조회
특히 "스캔 직후 곧바로 검증" 같은 흐름은 read-your-own-write가 깨지면 현장 작업자가 같은 상품을 두 번 스캔하는 실제 사고로 이어집니다.@Transactional(readOnly = true)를 관성적으로 붙이기 전에, API 단위로 stale 허용 여부를 문서화해 두는 게 먼저입니다.
2단계: 쓰기 부하 — 복제 지연과 Prime Cache
문제: MySQL 복제는 (당시) 싱글 스레드였다
업로드·댓글·좋아요가 폭증하면서, 혹은 물류 현장에서 스캔 이벤트가 초당 수천 건씩 들어오면서 쓰기 QPS가 올라가면 새로운 병목이 생깁니다.

Primary는 여러 스레드로 동시에 쓰는데 레플리카는 relay log를 하나씩 순차 적용합니다. 게다가 적용할 행이 메모리에 없으면 매번 디스크를 기다립니다. 결국 따라잡지 못하고 지연(replication lag)이 누적됩니다.
지연이 커지면 1단계에서 세운 "몇 초 정도는 괜찮다"는 전제가 통째로 무너집니다.
해법: Prime Cache — "미리 데이터를 데워두기"
YouTube는 Prime Cache라는 도구를 만들었습니다. 아이디어는 단순합니다.

즉 디스크 I/O 대기(disk-bound) 작업을 메모리 접근(memory-bound) 작업으로 바꾼 것입니다. 싱글 스레드라는 제약은 그대로 두고, 그 한 스레드가 기다리는 시간을 없애는 접근입니다.
포기한 것: 근본 해결
Prime Cache는 시간을 버는 기술이지 해법이 아닙니다. 복제 처리량을 크게 올려줬지만, 데이터 자체가 한 대에 안 들어가는 시점이 오면 소용없습니다.
🚚 물류 시스템에 대입하면: 파티셔닝도 같은 성격입니다. TMS의 배송 작업 로그든 WMS의 입고 작업 로그든, 각 시스템에서 자기 테이블을 월 단위 RANGE 파티션으로 나누면 오래된 파티션을 통째로 드롭할 수 있고, 최근 데이터 조회는 파티션 프루닝으로 빨라집니다. 하지만 여전히 한 대의 서버 위에 있습니다. 쓰기 부하와 전체 데이터 크기 문제는 그대로 남습니다.
파티셔닝은 "이번 분기를 버티는 카드"이고, 샤딩은 "다음 3년을 사는 카드"입니다. 둘을 같은 선택지로 놓고 비교하면 논의가 겉돕니다.
3단계: 샤딩 — 관계형 데이터베이스의 편의를 포기하다
수직 분할과 수평 분할

수직 분할은 상대적으로 쉽습니다. 도메인 경계가 명확하면 분리 비용이 낮고, 각각 독립적으로 스케일할 수 있습니다.
🚚 여기서 물류 시스템은 이미 한 걸음 앞서 있습니다. TMS와 WMS Inbound는 애초에 별개 시스템으로 설계되어 DB도 분리되어 있으니, 수직 분할은 이미 완료된 상태입니다. 그래서 두 팀에게 남은 선택지는 처음부터 "각자의 시스템 안에서 수평 분할을 할 것인가"뿐입니다.
이건 생각보다 큰 이점입니다. 서비스가 커진 뒤에 얽힌 테이블을 떼어내는 작업은 샤딩만큼이나 고통스럽기 때문입니다. 동시에, 더 이상 쉬운 카드가 남아 있지 않다는 뜻이기도 합니다.
수평 분할(샤딩) 은 하나의 테이블을 키 기준으로 여러 DB에 쪼갭니다. 여기서부터가 진짜입니다.
포기한 것 — 여기가 핵심 트레이드오프입니다
| 잃는 것 | 구체적으로 |
|---|---|
| 크로스 샤드 트랜잭션 | 두 샤드에 걸친 원자성 보장이 사실상 불가능 |
| 자유로운 JOIN | 다른 샤드의 테이블과 조인 불가 |
| 단순한 쿼리 | 샤드 키 없는 조회는 전 샤드 스캐터-개더 |
| 애플리케이션의 단순함 | 어느 샤드로 보낼지 앱이 알아야 함 |
발표가 강조하는 지점이 바로 여기입니다. 샤딩을 하면 애플리케이션 코드가 데이터베이스 토폴로지를 알게 됩니다.
// TMS(배송) 시스템이 자체적으로 샤딩을 감당할 때 (개념 예시)
// WMS Inbound는 별개 시스템이므로, 동일한 구조를 각자 따로 만들게 된다
class DeliveryWorkLogRepository(
private val shards: List<DataSource>, // 샤드 키: driver_id
private val primary: DataSource,
) {
fun findByDriver(driverId: Long, requireFresh: Boolean): List<DeliveryWorkLog> {
val shardId = (driverId % shards.size).toInt() // ① 샤드 라우팅
val ds = if (requireFresh) primary else shards[shardId] // ② 신선도 판단
return ds.query("SELECT * FROM delivery_work_log WHERE driver_id = ?", driverId)
}
// ③ 샤드 키가 없는 조회는? 전 샤드를 훑거나 별도 인덱스 테이블을 직접 관리해야 한다
fun findByTrackingNumber(trackingNo: String): DeliveryWorkLog? =
shards.asSequence()
.mapNotNull { it.queryOrNull("SELECT * FROM delivery_work_log WHERE tracking_no = ?", trackingNo) }
.firstOrNull()
}
여기서 놓치기 쉬운 비용이 하나 더 있습니다. 두 시스템이 분리되어 있다는 건, 이 샤딩 인프라를 두 번 만들어야 한다는 뜻이기도 합니다. 라우팅 로직, 크로스 샤드 조회, 리샤딩 절차, 운영 도구를 각 팀이 각자 구현하게 됩니다.
샤드 키 선택이 곧 설계입니다
물류 도메인에서 이 부분이 특히 아픕니다. 작업 로그의 자연스러운 조회 축이 하나가 아니기 때문입니다.
그리고 여기서 TMS와 WMS가 별개 시스템이라는 사실이 중요해집니다. 두 시스템은 조회 패턴이 다르므로, 각자 다른 샤드 키를 골라야 합니다. 하나의 정답을 정해 양쪽에 강요하면 둘 다 망가집니다.

TMS에서 샤드 키를 driver_id로 잡으면 송장번호 추적이 전 샤드 스캔이 됩니다. tracking_no로 잡으면 기사별 이력 조회가 깨집니다. WMS에서도 center_id와 sku_id 사이에 같은 종류의 딜레마가 반복됩니다.
정답은 없고, "어떤 조회를 1급 시민으로 대우할지" 결정하는 일입니다. 나머지는 크로스 샤드 인덱스를 직접 유지하거나, Elasticsearch 같은 조회 전용 스토어로 분리해야 합니다.
🚚 분리된 시스템의 양면성
좋은 점: 각 팀이 자기 도메인의 조회 패턴만 보고 샤드 키를 고르면 됩니다. 합의 비용이 없고, 한쪽 결정이 다른 쪽을 망치지 않습니다.
- 아픈 점: 샤딩 인프라·운영 도구·장애 대응 노하우가 두 벌 필요합니다. 팀별로 성숙도가 벌어지면 한쪽은 결국 뒤처집니다.
2편에서 다룰 Vitess 같은 공통 미들웨어가 매력적으로 보이는 지점이 정확히 여기입니다. 샤드 키는 시스템별로 다르게 가져가면서, 그것을 굴리는 인프라는 공유할 수 있기 때문입니다.
그리고 이 모든 판단을 기능 개발자가 매번 하게 되면, 실수 한 번이 장애가 됩니다.
1편 정리
| 단계 | 얻은 것 | 포기한 것 |
|---|---|---|
| 레플리카 읽기 | 읽기 처리량 확장 | 데이터 최신성 (stale read) |
| Prime Cache / 파티셔닝 | 복제 지연 완화, 관리 편의 | 근본 해결 (시간 벌기) |
| 샤딩 | 무제한에 가까운 수평 확장 | 크로스 샤드 트랜잭션, 자유로운 JOIN, 앱의 단순함 |
한 문장으로 요약하면 이렇습니다.
확장은 문제를 없애는 것이 아니라 옮기는 것입니다.
여기까지가 "무엇을 포기해야 하는가"였습니다. 그렇다면 포기한 복잡도를 어디에 떠넘길 것인가? YouTube의 답이 Vitess였습니다.
→ 2편: MySQL 샤딩을 미들웨어에 맡기다 — Vitess 아키텍처와 도입 판단 기준
참고 자료
Thanks
Hans