[번역] Netflix의 분산 카운터 추상화

Netflix의 분산 카운터 추상화

소개

이전 블로그 글에서 우리는 Netflix의 TimeSeries Abstraction에 대해 소개했습니다. 이는 대량의 시계열 이벤트 데이터를 매우 짧은 밀리초 단위 지연 시간으로 저장하고 조회하기 위해 만들어진 분산 서비스입니다. 이번 글에서는 Distributed Counter Abstraction을 소개하려고 합니다. 이 카운팅 서비스는 TimeSeries Abstraction 위에 구축되었으며, 유사한 낮은 지연 시간 성능을 유지하면서도 대규모 분산 카운팅을 가능하게 합니다. 모든 추상화에서와 마찬가지로, 이 서비스 역시 Data Gateway Control Plane을 사용해 샤딩, 구성, 전 세계 배포를 관리합니다.

분산 카운팅은 컴퓨터 과학에서 까다로운 문제입니다. 이번 블로그 글에서는 Netflix에서 필요한 다양한 카운팅 요구 사항, 거의 실시간에 가까운 정합성 높은 카운팅을 달성하기 위한 도전 과제, 그리고 이러한 접근 방식을 선택하게 된 이유와 필요한 트레이드오프에 대해 살펴보겠습니다.

참고: 분산 카운터에서 ‘정확하다(accurate)’ 혹은 ‘정밀하다(precise)’라는 표현은 다소 과장일 수 있습니다. 이 맥락에서 이 표현들은 실제 값에 매우 근접하며, 지연이 최소화된 상태의 카운트를 의미한다고 이해하시면 됩니다.


사용 사례와 요구 사항

Netflix 내부에서의 카운팅 용도는 아래와 같이 다양합니다.

  • 수백만 사용자 상호 작용 횟수 추적
  • 특정 기능이나 경험이 사용자에게 몇 번 노출되었는지 모니터링
  • A/B 테스트 실험에서 여러 측정 기준을 카운팅

이렇게 다양한 카운팅 용도를 Netflix에서는 크게 두 가지 범주로 나눕니다.

  1. Best-Effort

    • 카운트 정확도나 내구성(durability)이 엄격할 필요는 없지만, 현재 카운트를 거의 즉각적으로 조회할 수 있어야 합니다.
    • 조회 지연은 매우 짧아야 하며, 인프라 비용도 최소화해야 합니다.
  2. Eventually Consistent

    • 정확하고 내구성 높은 카운트가 필요하되, 정확도에 약간의 지연이나 조금 더 높은 인프라 비용이 들어가는 것을 감수할 수 있는 경우입니다.

두 범주 모두 공통적으로 매우 높은 처리량과 가용성이 요구됩니다. 아래 표(원문 참조)에서는 이 두 범주가 필요로 하는 다양한 요구 사항을 더 세부적으로 나열합니다.


Distributed Counter Abstraction

앞서 살펴본 요구 사항을 충족하기 위해, 이번 Counter Abstraction은 높은 구성 가능성을 갖도록 설계되었습니다. 사용자들은 Best-Effort 모드 혹은 Eventually Consistent 모드와 같은 다양한 카운팅 방식을 선택할 수 있고, 각 방식에서 기대할 수 있는 트레이드오프도 문서로 안내됩니다. 모드를 선택한 후에는 사용자들이 내부 스토리지 방식이나 카운팅 방식을 신경 쓰지 않고도 API로 간단히 상호 작용할 수 있습니다.

아래에서는 이 API가 어떤 구조와 기능을 제공하는지 살펴봅니다.


API

카운터는 여러 개의 namespace로 구분되어 관리됩니다. 각 namespace는 제각기 다른 Counter 유형, TTL(Time-To-Live), Counter Cardinality 등 여러 설정을 가질 수 있으며, 이 설정은 서비스의 Control Plane을 통해 구성됩니다.

Counter Abstraction API는 Java의 AtomicInteger 인터페이스와 유사합니다.

1. AddCount / AddAndGetCount

주어진 delta(증감량)만큼 카운터 값을 조정합니다. delta 값은 양수 혹은 음수일 수 있으며, AddAndGetCount는 조정된 뒤의 최종 카운트 값을 반환합니다.

{
  "namespace": "my_dataset",
  "counter_name": "counter123",
  "delta": 2,
  "idempotency_token": {
    "token": "some_event_id",
    "generation_time": "2024-10-05T14:48:00Z"
  }
}

idempotency_token은 이를 지원하는 카운터 유형에서 사용할 수 있습니다. 클라이언트는 이 토큰을 통해 요청을 안전하게 재시도하거나 헤징(hedging)할 수 있습니다. 분산 시스템에서는 장애가 필연적으로 발생하기 때문에, 이러한 안전한 재시도 메커니즘이 서비스 신뢰도를 높이는 핵심 요소가 됩니다.

2. GetCount

특정 dataset 내에서 지정된 카운터 값을 조회합니다.

{
  "namespace": "my_dataset",
  "counter_name": "counter123"
}

3. ClearCount

특정 dataset 내에서 지정된 카운터를 사실상 0으로 재설정합니다.

{
  "namespace": "my_dataset",
  "counter_name": "counter456",
  "idempotency_token": { ... }
}

이제 Abstraction에서 지원하는 다양한 카운터 유형을 살펴보겠습니다.


카운터 유형

이 서비스에서는 크게 Best-EffortEventually Consistent라는 두 가지 핵심 카운터 유형을 지원하며, 여기에 세 번째로 실험적인 Accurate 카운터 유형이 있습니다. 다음 섹션에서는 이 세 가지 유형이 어떤 방식으로 동작하며, 각각 어떤 트레이드오프를 가지는지 설명합니다.


1. Best Effort Regional Counter

이 카운터 유형은 Netflix가 자체 개발한 Memcached 기반 분산 캐싱 솔루션인 EVCache에 의해 동작합니다. 예를 들어 많은 동시 실험이 진행되고 카운트 기간이 상대적으로 짧은 A/B 테스트 같은 용도에서 대략적인 수치만으로 충분할 경우 이 카운터를 사용할 수 있습니다.

프로비저닝, 리소스 할당, 제어 플레인 관리와 같은 복잡한 과정을 제외하면, 이 솔루션의 기본 메커니즘은 매우 단순합니다.

// 카운터 캐시 키
counterCacheKey = <namespace>:<counter_name>

// add 연산
return delta > 0
    ? cache.incr(counterCacheKey, delta, TTL)
    : cache.decr(counterCacheKey, Math.abs(delta), TTL);

// get 연산
cache.get(counterCacheKey);

// 모든 복제본(replica)에서 카운트 삭제
cache.delete(counterCacheKey, ReplicaPolicy.ALL);

EVCache는 단일 리전(regional) 내에서 매우 높은 처리량과 밀리초 단위 이하의 지연 시간을 제공하며, 멀티 테넌트 환경에서 동일한 클러스터를 공유할 수 있어 인프라 비용 절감에도 유리합니다. 다만 몇 가지 트레이드오프가 존재합니다.

  • 교차 리전 간 증분(increment) 연산에 대한 복제 미지원
  • 정합성 보장 부재
  • idempotency 미지원 → 안전한 재시도나 헤징이 불가능

추가 내용: 확률적(probabilistic) 데이터 구조
HyperLogLog(HLL)나 Count-Min Sketch(CMS) 같은 확률적 데이터 구조를 이용해 대략적인 고유(distinct) 카운트를 구하는 경우가 있습니다. 그러나 본 서비스가 필요로 하는 ‘특정 키에 대한 정교한 증감 연산’(increment와 decrement) 및 ‘카운트 리셋’ 같은 기능까지 지원하려면 부가적인 구조가 필요합니다. 또한 Netflix는 이미 대규모로 운영 중인 데이터 스토어를 활용하는 방안을 선호했습니다. EVCache 기반 솔루션은 매우 간단하고(코드가 적음), 이미 검증된 인프라를 사용할 수 있으나, ‘카운터 키당 소량의 메모리를 차지한다’는 트레이드오프가 있습니다.


2. Eventually Consistent Global Counter

Best-Effort 카운터의 한계를 받아들일 수 없는 일부 사용자는 좀 더 정확하고 내구성이 보장되며, 전 세계 어디서나 사용할 수 있는 카운터를 필요로 합니다. 본 섹션에서는 이러한 요구에 부합하는 방법들을 살펴보고, 글로벌 분산 환경에서 정확도와 내구성을 갖춘 카운터를 구현할 때 마주치는 난관과 Netflix가 실제로 선택한 해법을 공유합니다.

접근 방식 1: 카운터당 단일 Row(Row)에 저장

가장 단순한 방법으로, 글로벌하게 복제되는 데이터스토어의 테이블에 카운터 키 하나당 하나의 행(row)을 매핑해 저장한다고 해봅시다.

[ 단일 Row 기반 설계 구조 (그림 예시) ]

이 방식에는 다음과 같은 문제가 있습니다.

  1. Idempotency 부족

    • 스토리지 레벨에서 idempotency 키를 고려하지 않았기 때문에, 안전한 재시도가 불가능합니다. idempotency를 보장하려면 외부 시스템에서 이를 관리해야 하는데, 이는 성능 저하나 레이스 컨디션을 유발할 수 있습니다.
  2. 심각한 경쟁(contention)

    • 정확한 업데이트를 위해서는 모든 쓰기(writer)가 Lock 또는 트랜잭션을 통해 Compare-And-Swap 연산을 해야 합니다. 쓰기 처리량이나 동시성이 높으면 콘텐츠션이 급격히 늘어나 성능에 큰 영향을 미칠 수 있습니다.
  3. Secondary Key

    • 콘텐츠션을 줄이기 위해 “버킷 아이디(bucket_id)” 같은 보조 키를 도입하는 방안이 있습니다. 이렇게 하면 카운터를 여러 버킷에 나눠 병렬로 쓰고, 조회할 때 이 버킷들을 합산해 최종 카운트를 구할 수 있습니다. 다만 “버킷을 몇 개나 만들지”가 정적이면 핫 키(hot key)가 여전히 집중될 수 있고, 동적으로 버킷 수를 운영하는 것은 더욱 복잡해집니다.

이런 문제를 해결하기 위해 다른 방식을 시도해봅시다.


접근 방식 2: 인스턴스별 메모리 내 집계

실시간으로 동일한 row를 업데이트해 발생하는 콘텐츠션 문제를 피하고자, 각 인스턴스에서 카운트를 메모리로 일시 집계한 뒤 일정 주기로 디스크(영속 스토리지)에 플러시(flush)한다고 가정해봅시다. 이때 플러시 간격에 임의의 지터(jitter)를 줘서 한순간에 몰리지 않도록 최적화할 수도 있습니다.

[ Per-Instance Aggregation (그림 예시) ]

그러나 이 방식에는 새로운 문제가 생깁니다.

  • 데이터 유실 위험
    • 인스턴스 장애, 재시작, 배포 시점에 메모리에 있던 데이터가 유실됩니다.
  • 리셋(reset) 시점 파악 난이도
    • 분산된 여러 머신에서 카운팅이 이루어지기 때문에, “카운터를 언제 리셋했는지”를 일관성 있게 합의하기 어렵습니다.
  • Idempotency 부족
    • 이 방식도 기본적으로 idempotency를 제공하지 않습니다. 한 가지 방법은 특정 카운터 그룹을 항상 같은 인스턴스가 처리하도록 라우팅하는 것이지만, 이는 리더 선출(leader election) 및 가용성·지연 관련 문제가 뒤따릅니다.

그럼에도 불구하고 위 단점들을 감수할 수 있다면, 이 방법이 적합한 상황도 있을 수 있습니다. 하지만 이번에는 이벤트 기반 접근 방식을 더 발전시켜보겠습니다.


접근 방식 3: 내구성 높은 큐(예: Kafka) 활용

이번에는 카운터 이벤트를 Apache Kafka 같은 내구성 높은 큐잉 시스템에 기록(log)하는 방식을 살펴봅시다. 카운터 키를 해싱해 여러 토픽 파티션에 분산하면, 각 파티션을 담당하는 소비자(consumer)가 동일한 카운터 집계를 전담하게 됩니다. 이를 통해 idempotency 체크나 리셋 시점 처리가 쉬워집니다. 또 Kafka StreamsApache Flink 같은 스트리밍 프레임워크를 이용해 윈도우(Window) 기반 집계를 구현할 수도 있습니다.

[ Durable Queue 기반 설계 (그림 예시) ]

하지만 이 방식에도 여러 과제가 있습니다.

  1. 지연 가능성
    • 특정 파티션에서 발생하는 모든 카운팅 이벤트가 한 소비자에게 몰리기 때문에, 처리 부하가 높아지면 이벤트가 밀려 카운트가 오래된 상태로 남을 수 있습니다.
  2. 파티션 리밸런싱
    • 카운터 개수(카디널리티)와 처리량이 증가함에 따라 파티션 수와 소비자를 어떻게 자동으로 확장하고 리밸런싱할지가 큰 과제입니다.

그리고 모든 미리 집계(pre-aggregation) 방식에는 아래의 두 요구 사항을 충족하기 어렵다는 공통 문제가 있습니다.

  • 감사(Auditing)
    • 최종 카운트가 맞는지 확인하기 위해, 그 과정에서 적용된 모든 증분을 오프라인 시스템에 추출해 검증하는 경우가 있습니다. 그러나 사전에 데이터가 집계되어 버리면 각 증분을 추적하기 어려워집니다.
  • 재계산(Recounting)
    • 어떤 이유로 특정 구간의 이벤트를 다시 계산하거나 조정해야 할 때, 이미 집계된 데이터만으로는 이를 수행하기 어려울 수 있습니다.

위 두 요건을 제외하면, 큐 기반 이벤트 스트리밍 방식도 적절한 방식이 될 수 있습니다. 다만 파티션과 소비자의 확장 전략, idempotency 유지 방안을 꼼꼼히 마련해야 합니다. 이제 다음 단계로, 이벤트 기반 접근 방식을 좀 더 개선해봅시다.


접근 방식 4: 증분 이벤트 각각을 모두 로그에 저장

이번에는 각 카운터 증분 이벤트마다 event_time, event_id를 기록하고, 이 event_time + event_id를 idempotency 키로 활용한다고 합시다.

[ Event Log of Individual Increments (그림 예시) ]

이 방식은 가장 직관적이지만, 단순 적용 시에는 다음과 같은 문제가 생깁니다.

  1. 읽기 지연
    • 특정 카운터 값을 조회하기 위해 해당 카운터의 모든 증분 이벤트를 스캔해야 할 수 있어, 조회 성능이 저하될 위험이 큽니다.
  2. 중복 작업
    • 여러 스레드 혹은 노드가 동일한 카운터 이벤트를 중복으로 집계하는 일이 발생할 수 있습니다.
  3. 너무 넓은 파티션(Wide Partition)
    • 예를 들어 Apache Cassandra에 매우 많은 증분 이벤트가 특정 파티션에 계속 쌓이면, 읽기 성능이 저하될 수 있습니다.
  4. 대규모 데이터 풋프린트
    • 모든 이벤트를 개별적으로 저장하면 시간이 지날수록 데이터가 엄청나게 커질 수 있습니다. 적절한 보존 정책(retention)을 세우지 않으면 인프라 비용이 크게 증가합니다.

이러한 단점에도 불구하고, 이벤트를 기반으로 하는 접근 방식은 앞서 본 여러 단점을 한 번에 해결할 수 있는 가능성을 열어줍니다. Netflix는 이 이벤트 기반 기법을 더욱 최적화하는 방법을 선택했습니다.


Netflix의 접근 방식

Netflix에서는 위에서 살펴본 방법들을 조합하여, 모든 카운팅 활동을 이벤트로 기록한 뒤, 이를 지속적으로 슬라이딩 윈도우(sliding window) 내에서 집계하는 전략을 사용합니다. 또한 버킷(bucket)을 나눠 특정 파티션이 지나치게 커지지 않도록 합니다. 다음 섹션에서는 이 접근 방식이 이전에 언급된 문제점을 어떻게 해결하며, 우리의 모든 요구 사항을 어떻게 충족시키는지 살펴보겠습니다.

참고: 이 글에서는 ‘rollup’과 ‘aggregate’를 동일하게 사용합니다. 둘 다 “개별 카운터 증분 이벤트들을 모아서 최종 값으로 계산한다”는 의미입니다.


TimeSeries 이벤트 스토어

우리는 카운트 증가/감소 이벤트를 기록하기 위해 TimeSeries Data Abstraction을 사용합니다. TimeSeries를 이벤트 스토어로 사용함으로써 얻을 수 있는 주요 이점은 아래와 같습니다.

  • 고성능: TimeSeries Abstraction은 이미 높은 가용성과 처리량, 안정적인 빠른 성능 등 다양한 요건을 충족합니다.
  • 코드 복잡도 감소: Counter Abstraction에서 처리해야 할 기능 상당 부분을 이미 검증된 TimeSeries 서비스에 위임할 수 있어, 구현 복잡도가 크게 줄어듭니다.

TimeSeries는 내부적으로 Apache Cassandra를 사용하지만, 다른 영속 스토어와 연동되도록도 구성할 수 있습니다. 구조는 다음과 같습니다:

[ TimeSeries 이벤트 스토어 구조 (그림 예시) ]
  • Wide Partition 방지
    • time_bucketevent_bucket이라는 컬럼을 사용해 이벤트가 특정 파티션에 몰리지 않도록 나눕니다. 자세한 내용은 이전 블로그(Netflix TimeSeries Abstraction 글)를 참고하세요.
  • 중복 카운트 방지
    • event_time, event_id, event_item_key 컬럼을 묶어 idempotency 키로 사용함으로써, 클라이언트가 같은 요청을 여러 번 보내도 중복 카운팅되지 않습니다.
  • 이벤트 정렬
    • TimeSeries는 이벤트를 time 기준 내림차순으로 정렬해 저장하므로, 카운트를 리셋하는 이벤트 같은 것들을 처리할 때 이 순서를 활용할 수 있습니다.
  • 이벤트 보존 정책
    • TimeSeries Abstraction에는 보존(retention) 정책이 있어, 이벤트를 무기한 보관하지 않고 일정 시간이 지나면 삭제할 수 있습니다. 필요한 경우에만 오래된 이벤트를 별도의 스토리지(감사 용도 등)로 옮기고, 메인 스토리지에서는 삭제해 비용을 절감합니다.

이벤트 집계 (Aggregating Count Events)

개별 이벤트를 모두 읽어서 합산하는 방식은 조회(읽기) 성능 면에서 비용이 매우 큽니다. 따라서 백그라운드에서 주기적으로 집계를 수행해, 조회 시 빠르게 응답할 수 있도록 해야 합니다.

문제는 쓰기가 계속 일어나고 있는 상황에서 어떻게 안전하게 집계를 진행하느냐입니다. Netflix는 이를 해결하기 위해 결국 약간의 시간 지연을 허용하는 Eventually Consistent 접근 방식을 선택했습니다. 즉, 어느 시점을 불변(immutable)한 시간 창(window) 으로 삼아서 그 창에 포함된 이벤트를 확정적으로 집계하는 것입니다. 이를 위해 acceptLimit이라는 설정을 둬서, 특정 시점(now) 이후의 이벤트를 거부하거나, 매우 짧은 지연 시간만 허용합니다.

[ Eventually Consistent 집계 (그림 예시) ]
  • lastRollupTs: 이 카운터가 마지막으로 집계된 시각입니다. 처음 사용 시에는 과거 시점으로 초기화합니다.
  • 불변 창(Immutable Window): 지금 시점에서 조금 이전까지만 유효한 창으로 간주해 집계에 포함합니다. TimeSeries Abstraction에서는 acceptLimit 파라미터를 설정해, 현재 시점보다 특정 초 이상 앞선 이벤트를 받지 않도록 제한합니다. 집계 시에는 여기에 시계 오차(clock skew) 등을 감안해 여유 시간을 둡니다.
  • 집계 프로세스(Aggregation Process): lastRollupTs 이후부터 이번에 불변 창에 포함된 시점까지의 모든 이벤트를 합산해 새로운 카운터 값을 얻습니다.

롤업 스토어(Rollup Store)

이렇게 얻은 집계 결과(rollup)는 별도의 영속 스토어에 저장합니다. 다음번 집계 시에는 이전 집계 시점부터 이어서 계산할 수 있도록 말이죠.

[ Rollup Store (그림 예시) ]
  • Rollup 테이블: dataset별로 하나씩 운영하며, Cassandra를 사용합니다(구성에 따라 다른 스토어도 가능).
  • LastWriteTs: 카운터에 새 이벤트(증감)가 들어올 때마다, 그 이벤트의 event_time을 Rollup 테이블에도 쓰면서 LWW(Last-Write-Win) 방식으로 업데이트합니다. 이를 통해 “가장 최근 이벤트가 언제 발생했는지”를 빠르게 알 수 있고, 실제 집계가 최신 상태를 놓치지 않도록 돕습니다.

롤업 캐시(Rollup Cache)

집계된 값(rollup 결과)의 조회 성능을 높이기 위해, 이 값을 EVCache에 캐싱합니다. lastRollupCountlastRollupTs를 함께 캐시에 저장함으로써, 이 둘이 서로 다른 시점에 읽히는 불일치를 방지합니다.

[ Rollup Cache (그림 예시) ]

이제, 어떤 카운터를 언제 롤업해야 할지 살펴보기 위해 Write 경로Read 경로를 살펴봅시다.


Write (Add/ClearCount)

  1. 이벤트 영구 저장: 먼저 TimeSeries Abstraction에 이벤트를 기록하고, Rollup 스토어에 LastWriteTs 컬럼을 업데이트합니다. 만약 영구 저장이 실패하면, 동일한 idempotency 토큰으로 재시도해도 중복 카운트가 일어나지 않습니다.
  2. 롤업 트리거: 영구 저장이 끝나면, 롤업 서버에 “이 카운터를 롤업해주세요”라는 경량 이벤트를 발행(fire-and-forget)합니다.
[ Write 경로 (그림 예시) ]

Read (GetCount)

  1. 캐시된 롤업 값 반환: 우선 캐시에 있는 마지막 롤업 값을 빠르게 반환합니다. 다만 이렇게 하면 방금 전 이벤트가 반영되지 않았을 가능성이 있으므로(약간의 지연이 존재함),
  2. 롤업 트리거: 추가로 이 카운터 롤업을 비동기로 트리거해서, 이후 조회할 때 더 최신 카운트를 반환할 수 있도록 합니다. 이전 롤업이 실패했다면 이 과정에서 자동으로 보정됩니다.
[ Read 경로 (그림 예시) ]

이처럼 카운터 값은 점진적으로 최신 상태에 수렴(converge)합니다. 이제 이 과정을 수백만 개 카운터와 수천 건의 동시 요청에 맞게 스케일링하기 위해, Netflix가 사용하는 Rollup Pipeline을 살펴보겠습니다.


롤업 파이프라인(Rollup Pipeline)

Counter-Rollup 서버는 내부에 롤업 파이프라인을 실행해, 대규모 카운터 집계를 효율적으로 수행합니다. 이 파이프라인이 카운터 추상화의 상당한 복잡도를 책임집니다. 여기서는 핵심 개념들만 짚어보겠습니다.

  1. 경량 롤업 이벤트 (Light-Weight Rollup Event)

    • 앞서 본 Write/Read 경로에서, Counter-Rollup 서버로 전송되는 이벤트({ "namespace": "my_dataset", "counter": "counter123" })는 실제 증분(delta) 정보를 담지 않습니다. 어떤 카운터가 액세스되었는지 인덱스 역할만 할 뿐입니다.
  2. 메모리 내 롤업 큐(In-Memory Rollup Queues)

    • 각 Rollup 서버 인스턴스는 여러 개의 메모리 큐를 두고, 들어오는 롤업 이벤트를 분산 처리합니다. 간단히 말해, 특정 해시 함수를 사용해 동일한 카운터는 항상 같은 큐에 들어가도록 합니다.
    • 첫 번째 버전에서는 인메모리 큐를 사용해 구현 복잡도를 낮추고 비용을 절감했습니다. 다만 이 경우 인스턴스가 다운되면 해당 큐에 있던 롤업 이벤트가 유실될 수 있습니다(“Stale Counts” 섹션에서 후술).
  3. 중복 작업 최소화

    • 동일한 카운터에 대한 이벤트가 여러 번 들어와도, 이를 한 번의 집계 작업에서 처리하도록 Set 자료구조 등을 사용해 중복을 합칩니다.
    • 또, 너무 많은 인스턴스를 사용하면 동일 카운터에 대한 집계가 불필요하게 중복 수행될 수 있기 때문에, 적정 수준으로 스케일링합니다.
  4. 가용성과 레이스 컨디션

    • 롤업 서버를 하나만 두면 중복 작업은 최소화할 수 있지만, 단일 장애 지점(SPOF)이 됩니다. 따라서 여러 서버 인스턴스를 운영할 때는 분산 락 없이도 서로 간에 롤업 결과를 덮어쓸 수 있게 합니다. 어차피 이벤트는 “불변 창” 내에서 집계되므로, 최종 카운트는 결국 일관된 값으로 수렴하게 됩니다.
  5. 큐 리밸런싱

    • 큐 개수를 변경해야 할 경우, Control Plane 설정을 수정하고 재배포(re-deploy)만 하면 됩니다. 그러면 새로 배포된 인스턴스가 업데이트된 큐 개수에 맞춰 이벤트들을 재할당합니다.
"eventual_counter_config": {             
  "queue_config": {                    
    "num_queues" : 8,  // 16으로 변경 후 재배포
    ...
  },
  ...
}
  1. 배포 처리

    • 배포 시, 기존 롤업 서버는 큐를 정상 종료(큐 draining)한 뒤 내려가고, 새 롤업 서버가 올라옵니다. 짧은 시간이지만 옛 서버와 새 서버가 동시에 활성화되어 있을 수 있으나, 위에서 언급했듯이 불변 창을 기반으로 하는 로직 덕분에 큰 문제가 없습니다.
  2. 롤업 최소화 기법

    • 동일 카운터에 대한 롤업 이벤트가 여러 번 쌓여도 단 한 번만 집계하도록 합치거나, 여러 카운터를 배치(batch)로 묶어 병렬로 TimeSeries 조회를 수행합니다.
    • TimeSeries Abstraction은 이 같은 범위 스캔(range scan)을 고도로 최적화해, 높은 동시성하에서도 밀리초 단위 응답 시간을 제공합니다.
  3. 동적 배칭(Dynamic Batching)

    • 롤업 소비자(consumer)는 이전 배치 처리 시간이 얼마나 걸렸는지에 따라 다음 배치의 크기나 대기 시간을 동적으로 조절해, Cassandra(또는 TimeSeries 스토어)에 과도한 부하가 걸리지 않도록 합니다.
  4. 카운터 값 수렴

    • 자주 사용되지 않는 카운터는 오랫동안 롤업 이벤트가 발생하지 않을 수 있습니다. 이 경우 너무 뒤늦게 한꺼번에 집계하면 많은 타임 파티션을 스캔해야 합니다.
    • 이를 방지하기 위해, 일정 간격마다 “마지막으로 쓰인 시간이 최신 상태인지”를 확인해 롤업 대기열에 재등록(re-queue)할지 결정합니다. 이렇게 하면 쓰기가 있은 지 오래된 카운터라도 적절히 최신 상태로 유지됩니다.

실험 중인 Accurate Global Counter

우리는 Eventually Consistent 카운터에서 조금 더 나아가, “Accurate”라고 부르는 실험적 카운터 유형도 시도하고 있습니다. 역시 ‘정확(Accurate)’이라는 용어는 상대적입니다.

이 카운터 유형과 Eventually Consistent 유형의 차이는, lastRollupTs 이후에 발생한 delta를 실시간으로 조회할 수 있도록 추가 스캔을 수행한다는 점입니다.

currentAccurateCount = lastRollupCount + delta

이 delta를 실시간으로 구하려면, lastRollupTs 이후 쌓인 이벤트를 즉석에서 스캔해야 해서 성능이 떨어질 수 있습니다. 특히 많은 이벤트 혹은 파티션을 스캔해야 한다면 지연이 늘어납니다. 반면, 해당 카운터가 자주 접근되어 스캔 범위가 항상 작다면(즉, lastRollupTs와 지금 시점이 크게 차이 나지 않음), 이 방식으로 충분히 빠른 카운트를 얻을 수 있습니다.


제어 플레인(Control Plane)

Data Gateway Platform Control Plane은 카운터 추상화를 포함해, Netflix의 각종 Abstraction과 namespace 설정을 일괄적으로 관리합니다. 아래 예시에서는 Eventually Consistent 카운터를 쓰되, 카디널리티가 낮은 경우에 맞춰 설정한 구성(JSON)을 보여줍니다.

"persistence_configuration": [
  {
    "id": "CACHE",                             
    "scope": "dal=counter", 
    "physical_storage": {
      "type": "EVCACHE",                       
      "cluster": "evcache_dgw_counter_tier1"   
    }
  },
  {
    "id": "COUNTER_ROLLUP",
    "scope": "dal=counter",                   
    "physical_storage": {                     
      "type": "CASSANDRA",                    
      "cluster": "cass_dgw_counter_uc1",      
      "dataset": "my_dataset_1"               
    },
    "counter_cardinality": "LOW",             
    "config": {
      "counter_type": "EVENTUAL",             
      "eventual_counter_config": {            
        "internal_config": {                  
          "queue_config": {                  
            "num_queues" : 8,                
            "coalesce_ms": 10000,            
            "capacity_bytes": 16777216       
          },
          "rollup_batch_count": 32            
        }
      }
    }
  },
  {
    "id": "EVENT_STORAGE",
    "scope": "dal=ts",                        
    "physical_storage": {
      "type": "CASSANDRA",                    
      "cluster": "cass_dgw_counter_uc1",      
      "dataset": "my_dataset_1"              
    },
    "config": {
      "time_partition": {
        "buckets_per_id": 4,                 
        "seconds_per_bucket": "600",          
        "seconds_per_slice": "86400"          
      },
      "accept_limit": "5s"                    
    },
    "lifecycleConfigs": {
      "lifecycleConfig": [
        {
          "type": "retention",               
          "config": {
            "close_after": "518400s",
            "delete_after": "604800s"         
          }
        }
      ]
    }
  }
]

위와 같은 Control Plane 설정을 기반으로, 각 추상화 레이어에 해당하는 컨테이너를 같은 호스트에 배포합니다. 각 컨테이너는 자신에게 맞는 scope 설정을 가져다 씁니다.


프로비저닝(Provisioning)

TimeSeries Abstraction과 마찬가지로, 카운터 서비스도 사용자들이 자신들의 워크로드 특성 및 카디널리티(유효 카운터 개수)에 관한 정보를 입력하면, 자동화된 절차가 적절한 인프라와 설정(컨트롤 플레인)을 산출해냅니다. 더 자세한 내용은 Netflix의 인프라 프로비저닝 사례를 다룬 Joey Lynch의 발표에서 확인할 수 있습니다(발표는 영어).


성능

이 글을 쓰는 시점에서, 이 카운터 서비스는 전 세계적으로 여러 dataset과 API 엔드포인트를 통틀어 초당 약 75K 정도의 요청을 처리하고 있으며,

[ 전 세계적으로 초당 75K 요청 처리량 (그래프 예시) ]

모든 엔드포인트에서 한 자리 수 밀리초(single-digit milliseconds) 미만의 응답 지연을 보이고 있습니다.

[ 싱글 밀리초 지연 (그래프 예시) ]

향후 과제 (Future Work)

우리 시스템은 이미 안정적으로 동작하지만, 더 높은 신뢰도와 기능 강화를 위해 해결해야 할 과제들이 여전히 남아 있습니다.

  1. 리전 단위 롤업 (Regional Rollups)

    • 교차 리전 간 복제 문제로 인해, 이벤트가 누락되어 글로벌 집계에서 반영되지 않을 수 있습니다. 이를 해결하기 위해 각 리전에 별도의 롤업 테이블을 두고, 이를 다시 글로벌 롤업 테이블로 합산하는 방식이 제안될 수 있습니다. 하지만 카운터 리셋 같은 이벤트를 전체 리전에 동기화하는 문제가 쉽지 않습니다.
  2. 오류 감지와 오래된(Stale) 카운트

    • 인스턴스 장애 등으로 롤업 이벤트가 손실되거나, 롤업이 실패하고 재시도되지 않으면 카운트가 매우 오래된 상태로 남을 수 있습니다. 자주 쓰이는 카운터라면 금방 다시 접근되어 재집계가 트리거되지만, 사용 빈도가 낮은 카운터는 첫 조회에서 한 번에 지연이 발생할 수 있습니다.
    • 이를 더욱 개선하기 위해 우리는 내구성 있는 큐, 롤업 상태 인수인계(handoff), 오류 감지 로직 등을 연구 중입니다.

결론

분산 카운팅은 컴퓨터 과학에서 여전히 까다로운 영역입니다. 이번 글에서는, Netflix가 다양한 대안을 탐색하고 극히 낮은 지연과 높은 처리량, 높은 가용성, idempotency 보장 등을 유지하면서 분산 카운팅 서비스를 어떻게 구축했는지 알아보았습니다.
그 과정에서, Netflix가 운영하는 수많은 카운팅 시나리오에 맞춰 성능과 비용, 정확도 사이에서 필요한 트레이드오프를 어떻게 선택했는지도 확인해보았습니다.

다음에는 Composite Abstractions 시리즈의 3부에서, Key-Value AbstractionTimeSeries Abstraction을 결합하여 고성능, 낮은 지연의 그래프(Graph) 서비스를 만들고 있는 Graph Abstraction에 대해 소개할 예정입니다.


감사의 글(Acknowledgments)

Counter Abstraction을 성공적으로 만들어 주신 훌륭한 동료들에게 특별한 감사를 전합니다:
Joey Lynch, Vinay Chella, Kaidan Fullerton, Tom DeVoe, Mengqing Wang, Varun Khaitan.

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

로그인하기