Architecture

MSA 관측 가능성(Observability) 완벽 가이드

백엔드 개발자 김승원 2026. 3. 31. 16:26

MSA에서 Observability가 중요한 이유

모놀리식 아키텍처에서는 하나의 애플리케이션 로그와 스택 트레이스만으로 대부분의 문제를 파악할 수 있었습니다. 하지만 MSA(Microservices Architecture) 환경에서는 수십에서 수백 개의 서비스가 네트워크를 통해 상호작용하므로, 단일 요청이 여러 서비스를 거치며 어디서 지연이 발생하고 어디서 오류가 났는지 추적하기가 극도로 어려워집니다.

관측 가능성(Observability)은 시스템의 외부 출력(로그, 메트릭, 트레이스)을 통해 내부 상태를 이해할 수 있는 능력을 의미합니다. 이는 단순 모니터링을 넘어, 예측하지 못한 장애 상황에서도 문제의 근본 원인을 파악할 수 있게 해줍니다.

Observability의 3축 (Three Pillars)

Observability는 로그(Logs), 메트릭(Metrics), 트레이스(Traces) 세 가지 축으로 구성됩니다. 각각은 서로 보완적이며, 세 가지 모두를 갖추어야 완전한 관측 가능성을 확보할 수 있습니다.

설명 특성 도구
Logs (로그) 이산적인 이벤트 기록 고해상도, 비구조화/구조화, 대용량 ELK, Loki
Metrics (메트릭) 시계열 수치 데이터 집계 가능, 저비용, 알림 설정 Prometheus, Datadog
Traces (트레이스) 분산 요청 흐름 추적 인과관계 파악, 지연 분석 Jaeger, Zipkin

분산 트레이싱 (Distributed Tracing)

분산 트레이싱은 하나의 요청이 여러 서비스를 거치는 전체 경로를 추적하는 기술입니다. 각 서비스 구간(Span)의 소요 시간과 관계를 시각화하여 병목 지점을 파악할 수 있습니다.

핵심 개념

  • Trace: 하나의 요청에 대한 전체 여정. 고유한 Trace ID로 식별됩니다.
  • Span: 하나의 서비스 내에서의 작업 단위. 시작/종료 시간, 태그, 로그를 포함합니다.
  • Context Propagation: Trace ID와 Span ID를 서비스 간 HTTP 헤더 또는 메시지 헤더로 전파합니다.

Zipkin 설정

# docker-compose.yml - Zipkin
zipkin:
  image: openzipkin/zipkin:latest
  ports:
    - "9411:9411"
  environment:
    STORAGE_TYPE: elasticsearch
    ES_HOSTS: http://elasticsearch:9200
<!-- Spring Boot + Zipkin 의존성 -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>
# application.yml - 트레이싱 설정
management:
  tracing:
    sampling:
      probability: 1.0  # 개발: 100%, 프로덕션: 0.1 (10%) 권장
  zipkin:
    tracing:
      endpoint: http://zipkin:9411/api/v2/spans

logging:
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

Jaeger 설정

# docker-compose.yml - Jaeger
jaeger:
  image: jaegertracing/all-in-one:latest
  ports:
    - "16686:16686"   # Jaeger UI
    - "4318:4318"     # OTLP HTTP
  environment:
    COLLECTOR_OTLP_ENABLED: "true"
    SPAN_STORAGE_TYPE: elasticsearch
    ES_SERVER_URLS: http://elasticsearch:9200
<!-- Jaeger + OpenTelemetry 의존성 -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
# application.yml - Jaeger/OTLP 설정
management:
  tracing:
    sampling:
      probability: 0.1
  otlp:
    tracing:
      endpoint: http://jaeger:4318/v1/traces

커스텀 Span 생성

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final ObservationRegistry observationRegistry;

    public PaymentResult processPayment(PaymentRequest request) {
        return Observation.createNotStarted("payment.process", observationRegistry)
            .lowCardinalityKeyValue("payment.method", request.getMethod())
            .highCardinalityKeyValue("payment.orderId", request.getOrderId())
            .observe(() -> {
                // PG사 API 호출
                PaymentResult result = pgClient.charge(request);

                // 결과에 따라 추가 태그
                if (!result.isSuccess()) {
                    Observation.current(observationRegistry)
                        .lowCardinalityKeyValue("payment.error", result.getErrorCode());
                }

                return result;
            });
    }
}

Micrometer를 활용한 메트릭 수집

Micrometer는 JVM 기반 애플리케이션의 메트릭 수집을 위한 벤더 중립적인 인터페이스입니다. SLF4J가 로깅의 Facade인 것처럼, Micrometer는 메트릭의 Facade 역할을 합니다.

커스텀 비즈니스 메트릭

@Component
@RequiredArgsConstructor
public class OrderMetrics {

    private final MeterRegistry meterRegistry;

    // Counter: 주문 수 카운팅
    public void recordOrderCreated(String paymentMethod) {
        Counter.builder("orders.created.total")
            .description("생성된 주문 수")
            .tag("payment_method", paymentMethod)
            .register(meterRegistry)
            .increment();
    }

    // Timer: 주문 처리 시간
    public void recordOrderProcessingTime(long durationMs, String status) {
        Timer.builder("orders.processing.duration")
            .description("주문 처리 소요 시간")
            .tag("status", status)
            .register(meterRegistry)
            .record(Duration.ofMillis(durationMs));
    }

    // Gauge: 현재 처리 중인 주문 수
    public void registerPendingOrdersGauge(AtomicInteger pendingCount) {
        Gauge.builder("orders.pending.count", pendingCount, AtomicInteger::get)
            .description("현재 처리 대기 중인 주문 수")
            .register(meterRegistry);
    }

    // Distribution Summary: 주문 금액 분포
    public void recordOrderAmount(double amount) {
        DistributionSummary.builder("orders.amount")
            .description("주문 금액 분포")
            .baseUnit("won")
            .publishPercentiles(0.5, 0.95, 0.99)
            .register(meterRegistry)
            .record(amount);
    }
}

Prometheus + Grafana 연동

Spring Boot Actuator + Prometheus 설정

<!-- 의존성 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  endpoint:
    health:
      show-details: always
      probes:
        enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
      environment: ${spring.profiles.active:default}
# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'spring-boot-apps'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 10s
    # Kubernetes 서비스 디스커버리
    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_path]
        action: replace
        target_label: __metrics_path__
        regex: (.+)

Grafana 대시보드 핵심 지표

# RED 메서드 기반 쿼리 (Rate, Errors, Duration)

# Rate: 초당 요청 수
rate(http_server_requests_seconds_count{application="order-service"}[5m])

# Errors: 에러율
sum(rate(http_server_requests_seconds_count{application="order-service", status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count{application="order-service"}[5m]))

# Duration: 95 퍼센타일 응답 시간
histogram_quantile(0.95,
  sum(rate(http_server_requests_seconds_bucket{application="order-service"}[5m])) by (le)
)

# USE 메서드 (Utilization, Saturation, Errors)
# CPU 사용률
process_cpu_usage{application="order-service"}

# JVM 메모리 사용량
jvm_memory_used_bytes{application="order-service", area="heap"}
/
jvm_memory_max_bytes{application="order-service", area="heap"}

중앙 로깅: ELK 스택

MSA 환경에서 각 서비스의 로그를 개별적으로 확인하는 것은 비현실적입니다. ELK(Elasticsearch + Logstash + Kibana) 스택을 통해 모든 서비스의 로그를 중앙에서 수집, 검색, 시각화할 수 있습니다.

구조화된 로깅 (JSON 로그)

<!-- logback-spring.xml -->
<configuration>
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeMdcKeyName>traceId</includeMdcKeyName>
            <includeMdcKeyName>spanId</includeMdcKeyName>
            <customFields>{"service":"order-service","env":"production"}</customFields>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="JSON" />
    </root>
</configuration>

<!-- 출력 예시 -->
<!--
{
  "@timestamp": "2026-03-25T10:30:15.123Z",
  "level": "INFO",
  "logger_name": "c.e.o.OrderService",
  "message": "주문 생성 완료",
  "traceId": "6a3f7b2c1d4e5f",
  "spanId": "a1b2c3d4",
  "service": "order-service",
  "env": "production",
  "orderId": "ORD-12345"
}
-->

Logstash 파이프라인 설정

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

filter {
  json {
    source => "message"
  }

  # traceId로 분산 트레이싱 연결
  if [traceId] {
    mutate {
      add_field => { "trace_link" => "http://jaeger:16686/trace/%{traceId}" }
    }
  }

  # 에러 로그 분류
  if [level] == "ERROR" {
    mutate {
      add_tag => ["error"]
    }
  }
}

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

Spring Boot Actuator 심화 활용

// 커스텀 Health Indicator
@Component
public class KafkaHealthIndicator implements HealthIndicator {

    private final KafkaAdmin kafkaAdmin;

    @Override
    public Health health() {
        try {
            Map<String, Object> configs = kafkaAdmin.getConfigurationProperties();
            AdminClient client = AdminClient.create(configs);
            DescribeClusterResult cluster = client.describeCluster();

            int nodeCount = cluster.nodes().get(5, TimeUnit.SECONDS).size();
            client.close();

            if (nodeCount > 0) {
                return Health.up()
                    .withDetail("broker_count", nodeCount)
                    .withDetail("cluster_id", cluster.clusterId().get())
                    .build();
            } else {
                return Health.down()
                    .withDetail("reason", "No brokers available")
                    .build();
            }
        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}

// 커스텀 Info Contributor
@Component
public class DeploymentInfoContributor implements InfoContributor {

    @Value("${app.version:unknown}")
    private String appVersion;

    @Override
    public void contribute(Info.Builder builder) {
        builder.withDetail("deployment", Map.of(
            "version", appVersion,
            "timestamp", LocalDateTime.now(),
            "javaVersion", System.getProperty("java.version")
        ));
    }
}

종합 아키텍처: Observability 스택

                   ┌─────────────────────────────────────────┐
                   │          Grafana Dashboard               │
                   │  (메트릭 시각화 + 알림)                    │
                   └─────┬──────────────┬────────────────┬────┘
                         │              │                │
                   ┌─────▼─────┐  ┌─────▼─────┐  ┌──────▼─────┐
                   │Prometheus │  │  Jaeger/   │  │   Kibana   │
                   │(메트릭)   │  │  Zipkin    │  │  (로그)    │
                   │           │  │(트레이스)  │  │            │
                   └─────▲─────┘  └─────▲─────┘  └──────▲─────┘
                         │              │                │
  ┌──────────┐     /actuator/      Trace 전파      JSON 로그
  │  Service │─────prometheus──────────┼────────────────┤
  │    A     │         │              │                │
  └──────────┘         │              │                │
  ┌──────────┐         │              │                │
  │  Service │─────────┼──────────────┼────────────────┤
  │    B     │         │              │                │
  └──────────┘         │              │                │
  ┌──────────┐         │              │                │
  │  Service │─────────┴──────────────┴────────────────┘
  │    C     │
  └──────────┘

Observability 도입 체크리스트

단계 항목 도구
1단계 구조화된 JSON 로깅 + 중앙 수집 Logback + ELK/Loki
2단계 Actuator + Prometheus 메트릭 Micrometer + Prometheus + Grafana
3단계 분산 트레이싱 Micrometer Tracing + Jaeger/Zipkin
4단계 알림 체계 구축 Grafana Alerting / PagerDuty
5단계 대시보드 표준화 (RED/USE) Grafana Dashboard
6단계 로그-메트릭-트레이스 연결 Grafana Tempo + Loki 연동

마무리

MSA 환경에서의 Observability는 선택이 아닌 필수입니다. 로그, 메트릭, 트레이스 세 축을 모두 갖추고, 이들을 상호 연결(Correlation)하여 활용해야 진정한 관측 가능성을 확보할 수 있습니다. 특히 Trace ID를 로그에 포함시켜 Jaeger의 트레이스와 Kibana의 로그를 자유롭게 오갈 수 있도록 구성하는 것이 핵심입니다. 단계별로 도입하되, 로그 중앙화부터 시작하여 메트릭, 트레이싱 순으로 확장하는 것을 권장합니다.