HomeEngineeringBackend

Latency 메트릭 수집 — 구현 방식 비교와 HdrHistogram 내부 구조

2026년 3월 22일15 min readavatarYejun Park
Java
Performance
Concurrency

직접 구현한 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개
HdrHistogram2~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

AtomicLong.incrementAndGet()은 내부적으로 CAS(compare-and-swap) 연산을 사용한다.

고빈도 환경에서 100k req/s가 들어오면 대부분이 p50 구간에 몰린다.
 특정 버킷 하나에 CAS가 집중된다.

1. Thread 1: 현재값 100 읽음, CAS(100101) 성공
2. Thread 2: 현재값 100 읽음, CAS(100101) 실패
              → 다시 읽음, CAS(101102) 성공
3. Thread 3: 현재값 100 읽음, CAS(100101) 실패
              → 다시 읽음, CAS(101102) 실패
              → 다시 읽음, CAS(102103) 성공

스레드 수가 증가할수록 실패 횟수가 급격히 증가한다. (경합 스레드 수에 대략 비례)

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));
}