MDC 에서 만든 로그 시스템에서는 다음과 같은 문제가 존재한다. MDCJVM 내부에서만 동작하기에 서로 다른 서버 간의 호환이 안된다. 이 때문에 하나의 요청이 여러 서버를 거칠 때 로그를 추적하기가 너무 어렵다.

Step 1. 서버 간 맥락 연결 - Header 이용하기

이를 극복할 수 있는 유일한 방법은 서버에서 외부와 소통할 때 사용하는 방식을 이용하는 것이다. 즉, HTTP를 이용한다.

이 때, BodyQuery Param 을 이용하면 비즈니스 데이터에 메타 정보가 섞여서 지저분해진다. 그래서 메타 정보를 위한 공간인 Header 를 적극 활용한다.

micrometer-tracingSpring Boot 4.x 와 함께 사용하면 아래와 같은 W3C Trace Context 표준 을 자동으로 Header 에 포함해준다

W3C Trace Context 표준:

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
             ↑   ↑                                ↑                ↑
             버전 trace-id (32자리)                 span-id          flags

Step 2. 로그 중앙 관리 - 과거의 시도들

흩어진 로그를 한 곳에서 보아야 추적이 쉽다. 과거에도 로그를 모으려고 별의별 시도를 다 했다.

  • 공유 디스크 : Lock 없으면 충돌나고, Lock 걸면 성능 떨어진다. 하나 죽으면 다 죽는 SPOF 문제도 있다.

  • rsync 복사 : 실시간이 아니다. 새벽에 장애 터졌는데 로그는 1분 뒤에나 도착하면 피눈물 난다.

  • UDP syslog : 패킷이 유실된다. “결제 실패인지 로그가 사라진 건지” 구분 안 되면 장애 분석은 포기해야 한다.

  • 비동기 전송 : 성능에 영향을 끼치면 안된다.

  • 실시간성 : 로그 찍히면 바로 볼 수 있어야 한다.

  • 신뢰성 : 네트워크 좀 끊겨도 버퍼링했다가 재시도해야 한다.

  • 구조화 : JSON처럼 기계가 읽기 좋아야 한다.

Step 3. 전문 로그 에이전트와 Loki의 등장

Logstash (2010~)     → 무겁다 (JVM 기반)
Fluentd (2011~)      → 중간 (Ruby 기반)
Filebeat (2015~)     → 가볍다 (Go 기반)
Promtail (2018~)     → Loki 전용, 근데 2025년에 deprecated
Alloy (2024~)        → Promtail 후속작! OTel + Loki 통합 수집기

가장 유명한 건 ELK지만, Loki 가 훨씬 가볍다고 해서 비교해봤다.

구분ELKLoki
인덱싱본문 전체 ( 무거움)라벨(메타데이터)만
철학”다 찾아줄게""필요한 것만 빠르게”
리소스무거움가벼움
검색 방식전문 검색라벨로 필터링 → 본문 grep

Loki LogQL의 핵심: 1차는 라벨로, 2차는 본문으로!

{service="order-service", level="ERROR"}  |=  "userId=12345"
└─────────────────────────────────────┘     └─────────────┘
   라벨 필터 (인덱스 타서 빠름)                  본문 grep (속도 빠름)
메타데이터라벨 적합?이유
service=order종류가 적음
level=ERROR고정된 값
trace_id=abc값 종류가 너무 많음 (Cardinality 폭발)

중요: trace_id 같은 고유값은 라벨로 만들면 Loki 서버가 OUT_OF_MEMORY 에 직면하게 됨

Step 4. 표준화 — ECS와 OpenTelemetry

ECS (Elastic Common Schema)

OpenTelemetry (OTel): 옛날엔 Zipkin, Jaeger 등등 파편화되어 있었는데 이제 OpenTelemetry 로 통일되었음. CNCF의 사실상 업계 표준이다.

Step 5. 우리가 만든 최종 아키텍처 (현대적 로깅)

graph TD
    A[Spring Boot App] -->|ECS JSON 작성| B("./logs/app.json")
    B -->|볼륨 마운트| C["Alloy (수집기)"]
    C -->|"Stage: Parsing & Labeling"| C
    C -->|HTTP Push| D["Loki (저장소)"]
    E[Grafana] -->|Query| D

Step 6. 설정

build.gradle.kts

dependencies {
    // [1] 로그를 예쁜 JSON으로 (Alloy가 읽기 좋게)
    implementation("co.elastic.logging:logback-ecs-encoder:1.5.0")
 
    // [2] Tracing - Spring Boot 4.x는 OTel 기반이다!
    implementation("io.micrometer:micrometer-tracing-bridge-otel")
    implementation("org.springframework.boot:spring-boot-starter-opentelemetry")
 
    // [3] 비동기(@Async) 돌려도 traceId 안 끊기게 해준다
    implementation("io.micrometer:context-propagation")
 
    // [4] 기타 필수템
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
}

2. logback-spring.xml

  • 콘솔 개발자가 직관적으로 보기 편하도록
  • 파일 기계 친화적으로
<?xml version="1.0" encoding="UTF-8"?>  
<configuration>  
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>  
  
    <springProperty scope="context" name="appName" source="spring.application.name"/>  
  
    <!-- 1. 사람이 읽는 콘솔 (디버깅용) -->  
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">  
        <encoder>  
            <pattern>%d{HH:mm:ss.SSS} %5p [${appName},%X{traceId:-},%X{spanId:-}] [%t] %-40.40logger{39} : %m%n  
            </pattern>  
        </encoder>  
    </appender>  
  
    <!-- 2. ECS JSON 파일 — Alloy가 수집할 파일! -->  
    <appender name="ECS_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">  
        <file>./logs/app.json</file>  
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">  
            <fileNamePattern>./logs/app-%d{yyyy-MM-dd}.%i.json.gz</fileNamePattern>  
            <maxFileSize>100MB</maxFileSize>  
            <maxHistory>7</maxHistory>  
            <totalSizeCap>1GB</totalSizeCap>  
        </rollingPolicy>  
        <encoder class="co.elastic.logging.logback.EcsEncoder">  
            <serviceName>${appName}</serviceName>  
            <serviceVersion>0.0.1-SNAPSHOT</serviceVersion>  
            <includeMarkers>true</includeMarkers>  
            <includeOrigin>false</includeOrigin>  
        </encoder>  
    </appender>  
  
    <root level="INFO">  
        <appender-ref ref="CONSOLE"/>  
        <appender-ref ref="ECS_FILE"/>  
    </root>  
</configuration>

3. docker-compose.yml

version: "3.8"  
  
services:  
  loki:  
    image: grafana/loki:2.9.4  
    container_name: loki  
    ports:  
      - "3100:3100"  
    command: -config.file=/etc/loki/local-config.yaml  
    volumes:  
      - ./loki/config.yaml:/etc/loki/local-config.yaml:ro  
      - loki-data:/loki  
  
  alloy:  
    image: grafana/alloy:latest  
    container_name: alloy  
    ports:  
      - "12345:12345"  
    volumes:  
      - ./alloy/config.alloy:/etc/alloy/config.alloy:ro  
      - ./logs:/var/log/app:ro      # 호스트의 ./logs 디렉토리를 컨테이너에 마운트  
    command:  
      - run  
      - --server.http.listen-addr=0.0.0.0:12345  
      - --storage.path=/var/lib/alloy/data  
      - /etc/alloy/config.alloy  
    depends_on:  
      - loki  
  
  grafana:  
    image: grafana/grafana:10.2.3  
    container_name: grafana  
    ports:  
      - "3000:3000"  
    environment:  
      - GF_AUTH_ANONYMOUS_ENABLED=true  
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin  
    volumes:  
      - grafana-data:/var/lib/grafana  
    depends_on:  
      - loki  
  
volumes:  
  loki-data:  
  grafana-data:

4. alloy/config.alloy

Alloy 가 어떻게 동작할 지 설정

// ===========================================  
// ① 어떤 파일을 수집할지 매칭  
// ===========================================  
local.file_match "app_logs" {  
path_targets = [  
  {  
    __path__ = "/var/log/app/*.json",  
    service  = "order-service",  
    env      = "local",  
  },  
]  
}  
  
// ===========================================  
// ② 파일을 tail로 읽기 (실시간)  
// ===========================================  
loki.source.file "app_logs" {  
targets    = local.file_match.app_logs.targets  
forward_to = [loki.process.parse_ecs.receiver]  
}  
  
// ===========================================  
// ③ ECS JSON 파싱 + 라벨 추출  
// ===========================================  
loki.process "parse_ecs" {  
forward_to = [loki.write.local_loki.receiver]  
  
// (3-1) JSON 필드 추출 — ECS는 점(.) 들어간 필드명을 씀  
stage.json {  
  expressions = {  
    level       = "\"log.level\"",  
    service     = "\"service.name\"",  
    logger_name = "\"log.logger\"",  
    trace_id    = "\"trace.id\"",  
    span_id     = "\"span.id\"",  
  }  
}  
  
// (3-2) 라벨로 등록할 것만 선별 — Cardinality 폭발 방지!  
stage.labels {  
  values = {  
    level   = "",  
    service = "",  
  }  
}  
  
// (3-3) timestamp 매핑 — ECS의 @timestamp를 Loki의 timestamp로  
stage.timestamp {  
  source = "@timestamp"  
  format = "RFC3339Nano"  
}  
}  
  
// ===========================================  
// ④ Loki로 push  
// ===========================================  
loki.write "local_loki" {  
endpoint {  
  url = "http://loki:3100/loki/api/v1/push"  
}  
}