57. Netflix는 어떻게 초당 7만 5천 건의 이벤트를 카운팅할까? — Distributed Counter Abstraction 완전 분석
Netflix는 조회수, 좋아요, A/B 테스트 지표 등 매일 수십억 건의 이벤트를 카운팅합니다. 이 글에서는 Netflix Tech Blog에 소개된 Distributed Counter Abstraction의 아키텍처를 API 설계부터 캐싱, Rollup 파이프라인까지 단계별로 뜯어보고, 우리 서비스에 적용할 수 있는 인사이트를 정리합니다.
이 글에서 다룰 내용
- 분산 카운팅이 왜 생각보다 어려운 문제인지
- Netflix가 정의한 두 가지 카운팅 요구사항 (Best-Effort vs Eventually Consistent)
- Counter Abstraction의 API 설계와 멱등성(Idempotency) 처리 방식
- Netflix가 검토했던 4가지 카운팅 구현 전략과 각각의 트레이드오프
- 최종적으로 채택한 하이브리드 아키텍처 (이벤트 로그 + Rollup + 캐싱)
- Rollup 파이프라인을 수십만 TPS로 확장하는 방법
- 한국 서비스 환경에 적용할 때 참고할 수 있는 실전 인사이트
1. 분산 카운팅은 왜 어려운가
"숫자 하나 세는 게 뭐가 어렵냐"고 생각하기 쉽지만, 전 세계 수억 명의 사용자가 동시에 이벤트를 발생시키는 환경에서는 이야기가 다릅니다. 서버 여러 대에서 동시에 같은 카운터를 증가시켜야 하고, 리전 간 데이터도 맞춰야 하고, 네트워크 재시도로 인한 중복 카운팅도 막아야 합니다.
Netflix는 이 문제를 풀기 위해 이미 공개했던 TimeSeries Abstraction(시계열 이벤트를 밀리초 단위 지연시간으로 저장·조회하는 내부 플랫폼) 위에 카운팅 서비스를 얹는 방식을 택했습니다. 즉, 카운터를 처음부터 새로 설계한 것이 아니라 기존에 검증된 이벤트 저장 계층을 재사용한 것이 핵심 설계 철학입니다.
2. 두 가지 카운팅 요구사항: 빠른 게 먼저냐, 정확한 게 먼저냐
Netflix는 사내의 다양한 카운팅 니즈를 딱 두 카테고리로 정리했습니다.
Best-Effort Counting (근사치 우선) 정확도보다 속도가 중요한 경우입니다. A/B 테스트 결과 확인, 실시간 트렌드 대시보드처럼 "얼추 맞으면 되는" 상황에 사용합니다. 데이터를 오래 보관할 필요도 없습니다.
Eventually Consistent Counting (정확도 우선) 과금, 규제 준수 리포트처럼 절대 틀리면 안 되는 지표에 사용합니다. 최종 값이 나오기까지 약간의 지연은 감수하되, 결과는 반드시 맞아야 합니다.
두 카테고리 모두 공통적으로 다음 세 가지를 만족해야 합니다.
| 고가용성 | 장애 상황에서도 카운팅이 멈추면 안 됨 (의사결정에 직결) |
| 높은 처리량 | 초당 수백만 건의 카운팅 연산 처리 |
| 수평 확장성 | 신작 공개 등 트래픽 급증 시 전 세계 리전에서 확장 가능 |
3. Counter Abstraction API 설계
API는 세 가지 핵심 오퍼레이션으로 단순하게 구성됩니다.
AddCount / AddAndGetCount
카운터를 지정한 값만큼 증가 또는 감소시킵니다. namespace(카운터 그룹), counter_name, delta(증감값)를 지정하며, 재시도로 인한 중복 반영을 막기 위해 idempotency_token을 함께 전달합니다.
{
"namespace": "playback_metrics",
"counter_name": "daily_play_count",
"delta": 1,
"idempotency_token": {
"token": "req-8f21-unique-id",
"generation_time": "2026-07-31T09:00:00Z"
}
}
같은 토큰으로 재시도가 들어와도 카운터가 두 번 반영되지 않는다는 점이 핵심입니다. 네트워크 재시도가 흔한 대규모 분산 환경에서는 이 멱등성 보장이 없으면 카운트가 슬금슬금 부풀어 오르게 됩니다.
GetCount
현재 카운터 값을 조회합니다. 속도를 위해 설정에 따라 약간 오래된(stale) 값을 반환할 수 있다는 점을 명시적으로 허용합니다.
ClearCount
카운터를 0으로 초기화합니다. 마찬가지로 멱등성 토큰을 지원합니다.
이 API를 Kotlin으로 감싼 클라이언트 코드는 대략 이런 형태가 될 것입니다.
data class IdempotencyToken(val token: String, val generationTime: Instant)
data class AddCountRequest(
val namespace: String,
val counterName: String,
val delta: Long,
val idempotencyToken: IdempotencyToken
)
suspend fun CounterClient.increment(namespace: String, counterName: String) {
val token = IdempotencyToken(token = UUID.randomUUID().toString(), generationTime = Instant.now())
addCount(AddCountRequest(namespace, counterName, delta = 1, idempotencyToken = token))
}
4. Netflix가 검토했던 카운팅 구현 전략 4+1가지
Netflix는 실제로 적용하기 전, 여러 방식을 검토하고 각각의 트레이드오프를 정리했습니다.
4.1 Best-Effort Regional Counter
EVCache(Netflix의 Memcached 기반 분산 캐시)에 카운터를 key-value로 저장하는 방식입니다. 밀리초 단위로 매우 빠르고 인프라 비용도 저렴하지만, 리전 간 복제가 없고 멱등성도 보장하지 않아 재시도 시 중복 카운팅 위험이 있습니다.
4.2 Single Row Per Counter
카운터 하나를 전역 복제 데이터스토어의 행(row) 하나로 표현하는 가장 단순한 방식입니다. 구현은 쉽지만 인스턴스가 flush 전에 죽으면 데이터가 유실되고, 여러 인스턴스 간 초기화 시점을 맞추기도 어렵습니다.
4.3 Per Instance Aggregation
각 인스턴스가 메모리에서 카운트를 모았다가 주기적으로 durable store에 flush합니다. Flush 타이밍에 지터(jitter)를 줘서 컨텐션을 줄이는 것이 핵심 아이디어입니다. 다만 이 역시 인스턴스 장애 시 유실 위험과 멱등성 부재 문제를 그대로 안고 있습니다.
4.4 Durable Queue (Kafka 기반)
카운터 이벤트를 Kafka 같은 durable queue에 기록하고, 컨슈머가 파티션 단위로 읽어 집계 후 저장하는 방식입니다. 로그가 내구성을 갖기 때문에 안정적이고 멱등성 구현도 상대적으로 쉽지만, 컨슈머가 밀리면 지연이 생기고 트래픽 증가에 따른 파티션 리밸런싱이 복잡해집니다.
4.5 Event Log of Increments
모든 증감을 event_time, event_id 등 메타데이터와 함께 개별 이벤트로 기록하는 방식입니다. event_time과 event_id 조합을 멱등성 키로 쓸 수 있다는 장점이 있지만, 모든 이벤트를 영구 보관해야 해서 저장 비용이 크고, 읽을 때마다 전체 이벤트를 스캔해야 해서 조회 성능이 떨어집니다. Cassandra라면 파티션이 지나치게 커지는 문제(wide partition)도 발생합니다.
전략 비교 요약
| Best-Effort Regional (EVCache) | 매우 빠름 | 근사치 | 낮음 | 미지원 | 낮음 |
| Single Row Per Counter | 보통 | 낮음 | 낮음 | 미지원 | 낮음 |
| Per Instance Aggregation | 빠름 | 중간 | 낮음 | 미지원 | 중간 |
| Durable Queue (Kafka) | 중간 | 높음 | 높음 | 지원 | 중간 |
| Event Log of Increments | 느림(조회) | 매우 높음 | 매우 높음 | 지원 | 높음 |
흥미로운 점은, 이 다섯 가지 중 어느 하나도 "정답"으로 채택되지 않았다는 것입니다. Netflix는 대신 이들의 장점만 골라 조합한 하이브리드 구조를 설계했습니다.
5. 최종 아키텍처: 이벤트 로그 + Rollup + 캐싱의 하이브리드
Netflix가 실제로 프로덕션에 배포한 구조는 세 계층으로 구성됩니다.
① TimeSeries Abstraction에 원본 이벤트 기록 모든 증감 이벤트를 event_time, event_id(멱등성 보장용 고유 ID), event_item_key(대상 카운터)와 함께 개별 레코드로 기록합니다. 이벤트는 분 단위·시간 단위 등 시간 버킷으로 묶여 Cassandra 파티션이 지나치게 커지는 것을 방지합니다.
② 백그라운드 Rollup(집계) 매 조회마다 원본 이벤트를 다시 계산하면 비효율적이므로, 별도 프로세스가 지속적으로 이벤트를 요약된 카운트로 집계합니다. "Last Rollup Timestamp"로 마지막 집계 시점을 추적해, 그 이후에 발생한 이벤트만 추가로 처리합니다. 이미 확정된(더 이상 바뀌지 않는) 시간 구간, 즉 immutable window 단위로만 집계하기 때문에 결과의 일관성이 보장됩니다. Rollup은 쓰기 시점(경량 이벤트로 트리거)과 읽기 시점(캐시된 값이 stale할 때) 양쪽에서 모두 트리거될 수 있습니다.
③ EVCache를 통한 읽기 최적화 집계된 카운트와 마지막 Rollup 시각을 EVCache에 캐싱해, 조회 시 즉시 캐시된 값을 반환합니다. 값이 오래됐으면 백그라운드로 새 Rollup을 트리거하되, 사용자 응답은 기다리게 하지 않습니다.
아래는 쓰기(AddCount) 경로를 단순화한 흐름입니다.

읽기(GetCount) 경로는 다음과 같이 캐시를 우선 조회하는 구조입니다.

이 구조의 핵심은 "정확한 계산"과 "빠른 응답"을 분리했다는 점입니다. 쓰기는 원본 이벤트를 그대로 남겨 나중에 다시 계산할 수 있게 하고, 읽기는 캐시된 근사값을 즉시 돌려주면서 뒤에서 조용히 값을 최신화합니다.
6. Rollup 파이프라인을 대규모로 확장하기
카운터가 수백만 개로 늘어나면 Rollup 파이프라인 자체도 병목이 될 수 있습니다. Netflix는 세 가지 장치로 이를 해결합니다.
Rollup Queue와 이벤트 중복 제거 — 각 Rollup 서버 인스턴스는 인메모리 큐를 여러 개 두고, XXHash 같은 비암호화 해시 함수로 같은 카운터에 대한 이벤트가 항상 같은 큐로 라우팅되도록 합니다. 같은 카운터에 대한 여러 이벤트는 Set으로 합쳐져, 한 Rollup 윈도우 내에서는 카운터당 한 번만 집계가 일어납니다. 큐를 인메모리로 둔 것은 의도적인 트레이드오프입니다 — 운영은 훨씬 단순해지지만, Rollup 서버가 죽으면 처리 중이던 이벤트가 유실될 수 있습니다. 다만 자주 갱신되는 카운터는 다음 이벤트가 다시 Rollup을 트리거하기 때문에 실질적인 영향은 제한적입니다.
동적 배칭과 백프레셔 — Rollup은 카운터를 배치로 묶어 처리하며, 배치 크기는 시스템 부하와 카운터 개수(cardinality)에 따라 동적으로 조정됩니다. 배치 처리 속도를 모니터링해 다음 배치 시작 전 대기 시간을 조절함으로써, 트래픽 급증 시 Cassandra 같은 저장소가 과부하되는 것을 막습니다.
카디널리티에 따른 처리 분리 — 자주 조회되는 저(低)카디널리티 카운터(예: 콘텐츠별 전체 조회수)는 계속 Rollup 순환 큐에 남겨 최신 상태를 유지합니다. 반대로 사용자별 지표처럼 고(高)카디널리티 카운터는 메모리 사용량을 줄이기 위해 마지막 쓰기 시각(last-write-timestamp) 기준으로 필요한 시점에만 재처리 큐에 올립니다.
7. Data Gateway Control Plane: 설정을 코드가 아닌 설정값으로
Netflix의 여러 데이터 계층 서비스를 관통하는 Data Gateway Control Plane은 Counter Abstraction의 저장 방식, 캐시 정책, 보존 기간 등을 중앙에서 관리합니다. 예를 들어 캐시 계층은 다음과 같이 선언적으로 설정합니다.
{
"id": "CACHE",
"physical_storage": {
"type": "EVCACHE",
"cluster": "evcache_dgw_counter_tier1"
}
}
이런 구조 덕분에 팀마다 다른 요구사항 — 예를 들어 user_metrics 네임스페이스는 실시간 대시보드용으로 저지연 캐싱을 우선시하고, billing_metrics 네임스페이스는 정확성과 내구성을 우선시하는 식 — 을 코드 변경 없이 설정만으로 반영할 수 있습니다. 즉, 카운터 하나하나에 대해 "이건 빠르게, 이건 정확하게"를 인프라 팀 개입 없이 선택할 수 있는 멀티테넌트 구조입니다.
이렇게 만들어진 시스템은 전 세계에서 초당 약 7만 5천 건의 카운터 요청을 처리하면서도 API 엔드포인트 기준 한 자릿수 밀리초 지연시간을 유지한다고 알려져 있습니다.
8. 한국 서비스에 적용할 때 참고할 인사이트
이 아키텍처를 그대로 복제할 필요는 없지만, 아래 판단 기준들은 어떤 스택(Kafka + Redis + DynamoDB, 혹은 Kafka + Cassandra 등)을 쓰든 그대로 적용할 수 있습니다.
- "정확도가 필요한 지표"와 "빠른 응답이 필요한 지표"를 먼저 분리하라. 하나의 카운터 시스템으로 모든 요구사항을 만족시키려 하면 결국 어느 쪽도 만족스럽지 않은 절충안이 됩니다. Netflix처럼 네임스페이스나 카운터 단위로 정책을 다르게 가져가는 편이 낫습니다.
- 원본 이벤트와 집계값을 분리해서 저장하라. 집계값만 저장하면 빠르지만 재계산이 불가능해집니다. 원본 이벤트를 남겨두면 집계 로직에 버그가 있었을 때도 다시 계산할 수 있는 여지가 생깁니다.
- 멱등성 키는 처음부터 설계에 넣어라. 재시도가 없는 분산 시스템은 없습니다. 이벤트 ID 기반 멱등성을 나중에 추가하려면 이미 쌓인 데이터 때문에 훨씬 힘들어집니다.
- 인메모리 큐를 쓸지 durable 큐를 쓸지는 "유실을 감당할 수 있는가"로 결정하라. Netflix는 자주 갱신되는 카운터는 인메모리 큐의 유실 위험을 감수했습니다. 이건 모든 상황에 맞는 정답이 아니라, "이 카운터는 조금 유실돼도 다음 업데이트가 곧 온다"는 특정 전제 위에서 내린 선택입니다. 우리 서비스의 카운터가 그 전제를 만족하는지 먼저 따져봐야 합니다.
- 카디널리티를 기준으로 처리 전략을 나눠라. 전체 조회수 같은 저카디널리티 카운터와 사용자별 지표 같은 고카디널리티 카운터는 애초에 접근 패턴이 다릅니다. 하나의 파이프라인으로 뭉뚱그리지 말고 처리 주기와 저장 방식을 다르게 가져가는 것이 확장성 확보의 핵심입니다.
마치며
Distributed Counter Abstraction은 "카운터 하나 세는 문제"가 결국 캐싱, 이벤트 소싱, 배치 처리, 컨피규레이션 관리까지 아우르는 종합적인 시스템 설계 문제라는 것을 잘 보여주는 사례입니다. 특히 "정확한 계산"과 "빠른 응답"을 분리하고, 그 사이를 백그라운드 Rollup으로 연결한다는 아이디어는 카운터뿐 아니라 대시보드, 랭킹, 알림 시스템 등 다양한 곳에 응용할 수 있는 패턴입니다.
참고 자료
- Netflix Tech Blog, Netflix's Distributed Counter Abstraction
- Netflix Tech Blog, Introducing Netflix's TimeSeries Data Abstraction Layer
Thanks
Hans