Latency 메트릭 수집 — 구현 방식 비교와 HdrHistogram 내부 구조
직접 구현한 Latency 메트릭 수집 시스템에서 발생 가능한 CAS contention과 false sharing 문제를 진단하고, HdrHistogram이 이를 내부 구조 수준에서 어떻게 해결 가능한지 정리한 글입니다.
'컴퓨터 밑바닥의 비밀'의 'Ch5. 작은 것으로 큰 성과 이루기, 캐시'를 읽으며 Latency 메트릭 수집을 위해 구현했던 서비스의 문제점을 진단하고 해결하는 과정을 거쳤다.
P95 Latency를 수집하기 위해 구현 가능한 4가지 방식을 간단히 비교하고, CAS contention과 false sharing이 실제 어떤 영향을 미치는지 살펴보았다. 해당 문제는 HdrHistogram 라이브러리가 내부적으로 해결하고 있으며, 그 구조를 분석했다.
Latency 메트릭 수집 방식 4가지
1. 버킷 누적 카운터 히스토그램
Prometheus Histogram 표준을 준수하는 기본 구현이다.
단점: 정확도가 버킷 경계에 종속된다. 실제 latency가 90ms여도 100ms 버킷에 기록된다.
2. Rolling Window 방식
전체 누적이 아닌 최근 N분 데이터만 반영한다.
장점: 최근 5분 데이터만 반영 → 실시간성 향상. 오래된 데이터 자동 제거. Grafana 쿼리 변경 불필요
단점: 메모리 사용량 증가 (~720B, 5슬롯 × 144B 기준)
3. Ring Buffer
최근 N개 샘플을 유지하고 정렬 기반으로 정확한 P95를 계산한다.
장점: 최소 메모리 (~8KB, 1,000 샘플 × 8 bytes). 최근 1,000개 기준 정확한 P95. 평균/표준편차 추가 제공
단점: 읽기 시점에 O(n log n) 정렬 발생. Prometheus Histogram 형식 미준수
4. HdrHistogram 라이브러리
장점: 업계 표준 (Netflix, Uber, Datadog 사용). Lock-free 구현. 메모리 압축 (2KB~64KB). 정확한 percentile (P50, P99, P99.9 등)
단점: 외부 의존성 추가
비교 요약
| 방식 | 메모리 | 쓰기 성능 | 읽기 성능 | 정확도 | 실시간성 |
|---|---|---|---|---|---|
| 누적 히스토그램 | ~144B | 높음 O(k) | 높음 O(k) | 중간 — 버킷 근사 | 낮음 — 전체 누적 |
| Rolling Window | ~720B | 높음 O(k) | 높음 O(k×n) | 중간 — 버킷 근사 | 높음 — 5분 윈도우 |
| Ring Buffer | ~8KB | 높음 O(1) | 낮음 O(n log n) | 높음 — 정확한 P95 | 중간 — 최근 1,000개 |
| HdrHistogram | 2~64KB | 최고 — Lock-free | 최고 O(1) | 최고 — 모든 percentile | 높음 — Interval 기반 |
고빈도 환경에서 발생하는 2가지 문제
Latency 수집은 고빈도 write + concurrent update 패턴이다. CPU cache 문제에 특히 민감하다.
private final AtomicLong[] bucketCounters = new AtomicLong[BUCKETS.length];
public void record(long latencyMs) {
for (int i = 0; i < BUCKETS.length; i++) {
if (latencyMs <= BUCKETS[i]) {
bucketCounters[i].incrementAndGet(); // ← ⚠️ 이 한 줄
}
}
}
record()가 호출될 때마다 해당 버킷의 AtomicLong에 incrementAndGet()을 호출하는 구조 — 이 한 줄이 고빈도 환경에서 성능 저하를 초래한다.
1. CAS Contention
request path 내에서 발생하는 문제
Latency 수집은 request path 위에 있다. record()가 느려지면 측정 대상인 tail latency 자체가 증가하는 구조다.
AtomicLong.incrementAndGet()은 내부적으로 CAS(compare-and-swap) 연산을 사용한다.
고빈도 환경에서 100k req/s가 들어오면 대부분이 p50 구간에 몰린다.
→ 특정 버킷 하나에 CAS가 집중된다.
1. Thread 1: 현재값 100 읽음, CAS(100 → 101) 성공
2. Thread 2: 현재값 100 읽음, CAS(100 → 101) 실패
→ 다시 읽음, CAS(101 → 102) 성공
3. Thread 3: 현재값 100 읽음, CAS(100 → 101) 실패
→ 다시 읽음, CAS(101 → 102) 실패
→ 다시 읽음, CAS(102 → 103) 성공
스레드 수가 증가할수록 실패 횟수가 급격히 증가한다. (경합 스레드 수에 대략 비례)
CAS 실패 증가 → spin retry 증가 (CPU 사이클 낭비) → cache invalidation 신호 폭증 → 다른 스레드까지 저하
2. False Sharing
논리적으로는 공유 데이터가 아닌데, cache line 단위 캐싱에 의해 공유처럼 행동하는 문제
AtomicLong 하나는 8 bytes → cache line 하나에 최대 8개 버킷이 들어간다.
메모리:
[ bucket[0] ][ bucket[1] ][ bucket[2] ][ bucket[3] ][ bucket[4] ][ bucket[5] ][ bucket[6] ][ bucket[7] ]
|←─────────────────── 64 bytes (cache line 1개) ──────────────────────────────→|
상황:
Thread A → bucket[0] 업데이트 (p10 구간)
Thread B → bucket[3] 업데이트 (p50 구간) — 논리적으로는 별개
실제 CPU에서 벌어지는 일:
Thread A가 bucket[0] 수정
→ CPU가 cache line을 "dirty"로 표시
→ Thread B의 같은 cache line을 무효화 (invalidate)
Thread B가 bucket[3]을 읽으려 함
→ cache가 무효화됨 → 다시 메모리에서 로드 (cache miss)
Thread B가 bucket[3] 수정
→ Thread A의 cache도 무효화
→ Thread A도 다시 메모리에서 로드
HdrHistogram이 이 문제를 해결하는 방법
1. CAS Contention 해결 — Writer-Reader 분리
직접 구현한 히스토그램은 record()와 getP95() 모두 같은 버킷 배열에 접근한다. HdrHistogram은 Write 전용 Recorder / Read 전용 Histogram을 분리한다.
Recorder 내부에는 두 개의 Histogram이 있다 (double buffering)
Recorder 내부:
[Histogram A] ← 현재 쓰기 중 (active)
[Histogram B] ← 사전 리셋 완료 (inactive)
getIntervalHistogram() 호출 시:
1. Histogram B(inactive)를 미리 reset
2. swap: B → active, A → inactive
3. flipPhase() — A에 진행 중이던 write 완료 대기
4. Histogram A(데이터 보존 상태)를 스냅샷으로 반환
(A는 다음 호출 시 inactive로 reset됨)
recordValue() 내부:
// Recorder.java
public void recordValue(final long value) {
// 1. WriterReaderPhaser에 진입 신고 (wait-free atomic increment)
final long criticalValueAtEnter = recordingPhaser.writerCriticalSectionEnter();
try {
// 2. 현재 active histogram에 기록
activeHistogram.recordValue(value);
} finally {
// 3. 진입 신고 해제
recordingPhaser.writerCriticalSectionExit(criticalValueAtEnter);
}
}
// WriterReaderPhaser.java
public long writerCriticalSectionEnter() {
return evenPhaseUpdater.getAndIncrement(this); // 블로킹 없는 atomic add
}
writer는 atomic increment만으로 구현되어 절대 블로킹되지 않는다. record 비용은 약 3~6ns 수준이다.
2. False Sharing 해결 — Cache Line Padding
일반 배열:
[bucket0][bucket1][bucket2]... ← 붙어있음 → false sharing
padding 적용:
[bucket0][///56bytes///][bucket1][///56bytes///]...
↑
8 bytes 데이터 + 56 bytes 패딩 = 64 bytes
→ 각 버킷이 독립된 cache line 차지
@Contended 어노테이션으로 JVM이 자동 처리한다. 사용자 클래스에 적용할 때는 -XX:-RestrictContended로 JVM 내부 클래스 전용 제한을 해제해야 한다.
3. 버킷 구조 — Log-scale Indexing
직접 구현한 코드는 버킷 경계를 배열로 선형 탐색한다:
private static final double[] BUCKETS = {1, 2, 5, 10, 20, 50, 75, 100, ...};
// record()가 O(k) 선형 탐색
HdrHistogram은 비트 연산으로 O(1)에 버킷 인덱스를 계산한다.
값 → 상위 비트(exponent) + 하위 비트(sub-bucket)
→ 2차원 배열 인덱스로 직접 계산
→ 선형 탐색 없음, 비교 없음
예: latency = 150ms
상위 비트: 7 (2^7 = 128 < 150 < 2^8 = 256)
하위 비트: (150 - 128) / (128 / subBucketCount)
→ counts[7][sub-index]++ 직접 접근
// AbstractHistogram.java
int countsArrayIndex(final long value) {
final int bucketIndex = getBucketIndex(value);
final int subBucketIndex = getSubBucketIndex(value, bucketIndex);
return countsArrayIndex(bucketIndex, subBucketIndex);
}
private int getBucketIndex(final long value) {
// value의 최상위 유효 비트 위치를 구함 → 어느 배율 구간인지 결정
return leadingZeroCountBase - Long.numberOfLeadingZeros(value | subBucketMask);
}
private int getSubBucketIndex(final long value, final int bucketIndex) {
// 해당 배율 구간 내에서 몇 번째 세부 슬롯인지 결정
return (int)(value >>> (bucketIndex + unitMagnitude));
}
결론
직접 구현한 히스토그램은 CAS contention과 false sharing 두 가지 이유로 고빈도 환경에서 병목이 된다. HdrHistogram은 Writer-Reader 분리(double buffering), cache line padding, log-scale O(1) 인덱싱으로 이 문제를 해결한다. request path 위에서 동작하는 메트릭 수집이라면, 외부 의존성 하나보다 안정적인 tail latency가 더 중요하다.