Skip to content

필사 모드: 로그를 검색 가능하게, 그리고 파산하지 않게 — 구조화, 매핑 폭발, 보존, 그리고 진짜 비용

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 로그 비용이 컴퓨트 비용을 넘어선 날

분기 인프라 비용을 정리하다가 로그 저장과 인덱싱이 애플리케이션 컴퓨트보다 비싸다는 사실을 발견하는 일은 드물지 않습니다. 그리고 그 로그의 대부분은 아무도 검색한 적이 없습니다.

원인은 대개 압축률이나 스토리지 단가가 아닙니다. 처음에 "일단 다 넣자"로 시작해서, 필드가 늘어나고, 인덱스가 커지고, 보존 기간을 줄이자니 감사 요건이 걸리고, 그러다 클러스터가 불안정해지는 경로입니다.

이 글은 그 경로를 되짚어 각 지점에서 무엇을 결정했어야 하는지를 다룹니다. OpenSearch 3.5와 OpenTelemetry Collector v0.157.0 기준으로 확인했습니다. Elasticsearch 계열도 개념은 같지만 ISM에 해당하는 기능 이름이 ILM으로 다릅니다.

로그가 답하는 질문, 답하지 못하는 질문

먼저 경계를 긋습니다. 로그는 개별 이벤트의 기록이므로 "왜"에 답합니다. 어떤 값이 들어와서 어떤 분기를 탔고 어떤 예외가 났는지는 로그만 알고 있습니다.

반대로 로그가 답하기에 비싼 질문들이 있습니다.

질문적합한 신호로그로 하면
이 요청이 왜 실패했는가로그정확히 이 용도입니다
지금 에러율이 몇 퍼센트인가메트릭매번 전체 스캔, 비용이 질의 횟수에 비례
어제와 비교해 어떤가메트릭보존 기간이 짧아 비교 대상이 없는 경우가 많음
요청이 어느 서비스에서 느려졌는가트레이스서비스별 로그를 시간으로 짝짓는 추측
30일 전 특정 주문의 처리 내역로그이것도 로그의 용도입니다

로그를 줄이는 첫 번째 방법은 압축이 아니라 다른 신호로 옮길 수 있는 질문을 옮기는 것입니다.

구조화 로그 — 무엇을 필드로 만들 것인가

구조화 로그는 문자열 대신 키와 값을 씁니다. 이유는 파싱 비용이 아니라 검색 가능성입니다.

# 나쁨 — 검색하려면 정규식이 필요하고, 포맷이 바뀌면 그 정규식이 깨진다
log.info(f"order {order_id} for tenant {tenant} failed after {ms}ms: {err}")

# 좋음 — 필드로 검색하고 집계한다
log.info(
    "order.failed",
    extra={
        "order.id": order_id,
        "tenant.id": tenant,
        "duration_ms": ms,
        "error.type": type(err).__name__,
        "error.message": str(err)[:500],
        "http.route": route,
    },
)

이벤트 이름을 메시지 자리에 넣은 것이 핵심입니다. order.failed 처럼 값이 유한한 이름을 쓰면 그것으로 그룹핑과 집계가 됩니다. 메시지에 ID가 섞이면 같은 사건이 전부 다른 문자열이 되어 집계가 불가능합니다.

필드 이름은 처음부터 규약을 정합니다. OpenTelemetry 시맨틱 규약을 그대로 쓰면 로그와 트레이스의 속성 이름이 일치해서 상관 조회가 쉬워집니다.

{
  "@timestamp": "2026-08-02T04:11:52.418Z",
  "severity_text": "ERROR",
  "severity_number": 17,
  "body": "order.failed",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "service.name": "checkout-api",
  "service.version": "2.7.1",
  "deployment.environment.name": "prod",
  "http.route": "/v1/orders/:id",
  "http.response.status_code": 502,
  "error.type": "UpstreamTimeout",
  "order.id": "A-99183",
  "tenant.id": "t-8871",
  "duration_ms": 1421
}

trace_id 가 들어 있는 것이 중요합니다. 이것 하나로 트레이스에서 로그로, 로그에서 트레이스로 건너갈 수 있습니다. 자동 계측이 로그 상관을 지원하면 코드 수정 없이 붙습니다.

# Python 자동 계측에서 로그에 trace_id 와 span_id 를 주입한다
export OTEL_PYTHON_LOG_CORRELATION=true
export OTEL_PYTHON_LOG_LEVEL=info

필드 설계의 원칙은 셋입니다.

  1. 이름은 고정, 값은 가변입니다. 필드 이름 자체에 ID나 날짜를 넣지 않습니다. 이 규칙 하나가 매핑 폭발의 90%를 막습니다.
  2. 한 필드는 한 타입입니다. duration 이 어떤 로그에서는 숫자이고 어떤 로그에서는 "1.4s" 문자열이면, 먼저 색인된 타입으로 매핑이 고정되고 나머지는 색인 실패로 버려집니다.
  3. 큰 덩어리는 필드가 아니라 본문입니다. 스택 트레이스, 요청 본문, 응답 페이로드는 검색 가능한 필드로 만들지 말고 인덱싱하지 않는 본문 필드에 넣습니다.

매핑 폭발 — 인덱스가 죽는 실제 경로

동적 매핑은 처음 보는 필드를 자동으로 등록합니다. 편리하지만, 필드 이름이 데이터에서 나오는 순간 위험해집니다.

{
  "message": "cache stats",
  "cache": {
    "user_8871_hits": 12,
    "user_8871_misses": 3,
    "user_9902_hits": 7
  }
}

사용자마다 새 필드가 생깁니다. 사용자가 10만 명이면 필드가 20만 개입니다. 매핑은 클러스터 상태에 들어가고, 클러스터 상태는 모든 노드가 공유하며 변경 시마다 전파됩니다. 결과는 다음과 같은 순서로 나타납니다.

  1. 색인 지연이 늘어납니다. 새 필드마다 매핑 갱신이 필요하고 이것은 마스터 노드를 거칩니다
  2. 마스터 노드의 CPU와 힙이 오릅니다
  3. 클러스터 상태 전파가 느려지고 노드가 이탈하기 시작합니다
  4. 샤드 재할당이 시작되면서 색인과 검색이 함께 느려집니다
  5. 필드 상한에 걸려 색인이 거부됩니다

가장 나쁜 점은 3번까지 진행되면 되돌리기가 어렵다는 것입니다. 매핑은 삭제할 수 없고, 인덱스를 다시 만드는 수밖에 없습니다.

방어선을 세 겹으로 칩니다.

PUT _index_template/logs-app
{
  "index_patterns": ["logs-app-*"],
  "data_stream": {},
  "priority": 200,
  "template": {
    "settings": {
      "index.number_of_shards": 3,
      "index.number_of_replicas": 1,
      "index.refresh_interval": "30s",
      "index.codec": "zstd_no_dict",
      "index.mapping.total_fields.limit": 1500,
      "index.mapping.depth.limit": 8,
      "index.mapping.nested_fields.limit": 20
    },
    "mappings": {
      "dynamic": "strict",
      "properties": {
        "@timestamp":        { "type": "date" },
        "severity_text":     { "type": "keyword" },
        "body":              { "type": "keyword" },
        "trace_id":          { "type": "keyword" },
        "span_id":           { "type": "keyword" },
        "service.name":      { "type": "keyword" },
        "service.version":   { "type": "keyword" },
        "http.route":        { "type": "keyword" },
        "http.response.status_code": { "type": "short" },
        "error.type":        { "type": "keyword" },
        "error.message":     { "type": "text", "index": true, "norms": false },
        "duration_ms":       { "type": "integer" },
        "tenant.id":         { "type": "keyword" },
        "order.id":          { "type": "keyword", "doc_values": true, "index": true },
        "stack_trace":       { "type": "text", "index": false },
        "attributes":        { "type": "flat_object" }
      }
    }
  }
}

세 겹이 각각 무엇을 막는지 봅니다.

첫째, dynamic: strict. 정의하지 않은 필드가 오면 문서를 거부합니다. 강하지만 정직합니다. 로그가 조용히 사라지는 것보다 색인 실패가 눈에 보이는 편이 낫습니다. 거부가 부담스러우면 false 로 두어 저장은 하되 색인하지 않게 할 수 있습니다.

둘째, flat_object 필드 하나. 예측할 수 없는 키-값이 반드시 들어오는 자리를 만들어 둡니다. flat_object는 객체 전체를 하나의 필드로 취급하므로 하위 키가 매핑에 등록되지 않습니다. 점 표기법으로 조회는 되지만 하위 키별 인덱스가 없어 검색은 느립니다. 그 트레이드오프가 정확히 우리가 원하는 것입니다.

셋째, 필드 수 상한. 사고가 나도 클러스터 전체가 아니라 그 인덱스만 멈춥니다.

방어선막는 것대가
dynamic strict예상 못 한 필드의 등록새 필드를 추가하려면 템플릿 변경 필요
flat_object임의 키의 매핑 등록하위 키 검색이 느리고 집계 제약
total_fields.limit사고의 폭발 범위한도 초과 시 색인 실패
index: false큰 텍스트의 색인 비용그 필드로 검색 불가, 조회만 가능
norms: false텍스트 필드의 스코어링 메타데이터관련도 순위가 덜 정교해짐

현재 상태 진단은 이렇게 합니다.

# 인덱스별 필드 수 — 수천 개가 나오면 이미 문제다
curl -s 'https://opensearch:9200/logs-app-000042/_mapping?pretty' \
  | jq '[paths(type=="object" and has("type")) | length] | length'

# 클러스터 상태 크기와 매핑 갱신 대기열
curl -s 'https://opensearch:9200/_cluster/stats?pretty' \
  | jq '.indices.mappings'

curl -s 'https://opensearch:9200/_cluster/health?pretty' \
  | jq '{status, number_of_pending_tasks, task_max_waiting_in_queue_millis}'

# 어떤 인덱스가 디스크를 쓰는가
curl -s 'https://opensearch:9200/_cat/indices/logs-*?v&s=store.size:desc&h=index,docs.count,store.size,pri,rep' \
  | head -20

number_of_pending_tasks 가 꾸준히 0이 아니면 매핑 갱신이 밀리고 있다는 신호입니다. 이 값에 알림을 걸어 두면 매핑 폭발을 3단계 전에 잡을 수 있습니다.

인덱스 수명주기 — 롤오버와 계층 이동

로그 인덱스는 하나로 두지 않고 시간이나 크기로 쪼갭니다. 이유는 단순합니다. 삭제가 인덱스 단위로는 즉시이고 문서 단위로는 비싸기 때문입니다. 하루치 로그를 지우려고 delete-by-query를 돌리면 세그먼트 재작성이 일어나 클러스터가 흔들립니다.

PUT _plugins/_ism/policies/logs-app-lifecycle
{
  "policy": {
    "description": "애플리케이션 로그 30일 보존",
    "default_state": "hot",
    "ism_template": [
      {
        "index_patterns": ["logs-app-*"],
        "priority": 200
      }
    ],
    "states": [
      {
        "name": "hot",
        "actions": [
          {
            "rollover": {
              "min_primary_shard_size": "30gb",
              "min_index_age": "1d"
            }
          }
        ],
        "transitions": [
          { "state_name": "warm", "conditions": { "min_index_age": "3d" } }
        ]
      },
      {
        "name": "warm",
        "actions": [
          { "replica_count": { "number_of_replicas": 0 } },
          { "force_merge": { "max_num_segments": 1 } }
        ],
        "transitions": [
          { "state_name": "cold", "conditions": { "min_index_age": "10d" } }
        ]
      },
      {
        "name": "cold",
        "actions": [
          { "read_only": {} }
        ],
        "transitions": [
          { "state_name": "delete", "conditions": { "min_index_age": "30d" } }
        ]
      },
      {
        "name": "delete",
        "actions": [{ "delete": {} }]
      }
    ]
  }
}

min_primary_shard_size 로 롤오버하는 것이 시간 기준보다 안전합니다. 트래픽이 튀는 날 하루치 인덱스가 300GB가 되면 샤드 하나가 100GB를 넘어 검색이 급격히 느려집니다. 크기 기준이면 그날은 인덱스가 여러 개로 나뉩니다.

warm 단계에서 복제본을 0으로 내리는 것은 비용과 내구성의 교환입니다. 오래된 로그가 노드 장애로 유실돼도 감당할 수 있는지를 먼저 정하고 결정합니다. 감사 대상 로그라면 이 단계를 넣지 않고 별도 인덱스와 별도 보존 정책으로 분리합니다.

정책이 실제로 도는지 확인하는 것을 잊지 않습니다. ISM은 조용히 실패합니다.

# 정책이 붙은 인덱스와 현재 상태
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
  | jq 'to_entries[] | select(.value.index != null)
        | {index: .value.index, state: .value."policy_id", step: .value.step.name, failed: .value.failed}'

# 실패한 관리 인덱스만
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
  | jq '[to_entries[] | select(.value.failed == true) | .key]'

샘플링 — 로그를 버리는 원칙 있는 방법

전량 보존이 비용에 맞지 않으면 버려야 하고, 문제는 무엇을 버리느냐입니다. 원칙은 하나입니다. 가치가 낮은 것이 아니라 중복이 높은 것을 버립니다.

가장 효과가 큰 것부터 나열합니다.

  1. 헬스체크와 프로브 로그 — 전체 로그의 20~40%를 차지하는 경우가 흔합니다. 정보량은 거의 0입니다
  2. 성공 경로의 DEBUG와 INFO — 오류가 없었던 요청의 상세 로그
  3. 반복되는 동일 이벤트 — 같은 error.type이 초당 수천 건이면 상위 N건과 카운트만 남깁니다
  4. 정적 자산 요청 — 이미지, JS, CSS 접근 로그

컬렉터에서 자릅니다. 앱을 고치지 않고 정책만 바꿀 수 있는 위치이기 때문입니다.

# otel-collector.yaml — 로그 파이프라인
processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20

  # 1) 헬스체크는 통째로 버린다
  filter/drop_health:
    error_mode: ignore
    logs:
      log_record:
        - 'attributes["http.route"] == "/healthz"'
        - 'attributes["http.route"] == "/readyz"'
        - 'attributes["http.route"] == "/metrics"'

  # 2) 정상 경로의 DEBUG 는 10% 만 남긴다. 오류는 건드리지 않는다
  probabilistic_sampler/debug:
    sampling_percentage: 10
    attribute_source: record
    from_attribute: trace_id

  # 3) 큰 필드는 잘라 낸다
  transform/trim:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          - truncate_all(attributes, 4096)
          - delete_key(attributes, "http.request.body")
          - set(attributes["error.message"], Substring(attributes["error.message"], 0, 500))
            where attributes["error.message"] != nil

  batch:
    timeout: 5s
    send_batch_size: 8192

exporters:
  opensearch:
    http:
      endpoint: https://opensearch.observability.svc:9200
    logs_index: logs-app
    sending_queue:
      enabled: true
      queue_size: 5000
    retry_on_failure:
      enabled: true
      max_elapsed_time: 300s

service:
  pipelines:
    # 오류와 경고는 무조건 전량 보존
    logs/critical:
      receivers: [otlp]
      processors: [memory_limiter, transform/trim, batch]
      exporters: [opensearch]
    # 정상 경로는 샘플링
    logs/routine:
      receivers: [otlp]
      processors: [memory_limiter, filter/drop_health, probabilistic_sampler/debug, transform/trim, batch]
      exporters: [opensearch]

from_attribute: trace_id 가 중요합니다. 같은 트레이스의 로그가 함께 남거나 함께 버려지므로, 남은 로그가 조각나지 않습니다. 무작위로 각 로그 레코드를 판정하면 한 요청의 로그 중 세 줄만 남아 조사에 쓸 수 없게 됩니다.

라우팅으로 신호별 파이프라인을 나누는 것도 방법입니다. 심각도에 따라 다른 인덱스와 다른 보존 기간으로 보내면, ERROR는 90일 남기고 INFO는 7일만 남기는 정책이 가능해집니다.

계층대상보존인덱싱상대 비용
뜨거움ERROR, WARN, 감사 이벤트30~90일전체 필드기준
미지근함INFO 중 비즈니스 이벤트14~30일핵심 필드만0.4배
차가움성공 경로 접근 로그3~7일최소0.15배
보관규제 대응용 원본1~7년없음, 객체 스토리지0.02배

맨 아래 줄이 자주 잊힙니다. 감사 요건 때문에 보존 기간을 못 줄인다면, 그 로그를 검색 클러스터가 아니라 객체 스토리지에 원본으로 두고 필요할 때만 꺼내는 구조가 훨씬 쌉니다. 검색 가능성과 보존은 다른 요구사항입니다.

로그로 메트릭을 흉내 낼 때 드는 진짜 비용

"로그가 다 있으니 거기서 에러율을 계산하면 되지 않나"는 자연스러운 생각이고, 소규모에서는 실제로 동작합니다. 규모가 커지면 비용 구조가 뒤집힙니다.

숫자로 봅니다.

# 규모 가정
rps            = 5_000          # 초당 요청
log_bytes      = 800            # 로그 1건의 색인 후 크기 (바이트)
seconds_of_day = 86_400

daily_gb = rps * log_bytes * seconds_of_day / 1024**3
print(f"하루 색인량: {daily_gb:.1f} GB")
# 하루 색인량: 321.8 GB

# 같은 정보를 메트릭으로 표현하면
routes, status_classes, pods = 120, 5, 40
series = routes * status_classes * pods
samples_per_day = series * (seconds_of_day / 15)      # 15초 스크레이프
metric_bytes = samples_per_day * 2                    # 압축 후 샘플당 약 2 바이트
print(f"시계열 {series:,}개, 하루 {metric_bytes/1024**3:.3f} GB")
# 시계열 24,000개, 하루 0.257 GB

1000배 이상 차이가 납니다. 그리고 이것은 저장 비용만 본 것입니다. 실제 격차는 질의에서 더 벌어집니다.

항목로그로 에러율 계산메트릭으로 에러율 계산
하루 저장량수백 GB수백 MB
30초마다 새로고침하는 대시보드매번 전체 구간 스캔미리 계산된 시계열 조회
30일 비교 질의30일치 문서를 훑음, 수십 초수백 ms
보존 기간비용 때문에 7~14일이 한계1년 이상 현실적
알림 평가1분마다 무거운 집계 질의저렴한 벡터 연산
스파이크 시 동작로그가 늘어난 만큼 질의도 느려짐시계열 수는 그대로

마지막 줄이 가장 위험합니다. 장애가 나면 로그가 폭증하고, 바로 그 순간 로그 기반 알림과 대시보드가 가장 느려집니다. 필요할 때 작동하지 않는 관측 시스템입니다.

옳은 방향은 로그를 줄이는 것이 아니라 집계는 메트릭으로 옮기고 로그는 조사용으로 남기는 것입니다. 컬렉터가 이 변환을 대신해 줍니다.

# 로그에서 카운트 메트릭을 만들어 Prometheus 로 보낸다
connectors:
  count:
    logs:
      log.error.count:
        description: 심각도별 로그 발생 수
        conditions:
          - 'severity_number >= 17'
        attributes:
          - key: service.name
          - key: error.type
          - key: http.route

service:
  pipelines:
    logs/in:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [opensearch, count]
    metrics/from_logs:
      receivers: [count]
      processors: [batch]
      exporters: [prometheusremotewrite]

attributes 목록에 무엇을 넣는지가 전부입니다. 여기 들어간 것이 그대로 메트릭 레이블이 되므로, order.idtenant.id 를 넣으면 시계열이 폭발합니다. 로그에서는 문제없던 고카디널리티 필드가 메트릭으로 넘어가는 순간 문제가 됩니다.

실패 모드와 진단

증상원인확인대응
로그가 일부만 도착필드 타입 충돌로 색인 거부색인 실패 응답, 데드레터필드 타입 고정, 앱에서 타입 통일
색인 지연이 계속 증가매핑 갱신 대기열 적체클러스터 대기 작업 수dynamic strict, flat_object 도입
특정 시간대 검색이 급격히 느려짐샤드 하나가 지나치게 큼인덱스별 샤드 크기크기 기준 롤오버
오래된 인덱스가 안 지워짐ISM 정책 실패 후 방치ISM explain 의 failed 필드실패 인덱스 재적용, 알림 추가
디스크가 갑자기 참force_merge 중 임시 공간병합 작업 상태warm 이동 전 여유 공간 확보
검색은 되는데 집계가 안 됨flat_object 하위 키매핑 확인집계가 필요한 키는 명시 필드로 승격
장애 때 로그가 유실컬렉터 큐 포화컬렉터 드롭 카운터큐 크기와 백프레셔 정책 조정

마지막 줄은 특히 신경 씁니다. 로그 파이프라인의 백프레셔가 앱까지 전달되면, 관측 시스템이 서비스를 죽이는 상황이 됩니다. 컬렉터의 큐가 차면 드롭하도록 두고, 드롭 카운터에 알림을 겁니다. 로그 유실은 나쁘지만 서비스 중단보다는 낫습니다.

exporters:
  opensearch:
    sending_queue:
      enabled: true
      queue_size: 5000
      # 큐가 차면 새 데이터를 버린다. 앱으로 백프레셔를 되돌리지 않는다
      block_on_overflow: false

마치며 — 로그의 비용은 양이 아니라 필드 설계가 정한다

같은 트래픽에서 로그 비용이 열 배 차이 나는 두 조직의 차이는 압축 알고리즘이 아닙니다. 하나는 필드 이름이 고정되어 있고 집계 질문을 메트릭으로 옮겨 두었으며, 다른 하나는 동적 매핑을 켜 둔 채 대시보드가 로그를 훑고 있습니다.

지금 할 수 있는 점검은 셋입니다. 첫째, 가장 큰 로그 인덱스의 필드 수를 세어 봅니다. 네 자리면 이번 분기의 과제가 정해진 것입니다. 둘째, 지난 30일 동안 실제로 검색된 인덱스 목록을 뽑아 봅니다. 검색된 적 없는 인덱스가 저장량의 절반을 차지하는 경우가 많습니다. 셋째, 로그를 스캔하는 대시보드 패널을 찾아 메트릭으로 옮길 수 있는지 확인합니다.

더 파고들 자료입니다.

현재 단락 (1/323)

분기 인프라 비용을 정리하다가 로그 저장과 인덱싱이 애플리케이션 컴퓨트보다 비싸다는 사실을 발견하는 일은 드물지 않습니다. 그리고 그 로그의 대부분은 아무도 검색한 적이 없습니다.

작성 글자: 0원문 글자: 10,900작성 단락: 0/323