전체 비유: 병원 의무 기록 관리#

로그 수집을 병원의 의무 기록 관리 시스템에 비유하면 이해하기 쉽습니다:

병원 의무 기록 비유로그 수집역할
각 병동의 진료 기록분산된 서버 로그개별 위치의 기록
중앙 의무 기록실로그 수집 시스템 (Loki/ELK)통합 저장소
환자명으로 검색키워드 검색특정 이벤트 찾기
진료 기록 양식구조화 로그 (JSON)표준화된 형식
진료일지, 처방전, 검사결과로그 레벨 (INFO, ERROR)기록 유형 분류
오래된 기록 창고 이관로그 보관 정책Hot → Warm → Cold
환자 차트 번호trace_id관련 기록 연결
카드 검색 시스템 (색인)Elasticsearch 인덱싱빠른 전문 검색
라벨 기반 분류Loki 라벨 인덱싱경량 검색

이처럼 병원이 환자 기록을 중앙에서 관리하듯, 로그 수집 시스템은 분산된 로그를 한 곳에서 검색할 수 있게 합니다.


대상 독자: 로그 시스템을 설계하려는 개발자, SRE 선수 지식: 관측성 3요소 소요 시간: 약 25-30분 이 문서를 읽으면: 로그 수집 시스템을 선택하고 효과적인 로그를 설계할 수 있습니다

TL;DR#

핵심 요약:

  • Loki: 라벨 기반, 경량, Grafana 통합 우수
  • ELK: 전문 검색 강력, 대규모 분석에 적합
  • 구조화 로그: JSON 형식으로 필드별 검색 용이
  • 로그 레벨: ERROR 이상만 장기 보관 권장

왜 로그 수집이 필요한가?#

마이크로서비스 환경에서는 애플리케이션이 수십, 수백 개의 컨테이너에서 실행됩니다. 각 컨테이너가 자체 로그 파일을 생성한다면, 장애 발생 시 어떤 서버의 어떤 로그를 봐야 할까요?

비유: 대형 도서관의 도서 관리

수백만 권의 책이 있는 도서관을 상상해보세요. 각 책이 제각각 다른 위치에 무질서하게 놓여 있다면, 원하는 책을 찾는 것은 거의 불가능합니다. 하지만 중앙 데이터베이스에 모든 책의 위치가 기록되어 있다면, 제목이나 저자만 검색하면 바로 찾을 수 있습니다.

로그 수집도 마찬가지입니다. 분산된 서버의 로그를 한 곳으로 모아 검색 가능하게 만들면, “어제 오후 3시에 발생한 결제 오류"를 몇 초 만에 찾을 수 있습니다.

중앙 집중식 로그 관리의 장점#

문제 상황분산 로그중앙 집중 로그
장애 발생20개 서버를 일일이 SSH 접속한 화면에서 검색
로그 보관서버 재시작 시 유실 가능영구 저장소에 보관
상관관계 분석시간 동기화 어려움trace_id로 연결
접근 제어서버별 권한 관리통합 권한 관리
graph LR
    subgraph "분산 로그 (비효율)"
        S1["서버 1<br>/var/log/app.log"]
        S2["서버 2<br>/var/log/app.log"]
        S3["서버 3<br>/var/log/app.log"]
        ADMIN["관리자"]
        ADMIN --> |"SSH"| S1
        ADMIN --> |"SSH"| S2
        ADMIN --> |"SSH"| S3
    end
graph LR
    subgraph "중앙 집중 로그 (효율)"
        A1["서버 1"]
        A2["서버 2"]
        A3["서버 3"]
        CENTRAL["로그 수집 시스템<br>(Loki/ELK)"]
        DASH["통합 대시보드"]
        A1 --> CENTRAL
        A2 --> CENTRAL
        A3 --> CENTRAL
        CENTRAL --> DASH
    end
핵심 원칙: 로그는 한 곳에서 검색할 수 있어야 합니다. 장애 대응 시간은 “로그를 찾는 시간"에 비례합니다.

Loki vs ELK 비교#

아키텍처 비교#

graph TB
    subgraph "Loki 스택"
        APP1["Application"] --> |"stdout"| PROM1["Promtail"]
        PROM1 --> |"push"| LOKI["Loki"]
        LOKI --> GF["Grafana"]
    end

    subgraph "ELK 스택"
        APP2["Application"] --> |"file/stdout"| FB["Filebeat"]
        FB --> LS["Logstash"]
        LS --> ES["Elasticsearch"]
        ES --> KI["Kibana"]
    end

상세 비교#

항목LokiELK
인덱싱라벨만 인덱싱전체 텍스트 인덱싱
검색라벨 필터 + grep전문 검색 (Lucene)
저장 비용낮음 (원본 압축)높음 (인덱스 용량)
쿼리 언어LogQLKQL, Lucene
설치 복잡도낮음높음
Grafana 통합네이티브플러그인 필요
알림 연동Grafana 알림Kibana 알림

선택 가이드#

graph TD
    Q1{"전문 검색이<br>중요한가?"}
    Q1 --> |"예"| ELK["ELK 스택"]
    Q1 --> |"아니오"| Q2{"Grafana를<br>이미 사용?"}
    Q2 --> |"예"| LOKI["Loki"]
    Q2 --> |"아니오"| Q3{"운영 인력이<br>충분한가?"}
    Q3 --> |"예"| ELK
    Q3 --> |"아니오"| LOKI
상황권장
Grafana 이미 사용Loki
전문 검색 필수Elasticsearch
저비용 필요Loki
대규모 분석Elasticsearch
빠른 구축Loki

구조화 로그 설계#

비구조화 vs 구조화#

# ❌ 비구조화 (파싱 어려움)
2026-01-12 10:30:00 ERROR OrderService - Failed to create order for user 123: insufficient stock

# ✅ 구조화 JSON
{
  "timestamp": "2026-01-12T10:30:00Z",
  "level": "ERROR",
  "service": "order-service",
  "message": "Failed to create order",
  "user_id": "123",
  "error": "insufficient stock",
  "trace_id": "abc123def456"
}

필수 필드#

필드설명예시
timestampISO 8601 형식2026-01-12T10:30:00Z
level로그 레벨INFO, ERROR
service서비스명order-service
message로그 메시지Order created
trace_id분산 추적 IDabc123def456

권장 필드#

필드용도
user_id사용자별 필터링
request_id요청별 추적
duration_ms성능 분석
error_code에러 분류
stack_trace디버깅

Spring Boot 설정#

# application.yml
logging:
  pattern:
    console: '{"timestamp":"%d{ISO8601}","level":"%level","service":"${spring.application.name}","message":"%message","logger":"%logger","thread":"%thread"}%n'

# Logback (logback-spring.xml)
<!-- logback-spring.xml -->
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <customFields>{"service":"order-service"}</customFields>
    </encoder>
  </appender>
</configuration>

로그 레벨 전략#

레벨 정의#

레벨용도보관 기간
TRACE상세 디버깅수집 안 함
DEBUG개발 디버깅1-3일
INFO정상 동작7-14일
WARN잠재적 문제30일
ERROR오류 발생90일+

환경별 설정#

# application.yml
spring:
  profiles:
    active: production

---
spring:
  config:
    activate:
      on-profile: development
logging:
  level:
    root: DEBUG
    com.example: TRACE

---
spring:
  config:
    activate:
      on-profile: production
logging:
  level:
    root: INFO
    com.example: INFO

Loki 설정#

Promtail 설정#

# promtail.yml
server:
  http_listen_port: 9080

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: containers
    static_configs:
      - targets:
          - localhost
        labels:
          job: containerlogs
          __path__: /var/log/containers/*.log

    pipeline_stages:
      - json:
          expressions:
            output: log
            stream: stream
            timestamp: time
      - labels:
          stream:
      - timestamp:
          source: timestamp
          format: RFC3339Nano
      - output:
          source: output

LogQL 쿼리#

# 서비스별 필터
{service="order-service"}

# 레벨 필터
{service="order-service"} |= "ERROR"

# JSON 파싱
{service="order-service"} | json | level="ERROR"

# 정규식
{service="order-service"} |~ "user_id=123"

# 에러 수 집계
sum(count_over_time({service="order-service"} |= "ERROR" [5m]))

ELK 설정#

Filebeat 설정#

# filebeat.yml
filebeat.inputs:
  - type: container
    paths:
      - '/var/lib/docker/containers/*/*.log'
    processors:
      - add_kubernetes_metadata:
          host: ${NODE_NAME}
          matchers:
            - logs_path:
                logs_path: "/var/lib/docker/containers/"

output.logstash:
  hosts: ["logstash:5044"]

Logstash 파이프라인#

# logstash.conf
input {
  beats {
    port => 5044
  }
}

filter {
  json {
    source => "message"
  }
  date {
    match => ["timestamp", "ISO8601"]
    target => "@timestamp"
  }
  mutate {
    remove_field => ["message"]
  }
}

output {
  elasticsearch {
    hosts => ["elasticsearch:9200"]
    index => "logs-%{[service]}-%{+YYYY.MM.dd}"
  }
}

로그 보관 정책#

비용 최적화#

graph LR
    HOT["Hot<br>7일<br>SSD"]
    WARM["Warm<br>30일<br>HDD"]
    COLD["Cold<br>90일<br>Object Storage"]
    DELETE["삭제"]

    HOT --> WARM --> COLD --> DELETE

Loki 보관 설정#

# loki.yml
schema_config:
  configs:
    - from: 2026-01-01
      store: boltdb-shipper
      object_store: s3
      schema: v11
      index:
        prefix: loki_index_
        period: 24h

limits_config:
  retention_period: 720h  # 30일

compactor:
  retention_enabled: true
  retention_delete_delay: 2h

Elasticsearch ILM#

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

모범 사례#

DO (권장)#

// ✅ 구조화된 컨텍스트 포함
log.info("Order created",
    kv("order_id", orderId),
    kv("user_id", userId),
    kv("amount", amount));

// ✅ 적절한 레벨 사용
log.debug("Processing step completed");
log.error("Failed to process order", exception);

DON’T (비권장)#

// ❌ 민감 정보 로깅
log.info("User login: password={}", password);

// ❌ 과도한 로깅
for (item : items) {
    log.info("Processing item: {}", item);  // 10만 건이면?
}

// ❌ 로그에서 예외 삼키기
try { ... } catch (Exception e) {
    log.error("Error");  // 스택 트레이스 없음
}

핵심 정리#

항목LokiELK
적합경량, Grafana 통합전문 검색, 대규모
쿼리LogQLKQL
비용낮음높음

로그 설계 원칙:

  1. JSON 구조화 필수
  2. trace_id 포함 (분산 추적 연결)
  3. 적절한 레벨 사용
  4. 민감 정보 제외

관련 문서#

다음 단계#

추천 순서문서배우는 것
1분산 추적로그와 트레이스 연결
2환경 구성Loki 실습