한 줄 요약: 메트릭 파이프라인은 수집 에이전트가 데이터를 밀어 넣고, Kafka가 폭발을 흡수하며, 시계열 DB가 압축 저장하고, 알림 엔진이 이상을 감지하는 4계층 구조다. 각 계층이 독립적으로 확장 가능해야 전체가 살아남는다.

실제 사고: 모니터링 파이프라인이 무너지면 어떤 일이 벌어지나

2021년 페이스북 대규모 장애는 6시간 동안 서비스 전체를 다운시켰습니다. 직접 원인은 BGP 경로 설정 오류였지만, 내부 엔지니어들이 복구에 그토록 오랜 시간이 걸린 진짜 이유는 따로 있었습니다. 모니터링 시스템 자체도 내부 네트워크에 의존하고 있었기 때문에, 인프라가 무너지는 순간 엔지니어들이 무슨 일이 벌어지고 있는지 볼 수 있는 창도 함께 사라졌습니다. 장애 상황을 파악하려면 인프라가 필요하고, 그 인프라가 장애의 원인이었던 역설이었습니다.

2022년 국내 한 커머스 플랫폼에서는 블랙 프라이데이 당일 메트릭 수집 서버가 먼저 다운됐습니다. 트래픽 폭증으로 메트릭 데이터포인트가 평소의 50배 이상 밀려들었고, 수집 서버의 메모리가 고갈됐습니다. 결과적으로 결제 서비스 응답 지연이 10분 이상 지속됐지만, 엔지니어들은 알림을 받지 못해 30분이 지나서야 고객 문의로 파악했습니다. 모니터링 시스템이 살아있었다면 3분 안에 탐지하고 오토스케일링을 트리거할 수 있었습니다.

2023년에는 카디널리티 폭발(Cardinality Explosion) 사고가 있었습니다. 한 개발팀이 HTTP 요청 메트릭에 user_id 라벨을 추가했습니다. 사용자가 100만 명이니, 갑자기 시계열 수가 100만 배 증가했습니다. Prometheus 서버의 메모리 사용량이 2GB에서 400GB로 치솟았고, 쿼리 응답 시간이 수십 초로 늘어나 대시보드가 사실상 마비됐습니다. 높은 카디널리티 라벨 하나가 전체 모니터링 인프라를 죽인 사례입니다.

이 세 사고의 공통 교훈은 명확합니다. 메트릭 파이프라인은 관찰 대상 시스템보다 더 강인해야 한다. 수집 버퍼가 트래픽 폭증을 흡수해야 하고, 저장 계층이 카디널리티를 제어해야 하며, 모니터링 인프라 자체는 관찰 대상과 격리돼 있어야 합니다.


설계 의사결정 로드맵

결정 1: 수집 방식 — Push vs Pull

후보 장점 단점 언제 적합한가
Push (StatsD, Telegraf) 수집 에이전트가 주도, 방화벽 안쪽 서버 지원, 에이전트가 집계 후 전송 메트릭 서버가 다운되면 푸시 실패, 백프레셔 제어 어려움 단기 이벤트 카운터, 내부 방화벽 환경, 배치 집계 필요 시
Pull (Prometheus 스크레이핑) 서버가 수집 시점 제어, 타겟 헬스체크 내장, 구성 중앙화 수집 대상이 HTTP 엔드포인트 노출 필요, 대규모 타겟 시 스크레이프 부하 쿠버네티스 환경, 서비스 디스커버리 필요, 헬스체크 통합
혼합 환경에 따라 최적 방식 선택 운영 복잡도 증가 멀티 클라우드, 레거시 + 클라우드 네이티브 혼재

우리의 선택: Pull 기반 Prometheus 스크레이핑 + Push 보조 (단기 카운터)

메트릭 수집을 Push로만 구성하면, 수집 서버에 문제가 생겨도 에이전트는 여전히 데이터를 계속 밀어 넣으려 합니다. 이는 마치 꽉 막힌 배수구에 물을 계속 붓는 것과 같습니다. Pull 방식은 수집 서버가 “지금 가져갈게”라고 요청하는 구조여서, 서버가 과부하일 때 스크레이프 간격을 늘리거나 일부 타겟을 건너뛰는 방식으로 자연스럽게 부하를 조절합니다.

쿠버네티스 환경에서는 Prometheus의 서비스 디스커버리가 새로 뜬 파드를 자동으로 수집 대상에 추가합니다. 단, 짧은 수명의 이벤트 카운터(배치 작업 완료 횟수, 큐 처리량 등)는 Pull 주기(15초~1분) 사이에 사라질 수 있어 StatsD 방식의 Push를 보조로 사용합니다.

결정 2: 전송 버퍼 — 직접 전송 vs Kafka 파이프라인

후보 장점 단점 언제 적합한가
직접 전송 (에이전트 → TSDB) 아키텍처 단순, 지연 최소화 저장 서버 장애 시 데이터 유실, 트래픽 폭증 시 저장 서버 과부하 소규모, 메트릭 수가 수천 단위
Kafka 버퍼 폭증 흡수, 저장 서버 장애 격리, 다중 소비자(TSDB + 알림 + 로그) 동시 지원 운영 복잡도, Kafka 클러스터 비용 초당 수십만 이상, 다운스트림 다수, 내결함성 필수
메시지 큐 (RabbitMQ) Kafka보다 단순, 라우팅 유연 높은 처리량에서 성능 한계, 장기 보관 어려움 메트릭보다 이벤트 라우팅이 주목적일 때

우리의 선택: Kafka 파이프라인 (수집 → Kafka → 다운스트림 분기)

초당 100만 데이터포인트는 직접 저장으로는 TSDB를 압사시킵니다. Kafka를 중간에 두면 수집 속도와 저장 속도를 완전히 분리할 수 있습니다. 수집 에이전트는 Kafka에 밀어 넣는 속도로 동작하고, TSDB Consumer는 자신의 처리 속도에 맞게 읽어 갑니다. 동시에 같은 토픽을 알림 엔진, 이상 탐지 서비스, 장기 보관 아카이브가 독립적으로 소비할 수 있습니다.

결정 3: 시계열 DB — InfluxDB vs Prometheus TSDB vs VictoriaMetrics

후보 장점 단점 언제 적합한가
Prometheus TSDB Prometheus 생태계 통합, PromQL 강력, 오픈소스 성숙 단일 노드 스케일 한계, 장기 보관 기본 미지원 중소규모, 쿠버네티스 표준 스택
InfluxDB 시계열 특화 쿼리(Flux), 다운샘플링 내장, 클러스터 지원 상용 클러스터 비용, Flux 학습 곡선 고빈도 시계열, IoT, 다운샘플링 파이프라인
VictoriaMetrics Prometheus 호환 API, 압축률 최고(10배), 수평 확장 상대적으로 작은 생태계, 운영 경험 부족 대용량 장기 보관, Prometheus 대체, 비용 최적화
Thanos / Cortex Prometheus + 수평 확장 + 장기 보관 통합 운영 복잡도 최고 멀티 클러스터, 글로벌 쿼리 필요 시

우리의 선택: VictoriaMetrics (단일 노드 → 클러스터 마이그레이션 경로)

초당 100만 포인트를 30일간 저장하면 원시 데이터만 수 TB입니다. VictoriaMetrics는 동일 데이터를 Prometheus TSDB 대비 약 10배 압축하고, PromQL과 100% 호환되며, 단일 바이너리로 운영 복잡도가 낮습니다. 초기엔 단일 노드로 시작하고, 부하가 늘면 클러스터 버전으로 전환합니다.

결정 4: 알림 방식 — 정적 임계값 vs 이상 탐지

후보 장점 단점 언제 적합한가
정적 임계값 설정 단순, 오탐 예측 가능 계절성·트렌드 반영 불가, 야간 트래픽이 낮을 때 야간 알림 오발 단순한 에러율, 응답 시간 등 절대 기준이 명확한 메트릭
동적 기준선 (Anomaly Detection) 계절성·트렌드 자동 반영, 새벽 2시 트래픽 감소를 이상으로 보지 않음 모델 학습 기간 필요, 오탐 초기에 많음 비즈니스 메트릭, 트래픽 패턴이 시간대·요일별로 변화
조합 명확한 기준은 정적, 복잡한 패턴은 동적 알림 규칙 관리 복잡 성숙한 운영 조직

우리의 선택: 정적 임계값 기본 + 동적 기준선 병행 (핵심 비즈니스 메트릭)

정적 임계값 알림은 “체온이 38.5도를 넘으면 알림”과 같습니다. 단순하고 명확합니다. 동적 기준선은 “오늘 체온 패턴이 지난 2주 같은 시간대 평균과 3-시그마 이상 벗어났을 때 알림”입니다. 계절성 트래픽을 가진 서비스에서는 동적 기준선이 훨씬 정확합니다.

에러율, 응답 시간 같은 서비스 레벨 메트릭은 정적 임계값(에러율 1% 초과)이 직관적입니다. 주문 수, 결제 완료 수 같은 비즈니스 메트릭은 요일·시간대·이벤트에 따라 정상 범위가 크게 달라지므로 동적 기준선이 오탐을 줄입니다.

결정 5: 카디널리티 관리 — 라벨 자유 vs 제한

후보 장점 단점 언제 적합한가
라벨 자유 개발자 편의, 풍부한 필터링 카디널리티 폭발로 TSDB 메모리 고갈 시계열 수 수만 이하 소규모
라벨 제한 (화이트리스트) 카디널리티 예측 가능, 안정적 운영 개발자 불편, 새 라벨 추가 프로세스 필요 중대형 프로덕션 환경
카디널리티 버짓 메트릭별 최대 시계열 수 제한, 초과 시 자동 집계 구현 복잡 대규모 멀티 테넌트

우리의 선택: 라벨 화이트리스트 + 카디널리티 사전 검증

user_id, session_id, request_id처럼 무한히 늘어날 수 있는 라벨을 메트릭에 붙이는 것은 TSDB에 독약입니다. 배포 파이프라인에 카디널리티 검증 단계를 추가해, 새 메트릭의 예상 시계열 수가 임계값(예: 10만)을 넘으면 리뷰를 요구합니다.


1. 요구사항 분석 및 규모 추정

기능 요구사항

  1. 메트릭 수집: 서버, 애플리케이션, 인프라에서 다양한 메트릭 타입(카운터, 게이지, 히스토그램) 수집
  2. 실시간 저장: 수집된 메트릭을 초 단위 해상도로 시계열 DB에 저장
  3. 쿼리 API: PromQL 호환 쿼리로 임의 시간 범위, 집계 함수 지원
  4. 대시보드: Grafana 연동, 실시간 시각화, 커스텀 패널
  5. 알림: 임계값 기반 + 이상 탐지 기반 알림, 다채널 발송(Slack, PagerDuty, 이메일)
  6. 다운샘플링: 오래된 데이터 자동 집계(1초 → 1분 → 1시간)로 저장 비용 절감
  7. 보관 정책: 원시 데이터 15일, 1분 집계 90일, 1시간 집계 1년

비기능 요구사항

  • 처리량: 초당 1,000,000 데이터포인트 수집
  • 지연: 수집부터 쿼리 가능까지 < 10초
  • 가용성: 99.9% (연간 다운타임 8.7시간 이하)
  • 내구성: 수집된 데이터 손실률 < 0.01%
  • 확장성: 수평 확장으로 데이터포인트 10배 증가 대응

규모 추정

항목 수치
초당 데이터포인트 1,000,000 포인트/초
일 데이터포인트 864억 개
포인트당 평균 크기 약 50바이트 (이름 + 라벨 + 값 + 타임스탬프)
원시 데이터 일 발생 864억 × 50B = 4.32TB/일
압축 후 (10:1) 432GB/일
15일 보관 원시 데이터 약 6.5TB
활성 시계열 수 500만 개 (카디널리티)
알림 규칙 수 5,000개
알림 평가 주기 30초
Kafka 토픽 파티션 200개

2. 고수준 아키텍처

비유: 메트릭 파이프라인은 도시의 수도 시스템과 같습니다. 수천 개의 수도꼭지(수집 에이전트)에서 물(메트릭)이 흘러 들어오고, 대형 저수지(Kafka)가 갑작스러운 폭우(트래픽 폭증)를 담아두며, 정수장(시계열 DB)이 물을 정제·압축 저장하고, 수질 감시 시스템(알림 엔진)이 이상을 감지해 주민(엔지니어)에게 알립니다.

graph LR
    A["수집 에이전트"] --> B["Kafka 버퍼"]
    B --> C["TSDB Writer"]
    B --> D["알림 엔진"]
    C --> E["VictoriaMetrics"]
    E --> F["Grafana"]
    D --> G["알림 채널"]
컴포넌트 역할
수집 에이전트 Prometheus Exporter / StatsD → 메트릭 노출 또는 Push
Kafka 버퍼 수집·저장 속도 분리, 트래픽 폭증 흡수, 다운스트림 팬아웃
TSDB Writer Kafka 소비 → 배치 압축 → VictoriaMetrics 적재
VictoriaMetrics 고압축 시계열 저장, PromQL 쿼리 엔드포인트
알림 엔진 스트리밍 평가, 임계값 + 이상 탐지, 중복 제거
Grafana PromQL 대시보드, 알림 시각화, 팀 공유

3. 핵심 컴포넌트 상세 설계

각 컴포넌트 동작 원리

컴포넌트 핵심 역할 내부 동작 흐름
수집 에이전트 메트릭 노출 또는 Push 시스템 메트릭 수집 → 타입 변환 → HTTP 노출 or UDP Push
Kafka Producer 폭증 없는 전송 배치 누적 → lz4 압축 → 파티셔닝 → 전송 확인
Kafka Consumer 순서 보장 소비 파티션별 순차 처리 → 오프셋 커밋 → lag 모니터링
TSDB Writer 효율적 적재 시간 기반 배치 → TSDB 프로토콜 변환 → 병렬 삽입
알림 평가기 규칙 지속 평가 30초마다 PromQL 평가 → 상태 전환 감지 → 알림 발송

3-1. 수집 에이전트 (Push vs Pull 구현)

비유: Pull 방식은 도서관 사서가 직접 책장을 순회하며 새 책을 확인하는 방식입니다. Push 방식은 책이 들어올 때마다 저자가 직접 사서에게 알리는 방식입니다. 도서관 규모가 작을 때는 Push가 빠르지만, 책이 수백만 권이 되면 사서가 주도하는 Pull이 더 체계적입니다.

Prometheus 방식의 Pull 수집에서는 각 서비스가 /metrics 엔드포인트를 노출하고, Prometheus 서버가 주기적으로 스크레이핑합니다. 수집 간격(scrape_interval), 타임아웃(scrape_timeout)을 메트릭 중요도에 따라 조정합니다.

# prometheus.yml - 스크레이프 설정 예시
global:
  scrape_interval: 15s       # 기본 수집 주기
  evaluation_interval: 30s   # 알림 규칙 평가 주기

scrape_configs:
  - job_name: "payment-service"
    scrape_interval: 5s      # 결제 서비스는 더 짧은 주기
    scrape_timeout: 4s
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      # 파드 어노테이션으로 수집 활성화 여부 제어
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: "true"
      # 포트 번호 동적 설정
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port]
        action: replace
        target_label: __address__
        regex: (.+)
        replacement: ${1}

  - job_name: "node-exporter"
    scrape_interval: 15s
    static_configs:
      - targets:
          - "node-01:9100"
          - "node-02:9100"
          - "node-03:9100"

애플리케이션 계층에서는 Prometheus 클라이언트 라이브러리로 메트릭을 직접 계측합니다. 카운터(Counter), 게이지(Gauge), 히스토그램(Histogram), 서머리(Summary) 네 가지 타입을 목적에 맞게 사용합니다.

@Component
public class PaymentMetrics {

    // 카운터: 누적 증가만 (결제 시도 횟수)
    private final Counter paymentAttempts = Counter.build()
        .name("payment_attempts_total")
        .help("Total payment attempts")
        .labelNames("method", "currency")   // 카디널리티 제한: 2개 라벨
        .register();

    // 게이지: 증가/감소 모두 (현재 처리 중인 결제 수)
    private final Gauge activePayments = Gauge.build()
        .name("payment_active_count")
        .help("Currently processing payments")
        .register();

    // 히스토그램: 분포 측정 (결제 처리 지연)
    private final Histogram paymentDuration = Histogram.build()
        .name("payment_duration_seconds")
        .help("Payment processing duration")
        .labelNames("method")
        .buckets(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5)
        .register();

    public void recordPaymentAttempt(String method, String currency) {
        paymentAttempts.labels(method, currency).inc();
    }

    public Timer startPaymentTimer(String method) {
        activePayments.inc();
        return paymentDuration.labels(method).startTimer();
    }

    public void endPaymentTimer(Timer timer, boolean success) {
        timer.observeDuration();
        activePayments.dec();
    }
}

카운터와 게이지를 혼용하면 안 됩니다. “현재 활성 세션 수”는 게이지, “지금까지 처리한 총 요청 수”는 카운터입니다. 카운터에 rate() 함수를 적용하면 초당 처리율로 변환됩니다. 게이지에 rate()를 적용하면 의미 없는 값이 나옵니다.


3-2. Kafka 파이프라인 (Producer·Consumer·Broker 설정)

비유: Kafka는 메트릭 세계의 물류 창고입니다. 수천 곳의 공장(에이전트)에서 상품(메트릭)이 쏟아져 들어와도 창고(토픽 파티션)가 순서대로 적재하고, 여러 배송 트럭(Consumer)이 각자의 속도로 꺼내갑니다. 창고가 꽉 차는 일은 없도록 보존 기간(retention)을 설정하고, 배송이 늦어지면 트럭을 더 투입(Consumer 스케일 아웃)합니다.

Producer 설정 — 처리량과 신뢰성의 균형:

# kafka-producer.properties - 메트릭 전송 최적화
bootstrap.servers=kafka-1:9092,kafka-2:9092,kafka-3:9092

# 압축: lz4 > snappy > gzip 순으로 처리량 우선
# lz4: 압축률 2~3x, CPU 사용 최소 → 메트릭 파이프라인 권장
# snappy: 압축률 1.5~2x, CPU 균형 → 범용
# zstd: 압축률 3~5x, CPU 높음 → 저장 비용 최우선 시
# gzip: 압축률 최고, CPU 최고 → 배치 처리 권장
compression.type=lz4

# 배치 크기: 클수록 처리량↑, 지연↑
batch.size=65536          # 64KB - 초당 100만 포인트에서 최적

# 배치 대기 시간: Producer가 배치를 채우기 위해 기다리는 최대 시간
linger.ms=10              # 10ms 대기 후 전송 (배치 효율 향상)

# 인플라이트 요청: 확인 없이 보낼 수 있는 최대 요청 수
max.in.flight.requests.per.connection=5

# acks: all = 모든 ISR(In-Sync Replica)에 기록 확인 (데이터 안전)
acks=all

# 재시도: 일시적 장애 자동 복구
retries=3
retry.backoff.ms=100

# 버퍼: Producer 전송 큐 메모리 (에이전트 메모리의 20% 이하 권장)
buffer.memory=67108864    # 64MB

Broker 설정 — 안정적 운영:

# kafka-server.properties - 메트릭 토픽 최적화
# 파티션 수 = 초당 처리량 / 파티션당 처리량 (파티션당 약 5만 포인트/초)
# 1,000,000 / 50,000 = 20 파티션 최소 → 여유분 10배 = 200 파티션

# 보존 기간: 메트릭은 1~2시간이면 충분 (TSDB로 이관 완료 후)
log.retention.hours=2
log.retention.bytes=107374182400  # 100GB per partition

# 세그먼트 롤링: 작은 세그먼트 → 빠른 삭제
log.segment.bytes=134217728       # 128MB

# 복제: 3 노드에서 2 복제본으로 가용성 확보
default.replication.factor=2
min.insync.replicas=2

Consumer 설정 — Lag 대응과 오토스케일링:

Consumer lag(메시지 미처리 잔량)은 메트릭 파이프라인 건강의 핵심 지표입니다. Kafka Consumer 그룹의 lag이 쌓이면 메트릭이 늦게 TSDB에 도달하고, 알림 발화도 지연됩니다.

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConsumerFactory<String, MetricBatch> consumerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092,kafka-2:9092");
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "metrics-tsdb-writer");

        // 배치 소비: 한 번 폴링에 여러 레코드 처리 → TSDB 배치 삽입 최적화
        props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 5000);
        props.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 1048576);   // 1MB 이상 모아서 가져오기
        props.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 500);     // 최대 500ms 대기

        // 오프셋 커밋: 자동 커밋 비활성화 → TSDB 삽입 성공 후 수동 커밋
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

        // 세션 타임아웃: Consumer 장애 감지 시간
        props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30000);
        props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 10000);

        return new DefaultKafkaConsumerFactory<>(props,
            new StringDeserializer(),
            new JsonDeserializer<>(MetricBatch.class));
    }
}

@Service
public class MetricsKafkaConsumer {

    private final TsdbWriter tsdbWriter;
    private final MeterRegistry meterRegistry;

    // Consumer lag 모니터링: lag 자체도 메트릭으로 수집
    private final Gauge consumerLag;

    public MetricsKafkaConsumer(TsdbWriter tsdbWriter, MeterRegistry meterRegistry) {
        this.tsdbWriter = tsdbWriter;
        this.meterRegistry = meterRegistry;
        this.consumerLag = Gauge.build()
            .name("kafka_consumer_lag")
            .help("Kafka consumer lag for metrics topic")
            .labelNames("partition")
            .register();
    }

    @KafkaListener(
        topics = "metrics-raw",
        groupId = "metrics-tsdb-writer",
        containerFactory = "batchKafkaListenerContainerFactory"
    )
    public void consumeBatch(
            List<ConsumerRecord<String, MetricBatch>> records,
            Acknowledgment ack) {

        try {
            // 배치 내 모든 포인트를 모아 단일 TSDB 삽입 호출
            List<TimeSeriesPoint> points = records.stream()
                .flatMap(r -> r.value().getPoints().stream())
                .collect(Collectors.toList());

            tsdbWriter.insertBatch(points);  // VictoriaMetrics HTTP API

            // TSDB 삽입 성공 후 오프셋 커밋
            ack.acknowledge();

            // lag 업데이트
            updateConsumerLag(records);

        } catch (TsdbException e) {
            // 삽입 실패: 재처리 (오프셋 커밋 안 함)
            log.error("TSDB 삽입 실패, 재처리 예정: {}", e.getMessage());
            // ack 호출하지 않으면 다음 폴링에서 재전송
        }
    }

    private void updateConsumerLag(List<ConsumerRecord<?, ?>> records) {
        records.stream()
            .collect(Collectors.groupingBy(
                r -> r.partition(),
                Collectors.summarizingLong(r -> r.offset())
            ))
            .forEach((partition, stats) ->
                consumerLag.labels(String.valueOf(partition))
                    .set(stats.getMax())
            );
    }
}

Consumer Lag 기반 오토스케일링 전략:

lag이 쌓이는 상황은 세 가지 원인 중 하나입니다. 첫째는 메트릭 폭증(생산 속도 > 소비 속도), 둘째는 TSDB 삽입 지연, 셋째는 Consumer 인스턴스 장애입니다.

# Kubernetes HPA - Consumer lag 기반 오토스케일링
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: metrics-consumer-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: metrics-tsdb-writer
  minReplicas: 3
  maxReplicas: 20       # 파티션 수(200)를 초과하면 여분 Consumer는 놀음
  metrics:
    # Kafka lag 기반 스케일링 (KEDA 또는 커스텀 메트릭 어댑터 사용)
    - type: External
      external:
        metric:
          name: kafka_consumer_lag_sum
          selector:
            matchLabels:
              topic: metrics-raw
              group: metrics-tsdb-writer
        target:
          type: Value
          value: "500000"   # lag 50만 초과 시 스케일 아웃
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60    # 1분 안정화 후 스케일
      policies:
        - type: Pods
          value: 3           # 한 번에 최대 3개씩 추가
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300   # 5분 안정화 후 스케일 다운

3-3. Redis 실시간 카운터 및 슬라이딩 윈도우

비유: Redis의 실시간 카운터는 편의점 입장 인원 카운터와 같습니다. 입장할 때마다 +1, 퇴장할 때마다 -1, 현재 카운터 값을 보면 지금 매장 안에 몇 명이 있는지 즉시 알 수 있습니다. INCR 연산은 원자적이기 때문에 여러 서버가 동시에 카운터를 업데이트해도 값이 꼬이지 않습니다.

기본 카운터·게이지 패턴:

import redis
import time

r = redis.Redis(host='redis-cluster', port=6379)

class MetricsRedisStore:

    def increment_counter(self, metric_name: str, labels: dict,
                           value: int = 1, ttl: int = 3600):
        """카운터 증가: INCR + EXPIRE"""
        key = self._build_key(metric_name, labels)
        pipe = r.pipeline()
        pipe.incrby(key, value)
        pipe.expire(key, ttl)     # 1시간 TTL (TSDB로 이관 완료 후 자동 삭제)
        pipe.execute()

    def set_gauge(self, metric_name: str, labels: dict,
                   value: float, ttl: int = 60):
        """게이지 갱신: SET + EXPIRE
        HSET으로 여러 필드를 하나의 키에 묶으면 메모리 효율 향상
        """
        key = self._build_key(metric_name, labels)
        r.hset(key, mapping={
            "value": value,
            "timestamp": int(time.time())
        })
        r.expire(key, ttl)

    def get_gauge(self, metric_name: str, labels: dict) -> dict:
        key = self._build_key(metric_name, labels)
        data = r.hgetall(key)
        if not data:
            return None
        return {
            "value": float(data[b"value"]),
            "timestamp": int(data[b"timestamp"])
        }

    def _build_key(self, metric_name: str, labels: dict) -> str:
        # 키 형식: metric_name{label1=v1,label2=v2}
        label_str = ",".join(f"{k}={v}" for k, v in sorted(labels.items()))
        return f"metric:{metric_name}}"

슬라이딩 윈도우 메트릭 (Sorted Set 활용):

특정 시간 창 안의 이벤트 수를 실시간으로 집계할 때는 Redis Sorted Set을 활용합니다. 타임스탬프를 score로 사용하면 ZREMRANGEBYSCORE로 오래된 데이터를 제거하고, ZCARD로 현재 윈도우 안의 개수를 O(log n)에 조회할 수 있습니다.

-- sliding_window.lua: 슬라이딩 윈도우 카운터 (Lua로 원자 실행)
-- KEYS[1]: 메트릭 키
-- ARGV[1]: 현재 타임스탬프 (ms)
-- ARGV[2]: 윈도우 크기 (ms)
-- ARGV[3]: 이벤트 ID (중복 방지)
-- ARGV[4]: TTL (초)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local event_id = ARGV[3]
local ttl = tonumber(ARGV[4])

-- 1. 윈도우 밖의 오래된 이벤트 제거
local cutoff = now - window_ms
redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff)

-- 2. 새 이벤트 추가 (score = 타임스탬프)
redis.call('ZADD', key, now, event_id)

-- 3. TTL 갱신
redis.call('EXPIRE', key, ttl)

-- 4. 현재 윈도우 내 이벤트 수 반환
return redis.call('ZCARD', key)
@Service
public class SlidingWindowCounter {

    private static final String SCRIPT_PATH = "scripts/sliding_window.lua";
    private final RedisScript<Long> slidingWindowScript;

    public SlidingWindowCounter(ResourceLoader resourceLoader) throws IOException {
        Resource resource = resourceLoader.getResource("classpath:" + SCRIPT_PATH);
        String scriptContent = StreamUtils.copyToString(
            resource.getInputStream(), StandardCharsets.UTF_8);
        this.slidingWindowScript = RedisScript.of(scriptContent, Long.class);
    }

    /**
     * 슬라이딩 윈도우 카운터 증가 및 현재 윈도우 카운트 반환
     * 예: 특정 호스트의 최근 1분간 에러 횟수
     */
    public long incrementAndCount(String metricName, Map<String, String> labels,
                                   Duration windowSize, String eventId) {
        String key = buildKey(metricName, labels);
        long nowMs = System.currentTimeMillis();
        long windowMs = windowSize.toMillis();
        int ttlSeconds = (int)(windowMs / 1000) * 2;  // 윈도우의 2배 TTL

        Long count = redisTemplate.execute(
            slidingWindowScript,
            List.of(key),
            String.valueOf(nowMs),
            String.valueOf(windowMs),
            eventId,
            String.valueOf(ttlSeconds)
        );

        return count != null ? count : 0L;
    }

    /**
     * 현재 윈도우 카운트만 조회 (이벤트 추가 없음)
     */
    public long getWindowCount(String metricName, Map<String, String> labels,
                                Duration windowSize) {
        String key = buildKey(metricName, labels);
        long cutoff = System.currentTimeMillis() - windowSize.toMillis();

        // 오래된 항목 제거 후 카운트
        redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, cutoff);
        Long count = redisTemplate.opsForZSet().zCard(key);
        return count != null ? count : 0L;
    }
}

슬라이딩 윈도우는 Rate Limiting, 이상 탐지의 단기 집계, 알림 조건 평가에 활용됩니다. “최근 5분간 에러율이 5%를 초과하면 알림”을 구현할 때 Sorted Set 두 개(전체 요청, 에러 요청)를 병렬로 관리하면 됩니다.


3-4. 시계열 DB 저장 전략 (VictoriaMetrics)

비유: 시계열 데이터의 다운샘플링은 책의 요약과 같습니다. 어제 일은 1분 단위로 기억하고, 지난 달 일은 1시간 단위로 기억하며, 작년 일은 하루 단위로 기억합니다. 모든 것을 초 단위로 기억하려면 뇌 용량(스토리지)이 무한해야 하지만, 시간이 지날수록 세밀한 정보가 덜 중요해지므로 요약해도 충분합니다.

VictoriaMetrics는 Prometheus의 Remote Write 프로토콜과 호환되어, 기존 Prometheus 스택과 즉시 연동할 수 있습니다.

@Service
public class VictoriaMetricsWriter {

    private final WebClient webClient;
    private final SnappyCompressor compressor;

    // VictoriaMetrics Remote Write 엔드포인트
    private static final String REMOTE_WRITE_URL = "http://victoria-metrics:8428/api/v1/import/prometheus";

    /**
     * 배치 포인트를 Prometheus 텍스트 포맷으로 변환 후 HTTP 전송
     * VictoriaMetrics는 Prometheus exposition format을 그대로 수용
     */
    public Mono<Void> insertBatch(List<TimeSeriesPoint> points) {
        String prometheusFormat = convertToPrometheusFormat(points);
        byte[] compressed = compressor.compress(prometheusFormat.getBytes(StandardCharsets.UTF_8));

        return webClient.post()
            .uri(REMOTE_WRITE_URL)
            .header("Content-Encoding", "snappy")
            .header("Content-Type", "text/plain")
            .bodyValue(compressed)
            .retrieve()
            .toBodilessEntity()
            .then();
    }

    private String convertToPrometheusFormat(List<TimeSeriesPoint> points) {
        StringBuilder sb = new StringBuilder();
        for (TimeSeriesPoint p : points) {
            // 형식: metric_name{label1="v1",label2="v2"} value timestamp_ms
            sb.append(p.getName())
              .append(formatLabels(p.getLabels()))
              .append(" ")
              .append(p.getValue())
              .append(" ")
              .append(p.getTimestampMs())
              .append("\n");
        }
        return sb.toString();
    }

    private String formatLabels(Map<String, String> labels) {
        if (labels.isEmpty()) return "";
        String labelStr = labels.entrySet().stream()
            .map(e -> e.getKey() + "=\"" + e.getValue() + "\"")
            .collect(Collectors.joining(","));
        return "{" + labelStr + "}";
    }
}

다운샘플링 정책 (Recording Rules):

VictoriaMetrics의 vmrules로 원시 데이터를 주기적으로 집계하면 오래된 쿼리 응답 속도를 대폭 향상할 수 있습니다.

# vmrules.yml - 다운샘플링 Recording Rules
groups:
  - name: downsampling_1m
    interval: 1m                 # 1분마다 평가
    rules:
      # 원시 데이터(15초 해상도)를 1분 평균으로 집계
      - record: http_request_duration_seconds:avg1m
        expr: >
          avg_over_time(http_request_duration_seconds_bucket[1m])
        labels:
          resolution: "1m"

      # 에러율 1분 집계
      - record: http_errors:rate1m
        expr: >
          sum(rate(http_requests_total{status=~"5.."}[1m]))
          /
          sum(rate(http_requests_total[1m]))
        labels:
          resolution: "1m"

  - name: downsampling_1h
    interval: 1h
    rules:
      # 1분 집계를 다시 1시간으로 집계 (2단계 다운샘플링)
      - record: http_request_duration_seconds:avg1h
        expr: avg_over_time(http_request_duration_seconds:avg1m[1h])
        labels:
          resolution: "1h"

보관 정책은 VictoriaMetrics의 -retentionPeriod 플래그로 간단히 설정합니다. 원시 데이터는 15일, 1분 집계는 90일, 1시간 집계는 365일로 각각 다른 스토리지 클래스에 저장합니다.


3-5. 트래픽 폭증 대응 (Sampling, Aggregation, Backpressure)

비유: 폭우가 쏟아질 때 모든 빗물을 댐에 담으려 하면 댐이 넘칩니다. 실제 댐은 홍수 조절 방류구를 열어 유입량이 처리 능력을 초과하지 않도록 조절합니다. 메트릭 파이프라인의 샘플링과 집계는 이 방류구와 같습니다.

1단계 — 에이전트 레벨 집계:

초당 100만 포인트가 모두 원시 값으로 Kafka에 도달할 필요는 없습니다. StatsD 방식에서는 에이전트가 플러시 간격(보통 10초) 동안 카운터를 합산하고 게이지를 최신값으로 유지합니다. 100만 포인트가 아니라 1만 개의 집계 값만 전송됩니다.

@Component
public class MetricsAggregator {

    // 플러시 간격 동안 집계 중인 카운터
    private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, AtomicReference<Double>> gauges = new ConcurrentHashMap<>();

    public void incrementCounter(String key, long value) {
        counters.computeIfAbsent(key, k -> new LongAdder()).add(value);
    }

    public void setGauge(String key, double value) {
        gauges.computeIfAbsent(key, k -> new AtomicReference<>(0.0))
              .set(value);
    }

    @Scheduled(fixedDelay = 10000)  // 10초마다 플러시
    public void flush() {
        long timestamp = System.currentTimeMillis();
        List<MetricPoint> batch = new ArrayList<>();

        // 카운터: sum 후 리셋
        counters.forEach((key, adder) -> {
            long sum = adder.sumThenReset();
            if (sum > 0) {
                batch.add(MetricPoint.counter(key, sum, timestamp));
            }
        });

        // 게이지: 현재 값 스냅샷
        gauges.forEach((key, ref) ->
            batch.add(MetricPoint.gauge(key, ref.get(), timestamp))
        );

        if (!batch.isEmpty()) {
            kafkaTemplate.send("metrics-raw", batch);
        }
    }
}

2단계 — Kafka 레벨 백프레셔:

Producer의 buffer.memory가 가득 차면 max.block.ms 동안 대기하다 예외를 발생시킵니다. 이 예외를 catch해서 로컬 메모리에 임시 보관하거나 샘플링해야 합니다.

@Service
public class MetricsKafkaProducer {

    private static final int MAX_QUEUE_SIZE = 100_000;
    private final BlockingQueue<MetricBatch> localBuffer = new LinkedBlockingQueue<>(MAX_QUEUE_SIZE);

    public void send(MetricBatch batch) {
        try {
            kafkaTemplate.send("metrics-raw", batch)
                .get(100, TimeUnit.MILLISECONDS);  // 100ms 타임아웃
        } catch (TimeoutException | ExecutionException e) {
            // Kafka 전송 실패: 로컬 버퍼에 임시 저장
            if (!localBuffer.offer(batch)) {
                // 로컬 버퍼도 가득 찬 경우: 샘플링 (10%만 보관)
                if (ThreadLocalRandom.current().nextInt(10) == 0) {
                    // 최종 방어선: 가장 오래된 항목 제거 후 삽입
                    localBuffer.poll();
                    localBuffer.offer(batch);
                }
                log.warn("메트릭 버퍼 가득 참, 일부 데이터 드롭");
            }
        }
    }

    @Scheduled(fixedDelay = 1000)
    public void drainLocalBuffer() {
        List<MetricBatch> drained = new ArrayList<>();
        localBuffer.drainTo(drained, 1000);
        for (MetricBatch batch : drained) {
            try {
                kafkaTemplate.send("metrics-raw", batch);
            } catch (Exception e) {
                log.error("로컬 버퍼 드레인 실패: {}", e.getMessage());
            }
        }
    }
}

3단계 — 카디널리티 폭발 방지:

카디널리티 폭발은 예방이 치료보다 훨씬 쉽습니다. 새 메트릭 등록 시점에 예상 시계열 수를 계산해 임계값을 초과하면 자동으로 거부합니다.

@Service
public class CardinalityGuard {

    private static final long MAX_SERIES_PER_METRIC = 100_000L;
    private static final Set<String> HIGH_CARDINALITY_LABELS =
        Set.of("user_id", "session_id", "request_id", "trace_id", "ip_address");

    /**
     * 새 메트릭 등록 전 카디널리티 검증
     * 고카디널리티 라벨 사용 또는 예상 시계열 수 초과 시 등록 거부
     */
    public ValidationResult validate(MetricDefinition metric) {
        // 1. 고카디널리티 라벨 화이트리스트 검사
        Set<String> invalidLabels = metric.getLabelNames().stream()
            .filter(HIGH_CARDINALITY_LABELS::contains)
            .collect(Collectors.toSet());

        if (!invalidLabels.isEmpty()) {
            return ValidationResult.rejected(
                "고카디널리티 라벨 사용 불가: " + invalidLabels +
                ". 로그에 기록하거나 라벨 값을 버킷으로 그루핑하세요."
            );
        }

        // 2. 예상 시계열 수 계산
        long estimatedSeries = metric.getLabelNames().stream()
            .mapToLong(label -> getLabelCardinality(label))
            .reduce(1L, (a, b) -> a * b);

        if (estimatedSeries > MAX_SERIES_PER_METRIC) {
            return ValidationResult.rejected(
                String.format("예상 시계열 수 %d개가 최대치 %d개를 초과합니다. " +
                    "라벨을 줄이거나 값을 버킷으로 집계하세요.",
                    estimatedSeries, MAX_SERIES_PER_METRIC)
            );
        }

        return ValidationResult.approved(estimatedSeries);
    }

    private long getLabelCardinality(String labelName) {
        // 현재 TSDB에서 해당 라벨의 고유 값 수 조회
        return victoriaMetricsClient.queryLabelValues(labelName).size();
    }
}

3-6. 알림 엔진 (Threshold + Anomaly Detection)

비유: 임계값 알림은 속도계가 제한속도를 넘으면 경고등이 켜지는 것과 같습니다. 명확하고 즉각적이지만, 새벽 2시의 고속도로 80km/h와 출퇴근 시간 80km/h가 같은 의미인지는 구분하지 못합니다. 동적 기준선 알림은 “지금 이 시간대 평균 속도와 비교했을 때 이상한가”를 판단합니다.

정적 임계값 알림 (Alertmanager):

# alerting-rules.yml
groups:
  - name: service_slo
    rules:
      # 에러율 1% 초과: p99 응답 지연 + 에러율 복합 조건
      - alert: HighErrorRate
        expr: >
          (
            sum(rate(http_requests_total{status=~"5.."}[5m])) by (service)
            /
            sum(rate(http_requests_total[5m])) by (service)
          ) > 0.01
        for: 2m           # 2분간 지속될 때만 알림 (순간 스파이크 필터)
        labels:
          severity: critical
          team: platform
        annotations:
          summary: " 에러율  초과"
          description: "5분간 평균 에러율이 1%를 초과합니다. 즉시 확인 필요."
          runbook: "https://wiki.internal/runbooks/high-error-rate"

      # p99 응답 지연 500ms 초과
      - alert: SlowResponseTime
        expr: >
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service)
          ) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: " p99 응답 시간  초과"

      # Kafka Consumer lag 10분치 이상 적체
      - alert: KafkaConsumerLagHigh
        expr: kafka_consumer_lag_sum{group="metrics-tsdb-writer"} > 600000
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "메트릭 Consumer lag 과다 적체: 개"
          description: "TSDB Writer가 따라가지 못하고 있습니다. 인스턴스 추가를 검토하세요."

동적 기준선 알림 (이상 탐지):

비즈니스 메트릭(주문 수, 결제 완료 수)은 요일·시간대에 따라 정상 범위가 다릅니다. 동일 요일 동일 시간대의 최근 N주 데이터를 기준선으로 삼아 3-시그마 이상 벗어날 때 알림을 발생시킵니다.

import numpy as np
from dataclasses import dataclass
from typing import List, Optional

@dataclass
class AnomalyAlert:
    metric_name: str
    current_value: float
    baseline_mean: float
    baseline_std: float
    z_score: float
    severity: str

class DynamicBaselineDetector:
    """
    3-시그마 기반 이상 탐지
    기준선: 동일 요일·시간대 최근 4주 데이터의 평균 ± 표준편차
    """

    def __init__(self, victoria_client, lookback_weeks: int = 4):
        self.client = victoria_client
        self.lookback_weeks = lookback_weeks

    def detect(self, metric_name: str, labels: dict,
                current_value: float, timestamp: int) -> Optional[AnomalyAlert]:
        # 기준선 데이터 수집: 동일 요일·시간대 최근 4주
        baseline_values = self._fetch_baseline(metric_name, labels, timestamp)

        if len(baseline_values) < 7:  # 최소 7개 데이터 포인트 필요
            return None

        mean = np.mean(baseline_values)
        std = np.std(baseline_values)

        if std == 0:
            return None  # 분산 없음 = 의미 있는 이상 탐지 불가

        z_score = (current_value - mean) / std

        # |z_score| > 3: 3-시그마 이상 → 알림
        if abs(z_score) > 3:
            severity = "critical" if abs(z_score) > 5 else "warning"
            return AnomalyAlert(
                metric_name=metric_name,
                current_value=current_value,
                baseline_mean=mean,
                baseline_std=std,
                z_score=z_score,
                severity=severity
            )
        return None

    def _fetch_baseline(self, metric_name: str, labels: dict,
                         timestamp: int) -> List[float]:
        values = []
        # 최근 4주의 동일 요일·시간대 데이터 조회
        for week_offset in range(1, self.lookback_weeks + 1):
            # 7일 전 같은 시간 ± 30분 범위 평균
            past_ts = timestamp - (week_offset * 7 * 24 * 3600)
            query = f"""
                avg_over_time(
                    {metric_name}{self._format_labels(labels)}
                    [{past_ts - 1800}s:{past_ts + 1800}s]
                )
            """
            result = self.client.query(query)
            if result:
                values.append(float(result[0]["value"]))
        return values

    def _format_labels(self, labels: dict) -> str:
        if not labels:
            return ""
        parts = [f'{k}="{v}"' for k, v in labels.items()]
        return "{" + ",".join(parts) + "}"

알림 중복 제거 및 라우팅 (Alertmanager):

알림 엔진에서 가장 중요한 설계 원칙은 Alert Fatigue(알림 피로도) 방지입니다. 같은 근본 원인에서 파생된 수십 개의 알림이 동시에 발생하면 엔지니어는 어느 것이 진짜 문제인지 파악하기 어렵습니다.

# alertmanager.yml
global:
  resolve_timeout: 5m

route:
  group_by: ["service", "alertname"]   # 같은 서비스의 알림 묶음 처리
  group_wait: 30s        # 첫 알림 후 30초 대기 (더 많은 알림 수집)
  group_interval: 5m     # 같은 그룹 반복 알림 간격
  repeat_interval: 4h    # 미해결 알림 재발송 간격

  routes:
    # Critical 알림: PagerDuty 즉시 발송
    - match:
        severity: critical
      receiver: pagerduty-critical
      group_wait: 10s    # Critical은 더 빠르게

    # Warning 알림: Slack 채널
    - match:
        severity: warning
      receiver: slack-warning
      group_wait: 60s    # Warning은 묶어서 발송

    # 비즈니스 메트릭 이상: 별도 채널
    - match:
        type: anomaly
      receiver: slack-business-metrics

receivers:
  - name: pagerduty-critical
    pagerduty_configs:
      - service_key: "<PAGERDUTY_KEY>"
        description: "\n"

  - name: slack-warning
    slack_configs:
      - api_url: "<SLACK_WEBHOOK>"
        channel: "#alerts-warning"
        title: " 경고 (건)"
        text: " \n"
        # 미해결 알림은 색상으로 구분
        color: 'dangergood'

  - name: slack-business-metrics
    slack_configs:
      - api_url: "<SLACK_WEBHOOK>"
        channel: "#alerts-business"
        title: "비즈니스 메트릭 이상 탐지"

# 억제 규칙: 인프라 장애 시 그 위에서 발생하는 애플리케이션 알림 억제
inhibit_rules:
  - source_match:
      alertname: "NodeDown"
    target_match_re:
      service: ".+"      # 같은 노드의 모든 서비스 알림 억제
    equal: ["node"]

3-7. Grafana 대시보드 연동

비유: Grafana 대시보드는 항공기 조종석 계기판입니다. 수천 개의 센서 값이 있지만 조종사는 핵심 계기(속도, 고도, 연료)만 주시합니다. USE 방법론(Utilization·Saturation·Errors)과 RED 방법론(Rate·Errors·Duration)에 따라 패널을 구성하면 한눈에 시스템 상태를 파악할 수 있습니다.

{
  "dashboard": {
    "title": "메트릭 파이프라인 — 시스템 개요",
    "refresh": "30s",
    "panels": [
      {
        "title": "수집 처리량 (포인트/초)",
        "type": "stat",
        "targets": [
          {
            "expr": "sum(rate(metrics_ingested_total[1m]))",
            "legendFormat": "현재 처리량"
          }
        ],
        "thresholds": {
          "steps": [
            {"color": "green", "value": 0},
            {"color": "yellow", "value": 800000},
            {"color": "red", "value": 950000}
          ]
        }
      },
      {
        "title": "Kafka Consumer Lag",
        "type": "timeseries",
        "targets": [
          {
            "expr": "kafka_consumer_lag_sum{group='metrics-tsdb-writer'}",
            "legendFormat": "Consumer Lag"
          }
        ],
        "alert": {
          "name": "Lag 급증",
          "conditions": [
            {
              "evaluator": {"type": "gt", "params": [500000]},
              "query": {"params": ["A", "5m", "now"]},
              "reducer": {"type": "avg"}
            }
          ]
        }
      },
      {
        "title": "TSDB 쿼리 응답 시간 (p99)",
        "type": "gauge",
        "targets": [
          {
            "expr": "histogram_quantile(0.99, rate(vm_request_duration_seconds_bucket[5m]))",
            "legendFormat": "p99 응답 시간"
          }
        ]
      },
      {
        "title": "활성 알림",
        "type": "alertlist",
        "options": {
          "stateFilter": {"firing": true, "pending": true}
        }
      }
    ]
  }
}

Grafana의 Variable 기능을 활용하면 하나의 대시보드로 여러 서비스를 조회할 수 있습니다. $service 변수를 정의하고 패널 쿼리에 {service="$service"}를 사용하면 드롭다운으로 서비스를 선택할 때마다 전체 대시보드가 자동 업데이트됩니다.


4. 데이터 흐름 상세

메트릭이 수집 에이전트에서 시작해 최종 대시보드에 표시되기까지 각 단계에서 어떤 변환이 이루어지는지 추적합니다.

graph LR
    A["애플리케이션"] --> B["Prometheus Exporter"]
    B --> C["Prometheus 서버"]
    C --> D["Kafka 토픽"]
    D --> E["TSDB Writer"]
    E --> F["VictoriaMetrics"]

각 단계별 데이터 변환:

단계 입력 처리 출력
1. 계측 애플리케이션 코드 Prometheus 클라이언트 라이브러리 /metrics HTTP 엔드포인트
2. 스크레이핑 HTTP GET /metrics 파싱, 라벨 추가, 타임스탬프 부여 Prometheus 내부 TSDB
3. Remote Write Prometheus 내부 TSDB Protobuf 직렬화, Snappy 압축 Kafka 메시지
4. Kafka 버퍼링 Kafka 메시지 파티션 분산, 복제, 보존 Consumer 대기 메시지
5. TSDB 적재 Kafka 메시지 배치 배치 디코딩, TSDB 포맷 변환 VictoriaMetrics 블록
6. 쿼리 서빙 PromQL 쿼리 시계열 스캔, 집계, 포맷팅 JSON 응답

5. 규모별 확장 전략

소규모 (초당 10만 포인트 이하)

카프카 없이 Prometheus + VictoriaMetrics 단일 노드로 충분합니다. Prometheus가 직접 Remote Write로 VictoriaMetrics에 데이터를 밀어 넣습니다. 운영 인력이 적은 초기 단계에 적합합니다.

graph LR
    A["서비스 파드"] --> B["Prometheus"]
    B --> C["VictoriaMetrics"]
    C --> D["Grafana"]

중규모 (초당 10만~100만 포인트)

Kafka를 도입해 수집과 저장을 분리합니다. TSDB Writer를 3~5개로 수평 확장하고, 알림 엔진을 별도 서비스로 분리합니다. 이 포스트에서 설명한 구성이 이 단계에 해당합니다.

대규모 (초당 100만 포인트 초과)

VictoriaMetrics 클러스터 모드로 전환합니다. vminsert (쓰기 라우팅), vmselect (쿼리 팬아웃), vmstorage (샤딩된 저장) 세 컴포넌트로 역할을 분리합니다. 각 컴포넌트를 독립적으로 수평 확장할 수 있습니다.

graph LR
    A["Kafka Consumer"] --> B["vminsert x5"]
    B --> C["vmstorage x10"]
    D["Grafana"] --> E["vmselect x3"]
    E --> C

6. 장애 대응 시나리오

시나리오 1: TSDB Writer 전체 장애

증상: Consumer lag이 분당 6만씩 증가, 메트릭 쿼리는 정상 (기존 데이터 유효).

대응: TSDB Writer는 Stateless이므로 파드를 재시작하거나 추가합니다. Kafka는 오프셋 커밋이 안 된 메시지를 보관하고 있으므로, Writer가 복구되면 밀린 메시지부터 순차 처리합니다. Kafka 보존 기간(2시간) 안에 복구하면 데이터 손실 없음.

시나리오 2: VictoriaMetrics 디스크 공간 부족

증상: 새 데이터 삽입 거부, 쿼리는 기존 데이터로 정상 동작.

대응: 즉각적으로 Kafka Consumer를 일시 정지해 추가 데이터 유입을 차단합니다. 다운샘플링 정책을 검토해 원시 데이터 보존 기간을 단축합니다. 디스크 확장 또는 오래된 데이터 수동 삭제 후 Consumer를 재개합니다.

시나리오 3: 카디널리티 폭발

증상: VictoriaMetrics 메모리 사용량 급증, 쿼리 응답 시간이 수십 초로 증가.

대응: vmctl 툴 또는 VictoriaMetrics API로 문제 메트릭의 시계열을 확인합니다. 해당 메트릭의 고카디널리티 라벨을 즉시 제거하는 Recording Rule을 추가하고, 원본 메트릭의 수집을 중단합니다. delete_series API로 폭발한 시계열을 강제 삭제해 메모리를 회수합니다.

# 카디널리티 폭발 메트릭 확인
curl "http://victoria-metrics:8428/api/v1/label/__name__/values"

# 문제 시계열 확인 (user_id 라벨이 붙은 http_requests 메트릭)
curl "http://victoria-metrics:8428/api/v1/series?match=http_requests_total{user_id=~'.+'}"

# 해당 시계열 삭제
curl -X POST "http://victoria-metrics:8428/api/v1/admin/tsdb/delete_series" \
  --data-urlencode "match=http_requests_total{user_id=~'.+'}"

시나리오 4: 알림 폭풍 (Alert Storm)

증상: 인프라 장애 하나로 수백 개의 알림이 동시에 발생.

대응: Alertmanager의 inhibit_rules로 근본 원인 알림이 발화하면 파생 알림을 자동 억제합니다. 사후에 근본 원인 → 파생 알림 체인을 분석해 억제 규칙을 추가합니다. 알림 설계 원칙으로 “증상 기반”(높은 에러율) 알림을 “원인 기반”(디스크 가득 참) 알림보다 우선합니다.


7. 보안 고려사항

메트릭 데이터는 민감 정보를 담을 수 있습니다. 라벨 값에 사용자 이름, 이메일, 결제 금액 같은 개인정보가 포함되면 메트릭 쿼리만으로 개인정보가 노출됩니다.

위협 방어 전략
민감 라벨 노출 배포 시점 카디널리티 검증 + 금지 라벨 화이트리스트
메트릭 엔드포인트 무단 접근 mTLS 또는 Bearer Token 인증, 네트워크 정책으로 내부망 제한
알림 채널 스푸핑 Slack Webhook URL, PagerDuty Key를 Secret Manager에 저장
TSDB 데이터 삭제 Admin API 별도 인증 (기본 비활성화), RBAC 적용
Kafka 토픽 무단 접근 SASL/SCRAM 인증, ACL로 Consumer Group 권한 분리

메트릭 이름 자체도 정보가 될 수 있습니다. payment_failure_fraud_total처럼 내부 보안 정책을 드러내는 메트릭 이름은 외부에 노출하지 않도록 Prometheus 스크레이프 레이블 재작성 규칙으로 필터링합니다.


8. 운영 체크리스트

초기 구축 시 확인 사항

  • 모든 메트릭에 service, env (prod/staging/dev) 라벨 표준화
  • 카디널리티 검증 파이프라인 배포 CI에 통합
  • Kafka 토픽 파티션 수 = Consumer 최대 인스턴스 수로 설정
  • VictoriaMetrics -retentionPeriod 설정 확인
  • Alertmanager inhibit_rules 핵심 인프라 알림 체인 등록
  • Grafana 대시보드 팀별 USE/RED 패널 기본 구성

정기 점검 사항 (월 1회)

  • 상위 10개 고카디널리티 메트릭 확인 및 라벨 정리
  • Consumer lag 추이 검토 (피크 시 최대 lag, 회복 시간)
  • 알림 발화 횟수 분석 (오탐 알림 임계값 튜닝)
  • 다운샘플링 정확도 검증 (원시 vs 집계 데이터 오차)
  • TSDB 디스크 사용량 증가 추이 예측 (90일 프로젝션)

9. 면접 예상 질문

Q. Push vs Pull에서 어느 것을 선택하겠습니까?

쿠버네티스 환경에서는 Pull(Prometheus 스크레이핑)을 기본으로 선택합니다. 파드가 뜨고 내릴 때마다 수집 대상이 자동으로 등록·해제되는 서비스 디스커버리가 핵심 이유입니다. 단, 배치 작업이나 짧은 수명의 서비스처럼 Pull 주기 사이에 데이터가 사라질 수 있는 경우는 Pushgateway를 통한 Push를 보조로 사용합니다.

추가 고려사항 펼치기 방화벽 안쪽의 레거시 서버에서 메트릭을 수집해야 한다면 Pull이 어려울 수 있습니다. 이 경우 Telegraf 같은 에이전트를 서버에 설치해 내부에서 Pull하고, 외부로 Push하는 하이브리드 구조를 씁니다. 결국 환경 제약이 방식을 결정합니다.

Q. 카디널리티 폭발을 어떻게 방지합니까?

세 가지 방어선을 운용합니다. 첫째, 배포 파이프라인에 카디널리티 검증 단계를 추가해 새 메트릭의 예상 시계열 수가 임계값을 넘으면 리뷰를 요구합니다. 둘째, user_id, session_id, request_id 같은 고카디널리티 라벨을 화이트리스트로 금지합니다. 셋째, VictoriaMetrics의 시계열 수 알림을 설정해 특정 메트릭이 비정상적으로 증가하면 자동 알림을 받습니다.

추가 고려사항 펼치기 이미 폭발이 발생했다면 `delete_series` API로 문제 시계열을 즉시 삭제하고, Prometheus 스크레이프 설정에서 해당 라벨을 `metric_relabel_configs`로 제거합니다. 근본 원인 코드를 수정하기까지 시간이 걸린다면, 라벨 값을 버킷(예: 사용자 ID 대신 사용자 등급)으로 대체하는 임시 패치를 먼저 적용합니다.

Q. Consumer lag이 급증하면 어떻게 대응합니까?

먼저 lag 증가 속도를 확인합니다. 생산 속도가 일정한데 소비 속도가 떨어진 것이라면 TSDB Writer 병목입니다. 생산 속도 자체가 급증했다면 트래픽 폭증입니다. 전자라면 Writer 인스턴스를 즉시 추가(파티션 수 한도 내)합니다. 후자라면 에이전트 레벨 집계 주기를 늘려 초당 포인트 수를 줄이는 응급 처치를 하고, 중기적으로 Kafka 파티션과 Consumer 인스턴스를 함께 늘립니다.

추가 고려사항 펼치기 Consumer lag을 그 자체로 메트릭으로 수집해야 합니다. "메트릭 파이프라인의 메트릭"이 없으면 이 장애를 외부 알림이 아닌 사용자 불만으로 처음 인지하게 됩니다. Kafka JMX 메트릭을 Prometheus로 수집하고, `kafka_consumer_lag_sum > 500000` 알림을 항상 활성화해 두세요.

Q. 메트릭 시스템 자체가 장애일 때 어떻게 운영합니까?

메트릭 파이프라인은 관찰 대상 시스템과 물리적으로 분리된 인프라에서 운영하는 것이 원칙입니다. 같은 쿠버네티스 클러스터의 다른 네임스페이스가 아니라, 별도 클러스터 또는 별도 리전에 구성합니다. Prometheus 자체도 외부 Alertmanager를 통해 감시합니다. 최후 수단으로는 클라우드 제공업체의 managed 모니터링(AWS CloudWatch, GCP Cloud Monitoring)을 이중화 레이어로 운영합니다. 완전한 단일 장애점이 없는 구조가 목표입니다.

추가 고려사항 펼치기 "Dead Man's Switch" 패턴을 사용합니다. 메트릭 파이프라인이 정상이면 매분 PagerDuty에 "나 살아있음" 신호를 보내고, 신호가 3분간 오지 않으면 PagerDuty가 자동으로 알림을 발생시킵니다. 메트릭 시스템이 죽어서 알림을 못 보내는 역설적 상황을 외부에서 탐지하는 방법입니다.

10. 전체 아키텍처 요약

graph LR
    A["수집 에이전트"] --> B["Kafka 클러스터"]
    B --> C["TSDB Writer"]
    B --> D["알림 평가기"]
    C --> E["VictoriaMetrics"]
    D --> F["Alertmanager"]
    E --> G["Grafana"]
    F --> H["알림 채널"]
레이어 기술 선택 핵심 설정
수집 Prometheus Scrape + StatsD Push scrape_interval 15s, 라벨 화이트리스트
버퍼 Kafka (200 파티션, 2시간 보존) lz4 압축, acks=all, Consumer lag HPA
저장 VictoriaMetrics 단일 → 클러스터 10:1 압축, 15일 원시 + 90일 1분 집계
알림 Prometheus Alertmanager + 동적 기준선 3-시그마 이상 탐지, inhibit_rules 설정
시각화 Grafana + USE/RED 패널 Variable로 멀티 서비스 단일 대시보드

메트릭 파이프라인 설계의 핵심은 “수집·전송·저장·알림”의 네 계층이 서로 독립적으로 확장 가능해야 한다는 점입니다. 특정 계층이 병목이 되더라도 다른 계층에 영향을 주지 않도록 Kafka가 완충제 역할을 하고, 각 계층은 자신의 처리 속도에 맞게 동작합니다. 카디널리티 관리와 알림 피로도 방지는 기술 문제가 아니라 조직 문화와 프로세스 문제이므로, 배포 파이프라인과 리뷰 프로세스에 녹여 넣는 것이 지속 가능한 운영의 핵심입니다.


11. 시니어 리드의 비판적 분석 — 이 설계가 실패하는 순간들

설계 문서는 대부분 “잘 동작할 때”만 묘사합니다. 시니어 엔지니어가 읽어야 할 부분은 바로 이 장입니다. “이게 실패하면 어떻게 되는가”를 먼저 묻는 사람이 프로덕션을 살립니다.

11-1. TSDB 디스크 폭발 — 예상보다 10배 빠르게 찬다

앞서 규모 추정에서 “압축 후 432GB/일”이라고 계산했습니다. 이 숫자를 믿으면 안 됩니다. 실제 운영에서 TSDB 디스크는 세 가지 이유로 예측보다 훨씬 빠르게 찹니다.

첫째, 카디널리티가 증가하면 압축률이 떨어집니다. VictoriaMetrics의 10:1 압축은 시계열이 규칙적이고 연속적일 때의 수치입니다. 새 서비스가 배포되고 라벨이 추가될수록 비연속 시계열이 늘어나고, 실제 압축률은 3~5:1로 수렴하는 경우가 많습니다.

둘째, 인덱스 크기를 간과합니다. TSDB는 데이터 블록 외에 시계열 인덱스를 별도로 관리합니다. 활성 시계열 500만 개 기준, 인덱스만 수십 GB를 차지합니다. 이 비용은 데이터 보존 기간과 무관하게 계속 쌓입니다.

셋째, 다운샘플링이 원본을 즉시 삭제하지 않습니다. Recording Rule이 1분 집계를 생성해도 원본 15초 데이터는 보존 기간이 만료될 때까지 함께 존재합니다. 즉, 집계와 원본이 동시에 디스크를 점유하는 기간이 반드시 생깁니다.

현실적인 대응 전략:

계층 1 (Hot): NVMe SSD — 원시 데이터 15일
계층 2 (Warm): HDD — 1분 집계 90일
계층 3 (Cold): S3/GCS — 1시간 집계 3년

VictoriaMetrics Enterprise의 vmbackup이나 오픈소스 진영의 Thanos Object Storage를 활용하면 Cold 계층으로 자동 오프로드할 수 있습니다. 중요한 것은 이 전략을 초기 설계 시점에 결정하는 것입니다. 디스크가 80%를 넘은 뒤 마이그레이션하는 것은 서비스 중단 위험을 동반합니다.

비유: TSDB 디스크를 욕조라고 생각하세요. 수도꼭지(수집)는 항상 틀려있고, 배수구(만료 삭제)는 15일에 한 번만 열립니다. 욕조 크기를 15일치로만 계산하면 중간에 넘칩니다. 집계 데이터가 만료 전까지 같이 쌓이는 ‘중간 수위’를 항상 고려해야 합니다.


11-2. 카디널리티 폭발 — 라벨 하나가 시스템을 죽이는 구체적 시나리오

앞서 소개한 2023년 사례를 더 구체적으로 분석합니다. user_id 라벨 하나가 왜 그렇게 치명적인지 수치로 보겠습니다.

기존 메트릭: http_requests_total{method="GET", status="200"}
라벨 조합 수: 5 (method) × 3 (status) = 15개 시계열

user_id 추가 후: http_requests_total{method="GET", status="200", user_id="user_1234567"}
라벨 조합 수: 5 × 3 × 1,000,000 (사용자 수) = 1,500만 개 시계열

시계열 수가 1,000배 증가합니다. TSDB는 각 시계열에 대해 인덱스 엔트리, 청크 메타데이터, WAL 항목을 유지합니다. VictoriaMetrics 기준으로 시계열당 약 1KB의 메모리가 필요하므로, 1,500만 시계열은 약 15GB의 메모리를 추가로 요구합니다. 서버 메모리가 부족해지면 TSDB가 OOM으로 죽거나, GC 압력으로 쿼리 응답이 수십 초로 늘어납니다.

덜 알려진 위험한 라벨들:

라벨 이름 왜 위험한가 대안
user_id 사용자 수만큼 폭발 user_tier (vip/general/trial)
request_id 요청마다 유일 로그에 기록, 메트릭 제외
error_message 오류 메시지 문자열 다양성 error_code (enum)
pod_name 파드 재시작마다 새 값 deployment 또는 service
version + commit_hash 배포마다 새 조합 version만 유지

pod_name이 특히 함정입니다. 쿠버네티스에서 파드가 재시작되면 이름이 바뀝니다. 파드가 100번 재시작되면 100개의 “죽은” 시계열이 쌓이고, 이것들은 보존 기간이 끝날 때까지 메모리를 점유합니다.

graph LR
    A["user_id 라벨 추가"] --> B["시계열 1000배 증가"]
    B --> C["TSDB 메모리 고갈"]
    C --> D["쿼리 응답 수십 초"]
    D --> E["대시보드 마비"]
    E --> F["장애 감지 불가"]

11-3. 알림 폭풍 — 알림이 많을수록 아무도 보지 않는다

인프라 노드 하나가 다운됐다고 가정합니다. 그 노드에서 10개의 서비스가 실행 중이었고, 각 서비스마다 5개의 알림 규칙이 있다면 이론적으로 50개의 알림이 동시에 발화합니다. Slack 채널에 50개의 메시지가 1분 안에 쏟아지면 엔지니어는 어느 것부터 봐야 할지 알 수 없습니다.

알림 폭풍 방어의 3단계:

1단계 — 그룹핑 (Grouping): 같은 근본 원인에서 나온 알림을 하나의 메시지로 묶습니다. Alertmanager의 group_by가 이 역할을 합니다. group_by: ["cluster", "alertname"]으로 설정하면 같은 클러스터에서 발생한 동일 유형 알림이 하나의 Slack 메시지로 집약됩니다.

2단계 — 억제 (Inhibition): 근본 원인 알림이 발화하면 파생 알림을 자동으로 침묵시킵니다. NodeDown 알림이 발화하면 그 노드의 모든 서비스 알림을 억제하는 규칙이 대표적입니다.

3단계 — 에스컬레이션 (Escalation): 알림의 심각도에 따라 도달 경로를 다르게 합니다.

Warning → Slack 채널 (업무 시간 중 확인)
Critical → PagerDuty 즉시 호출
Critical + 15분 미해결 → 팀 리드 추가 호출
Critical + 30분 미해결 → 임원 에스컬레이션

Alertmanager의 repeat_interval과 다단계 라우팅으로 구현합니다. 중요한 원칙은 “증상 기반 알림”을 “원인 기반 알림”보다 우선하는 것입니다. “디스크 80% 사용”보다 “쿼리 응답 시간 500ms 초과”가 더 중요한 알림입니다. 엔지니어가 행동을 취해야 하는 알림만 발화해야 합니다.

비유: 소방서에 연기 감지기, 열 감지기, 화재 감지기가 각각 울린다면 소방관은 어느 것부터 대응해야 할지 혼란스럽습니다. 실제 소방 대응 프로토콜은 “불꽃 감지 = 즉시 출동”, 나머지는 확인 후 결정입니다. 알림도 마찬가지입니다. 서비스 중단(불꽃)과 디스크 경고(연기)는 다른 채널로 가야 합니다.


11-4. Kafka 파이프라인 지연 — 조용히 진행되는 장애 감지 실패

Kafka Consumer lag이 쌓이는 상황은 단순히 “메트릭이 늦게 도착”하는 것이 아닙니다. 연쇄 실패 체인이 작동합니다.

graph LR
    A["Consumer lag 증가"] --> B["메트릭 TSDB 지연"]
    B --> C["알림 규칙 평가 지연"]
    C --> D["임계값 초과해도 알림 미발화"]
    D --> E["장애 감지 실패"]
    E --> F["사용자 불만으로 첫 인지"]

이 체인의 무서운 점은 각 단계가 정상처럼 보인다는 것입니다. Kafka 브로커는 살아 있고, TSDB는 쿼리에 응답하며, Alertmanager도 동작합니다. 단지 데이터가 10분 전 것일 뿐입니다. Grafana 대시보드의 그래프가 “지금”이 아니라 “10분 전”을 보여주는 동안, 실제 서비스에서는 에러율이 20%를 넘고 있을 수 있습니다.

핵심 방어 메트릭 — Consumer lag 자체를 모니터링:

# 메트릭 파이프라인 헬스 알림 (메타 모니터링)
- alert: MetricsPipelineLagCritical
  expr: kafka_consumer_lag_sum{group="metrics-tsdb-writer"} > 1000000
  for: 5m
  labels:
    severity: critical
    meta: "true"   # 메타 알림 표시: 모니터링 시스템 자체의 알림
  annotations:
    summary: "메트릭 파이프라인 10분치 이상 지연  알림 신뢰 불가"
    description: "현재 TSDB의 데이터는  이전 값입니다.  시간 동안 발생한 장애는 감지되지 않았을  있습니다."

lag 알림은 다른 채널로 보내야 합니다. lag이 심각하면 알림 파이프라인 자체가 지연 중이므로, 같은 경로로 보낸 알림은 늦게 도착합니다. Dead Man’s Switch 패턴과 결합해, 메트릭 파이프라인 헬스 신호를 외부 채널(예: 클라우드 모니터링 서비스)로 별도 송출해야 합니다.


11-5. Prometheus Pull 모델의 구조적 한계

Pull 방식을 선택했을 때 반드시 직면하는 두 가지 한계입니다.

한계 1 — 방화벽 뒤 서비스:

클라우드 네이티브 환경에서 모든 서비스가 같은 네트워크에 있다고 가정하지만, 현실은 다릅니다. 온프레미스 레거시 서버, DMZ 구간의 서비스, 멀티 클라우드 환경에서는 Prometheus 서버가 /metrics 엔드포인트에 직접 접근할 수 없습니다. 방화벽 규칙 변경이 어렵거나 보안 정책상 불가한 경우, 대안은 두 가지입니다.

  • Prometheus Pushgateway: 에이전트가 Pushgateway에 Push → Prometheus가 Pushgateway를 Pull. 단, Pushgateway는 상태를 유지하므로 에이전트가 죽어도 마지막 값이 계속 노출되는 “좀비 메트릭” 문제가 생깁니다.
  • Alloy / Telegraf: Grafana Alloy(구 Grafana Agent)를 방화벽 안에 설치해 내부에서 수집 후 외부 TSDB로 Remote Write. Pull의 장점 없이 사실상 Push와 동일합니다.

한계 2 — 짧은 수명의 Job:

배치 작업, 서버리스 함수, CI/CD 파이프라인 같은 짧은 수명의 프로세스는 Prometheus 스크레이프 주기(15초~1분) 사이에 시작하고 끝납니다. Prometheus가 스크레이핑하러 갔을 때 프로세스가 이미 종료됐다면 메트릭을 수집할 수 없습니다.

배치 작업 실행 시간: 5초
Prometheus 스크레이프 주기: 15초

→ 배치 작업이 스크레이프 직후 시작하면 다음 스크레이프까지 15초를 기다려야 하는데, 그때 이미 종료됨
→ 해당 배치 실행의 메트릭: 0건 수집

이 경우 Pushgateway를 사용하되, 작업 완료 시 명시적으로 Push하고 TTL을 설정해 좀비 메트릭을 방지해야 합니다. 또는 해당 데이터를 메트릭이 아닌 이벤트 로그로 처리하는 것이 더 자연스럽습니다.


12. 동시성과 데이터 경합 — 시계열 DB는 왜 락이 필요 없는가

12-1. 시계열 DB의 append-only 구조

TSDB가 동시 쓰기에서도 락을 최소화할 수 있는 이유는 append-only 설계에 있습니다.

일반 관계형 DB는 같은 행을 여러 트랜잭션이 동시에 수정할 수 있으므로 행 레벨 락이 필요합니다. 시계열 DB는 다릅니다. 각 타임스탬프는 단 한 번만 기록되고, 과거 값은 절대 덮어쓰지 않습니다. (metric_name, labels, timestamp) 조합은 전 세계에서 유일하며, 두 프로세스가 동일한 키에 동시에 쓸 가능성이 없습니다.

시계열 DB 쓰기:
t=1000: {cpu_usage, host="a"} = 0.75  ← 한 번 기록, 불변
t=1001: {cpu_usage, host="a"} = 0.78  ← 새 타임스탬프, 새 항목
t=1002: {cpu_usage, host="a"} = 0.80  ← 또 새 항목

→ 동일 키에 동시 쓰기 = 불가능한 시나리오
→ 행 레벨 락 불필요

단, 인덱스 쓰기는 예외입니다. 새로운 시계열(새 라벨 조합)이 처음 등록될 때 인덱스를 업데이트해야 합니다. 이 시점에는 짧은 락이 필요하지만, 기존 시계열에 대한 데이터 쓰기와는 분리됩니다. VictoriaMetrics는 이 인덱스 쓰기를 WAL(Write Ahead Log)로 처리해 충돌을 최소화합니다.

12-2. 알림 중복 발송 — 분산 환경에서 같은 알림이 두 번 울린다

Alertmanager를 HA(고가용성) 구성으로 3개 인스턴스 운영할 때, 같은 알림이 세 인스턴스 모두에서 평가됩니다. 만약 중복 제거 로직이 없다면 PagerDuty에 동일한 인시던트가 3번 생성됩니다.

Alertmanager는 이를 Gossip 프로토콜로 해결합니다. 각 인스턴스가 “나는 이 알림을 이미 발송했다”를 클러스터 전체에 브로드캐스트합니다. 다른 인스턴스는 이 정보를 받아 중복 발송을 건너뜁니다.

# alertmanager.yml - HA 클러스터 설정
# 세 인스턴스가 서로를 인식하고 Gossip 동기화
cluster:
  listen-address: "0.0.0.0:9094"
  peers:
    - "alertmanager-1:9094"
    - "alertmanager-2:9094"
    - "alertmanager-3:9094"
  peer-timeout: 15s
  # settle-timeout: 클러스터 안정화 대기 시간 (재시작 후 중복 발송 방지)
  settle-time: 1m

주의할 점은 Gossip 동기화에 수 초의 지연이 있다는 것입니다. 한 인스턴스가 발송하고 다른 인스턴스가 동기화 정보를 받기 전에 평가를 실행하면 중복이 발생할 수 있습니다. 이것이 group_wait를 30초 이상으로 설정하는 이유 중 하나입니다.

12-3. Recording Rule 실행 중 원본 데이터 삭제 시 경합

Recording Rule은 원본 메트릭을 집계해 새 시계열을 생성합니다. 만약 원본 데이터의 보존 기간이 만료돼 삭제되는 시점에 Recording Rule이 해당 구간을 집계하려 한다면 어떻게 될까요?

TSDB는 이 경합을 읽기-삭제 순서 보장으로 처리합니다. 보존 기간 만료 데이터 삭제는 배치로, 정해진 시간 간격(VictoriaMetrics 기본 1시간)에만 실행됩니다. Recording Rule은 실행 시점의 스냅샷을 읽으므로, 삭제 배치가 시작되기 전에 완료된 Rule 평가는 항상 완전한 데이터를 봅니다.

실제로 문제가 생기는 경우는 다운샘플링 체인에서 중간 집계가 생성되기 전에 원본이 삭제될 때입니다. 예를 들어 원본 보존 기간이 15일인데, 15일치 원본을 1시간 집계로 만드는 Recording Rule이 14일 만에 실패했다면, 이후 1시간 집계 데이터에 구멍이 생깁니다. 이를 방지하려면 다운샘플링 Recording Rule의 실행 성공률을 메트릭으로 추적해야 합니다.


13. 오버엔지니어링 경고 — 규모에 맞는 도구를 써야 한다

“복잡한 시스템을 구축하는 능력보다, 단순한 시스템으로 충분하다는 판단이 더 어렵습니다.” — 시니어 엔지니어의 가장 중요한 역량은 멈추는 것입니다.

서버 규모별 적정 스택

이 포스트에서 설명한 Kafka + VictoriaMetrics + Alertmanager 스택은 초당 100만 포인트 규모에 맞춰 설계됐습니다. 규모가 다르면 도구도 달라야 합니다.

graph LR
    A["서버 5대"] --> B["로그 파일 + tail + grep"]
    C["서버 50대"] --> D["Prometheus + Grafana"]
    E["서버 500대 이상"] --> F["전체 파이프라인 필요"]

서버 5대 이하 — 로그와 grep으로 충분:

서버 5대짜리 스타트업에서 Kafka 클러스터를 구축하는 것은 엔진이 하나인 자전거에 항공기용 제트 터빈을 달려는 것과 같습니다. journalctl, tail -f, grep으로 모든 로그를 실시간으로 볼 수 있습니다. Prometheus 설치조차 과잉입니다. Datadog Free Tier나 Grafana Cloud 무료 플랜을 쓰는 것이 훨씬 현명합니다.

서버 50대 이하 — Prometheus + Grafana로 충분:

Prometheus 단일 인스턴스는 초당 수십만 포인트까지 처리합니다. 서버 50대 규모라면 Kafka 파이프라인 없이 Prometheus Remote Write → VictoriaMetrics로 직접 연결해도 됩니다. Kafka를 도입하는 순간 운영해야 할 컴포넌트가 두 배로 늘고, Kafka 자체의 장애가 새로운 장애 포인트가 됩니다.

서버 500대 이상 — 그제서야 전체 파이프라인:

초당 수십만~수백만 포인트가 발생하고, 다운스트림 소비자(TSDB, 알림, 이상 탐지, 장기 아카이브)가 여럿일 때 Kafka 파이프라인이 진가를 발휘합니다.

규모 권장 스택 Kafka 필요?
서버 1~5대 로그 파일 + grep + 클라우드 무료 플랜 불필요
서버 10~50대 Prometheus + Grafana + VictoriaMetrics 불필요
서버 100~300대 Prometheus + VictoriaMetrics + Alertmanager 선택적
서버 500대 이상 전체 파이프라인 (Kafka + 분리된 각 계층) 필수

“대시보드를 만드는 것보다 보는 사람이 더 중요하다”

메트릭 파이프라인 구축 프로젝트에서 가장 흔한 실패 패턴은 기술적으로 완벽한 파이프라인을 만들고 아무도 보지 않는 것입니다.

100개의 패널을 가진 Grafana 대시보드를 만들었습니다. 처음 두 주는 팀이 열심히 봅니다. 한 달 뒤에는 장애가 났을 때만 열어봅니다. 세 달 뒤에는 “저 대시보드 어떻게 들어가는 거였지?”가 됩니다.

이것은 기술 문제가 아닙니다. 다음 두 가지가 없으면 아무리 정교한 파이프라인도 무용지물입니다.

  1. 팀이 정기적으로 보는 루틴: 매일 스탠드업에서 핵심 메트릭 3개를 함께 봅니다. 대시보드 링크를 채널에 고정합니다.
  2. 알림에 행동 지침(Runbook)이 붙어 있는가: runbook: "https://wiki.internal/runbooks/high-error-rate" 링크가 없는 알림은 엔지니어가 무엇을 해야 할지 몰라 무시합니다.

“완벽한 모니터링 시스템이 있어도, 아무도 응답하지 않으면 없는 것과 같습니다. 알림 피로도를 줄이는 가장 좋은 방법은 행동이 필요 없는 알림을 없애는 것입니다.”


14. Kafka 심층 분석 — 메트릭 파이프라인 전용 고려사항

14-1. 메트릭 vs 로그 vs 트레이스 — 같은 Kafka 클러스터를 써야 하는가

세 종류의 관측 데이터(Observability Pillars)를 같은 Kafka 클러스터로 처리하면 비용 효율은 높지만, 한 쪽의 폭발이 다른 쪽을 죽입니다.

graph LR
    A["메트릭 토픽"] --> D["Kafka 클러스터"]
    B["로그 토픽"] --> D
    C["트레이스 토픽"] --> D
    D --> E["각 Consumer"]

로그는 트래픽 폭증 시 메트릭보다 훨씬 큰 폭발을 일으킵니다. 에러가 대량 발생하면 에러 로그가 수십 배 늘어나고, 이 폭발이 같은 클러스터의 메트릭 토픽에도 영향을 줍니다. Kafka 브로커가 네트워크 대역폭과 디스크 I/O를 로그 처리에 써버리면, 메트릭 Consumer의 응답이 느려지고 lag이 쌓입니다.

권장하는 분리 전략:

데이터 종류 특성 권장 분리 수준
메트릭 작은 크기, 높은 빈도, 엄격한 지연 요구 전용 클러스터 또는 전용 브로커
로그 큰 크기, 폭발적 증가 가능 전용 클러스터
트레이스 중간 크기, 샘플링 허용 메트릭과 공유 가능 (샘플링 적용 시)

서버 50대 이하에서는 단일 클러스터 + 토픽 분리로 시작해도 됩니다. 중요한 것은 메트릭 토픽에 QoS(우선순위) 설정을 적용해 메트릭 처리를 로그보다 우선시하는 것입니다.

14-2. 메트릭 폭주 시 Kafka 자체가 병목이 된다

초당 100만 포인트가 모두 Kafka에 개별 메시지로 도달하면 Kafka 자체가 병목이 됩니다. 100만 개의 작은 메시지는 Kafka에게 최악의 패턴입니다. 작은 메시지는 배치 효율이 낮고, 파티션당 오프셋 관리 오버헤드가 큽니다.

해결: Producer 배치 + 에이전트 로컬 집계

두 가지를 함께 적용해야 합니다.

1. 에이전트 레벨: 10초 동안 로컬에서 집계 → 100만 포인트 → 1만 개 집계값
2. Producer 레벨: linger.ms=10으로 1만 개도 배치로 묶어 전송 → 수백 개 Kafka 메시지

이렇게 하면 Kafka가 처리하는 메시지 수가 100만에서 수백으로 줄어듭니다. 대신 에이전트 장애 시 최대 10초치 데이터가 유실될 수 있다는 트레이드오프를 명시적으로 수용해야 합니다.

// Producer 배치 설정 — 작은 메시지 폭발 방지
Properties props = new Properties();
// linger.ms: 배치가 가득 차지 않아도 이 시간 후 전송
props.put("linger.ms", "10");
// batch.size: 배치 최대 크기 (이 크기에 도달하면 즉시 전송)
props.put("batch.size", "65536");  // 64KB
// compression.type: 배치 압축으로 네트워크 대역폭 절감
props.put("compression.type", "lz4");

// 결과:
// 10초 동안 100만 포인트 → 에이전트 집계 → 1만 집계값
// 1만 집계값 → Producer 배치 → 수백 개 Kafka 메시지 (각 64KB)
// Kafka 처리 부하: 1/1000 수준으로 감소

에이전트 크래시 시 시나리오:

에이전트가 집계 중 크래시되면 최대 집계 주기(10초) 분량의 데이터가 유실됩니다. 이것은 설계 선택입니다. “초당 100만 포인트를 실시간으로 모두 Kafka에 넣을 것인가” vs “최대 10초의 유실을 허용하고 Kafka 부하를 100분의 1로 줄일 것인가”. 운영 메트릭 파이프라인에서는 후자가 현실적입니다.


15. 면접 추가 질문 — 시니어 관점

Q. “메트릭 파이프라인 설계해보세요”라는 면접 질문에서 가장 먼저 물어야 할 것은?

규모입니다. 서버 5대인지 5,000대인지에 따라 설계가 완전히 달라집니다. 면접관이 “초당 100만 포인트”를 명시하지 않았다면 이 질문부터 해야 합니다. 그다음은 “메트릭 외에 로그와 트레이스도 같은 파이프라인으로 처리해야 하는가”입니다. 한 번에 전체 관측 파이프라인을 설계하는 것과 메트릭만 다루는 것은 복잡도가 3배 이상 차이납니다.

추가 고려사항 펼치기 "얼마나 오래 데이터를 보관해야 하는가"도 초기에 물어야 합니다. 7일 보관과 3년 보관은 Cold Storage 전략, 다운샘플링 계층 수, 비용 모두를 다르게 만듭니다. 비용 제약을 묻는 것도 좋습니다. 무한정 예산이 있다면 Datadog을 쓰면 되고, 비용이 제약이라면 자체 구축을 논의하면 됩니다.

Q. Recording Rule(다운샘플링 집계)이 실패하면 어떻게 되는가?

단기적으로는 원본 데이터가 남아있으므로 쿼리는 정상입니다. 단, 쿼리가 집계 시계열 대신 원본을 스캔해야 하므로 응답이 느려집니다. 장기적으로는 원본 보존 기간이 지나면 해당 구간의 고해상도 데이터가 영구 유실됩니다. 이를 방지하려면 Recording Rule 평가 성공률을 메트릭으로 추적하고, 실패 시 즉시 알림을 받아야 합니다.

추가 고려사항 펼치기 더 나쁜 시나리오는 Recording Rule이 "부분 성공"하는 경우입니다. 규칙이 실행되긴 했지만 일부 시계열을 누락한 채로 집계를 생성하면, 집계 데이터에 조용히 구멍이 생깁니다. 이후 집계 쿼리를 신뢰하고 의사결정했는데, 사실 데이터가 불완전했던 경우입니다. 다운샘플링 파이프라인에는 원본과 집계의 합계를 주기적으로 비교하는 검증 쿼리를 추가하는 것이 좋습니다.

Q. 카디널리티 폭발이 이미 발생했다면 서비스 중단 없이 복구할 수 있는가?

가능하지만 느립니다. 세 단계로 진행합니다. 첫째, 문제 라벨을 즉시 수집 중단합니다(metric_relabel_configs로 해당 라벨 드롭). 새 시계열 생성이 멈춥니다. 둘째, delete_series API로 폭발한 시계열을 삭제합니다. 이 작업은 TSDB의 백그라운드 GC로 처리되므로 메모리 회수까지 수 분~수십 분이 걸립니다. 셋째, 원인이 된 코드를 수정해 재배포합니다. 서비스 중단 없이 가능하지만, 회복 시간 동안 TSDB가 고부하 상태임을 감수해야 합니다.

추가 고려사항 펼치기 `delete_series`가 즉각적인 메모리 해제를 보장하지 않는다는 점이 함정입니다. VictoriaMetrics 내부적으로 삭제 마킹만 해두고, 실제 메모리 해제는 다음 합병(merge) 사이클에서 이루어집니다. 급박한 상황에서는 인스턴스를 재시작하는 것이 더 빠르게 메모리를 회수하는 방법일 수 있습니다. 단, 재시작 중 수집 중단과 Kafka lag 증가를 감수해야 합니다.

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →