들어가며: 시계열 데이터의 숨은 적, '넓은 파티션'

넷플릭스는 방대한 양의 이벤트 데이터(시청 기록, 검색 로그 등)를 처리하기 위해 자체 'TimeSeries Abstraction' 시스템을 구축했고, 그 기반 스토리지로 Apache Cassandra 4.x를 선택했습니다. Cassandra는 수백만 건의 저지연 쓰기와 운영의 성숙도라는 강력한 장점을 제공하지만, 시계열 워크로드에서는 치명적인 약점을 드러냅니다.

바로 시간이 지날수록 특정 파티션(Partition)이 비대해지는 '넓은 파티션(Wide Partition)' 문제입니다. 파티션이 과도하게 커지면 읽기 지연 시간이 수 밀리초에서 수 초로 급증하고, 심한 경우 GC(가비지 컬렉션) 일시 중지나 스레드 큐잉(Thread Queueing)까지 발생시킵니다.

이 글에서는 넷플릭스 기술 블로그에 공개된 내용을 바탕으로, 그들이 어떻게 이 문제를 해결했는지, 그리고 우리 프로젝트에는 어떤 인사이트를 얻을 수 있는지 자세히 살펴보겠습니다. (참고: 이 글에서 다루는 기법은 Cassandra에만 국한되지 않고, 유사한 데이터 모델을 가진 다른 데이터스토어에도 확장 적용이 가능합니다.)

문제의 본질: 왜 파티션이 커지면 안 되는가?

Cassandra는 분산 데이터베이스이지만, 데이터는 파티션 키를 기준으로 특정 노드에 저장됩니다. 시계열 데이터의 경우, 특정 ID(예: 프로필 ID)에 대한 이벤트가 계속 쌓이면서 하나의 파티션에 수 GB 이상의 데이터가 몰릴 수 있습니다.

이렇게 되면:

  1. 읽기 증폭(Read Amplification) 증가: 하나의 큰 파티션을 읽기 위해 디스크에서 많은 데이터를 스캔해야 합니다.
  2. 타임아웃 및 지연 시간 급증: p99 지연 시간이 수 초를 넘어가며, 결국 요청이 타임아웃됩니다.
  3. 리소스 불균형: 특정 노드에만 부하가 집중되어 클러스터 전체의 안정성을 해칩니다.

물론 클러스터를 수평 확장하는 방법도 있지만, 넷플릭스는 "돈을 더 쓰는 것보다 더 스마트한 해결책"을 찾고자 했습니다.

기존 전략: 시간 슬라이스(Time Slice) 단위 분할

넷플릭스의 TimeSeries Abstraction은 처음부터 이 문제를 어느 정도 완화하고자 설계되었습니다. 데이터를 시간 버킷(Time Bucket)이벤트 버킷(Event Bucket)으로 나누는 시간 슬라이스(Time Slice) 개념을 도입한 것입니다.

  • Time Bucket: 데이터가 생성된 시간을 기준으로 특정 기간(예: 60초, 1시간) 단위로 데이터를 묶습니다.
  • Event Bucket: 같은 시간 버킷 내에서도 데이터 양이 많을 경우, 이벤트 수를 기준으로 다시 세분화합니다.

이 전략은 데이터를 물리적으로 분산시켜 파티션 크기를 일정 수준으로 유지하고, 시간 기반 쿼리 및 데이터 삭제를 효율적으로 만듭니다. (이 파티셔닝 전략에 대한 자세한 내용은 넷플릭스의 이전 기술 블로그에서 확인할 수 있습니다.)

한계: 모든 워크로드는 시간이 지나면 변한다

하지만 이 고정된 전략은 다음과 같은 상황에서 한계를 드러냈습니다.

  • 초기 설정의 부정확성: 프로젝트 초기에 예상한 트래픽이 실제와 다르거나, 파티션 크기 설정을 잘못하는 경우가 빈번합니다.
  • 워크로드 변화: 시간이 지나면서 특정 ID에 대한 이벤트 발생량이 급증하는 등 데이터 특성이 변합니다.
  • 데이터 이상치(Outlier): 일부 특정 ID(예: 인기 콘텐츠의 ID)가 다른 ID보다 압도적으로 많은 이벤트를 받는 경우가 있습니다.

이에 넷플릭스는 두 가지 단계의 해결책을 제시합니다.

해결책 1: 테이블 단위 동적 재분할 (Dynamic Time Slice Re-Partitioning)

첫 번째 해결책은 백그라운드 워커(Background Worker) 를 도입하여 테이블 전체의 파티션 크기를 주기적으로 모니터링하고, 설정된 목표 밀도(예: 2MiB ~ 10MiB)를 벗어나면 미래의 시간 슬라이스에 대한 파티셔닝 전략을 자동으로 변경하는 것입니다.

Cassandra의 nodetool tablehistograms와 같은 API를 활용해 파티션 크기 분포를 파악하고, 만약 파티션이 너무 작게 쪼개져 있어 오버헤드가 발생한다면 time_bucket 간격을 늘리는 방식입니다.

# DynamicTimeSliceConfigWorker 설정 예시
namespace: my_dataset_1
Observed: TimeSlices have p99 partitions below configured target of 10MB.
Proposed: time_bucket interval: 60s -> 604800s  # 60초 간격을 7일 간격으로 증가

이 방법은 데이터 대부분이 비슷한 패턴을 보일 때 효과적입니다. 하지만 일부 ID만 비대해지는 경우에는 테이블 전체의 전략을 바꾸는 것은 비효율적입니다.

해결책 2: ID 단위 동적 분할 (Dynamic Partitioning per ID) - 핵심 전략

두 번째 해결책은 문제의 범위를 더 좁혀 특정 TimeSeries ID에 대해서만 파티션을 동적으로 분할하는 것입니다. 이는 다음 3단계의 비동기 파이프라인으로 구성됩니다.

  1. 탐지(Detection): 읽기 경로에서 특정 파티션의 읽기 바이트 수가 임계값을 초과하면 Kafka로 이벤트를 보냅니다.
// 탐지 이벤트 예시
{
  "time_slice": "data_20260328",
  "time_series_id": "profileId:123",
  "time_bucket": 7,
  "event_bucket": 2,
  "immutable": true, // 더 이상 쓰기가 발생하지 않는 파티션인지 여부
  "version": "0"
}
  1. 계획 및 분할(Planning & Splitting): 탐지된 넓은 파티션을 읽어 전체 데이터를 확인하고, 최적의 크기로 분할하는 계획을 세웁니다. 이 과정에서 체크포인트를 기록하여 실패 시 이어서 진행할 수 있게 합니다. 분할 후에는 체크섬(Checksum)을 비교하여 데이터 무결성을 검증합니다.

  2. 읽기 서빙(Serving Reads): 분할이 완료되면, 서버는 분할된 파티션의 키를 블룸 필터(Bloom Filter) 에 로드합니다. 이후 모든 읽기 요청은 블룸 필터를 확인하여 분할된 파티션으로 쿼리를 우회합니다. 블룸 필터 조회는 수 마이크로초밖에 걸리지 않아 성능 저하가 거의 없습니다.

# 읽기 경로 우회 의사 코드 (Python)
def read_events(ts_id, start_time, end_time):
    if bloom_filter.contains(ts_id):  # 분할된 ID인지 빠르게 확인
        # 분할된 파티션 메타데이터 조회 후 병렬 읽기
        split_info = wide_row_metadata_lookup(ts_id)
        return read_from_split_partitions(split_info)
    else:
        # 기존 경로로 읽기
        return read_from_original_partition(ts_id, start_time, end_time)

여기서 중요한 점은 기존의 넓은 파티션을 삭제하지 않는다는 것입니다. 이는 부분 실패나 일관성 문제 발생 시 안전한 폴백(Fallback) 경로를 제공하여 운영 안전성을 크게 높입니다.

결과: 수 초에서 수십 밀리초로

이 동적 분할 기법을 적용한 결과는 극적이었습니다.

  • 평균 읽기 지연 시간: 수 초 → 수십 밀리초 (10ms 대)
  • 꼬리 지연 시간 (Tail Latency): 수 초 → 약 200ms 이하
  • 읽기 타임아웃: 대폭 감소
  • 클러스터 안정성: CPU 사용률 및 스레드 큐잉 감소

또한, 극단적으로 큰 파티션(500MB 이상)의 경우에도 서비스가 중단되지 않고, 지연 시간이 다소 늘어나더라도 페이징(paging) 방식으로 데이터를 처리할 수 있게 되었습니다.

현실 적용 시 주의사항 및 한계

이 전략은 매우 훌륭하지만, 은탄환은 아닙니다. 적용 전에 다음 사항을 꼭 확인해야 합니다.

  • 쓰기 중인 파티션 (Mutable Partition) 문제: 넷플릭스는 우선 불변(Immutable) 파티션에 대해서만 분할을 적용했습니다. 쓰기가 진행 중인 파티션을 분할하는 것은 훨씬 복잡한 문제이며, 아직은 해결 과제로 남아 있습니다.
  • 블룸 필터 메모리: 분할된 파티션의 키를 메모리에 유지해야 하므로, 파티션 수가 많아지면 메모리 사용량이 증가할 수 있습니다. 넷플릭스는 파티션 키의 크기가 작아 큰 문제가 아니라고 하지만, 데이터 특성에 따라 모니터링이 필요합니다.
  • 초기 탐지 지연: 읽기 경로에서 문제를 탐지하기 때문에, 문제가 되는 파티션에 대한 첫 번째 읽기는 여전히 느릴 수 있습니다. (일반적으로 수 초 이내에 탐지 및 분할이 완료됩니다.)

결론: 진화하는 데이터에 맞춰 설계하기

넷플릭스의 이번 사례는 단순히 Cassandra의 성능 최적화를 넘어, '데이터의 변화'를 시스템 설계에 어떻게 반영할 것인가에 대한 훌륭한 교훈을 줍니다.

  1. 문제의 표면적 줄이기: 처음부터 모든 문제를 해결하려 하지 말고, 영향력이 크면서도 구현이 상대적으로 간단한 불변 파티션 분할부터 시작했습니다.
  2. 신뢰 구축의 중요성: 체크섬 비교, 섀도우 모드(Shadow Mode)를 통한 신·구 경로 비교 등 다양한 단계를 거쳐 시스템에 대한 신뢰를 확보했습니다.
  3. 안전망 확보: 기존 파티션을 삭제하지 않고 유지함으로써, 예상치 못한 실패 상황에서도 빠르게 롤백할 수 있는 안전장치를 마련했습니다.

시계열 데이터를 다루는 시스템을 설계할 때, 초기 파티셔닝 전략이 완벽하다고 생각하지 말고, 데이터가 성장함에 따라 파티셔닝 전략도 함께 진화할 수 있는 유연한 구조를 고려하는 것이 중요합니다.

다음 단계 학습 방향

  • Cassandra 파티셔닝 심화: nodetool tablehistograms 외에도 다양한 진단 도구를 학습해보세요.
  • 블룸 필터 최적화: 메모리와 CPU 사용량 간의 트레이드오프를 이해하고, 대용량 데이터에서 효율적으로 활용하는 방법을 연구해보세요.
  • 데이터 모델링: 시계열 데이터에 특화된 다양한 데이터 모델링 기법(예: 1시간 버킷 vs 1일 버킷)의 장단점을 비교 분석해보세요.

함께 보면 좋은 글

참고: 본 포스팅은 넷플릭스 기술 블로그의 기술 분석을 기반으로 재구성되었습니다.

Netflix engineers monitoring Cassandra cluster performance metrics and read latency graphs on large server racks System Abstract Visual

본 콘텐츠는 신뢰할 수 있는 출처를 바탕으로 AI 도구를 활용하여 초안이 작성되었으며, 편집자의 검토를 거쳐 발행되었습니다. 전문가의 조언을 대체하지 않습니다.