본문 바로가기

IT

62. Netflix는 1억 명의 시청 기록을 어떻게 저장할까? — Cassandra 시계열 데이터 스토리지 확장기

Netflix Tech Blog의 «Scaling Time Series Data Storage» Part I(2018.01) · Part II(2018.11)를 재구성한 글입니다.
하루 1억 4천만 시간 분량의 시청 데이터를 저장하기 위해 Netflix가 거쳐온 세 번의 아키텍처 진화를 따라가 봅니다.


1. 시작하기 전에: 이 글에서 얻어갈 것

  • 시계열 데이터를 Cassandra에 저장할 때 "한 유저 = 한 로우" 모델이 왜 무너지는가
  • Live / Compressed 분리롤업(rollup) 패턴의 동작 원리
  • 초대형 데이터를 다루는 청킹(chunking) + 병렬 읽기/쓰기 전략
  • 데이터의 종류(type) · 나이(age) · 상세도(level of detail) 를 기준으로 클러스터를 쪼개는 3차원 샤딩
  • 우리 서비스에 바로 적용해볼 수 있는 설계 체크리스트

2. 문제 정의: 시청 기록 데이터는 세 방향으로 자란다

Netflix에서 회원이 콘텐츠를 재생하면 여러 개의 데이터 포인트가 시청 기록(viewing record) 으로 저장됩니다. 이 데이터는 이어보기 지점(북마크)과 개인화 추천의 원천이 되는, 서비스의 핵심 데이터입니다.

문제는 이 데이터가 한 방향이 아니라 세 방향으로 동시에 증가한다는 점입니다.

증가 축 설명
시간 시간이 흐를수록 회원 1명당 누적 기록이 늘어난다
회원 수 글로벌 확장으로 기록을 저장해야 할 회원이 늘어난다
시청 시간 회원 1인당 월 시청 시간이 늘어나면서 기록 밀도가 높아진다

 

서비스 10년 만에 회원 수가 1억 명을 넘어서면서, 이 세 축이 곱해진 형태로 데이터가 폭증했습니다. "스토리지를 조금 더 늘리면 되지 않나" 로 해결되지 않는 종류의 문제입니다.

워크로드의 특성

설계 판단의 근거가 된 데이터 특성은 다음과 같습니다.

  • 쓰기 : 읽기 ≈ 9 : 1 — 압도적인 쓰기 중심 워크로드
  • 로우마다 컬럼 개수가 가변적인 전형적인 시계열 구조
  • CAP 정리 관점에서 가용성 손실보다 최종적 일관성(eventual consistency)을 선호

이 세 가지 때문에 Netflix는 Cassandra를 선택했습니다. 쓰기에 강하고, 와이드 로우 모델을 지원하며, 튜너블 컨시스턴시로 일관성 수준을 조절할 수 있기 때문입니다.


3. 1세대 아키텍처: 회원 1명 = 로우 1개

가장 단순한 모델부터 출발합니다.

Row Key: CustomerId
├── [viewing_record_1]
├── [viewing_record_2]
├── ...
└── [viewing_record_N]   ← 재생할 때마다 컬럼이 하나씩 추가된다
  • 쓰기: 재생을 시작하면 컬럼 하나를 추가하고, 일시정지·종료 시 그 컬럼을 갱신합니다. 단일 컬럼 쓰기라 매우 빠릅니다.
  • 읽기: CustomerId 하나로 회원의 전체 시청 기록을 가져옵니다. 구현이 단순하고 직관적입니다.

CustomerId 기준 수평 파티셔닝이므로 회원 수 증가에는 잘 견딥니다. 문제는 다른 곳에서 터졌습니다.

왜 느려졌나 — Cassandra 내부 관점

데이터가 쌓이면서 로우 하나가 계속 넓어지는(wide row) 것이 병목이 되었습니다.

  1. SSTable 개수 증가 — 최신 데이터만 메모리에 있으므로, 한 회원의 기록을 읽으려면 memtable과 여러 SSTable을 함께 읽어야 합니다. 읽기 지연이 커집니다.
  2. Compaction 비용 증가 — 데이터가 커질수록 compaction에 더 많은 IO와 시간이 소요됩니다.
  3. Read repair / 전체 컬럼 패밀리 repair 지연 — 로우가 넓어질수록 repair가 느려집니다.

여기서 중요한 포인트는, 읽기 방식을 바꿔도 근본 해결이 안 됐다는 점입니다.

  • 전체 로우 읽기 → 기록이 많은 헤비 유저에서 지연 폭증
  • 시간 범위 쿼리 → 범위 안 레코드 수에 따라 성능이 들쭉날쭉
  • 페이지네이션 → 클라이언트 타임아웃은 막았지만 전체 읽기 지연 총량은 오히려 증가

캐싱 레이어 투입 (EVCache)

읽기 지연을 개선하기 위해 Cassandra 앞단에 인메모리 샤딩 캐시(EVCache)를 두었습니다.

  • Key: CustomerId, Value: 압축된 바이너리 형태의 시청 기록
  • 쓰기 시 캐시를 조회해 기존 값과 병합 → 쓰기 경로의 부담을 늘리는 대신 읽기를 살리는 트레이드오프
  • 읽기는 캐시 우선, 미스 시 Cassandra에서 읽어 압축 후 캐시에 적재

이 조합으로 몇 년을 버텼습니다. 하지만 2012년 무렵 이 클러스터는 Netflix 내 최대 규모의 전용 Cassandra 클러스터가 되었고, 더 확장하려면 클러스터 크기를 두 배로 키워야 하는 미지의 영역에 들어서게 됩니다.

💡 캐시는 시간을 벌어주지만 데이터 모델의 문제를 대신 풀어주지 않는다. 1세대 아키텍처가 남긴 가장 큰 교훈입니다.


4. 2세대 아키텍처: Live와 Compressed의 분리

향후 5년치 성장을 감당하기 위해 팀은 두 가지 목표를 세웠습니다.

  1. 더 작은 스토리지 풋프린트
  2. 회원당 시청량이 늘어도 일정한 읽기/쓰기 성능

핵심 인사이트는 접근 패턴에 있었습니다. 대부분의 요청은 최근 데이터를 향하고, 오래된 데이터는 거의 갱신되지 않습니다. 그렇다면 둘을 같은 방식으로 저장할 이유가 없습니다.

데이터를 두 덩어리로 나누기

구분 LiveVH (최근) CompressedVH (아카이브)
데이터량 적음 많음
갱신 빈도 잦음 드묾
저장 형태 비압축 압축, 로우당 단일 컬럼
Compaction 자주 실행 수동·저빈도 full compaction
gc_grace_seconds 작게 설정
Repair read repair / full repair 자주 갱신 시점에 정합성 검증 → repair 불필요

같은 Cassandra여도 테이블 성격에 따라 완전히 다르게 튜닝한 것이 이 설계의 묘미입니다.

읽기 API를 두 갈래로 열기

  • 최근 시청 기록 조회 → 대부분 LiveVH만 읽음. 데이터 크기가 작아 지연이 크게 감소
  • 전체 시청 기록 조회 → LiveVH와 CompressedVH를 병렬로 읽어서 병합. 압축 + 적은 컬럼 수 덕분에 실제 읽는 양이 확 줄어듦

롤업(Rollup) 플로우

LiveVH를 읽는 시점에 레코드 수가 임계치를 넘으면, 백그라운드 작업이 다음을 수행합니다.

설계에서 눈여겨볼 부분:

  • 버전 컬럼이 최신 롤업 데이터를 가리키므로, 읽기는 항상 최신 버전만 봅니다.
  • 롤업 중 락을 걸지 않습니다. 극히 드문 중복 쓰기는 Cassandra의 last-writer-wins로 정리됩니다. 복잡한 분산 락 대신 데이터 특성을 이용해 단순함을 선택한 사례입니다.
  • 롤업 후 LiveVH에 최근 며칠치를 남겨두어, 헤비 유저의 롤업 빈도 자체를 낮춥니다.

5. 청킹(Chunking): 예외 케이스에 상한을 씌우기

대부분의 회원은 압축된 기록이 로우 하나에 잘 들어갑니다. 문제는 극단적으로 시청 기록이 많은 소수의 회원입니다. 이들에게는 1세대와 똑같은 와이드 로우 문제가 재발합니다.

여기서의 요구사항이 중요합니다.

일반 케이스의 성능을 해치지 않으면서, 예외 케이스의 지연에 상한을 둘 것.

해법은 압축 데이터를 설정 가능한 크기 기준으로 여러 청크로 쪼개어 서로 다른 노드에 분산 저장하는 것이었습니다.

[일반 케이스]  Row Key: CustomerId
               └── 메타데이터 + 압축 데이터  (읽기 1회)

[예외 케이스]  Row Key: CustomerId                        ← 메타데이터 전용 (data 컬럼 비움)
               Row Key: CustomerId$Version$ChunkNumber   ← 청크 1
               Row Key: CustomerId$Version$ChunkNumber   ← 청크 2 ...  (읽기 2회)

메타데이터 로우에는 최신 버전, 오브젝트 크기, 청크 수 같은 정보가 들어갑니다.

  • 쓰기: 모든 청크를 병렬로 쓴 뒤, 성공하면 메타데이터 로우를 씁니다 → 쓰기 2회로 상한
  • 읽기: 위 그림처럼 읽기 2회로 상한
  • 일반 케이스에서는 메타데이터를 데이터와 같은 로우에 합쳐 추가 조회 자체를 없앴습니다.
  • 캐시 레이어도 동일하게 청킹을 지원하도록 확장했습니다(첫 청크에 메타데이터 동봉).

💡 "흔한 경로는 최대한 짧게, 드문 경로는 최악을 유한하게." 지연 시간 설계에서 반복해서 쓸 수 있는 원칙입니다.

2세대의 성과

지표 개선폭
데이터 크기 약 6배 감소
Cassandra 유지보수에 쓰인 시스템 시간 약 13배 감소
평균 읽기 지연 약 5배 감소
평균 쓰기 지연 약 1.5배 감소

6. 3세대 아키텍처: 종류·나이·상세도로 샤딩하기 (Part II)

2016년, 두 가지 변화가 다시 아키텍처를 한계로 밀어붙입니다.

  • 글로벌 확장 — 130개국 추가 오픈, 20개 언어 지원
  • 미리보기(preview) 재생 — 브라우징 중 재생되는 짧은 영상이 기록으로 쌓이기 시작

미리보기 대 전체 재생의 비율이 빠르게 커지면서, 2016년 말에는 한 분기에 30% 성장하는 상황이 되었습니다. 심지어 스토리지 영향 때문에 미리보기 기능의 롤아웃이 지연되는 지경에 이르렀습니다. 클러스터를 더 키우는 단순 해법은, 이미 사내 최대 규모였고 성공적으로 넘어간 사례가 드문 크기 한계에 근접해 있었기 때문에 선택지가 아니었습니다.

6.1 접근 패턴 분석에서 드러난 것

팀은 최소 5배 성장을 감당할 설계를 목표로 삼고, 데이터셋의 접근 패턴부터 다시 분석했습니다. 성격이 뚜렷하게 다른 세 종류가 한 클러스터에 섞여 있었습니다.

  1. 전체 재생(Full title plays) — 이어보기·추천의 핵심
  2. 미리보기 재생(Video preview plays) — 양은 폭증하지만 중요도는 낮음
  3. 언어 선호(Language preference) — 어떤 자막/더빙을 재생했는지

그리고 세 종류 모두에서 공통 패턴이 나타났습니다. 접근의 대부분은 최근 데이터를 향하고, 데이터가 오래될수록 필요한 상세도는 낮아진다. 팀은 여기서 멈추지 않고 데이터 소비자(내부 파트너 팀)들과 직접 협의해, 어떤 데이터를 어느 수준으로 얼마나 오래 보관할지 합의했습니다. 저장 전략을 기술만으로 정하지 않고 소비자와 합의로 정했다는 점이 인상적인 대목입니다.

6.2 두 가지 낭비

낭비 ①: 저장 효율

  • 아주 짧은 미리보기 재생은 콘텐츠에 대한 긍정·부정 신호로 보기 어려워, 파트너 팀들이 어차피 조회 후 걸러내고 있었습니다.
  • 대다수 회원은 대부분의 작품에서 같은 자막/더빙 언어를 선택합니다. 그런데 시청 레코드마다 언어 선호를 함께 저장하고 있었으니 중복이 막대했습니다.

낭비 ②: 클라이언트 복잡도

클라이언트 라이브러리는 영상 종류, 기간, 상세도, 자막/더빙 포함 여부 같은 조건으로 데이터를 걸러줬는데 — 대부분의 경우 백엔드에서 전체 데이터를 받아온 뒤 클라이언트 쪽에서 필터링하고 있었습니다. 불필요한 데이터 전송이 발생했고, 시청 데이터가 큰 회원일수록 성능이 급격히 나빠져 P99 읽기 지연의 편차가 매우 커졌습니다.

💡 필터를 어느 계층에서 적용하느냐가 P99를 좌우합니다. "일단 다 가져와서 앱에서 거른다"는 구조는 데이터가 커지는 순간 무너집니다.

6.3 재설계 원칙

원칙
데이터 종류 종류별로 샤딩한다 / 필드를 꼭 필요한 것만 남긴다
데이터 나이 나이별로 샤딩한다 / 최근 데이터는 TTL로 만료시킨다 / 오래된 데이터는 요약해 아카이브 클러스터로 회전시킨다
성능 최근·과거 데이터를 아우르는 통합 추상화를 병렬 읽기로 제공한다

핵심은 접근 방향을 뒤집은 것입니다. 이전에는 하나의 클러스터에 모두 담고 클라이언트가 종류·나이·상세도로 걸렀다면, 이제는 클러스터 자체를 종류·나이·상세도로 나눕니다. 이렇게 하면 데이터셋마다 다른 성장 속도가 서로 분리되고, 클라이언트는 단순해지며, 읽기 지연도 개선됩니다.

6.4 저장 효율을 확보한 방법

  • 아주 짧은 미리보기 재생은 아예 저장하지 않습니다. (쓰기 진입 전 필터링)
  • 언어 선호는 최초 값만 저장하고, 이후에는 변경분(델타)만 저장합니다. 대다수 회원은 결국 레코드 1건만 남게 되어 절감폭이 큽니다.
  • 미리보기와 언어 선호에는 전체 재생보다 짧은 TTL을 적용해 더 공격적으로 만료시킵니다.
  • 필요한 곳에는 2세대의 Live/Compressed 기법을 그대로 적용합니다.
  • 오래된 데이터를 담는 클러스터는 전부 압축 상태로 보관합니다. 저장 비용을 낮추는 대신 접근 시점의 연산 비용을 감수하는 트레이드오프입니다.
  • 히스토리컬 전체 재생은 상세 레코드 대신 컬럼 수를 줄인 요약 뷰로 별도 테이블에 저장하고, 이 요약 뷰도 압축합니다.

정리하면 이렇습니다.

데이터 종류 나이 저장 상세도
미리보기 재생 최근 필터링된 데이터
전체 재생 최근 완전한 상세 레코드
전체 재생 히스토리컬 요약
언어 선호 최근 델타

6.5 데이터 흐름

쓰기 — 항상 가장 최신 클러스터로 들어갑니다. 진입 전에 필터가 적용됩니다(아주 짧은 미리보기 제외, 이전 언어 선호와 비교해 변경이 있을 때만 저장).

읽기 — 필요한 범위에 따라 병렬 읽기 조합이 달라집니다.

요청 범위 읽기 방식
최근 며칠 전체 재생 "Recent" 클러스터의 LIVE · COMPRESSED 테이블 병렬 읽기
+ 언어 선호 필요 시 "Language Preference" 클러스터에 병렬 읽기 추가
+ 미리보기 필요 시 "Preview Titles" 클러스터의 LIVE · COMPRESSED 병렬 읽기 추가
최근 수개월 전체 재생 "Recent" + "Past" 병렬 읽기
요약 뷰 "Recent" + "Past" + "Historical" 병렬 읽기 후 스티칭

 

여기서 눈여겨볼 디테일이 있습니다. "Historical" 클러스터의 요약 뷰에는 최근 수년치 갱신이 반영되어 있지 않습니다. 저장 크기와 비용을 줄이기 위한 선택이고, 그래서 전체 요약을 만들 때는 Recent·Past 데이터를 요약해 덧붙여 보완합니다. 저장 비용을 줄이는 대신 읽기 경로에 조립 로직을 넣은 것입니다.

LIVE 테이블 읽기 중 레코드 수가 임계값을 넘으면 롤업·압축 후 같은 로우 키에 새 버전으로 COMPRESSED에 기록하는 흐름은 2세대와 동일합니다.

6.6 데이터 로테이션 — 클러스터 간 이동

나이별로 클러스터가 나뉘어 있으니, 데이터를 옮기는 장치가 필요합니다. 이 이동은 비동기로 처리됩니다.

  • 이 백그라운드 전송은 배치로 묶여 처리되므로 모든 읽기마다 트리거되지 않습니다.
  • Past → Historical 이동도 같은 방식입니다. 다만 이때는 대상 레코드를 기존 요약 레코드와 함께 재가공해 새 요약 레코드를 만든 뒤 압축해 기록합니다.
  • 새 버전 기록 성공 → 이전 버전 삭제 순서는 2세대 롤업과 동일합니다. 버전 기반 교체는 이 아키텍처 전체를 관통하는 기본기입니다.

6.7 캐시 계층도 스토리지 구조를 그대로 따라간다

Cassandra에서 큰 청크를 병렬로 읽는 일이 많기 때문에 캐시의 효용이 매우 큽니다. 그래서 EVCache 계층 구조도 백엔드 스토리지 구조를 그대로 미러링하도록 변경했습니다. 모든 캐시가 99%에 가까운 히트율을 기록하며 Cassandra로 가는 읽기 요청을 크게 줄여줍니다.

한 가지 차이가 있습니다. 스토리지에서는 요약 뷰가 나이별로 흩어져 있지만, 캐시의 "Summary" 클러스터는 전체 재생 시청 데이터 전 구간의 압축 요약을 통째로 들고 있습니다.

 

비싼 조립 경로는 전체 요청의 1% 남짓에서만 실행됩니다. 읽기 경로를 복잡하게 만든 대신, 그 복잡한 경로가 실제로 타는 빈도를 캐시로 눌러버린 구조입니다.

6.8 마이그레이션 중간 결과

원문 작성 시점(2018년 11월) 기준으로 팀은 변경 작업을 절반 이상 진행한 상태였고, 데이터 종류별 샤딩을 활용하는 유즈케이스는 이미 마이그레이션이 끝난 상황이었습니다. 공개된 중간 결과는 다음과 같습니다.

  • 종류별 샤딩만으로도 Cassandra의 운영 특성(compaction, GC 압력, 지연)이 크게 개선됨
  • 전체 재생 시청 데이터 클러스터에 최소 5배 성장을 감당할 여유 확보
  • 더 공격적인 압축과 TTL 덕분에 상당한 비용 절감
  • 재설계가 하위 호환이라 기존 API는 그대로 동작하며, 지연은 더 좋아지고 예측 가능해질 것으로 전망

마지막 항목은 실무적으로 특히 중요합니다. 데이터의 부분집합에 접근하는 신규 API는 추가적인 지연 이득이 크지만 클라이언트 변경이 필요합니다. 하위 호환을 유지한 덕분에 서버 변경을 클라이언트 변경과 독립적으로 롤아웃할 수 있었고, 각 클라이언트 팀은 자기 일정에 맞춰 옮겨갈 수 있었습니다.

💡 대규모 스토리지 재설계에서 하위 호환성은 기술 부채가 아니라 마이그레이션 속도를 결정하는 설계 요소입니다.


7. Kotlin으로 보는 핵심 패턴

개념을 코드로 옮기면 이해가 빨라집니다. 병렬 읽기와 롤업 임계값 판단을 코루틴으로 단순화한 예시입니다.

class ViewingHistoryRepository(
    private val liveVH: LiveViewingHistoryDao,
    private val compressedVH: CompressedViewingHistoryDao,
    private val rollupThreshold: Int = 100,
) {
    /** 최근 기록만 필요한 경우 — LiveVH만 조회하므로 지연이 가장 짧다 */
    suspend fun findRecent(customerId: Long): List<ViewingRecord> =
        liveVH.findAll(customerId)

    /** 전체 기록 — LiveVH와 CompressedVH를 병렬로 읽어 병합한다 */
    suspend fun findAll(customerId: Long): List<ViewingRecord> = coroutineScope {
        val live = async { liveVH.findAll(customerId) }
        val archived = async { compressedVH.findAll(customerId) }  // 내부에서 청크 병렬 조회

        val liveRecords = live.await()
        if (liveRecords.size > rollupThreshold) {
            launch { rollup(customerId, liveRecords) }   // 읽기 응답을 막지 않는 백그라운드 작업
        }
        (liveRecords + archived.await()).sortedByDescending { it.viewedAt }
    }

    private suspend fun rollup(customerId: Long, liveRecords: List<ViewingRecord>) {
        val (keep, merge) = liveRecords.partition { it.viewedAt >= recentWindowStart() }

        val newVersion = compressedVH.writeNewVersion(customerId, merge)  // 압축 + 청킹 저장
        if (compressedVH.verify(customerId, newVersion)) {                // 검증 후에만
            compressedVH.deleteOldVersions(customerId, newVersion)        // 이전 버전 제거
            liveVH.retainOnly(customerId, keep)
        }
    }
}
/** 청킹된 압축 데이터 읽기 — 메타데이터 1회 + 청크 병렬 1회로 상한 */
suspend fun CompressedViewingHistoryDao.findAll(customerId: Long): List<ViewingRecord> = coroutineScope {
    val metadata = readMetadata(customerId)          // 읽기 1회

    val payload = if (metadata.chunkCount == 1) {
        metadata.inlineData                          // 일반 케이스: 추가 조회 없음
    } else {
        (0 until metadata.chunkCount)
            .map { async { readChunk("$customerId\$${metadata.version}\$$it") } }  // 읽기 2회째
            .awaitAll()
            .reduce(ByteArray::plus)
    }
    decompress(payload)
}
/** 3세대 — 요청 범위에 따라 필요한 클러스터만 병렬 조회 */
suspend fun findViewingData(customerId: Long, req: ViewingDataRequest): ViewingData = coroutineScope {
    val full = when (req.range) {
        Range.RECENT   -> listOf(async { recentCluster.read(customerId) })
        Range.MONTHS   -> listOf(async { recentCluster.read(customerId) },
                                 async { pastCluster.read(customerId) })
        Range.SUMMARY  -> listOf(async { recentCluster.summarize(customerId) },
                                 async { pastCluster.summarize(customerId) },
                                 async { historicalCluster.read(customerId) })
    }
    val language = if (req.includeLanguage) async { languageCluster.read(customerId) } else null
    val previews = if (req.includePreviews) async { previewCluster.read(customerId) } else null

    stitch(full.awaitAll(), language?.await(), previews?.await())
}

 

필터가 서버 진입 시점에 적용되고, 필요 없는 클러스터는 아예 조회하지 않는다는 점이 3세대의 핵심입니다.


8. 우리 서비스에 적용할 수 있는 설계 체크리스트

Netflix 규모가 아니어도 그대로 쓸 수 있는 원칙들입니다.

  • 데이터의 접근 패턴을 나이·종류로 나눠봤는가? 최근 데이터와 오래된 데이터를 같은 방식으로 저장하고 있다면 개선 여지가 큽니다.
  • 필터를 클라이언트에서 적용하고 있지 않은가? 다 가져와서 거르는 구조는 P99 편차의 주범입니다.
  • P99를 결정하는 것은 소수의 헤비 유저다. 평균이 아니라 최악 케이스에 상한을 두는 설계인지 확인하세요.
  • 데이터 소비자와 보관 정책을 협의했는가? "어차피 안 쓰는 데이터"를 찾아내는 가장 빠른 방법은 대화입니다.
  • 중복 저장되는 필드는 없는가? 반복되는 값은 최초 1건 + 델타로 바꿀 수 있습니다.
  • 같은 DB라도 테이블마다 다르게 튜닝할 수 있다. compaction 주기, TTL, repair 정책은 테이블 성격에 맞춰 갈라야 합니다.
  • 중요도가 다른 데이터가 한 클러스터에 섞여 있지 않은가? 덜 중요한 데이터의 폭증이 핵심 기능의 발목을 잡을 수 있습니다.
  • 저장 비용과 연산 비용 중 무엇이 더 비싼가? 압축은 이 둘을 맞바꾸는 레버입니다.
  • 재설계는 하위 호환인가? 서버와 클라이언트를 독립적으로 배포할 수 있어야 마이그레이션이 굴러갑니다.
  • 한 번 만든 패턴을 다른 곳에 재사용할 수 있게 설계했는가?

9. 자주 묻는 질문 (FAQ)

Q. 지금도 Netflix는 이 구조를 쓰나요?
A. 위 내용은 2018년 공개된 아키텍처 기준입니다. 이후 Netflix는 이런 패턴들을 일반화한 TimeSeries Data Abstraction Layer를 별도로 공개했습니다. 계층형 스토리지, 동적 이벤트 버케팅 등으로 발전한 형태입니다.

Q. 꼭 Cassandra여야 하나요?
A. 아닙니다. 핵심은 특정 DB가 아니라 접근 패턴에 따라 데이터를 분리하고, 최악 케이스에 상한을 두는 사고방식입니다. DynamoDB, HBase, ScyllaDB 등에서도 동일하게 적용할 수 있습니다.

Q. 클러스터를 이렇게 잘게 나누면 운영 부담이 늘지 않나요?
A. 늘어납니다. 대신 각 데이터셋의 성장 속도가 서로 분리되어, 한쪽의 폭증이 다른 쪽을 위협하지 않게 됩니다. Netflix의 경우 종류별 샤딩만으로도 compaction·GC 압력·지연이 개선되어 그 부담을 상쇄했습니다.

Q. 우리 서비스는 데이터가 훨씬 적은데도 참고할 가치가 있나요?
A. 있습니다. 특히 "일부 헤비 유저 때문에 특정 API의 P99가 튄다"는 문제는 규모와 무관하게 자주 발생하며, Live/Compressed 분리는 그 상황의 표준적인 해법 중 하나입니다.


10. 마치며

세 세대의 아키텍처를 관통하는 흐름은 결국 하나입니다.

모든 데이터를 똑같이 취급하기를 멈추고, 데이터의 성격에 맞게 저장 방식을 나눈다.

 

1세대는 단순함을 얻고 확장성을 잃었고, 2세대는 나이로 나눠 성능과 용량을 되찾았으며, 3세대는 종류와 상세도까지 나눠 성장의 격리를 얻었습니다. 그리고 3세대는 새로운 기법을 발명하기보다 2세대에서 검증한 Live/Compressed 패턴을 클러스터 간 데이터 이동에 재사용했습니다. 확장 가능한 빌딩 블록을 만들어두면 다음 확장이 쉬워진다는 것 — 이것이 이 시리즈가 남긴 가장 실용적인 교훈일지도 모릅니다.

우리 서비스의 스토리지도 언젠가 같은 질문 앞에 서게 됩니다. "이 데이터, 정말 다 같은 데이터인가?"


참고 자료

Thanks

Hans