Redis·ELK·Prometheus·Grafana·Jaeger로 서비스 운영 스택 구성하기

이 글의 핵심

로그, 메트릭, 트레이스는 서로 답하는 질문이 달라서 한 도구로 모두 해결하려 하면 비용이나 복잡도가 커집니다. 각 도구의 아키텍처와 설정 예시를 살펴본 뒤 종합 비교표와 선택 흐름으로 우리 서비스에 무엇이 먼저 필요한지 고르고, 전체 관측성 스택을 하나의 구성으로 조립하는 데까지 이어집니다.

들어가며: 현대 기술 스택의 필수 요소

서비스가 한 대의 서버에서 여러 대, 여러 서비스로 나뉘기 시작하면 “지금 느린가?”, “왜 느린가?”, “어느 요청이 실패했나?”라는 질문에 답하기가 급격히 어려워집니다. 메트릭은 “무언가 이상하다”를 가장 싸게 알려 주고, 로그는 “그 순간 무슨 일이 있었나”를 자세히 보여 주며, 트레이스는 “요청이 어느 서비스에서 시간을 썼나”를 연결해 줍니다. 세 가지는 서로를 대체하지 못하고, 저장 비용 구조도 크게 다릅니다. 여기에 DB 부하를 줄이는 캐시(Redis)까지 더하면 대부분의 서비스가 운영 단계에서 마주치는 기본 스택이 됩니다. 이 글은 각 도구가 어떤 질문에 답하는지, 그리고 처음 구성할 때 실제로 막히는 지점이 어디인지를 중심으로 정리합니다.

모든 도구를 한 번에 도입할 필요는 없습니다. 작은 서비스라면 Prometheus와 Grafana로 메트릭 대시보드와 알림을 먼저 갖추고, 로그는 클라우드 기본 로그 서비스나 Loki로 시작한 뒤, 서비스가 여러 개로 나뉘어 “어느 구간이 느린지”를 추적할 필요가 생길 때 트레이싱을 붙이는 순서가 운영 부담이 가장 적습니다. Elasticsearch는 강력하지만 JVM 힙, 샤드, 디스크 관리가 따라오므로 로그 검색이 정말 필요한지 먼저 따져 볼 만합니다.

다룰 기술 스택:

  • Redis: 인메모리 데이터 저장소 및 캐시
  • Elasticsearch: 분산 검색 및 분석 엔진
  • Kibana: 로그 시각화 및 분석
  • Prometheus: 메트릭 수집 및 모니터링
  • Grafana: 메트릭 대시보드
  • Jaeger: 분산 추적 (Tracing)

Redis: 초고속 인메모리 데이터베이스

Redis란?

Redis(Remote Dictionary Server)는 인메모리 키-값 저장소로, 캐싱, 세션 관리, 실시간 분석에 사용됩니다.

주요 특징

  • 속도: 마이크로초 단위 응답 시간
  • 자료구조: String, List, Set, Hash, Sorted Set, Stream
  • 영속성: RDB, AOF 스냅샷
  • 클러스터링: 샤딩 및 복제 지원

Redis 아키텍처

flowchart TB
    subgraph App[Application Servers]
        A1[App 1]
        A2[App 2]
        A3[App 3]
    end
    
    subgraph Redis_Cluster[Redis Cluster]
        M1["Master 1\nSlots: 0-5460"]
        M2["Master 2\nSlots: 5461-10922"]
        M3["Master 3\nSlots: 10923-16383"]
        
        S1[Replica 1]
        S2[Replica 2]
        S3[Replica 3]
        
        M1 --> S1
        M2 --> S2
        M3 --> S3
    end
    
    A1 --> M1
    A2 --> M2
    A3 --> M3

캐싱, 세션, Rate Limiting, 리더보드 예시

캐싱

import redis
import json
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# 캐시 읽기
def get_user(user_id):
    # 캐시 확인
    cached = r.get(f"user:{user_id}")
    if cached:
        return json.loads(cached)
    
    # DB에서 조회
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    
    # 캐시 저장 (10분)
    r.setex(f"user:{user_id}", 600, json.dumps(user))
    return user

이 패턴을 cache-aside(지연 로딩)라고 부르며, 캐시가 비어 있거나 Redis가 죽어도 DB에서 읽으면 되므로 가장 안전한 출발점입니다. 대신 두 가지 문제를 알고 써야 합니다. 첫째, 사용자 정보를 수정할 때 캐시를 지우지 않으면 최대 10분간 옛 데이터가 보입니다. 수정 코드에서 r.delete(f"user:{user_id}")를 호출하는 것이 보통이고, TTL은 “지우기를 잊었을 때 최대로 틀려도 되는 시간”이라는 안전망 역할을 합니다. 둘째, 인기 키의 TTL이 만료되는 순간 동시에 들어온 요청들이 모두 캐시 미스를 보고 DB로 몰리는 cache stampede가 생깁니다. 트래픽이 큰 키라면 TTL에 무작위 값을 더해 만료 시점을 흩뜨리거나, 한 요청만 DB를 읽게 하는 락을 둡니다. 또 if cached:는 빈 문자열이나 “없는 사용자”를 캐시한 경우를 구분하지 못하므로, 존재하지 않는 ID로 반복 요청하면 매번 DB를 치게 됩니다(cache penetration).

세션 관리

# 세션 저장
def create_session(user_id, session_data):
    session_id = generate_session_id()
    r.setex(
        f"session:{session_id}",
        3600,  # 1시간
        json.dumps({"user_id": user_id, **session_data})
    )
    return session_id
# 세션 조회
def get_session(session_id):
    data = r.get(f"session:{session_id}")
    return json.loads(data) if data else None

Rate Limiting

def check_rate_limit(user_id, limit=100, window=60):
    key = f"rate_limit:{user_id}"
    current = r.incr(key)
    
    if current == 1:
        r.expire(key, window)
    
    return current <= limit

고정 윈도(fixed window) 방식이라 구현은 짧지만, 윈도 경계 직전과 직후에 몰아서 보내면 짧은 시간에 한도의 두 배까지 통과한다는 한계가 있습니다. 더 큰 문제는 INCR과 EXPIRE가 별개의 명령이라는 점입니다. 첫 INCR 직후 프로세스가 죽거나 네트워크가 끊기면 TTL 없는 키가 남아, 해당 사용자는 영원히 한도 초과 상태가 됩니다. 두 명령을 pipeline(transaction=True)로 묶거나, 아래 Lua 스크립트처럼 서버 쪽에서 원자적으로 실행하는 것이 안전합니다. 정확한 슬라이딩 윈도가 필요하면 Sorted Set에 요청 시각을 쌓고 오래된 항목을 ZREMRANGEBYSCORE로 지우는 방식을 씁니다.

실시간 리더보드

# 점수 추가
r.zadd("leaderboard", {"user1": 1000, "user2": 1500})
# 상위 10명 조회
top10 = r.zrevrange("leaderboard", 0, 9, withscores=True)
for user, score in top10:
    print(f"{user}: {score}")

Redis 성능 팁

# ✅ Pipeline으로 네트워크 왕복 최소화
pipe = r.pipeline()
for i in range(1000):
    pipe.set(f"key:{i}", f"value:{i}")
pipe.execute()
# ✅ Lua 스크립트로 원자적 연산
lua_script = """
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current < tonumber(ARGV[1]) then
    return redis.call('SET', KEYS[1], ARGV[1])
end
return 0
"""
r.eval(lua_script, 1, "counter", 100)

Lua 스크립트에서 GET은 키가 없으면 false(Lua의 nil 대용)를 돌려주므로, tonumber(current)를 그대로 비교하면 첫 실행에서 attempt to compare nil with number 에러가 납니다. 위처럼 or '0'으로 기본값을 주는 것이 흔한 처리입니다. 스크립트가 실행되는 동안 Redis는 다른 명령을 처리하지 않으므로 원자성이 보장되지만, 같은 이유로 스크립트 안에서 큰 루프를 돌면 서버 전체가 멈춥니다. 파이프라인은 원자성 없이 네트워크 왕복만 줄이는 도구라는 점도 구분해야 합니다. 1000개의 SET을 파이프라인으로 보내면 왕복은 한 번이지만, 중간에 다른 클라이언트의 명령이 끼어들 수 있습니다.


ELK Stack: 로그 수집과 분석

ELK Stack이란?

ELK는 Elasticsearch, Logstash, Kibana의 조합으로, 로그 수집·저장·분석·시각화를 위한 통합 솔루션입니다.

flowchart LR
    subgraph Sources[로그 소스]
        App[Application]
        Web[Web Server]
        DB[Database]
    end
    
    subgraph Logstash[Logstash]
        Input[Input]
        Filter[Filter]
        Output[Output]
        Input --> Filter --> Output
    end
    
    subgraph Elasticsearch[Elasticsearch Cluster]
        ES1[Node 1]
        ES2[Node 2]
        ES3[Node 3]
    end
    
    subgraph Kibana[Kibana]
        Dashboard[Dashboard]
        Discover[Discover]
        Visualize[Visualize]
    end
    
    App --> Logstash
    Web --> Logstash
    DB --> Logstash
    
    Logstash --> ES1
    ES1 <--> ES2
    ES2 <--> ES3
    
    ES1 --> Kibana

Elasticsearch

분산 검색 및 분석 엔진으로, JSON 문서를 인덱싱하고 빠르게 검색합니다.

인덱스 생성 및 문서 추가

# 인덱스 생성
curl -X PUT "localhost:9200/logs" -H 'Content-Type: application/json' -d'
{
  "mappings": {
    "properties": {
      "timestamp": {"type": "date"},
      "level": {"type": "keyword"},
      "message": {"type": "text"},
      "user_id": {"type": "keyword"}
    }
  }
}'
# 문서 추가
curl -X POST "localhost:9200/logs/_doc" -H 'Content-Type: application/json' -d'
{
  "timestamp": "2026-04-01T10:00:00Z",
  "level": "ERROR",
  "message": "Database connection failed",
  "user_id": "user123"
}'

검색 쿼리

from elasticsearch import Elasticsearch
es = Elasticsearch(['http://localhost:9200'])
# 에러 로그 검색
response = es.search(
    index="logs",
    query={
        "bool": {
            "filter": [
                {"term": {"level": "ERROR"}},
                {"range": {"timestamp": {"gte": "now-1h"}}}
            ]
        }
    },
    sort=[{"timestamp": {"order": "desc"}}],
    size=100,
)
for hit in response['hits']['hits']:
    print(hit['_source'])

매핑에서 level을 keyword로, message를 text로 나눈 것이 핵심입니다. keyword는 값 전체를 하나의 토큰으로 저장해 정확 일치와 집계에 쓰이고, text는 분석기를 거쳐 단어 단위로 쪼개져 전문 검색에 쓰입니다. 매핑 없이 문서를 넣으면 Elasticsearch가 문자열 필드를 text와 .keyword 하위 필드 두 가지로 동적 매핑하므로 저장 공간이 늘고, 나중에 필드 타입을 바꾸려면 인덱스를 새로 만들어 reindex해야 합니다. 로그처럼 필드가 계속 늘어나는 데이터는 인덱스 템플릿으로 매핑을 미리 고정해 두는 것이 좋습니다.

level처럼 점수가 필요 없는 조건은 must 대신 filter에 넣으면 스코어 계산을 건너뛰고 결과가 캐시될 수 있습니다. Python 클라이언트 8.x부터는 body= 인자가 폐기 예정(deprecated)이 되어 query=, sort=처럼 키워드 인자로 넘기는 방식이 권장됩니다.

Logstash

데이터 수집 및 변환 파이프라인입니다.

Logstash 설정 예시

# logstash.conf
input {
  file {
    path => "/var/log/app/*.log"
    start_position => "beginning"
  }
  
  tcp {
    port => 5000
    codec => json
  }
}
filter {
  # JSON 파싱
  json {
    source => "message"
  }
  
  # 날짜 파싱
  date {
    match => ["timestamp", "ISO8601"]
  }
  
  # 필드 추가
  mutate {
    add_field => { "environment" => "production" }
  }
  
  # Grok 패턴으로 로그 파싱
  grok {
    match => { "message" => "%{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level} %{GREEDYDATA:msg}" }
  }
}
output {
  elasticsearch {
    hosts => ["localhost:9200"]
    index => "logs-%{+YYYY.MM.dd}"
  }
  
  stdout {
    codec => rubydebug
  }
}

위 설정은 JSON 로그와 텍스트 로그 처리를 한 파이프라인에 섞어 보여 주는 예시라서 실제로는 둘 중 하나만 쓰는 것이 보통입니다. 애플리케이션이 JSON으로 로그를 남긴다면 json 필터만으로 필드가 나오므로 grok이 필요 없고, 반대로 텍스트 로그에 json 필터를 걸면 모든 이벤트에 _jsonparsefailure 태그가 붙습니다. grok 패턴이 맞지 않을 때도 _grokparsefailure 태그가 붙으므로, Kibana에서 tags: _grokparsefailure로 검색해 보면 파싱에 실패한 로그를 빠르게 찾을 수 있습니다. 가능하다면 애플리케이션에서 처음부터 구조화된 JSON 로그를 남기는 것이 grok 정규식을 유지보수하는 것보다 훨씬 편합니다.

file 입력의 start_position => "beginning"은 Logstash가 처음 보는 파일에만 적용되고, 이후에는 sincedb 파일에 기록된 위치부터 읽습니다. 설정을 바꾸고 재시작했는데 예전 로그가 다시 들어오지 않는 것은 이 때문입니다. 실무에서는 각 서버에 가벼운 Filebeat를 두고 Logstash는 변환 전용으로 쓰는 구성이 일반적입니다(아래 관측성 스택 아키텍처 참고).

Kibana

Elasticsearch 데이터 시각화 도구입니다.

주요 기능

  1. Discover: 로그 검색 및 필터링
  2. Visualize: 차트, 그래프 생성
  3. Dashboard: 여러 시각화를 하나의 대시보드로
  4. Alerting: 조건 기반 알림

Kibana 쿼리 예시 (KQL)

# 에러 로그 검색
level: "ERROR"
# 특정 사용자의 로그
user_id: "user123" AND status: 500
# 느린 API 요청
response_time > 1000 AND endpoint: /api/*

KQL에서 따옴표로 감싼 값은 구문(phrase) 그대로 검색하므로 "/api/*"처럼 쓰면 *가 와일드카드가 아니라 문자 그대로 취급됩니다. 와일드카드를 쓰려면 따옴표 없이 적어야 합니다. 또 keyword 필드는 대소문자를 구분하므로 level: error로는 ERROR 로그가 검색되지 않습니다. Kibana 알림(Alerting)은 로그 조건으로 알림을 보낼 수 있지만, “5분간 에러 비율” 같은 수치 기반 알림은 Prometheus 쪽이 더 싸고 안정적입니다.


Prometheus: 메트릭 기반 모니터링

Prometheus란?

Prometheus는 시계열 데이터베이스 기반의 모니터링 및 알림 시스템입니다. Pull 모델로 메트릭을 수집합니다.

Prometheus 아키텍처

flowchart TB
    subgraph Targets[모니터링 대상]
        App1["App Server 1\n/metrics"]
        App2["App Server 2\n/metrics"]
        Node1[Node Exporter]
        DB[Database Exporter]
    end
    
    subgraph Prometheus[Prometheus Server]
        Scraper[Scraper]
        TSDB[Time Series DB]
        Rules[Alert Rules]
        
        Scraper --> TSDB
        TSDB --> Rules
    end
    
    subgraph Alerting[Alerting]
        AM[Alertmanager]
        Email[Email]
        Slack[Slack]
        PagerDuty[PagerDuty]
    end
    
    subgraph Visualization[시각화]
        Grafana[Grafana]
        PromUI[Prometheus UI]
    end
    
    App1 --> Scraper
    App2 --> Scraper
    Node1 --> Scraper
    DB --> Scraper
    
    Rules --> AM
    AM --> Email
    AM --> Slack
    AM --> PagerDuty
    
    TSDB --> Grafana
    TSDB --> PromUI

메트릭 타입

Counter (카운터)

증가만 하는 값 (요청 수, 에러 수)

from prometheus_client import Counter
request_count = Counter('http_requests_total', 'Total HTTP requests', ['method', 'endpoint'])
@app.route('/api/users')
def get_users():
    request_count.labels(method='GET', endpoint='/api/users').inc()
    return users

Gauge (게이지)

증가/감소하는 값 (CPU 사용률, 메모리)

from prometheus_client import Gauge
memory_usage = Gauge('memory_usage_bytes', 'Memory usage in bytes')
def update_metrics():
    import psutil
    memory_usage.set(psutil.virtual_memory().used)

Histogram (히스토그램)

값의 분포 (응답 시간, 요청 크기)

from prometheus_client import Histogram
response_time = Histogram('http_response_time_seconds', 'HTTP response time')
@app.route('/api/data')
@response_time.time()
def get_data():
    # 자동으로 응답 시간 측정
    return process_data()

Summary (요약)

분위수 계산 (P50, P95, P99)

from prometheus_client import Summary
latency = Summary('request_latency_seconds', 'Request latency')
@latency.time()
def process_request():
    # 처리
    pass

Histogram과 Summary는 비슷해 보이지만 결정적인 차이가 있습니다. Summary는 분위수를 각 프로세스 안에서 계산하므로, 서버 세 대의 p95를 평균 내도 전체 p95가 되지 않습니다. 분위수는 수학적으로 합치거나 평균 낼 수 없는 값이기 때문입니다. Histogram은 버킷별 개수만 내보내고 분위수는 Prometheus가 쿼리 시점에 계산하므로, 여러 인스턴스의 버킷을 더한 뒤 histogram_quantile을 적용할 수 있습니다. 서버가 여러 대라면 거의 항상 Histogram이 맞습니다. 대신 기본 버킷(5ms~10s)이 서비스의 실제 지연 분포와 맞지 않으면 분위수가 버킷 경계 사이에서 선형 보간된 부정확한 값이 되므로, buckets= 인자로 SLO 기준값(예: 100ms, 300ms) 주변에 버킷을 촘촘히 두는 것이 좋습니다. 또 Python 클라이언트는 Summary의 분위수를 아예 계산하지 않고 개수와 합계만 내보낸다는 점도 알아 둘 만합니다.

Prometheus 설정

# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s
scrape_configs:
  - job_name: 'node'
    static_configs:
      - targets: ['localhost:9100']
  
  - job_name: 'app'
    static_configs:
      - targets: ['localhost:8080']
    metrics_path: '/metrics'
  
  - job_name: 'kubernetes'
    kubernetes_sd_configs:
      - role: pod

PromQL 쿼리 예시

# CPU 사용률 (5분 평균, node_exporter 기준)
1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))
# 에러율 (지난 1시간)
sum(rate(http_requests_total{status=~"5.."}[1h])) / 
sum(rate(http_requests_total[1h]))
# P95 응답 시간 (모든 인스턴스 합산)
histogram_quantile(0.95, 
  sum by (le) (rate(http_response_time_seconds_bucket[5m])))
# 메모리 사용률
(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / 
node_memory_MemTotal_bytes * 100

PromQL에서 가장 흔한 실수는 Counter에 rate를 씌우지 않는 것입니다. http_requests_total의 원래 값은 프로세스 시작 이후 누적치라서, 재시작하면 0으로 떨어지고 그래프는 계단 모양이 됩니다. rate는 구간 내 증가율을 초당 값으로 바꾸면서 카운터 리셋도 자동으로 보정해 줍니다. 범위([5m])는 스크랩 간격의 최소 4배 정도로 잡는 것이 권장되며, 15초 간격에 [30s]처럼 짧게 주면 구간 안에 샘플이 두 개도 안 들어와 그래프가 끊깁니다. histogram_quantile에 sum by (le)를 빼면 인스턴스별 분위수가 각각 계산돼 선이 여러 개 나오고, le 레이블까지 집계해 버리면 histogram_quantile이 버킷을 찾지 못해 빈 결과가 나옵니다.

Alert Rules

# alert.rules.yml
groups:
  - name: example
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m])) /
          sum(rate(http_requests_total[5m])) > 0.05
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "High error rate detected"
          description: 'Error rate is {{ $value | humanizePercentage }} over the last 5m'
      
      - alert: HighMemoryUsage
        expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High memory usage on {{ $labels.instance }}"

for: 10m은 조건이 10분 연속 참이어야 알림을 보낸다는 뜻으로, 순간적인 스파이크마다 호출이 울리는 것을 막아 줍니다. 대신 그만큼 알림이 늦어지므로, 결제 실패처럼 즉시 알아야 하는 것은 짧게, 디스크 사용량처럼 천천히 변하는 것은 길게 잡습니다. $value는 식의 결과값(여기서는 0.05 같은 비율)이라서 그대로 %를 붙이면 “0.05%“처럼 오해를 부르는 메시지가 나갑니다. 위처럼 humanizePercentage 함수를 쓰면 “5%“로 표시됩니다. 처음 알림을 설계할 때 흔히 빠지는 함정은 CPU, 메모리 같은 원인 지표마다 알림을 거는 것입니다. 원인 지표 알림은 사용자에게 영향이 없는 상황에서도 자주 울려 곧 무시당하게 되므로, 에러율과 지연처럼 사용자가 체감하는 증상을 기준으로 알림을 걸고 원인 지표는 대시보드에서 보는 편이 오래 유지됩니다.


Grafana: 통합 대시보드

Grafana란?

Grafana는 메트릭, 로그, 트레이스를 시각화하는 오픈소스 플랫폼입니다. Prometheus, Elasticsearch, MySQL 등 다양한 데이터 소스를 지원합니다.

주요 기능

  1. 대시보드: 여러 패널을 조합한 시각화
  2. 알림: 임계값 기반 알림
  3. 템플릿: 변수를 사용한 동적 대시보드
  4. 플러그인: 다양한 데이터 소스 및 패널

Grafana 대시보드 예시

{
  "dashboard": {
    "title": "Application Monitoring",
    "panels": [
      {
        "title": "Request Rate",
        "targets": [
          {
            "expr": "rate(http_requests_total[5m])",
            "legendFormat": "{{method}} {{endpoint}}"
          }
        ],
        "type": "timeseries"
      },
      {
        "title": "Error Rate",
        "targets": [
          {
            "expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m]))"
          }
        ],
        "type": "stat",
        "thresholds": [
          {"value": 0, "color": "green"},
          {"value": 0.01, "color": "yellow"},
          {"value": 0.05, "color": "red"}
        ]
      },
      {
        "title": "Response Time (P95)",
        "targets": [
          {
            "expr": "histogram_quantile(0.95, sum by (le) (rate(http_response_time_seconds_bucket[5m])))"
          }
        ],
        "type": "timeseries"
      }
    ]
  }
}

Grafana 알림 설정

# Grafana Alert
apiVersion: 1
groups:
  - name: app-alerts
    interval: 1m
    rules:
      - uid: high-error-rate
        title: High Error Rate
        condition: A
        data:
          - refId: A
            queryType: ''
            relativeTimeRange:
              from: 600
              to: 0
            datasourceUid: prometheus-uid
            model:
              expr: 'rate(http_errors_total[5m]) > 10'
        noDataState: NoData
        execErrState: Alerting
        for: 5m
        annotations:
          summary: 'Error rate is too high'
        labels:
          severity: critical

대시보드 JSON을 손으로 작성하는 경우는 드뭅니다. 보통은 UI에서 만든 뒤 JSON으로 내보내 Git에 저장하고, 프로비저닝 디렉터리(/etc/grafana/provisioning/dashboards)로 자동 로드하게 합니다. 이렇게 해야 Grafana 컨테이너를 새로 띄워도 대시보드가 사라지지 않습니다. 위 JSON의 임계값은 요약을 위한 단순화된 형태이며, 실제 스키마에서는 fieldConfig.defaults.thresholds 아래에 들어갑니다. Prometheus 알림 규칙과 Grafana 알림을 모두 쓸 수 있는데, 같은 조건을 양쪽에 걸어 두면 알림이 중복으로 옵니다. 한쪽을 알림의 기준으로 정해 두는 것이 좋고, 여러 데이터 소스(로그와 메트릭)를 섞은 조건이 필요할 때 Grafana 알림이 장점을 가집니다.


Jaeger: 분산 추적

Jaeger란?

Jaeger는 마이크로서비스 환경에서 요청의 전체 흐름을 추적하는 분산 추적 시스템입니다.

Jaeger 아키텍처

flowchart TB
    subgraph Services[Microservices]
        S1[API Gateway]
        S2[Auth Service]
        S3[Order Service]
        S4[Payment Service]
    end
    
    subgraph Jaeger[Jaeger]
        Agent["OTLP 수신\n4317/4318"]
        Collector[Collector]
        Storage["Storage\nElasticsearch/Cassandra"]
        UI[Jaeger UI]
    end
    
    S1 --> Agent
    S2 --> Agent
    S3 --> Agent
    S4 --> Agent
    
    Agent --> Collector
    Collector --> Storage
    Storage --> UI

OpenTelemetry로 Tracing 구현

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Tracer 설정 (Jaeger는 OTLP를 직접 수신)
provider = TracerProvider(resource=Resource.create({"service.name": "order-api"}))
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="localhost:4317", insecure=True))
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
# Span 생성
@app.route('/api/order')
def create_order():
    with tracer.start_as_current_span("create_order") as span:
        span.set_attribute("user_id", user_id)
        span.set_attribute("order_id", order_id)
        
        # 하위 작업
        with tracer.start_as_current_span("check_inventory"):
            check_inventory(order_id)
        
        with tracer.start_as_current_span("process_payment"):
            process_payment(order_id)
        
        return {"order_id": order_id}

예전 자료에는 opentelemetry-exporter-jaeger로 Jaeger Agent(UDP 6831)에 보내는 코드가 많지만, 이 익스포터는 폐기되어 더 이상 배포되지 않고 Jaeger Agent 자체도 폐기 단계를 밟았습니다. Jaeger는 1.35부터 OTLP를 직접 받을 수 있으므로(초기 버전에서는 COLLECTOR_OTLP_ENABLED=true 환경 변수가 필요했습니다) 위처럼 표준 OTLP 익스포터로 보내면 되고, 나중에 백엔드를 Tempo나 상용 APM으로 바꿔도 애플리케이션 코드는 그대로 둘 수 있습니다. service.name을 지정하지 않으면 Jaeger UI의 서비스 목록에 unknown_service로 표시되어 여러 서비스를 구분할 수 없습니다.

직접 span을 여는 것은 비즈니스 단위 구간을 표시할 때만 필요합니다. HTTP 서버, HTTP 클라이언트, DB 드라이버 구간은 opentelemetry-instrumentation-flask, -requests, -psycopg2 같은 자동 계측 패키지가 만들어 주며, 서비스 간 호출에서 traceparent 헤더를 실어 보내는 컨텍스트 전파도 이들이 처리합니다. 처음 트레이싱을 붙였을 때 서비스마다 트레이스가 따로 끊겨 보인다면, 대개 호출하는 쪽 HTTP 클라이언트가 계측되지 않아 이 헤더가 전달되지 않은 경우입니다. 트래픽이 많은 서비스에서는 모든 요청을 저장하면 스토리지 비용이 급증하므로 샘플링 비율을 정해야 하는데, 에러가 난 요청만은 빠짐없이 남기고 싶다면 수집기(OpenTelemetry Collector)에서 tail 기반 샘플링을 설정합니다.

Trace 시각화

Jaeger UI에서 다음을 확인할 수 있습니다:

Trace: create_order (총 500ms)
├── check_inventory (100ms)
│   └── database_query (80ms)
├── process_payment (350ms)
│   ├── validate_card (50ms)
│   ├── charge_card (250ms)
│   └── send_receipt (50ms)
└── update_inventory (50ms)

다섯 가지 도구 비교와 선택

종합 비교표

기술목적데이터 타입지연시간저장 방식주요 사용 사례
Redis캐싱, 세션키-값마이크로초인메모리캐시, 세션, Rate Limiting
Elasticsearch검색, 분석JSON 문서밀리초디스크로그 검색, 전문 검색
Prometheus메트릭 수집시계열초 단위디스크CPU, 메모리, 요청 수 모니터링
Grafana시각화---대시보드, 알림
Jaeger분산 추적Span밀리초디스크마이크로서비스 추적

선택 플로우차트

flowchart TD
    Start[무엇을 하고 싶나요?] --> Q1{데이터 타입은?}
    
    Q1 -->|로그| Q2{검색 필요?}
    Q2 -->|Yes| ELK[Elasticsearch + Kibana]
    Q2 -->|No| Loki[Loki + Grafana]
    
    Q1 -->|메트릭| Q3{시계열 데이터?}
    Q3 -->|Yes| Prom[Prometheus + Grafana]
    Q3 -->|No| InfluxDB[InfluxDB]
    
    Q1 -->|캐시/세션| Q4{영속성 필요?}
    Q4 -->|Yes| Redis_AOF[Redis + AOF]
    Q4 -->|No| Redis_Mem[Redis 인메모리]
    
    Q1 -->|분산 추적| Jaeger[Jaeger/Zipkin]

관측성 스택 아키텍처와 Docker Compose

완전한 관측성(Observability) 스택

flowchart TB
    subgraph App[Application Layer]
        API[API Server]
        Worker[Background Worker]
        Service[Microservices]
    end
    
    subgraph Logs[로그 수집]
        Filebeat[Filebeat]
        Logstash[Logstash]
        ES[Elasticsearch]
        Kibana[Kibana]
    end
    
    subgraph Metrics[메트릭 수집]
        Prometheus[Prometheus]
        Grafana[Grafana]
    end
    
    subgraph Traces[분산 추적]
        Jaeger[Jaeger]
    end
    
    subgraph Cache[캐싱]
        Redis[Redis]
    end
    
    API --> Filebeat
    Worker --> Filebeat
    Service --> Filebeat
    Filebeat --> Logstash
    Logstash --> ES
    ES --> Kibana
    
    API --> Prometheus
    Worker --> Prometheus
    Service --> Prometheus
    Prometheus --> Grafana
    
    API --> Jaeger
    Service --> Jaeger
    
    API --> Redis
    Service --> Redis

Docker Compose 예시

services:
  # Redis
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes
  
  # Elasticsearch
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false  # 로컬 실습 전용
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"
    volumes:
      - es-data:/usr/share/elasticsearch/data
  
  # Kibana
  kibana:
    image: docker.elastic.co/kibana/kibana:8.12.0
    ports:
      - "5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    depends_on:
      - elasticsearch
  
  # Prometheus
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
  
  # Grafana
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana
    depends_on:
      - prometheus
  
  # Jaeger
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"  # UI
      - "4317:4317"    # OTLP gRPC
      - "4318:4318"    # OTLP HTTP
volumes:
  redis-data:
  es-data:
  prometheus-data:
  grafana-data:

이 Compose 파일은 로컬에서 흐름을 확인하기 위한 구성이며, 처음 띄울 때 막히는 지점이 몇 군데 있습니다. Elasticsearch 8.x는 보안 기능이 기본으로 켜져 있어 HTTPS와 인증서, elastic 계정 비밀번호를 요구합니다. 위처럼 xpack.security.enabled=false를 주지 않으면 http://elasticsearch:9200으로 접속하려는 Kibana가 연결에 실패하고 브라우저에는 “Kibana server is not ready yet”만 계속 보입니다. 이 설정은 실습용일 뿐이며, 외부에 노출되는 환경에서는 절대 끄면 안 됩니다. Linux 호스트에서는 Elasticsearch가 max virtual memory areas vm.max_map_count [65530] is too low 에러로 곧바로 종료되기도 하는데, 호스트에서 sysctl -w vm.max_map_count=262144를 설정하면 해결됩니다.

Compose v2에서는 version: 키가 더 이상 쓰이지 않아 넣으면 “the attribute version is obsolete” 경고만 나오므로 뺐습니다. latest 태그는 어느 날 메이저 버전이 올라가 설정이 깨질 수 있으므로 운영에서는 버전을 고정하는 것이 좋습니다. Redis 포트 6379를 인증 없이 외부에 열어 두면 인터넷 스캐너가 곧바로 찾아내므로, 실제 서버에서는 ports를 빼고 내부 네트워크로만 접근하게 해야 합니다. Grafana의 GF_SECURITY_ADMIN_PASSWORD=admin도 같은 이유로 실습 전용입니다.


Redis·Elasticsearch·Prometheus 운영 수칙

Redis 운영 수칙

적절한 만료 시간 설정

# ✅ 캐시에는 항상 TTL 설정
r.setex("user:123", 600, json.dumps(user_data))  # 10분
# ❌ TTL 없으면 메모리 부족 위험
r.set("user:123", json.dumps(user_data))

키 네이밍 규칙

# ✅ 명확한 네이밍
"user:123:profile"
"session:abc123"
"cache:product:456"
# ❌ 모호한 네이밍
"u123"
"s1"

대용량 데이터 처리

# ✅ SCAN으로 안전하게 순회
cursor = 0
while True:
    cursor, keys = r.scan(cursor, match="user:*", count=100)
    for key in keys:
        process(key)
    if cursor == 0:
        break
# ❌ KEYS는 프로덕션에서 금지 (블로킹)
keys = r.keys("user:*")  # 위험!

Elasticsearch 운영 수칙

인덱스 라이프사이클 관리

{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": {
            "max_size": "50GB",
            "max_age": "1d"
          }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "shrink": {"number_of_shards": 1}
        }
      },
      "delete": {
        "min_age": "30d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

샤드 크기 최적화

# 샤드당 20-40GB 권장
# 인덱스 크기 100GB → 샤드 3-5개

쿼리 최적화

# ✅ 필터 컨텍스트 사용 (캐싱)
{
  "query": {
    "bool": {
      "filter": [
        {"term": {"status": "active"}},
        {"range": {"timestamp": {"gte": "now-1h"}}}
      ]
    }
  }
}
# ❌ 쿼리 컨텍스트 (스코어 계산 오버헤드)
{
  "query": {
    "match": {"status": "active"}
  }
}

Prometheus 운영 수칙

레이블 카디널리티 주의

# ✅ 낮은 카디널리티
http_requests_total{method="GET", endpoint="/api/users"}
# ❌ 높은 카디널리티 (user_id는 수백만 개)
http_requests_total{user_id="123456"}  # 위험!

적절한 스크랩 간격

# 일반 애플리케이션: 15-30초
scrape_interval: 15s
# 고빈도 메트릭: 5초
scrape_interval: 5s
# 저빈도 메트릭: 1분
scrape_interval: 1m

Recording Rules로 쿼리 최적화

# 자주 사용하는 쿼리를 미리 계산
groups:
  - name: aggregations
    interval: 30s
    rules:
      - record: job:http_requests:rate5m
        expr: sum(rate(http_requests_total[5m])) by (job)
      
      - record: job:http_errors:rate5m
        expr: sum(rate(http_requests_total{status=~"5.."}[5m])) by (job)

전자상거래 모니터링과 로그 기반 알림 예시

시나리오 1: 전자상거래 모니터링

# 애플리케이션 메트릭
from prometheus_client import Counter, Histogram, Gauge
import redis
import logging
# Redis 캐시
cache = redis.Redis(host='localhost', port=6379)
# Prometheus 메트릭
order_count = Counter('orders_total', 'Total orders', ['status'])
order_amount = Histogram('order_amount_dollars', 'Order amount')
active_users = Gauge('active_users', 'Currently active users')
# Elasticsearch 로깅
logger = logging.getLogger('app')
logger.addHandler(LogstashHandler('localhost', 5000))
@app.route('/api/order', methods=['POST'])
def create_order():
    # 캐시 확인
    user = cache.get(f"user:{user_id}")
    if not user:
        user = db.get_user(user_id)
        cache.setex(f"user:{user_id}", 600, json.dumps(user))
    
    # 주문 처리
    order = process_order(request.json)
    
    # 메트릭 기록
    order_count.labels(status='success').inc()
    order_amount.observe(order['amount'])
    
    # 로그 기록
    logger.info(f"Order created: {order['id']}", extra={
        'user_id': user_id,
        'order_id': order['id'],
        'amount': order['amount']
    })
    
    return jsonify(order)

시나리오 2: 로그 기반 알림

# Logstash 필터로 에러 감지 후 Slack 알림
filter {
  if [level] == "ERROR" {
    mutate {
      add_tag => ["alert"]
    }
  }
}
output {
  if "alert" in [tags] {
    http {
      url => "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
      http_method => "post"
      format => "json"
      content_type => "application/json"
      message => '{"text": "🚨 Error detected: %{message}"}'
    }
  }
}

관측성 스택 요약

레이어기술역할
캐싱Redis데이터 캐싱, 세션 관리, Rate Limiting
로그ELK Stack로그 수집, 검색, 분석, 시각화
메트릭Prometheus시스템/애플리케이션 메트릭 수집
시각화Grafana통합 대시보드, 알림
추적Jaeger분산 요청 추적, 성능 분석

관측성의 3가지 기둥

flowchart TB
    Observability["관측성\nObservability"]
    
    Logs["로그\nLogs"]
    Metrics["메트릭\nMetrics"]
    Traces["추적\nTraces"]
    
    Observability --> Logs
    Observability --> Metrics
    Observability --> Traces
    
    Logs --> ELK[ELK Stack]
    Metrics --> Prom[Prometheus + Grafana]
    Traces --> Jaeger[Jaeger]

시작 가이드

1단계: 기본 스택 구축

# Docker Compose로 한 번에 실행
docker-compose up -d
# 접속 URL
# Kibana: http://localhost:5601
# Grafana: http://localhost:3000
# Prometheus: http://localhost:9090
# Jaeger: http://localhost:16686

2단계: 애플리케이션 계측

# 1. Redis 캐싱 추가
# 2. Prometheus 메트릭 노출
# 3. Structured 로깅 (JSON)
# 4. OpenTelemetry Tracing

3단계: 대시보드 구성

1. Grafana에서 Prometheus 데이터 소스 추가
2. 주요 메트릭 대시보드 생성
3. Kibana에서 로그 인덱스 패턴 설정
4. Jaeger에서 서비스 추적 확인

자주 묻는 질문 (FAQ)

Q. Prometheus 메트릭에 user_id 같은 레이블을 붙이면 왜 안 되나요?

A. Prometheus는 레이블 값 조합마다 별도의 시계열을 만들기 때문에, 수백만 개가 될 수 있는 user_id를 레이블로 쓰면 시계열 수가 폭증해 메모리와 쿼리 성능이 무너집니다. method나 endpoint처럼 값의 종류가 적은 레이블만 두고, 사용자 단위 분석은 로그(ELK)나 트레이스(Jaeger) 쪽에서 하는 것이 맞습니다. 자주 쓰는 집계는 Recording Rules로 미리 계산해 두면 대시보드 부하도 줄일 수 있습니다.

참고 자료


같이 보면 좋은 글