프로덕션에서 서버가 돌아가고 있을 때, 안에서 무슨 일이 벌어지는지 알 수 있는 능력. 이것이 Observability(관측 가능성)다. println이나 로그만으로는 한계가 있다.

Observability의 세 축

                    Observability (관측 가능성)
                    ┌──────────┬──────────┬──────────┐
                    │  Logs    │ Metrics  │ Traces   │
                    │  (로그)   │ (메트릭)  │ (트레이스) │
                    └──────────┴──────────┴──────────┘
뭘 알 수 있나 예시
Logs “무슨 일이 일어났는가” ERROR PaymentService - 결제 실패: 잔액 부족
Metrics “지금 상태가 어떤가” CPU 70%, 요청 처리 평균 200ms, 분당 에러 5건
Traces “요청이 어디를 거쳐갔는가” 사용자 → API Gateway → 주문 → 결제 → DB (총 350ms)

실제 장애에서 셋이 어떻게 연계되는가

[새벽 3시, 알림 발생]

1. Metrics 대시보드 확인
   → "주문 API 응답 시간이 평소 200ms에서 5초로 급증"
   → "DB 커넥션 풀 pending이 30으로 급증"
   → 원인 범위 좁힘: DB 커넥션 문제

2. Traces 확인
   → 느린 요청 하나를 추적
   → 주문 서비스 → 결제 서비스 (50ms) → DB 쿼리 (4,800ms) ← 여기가 병목
   → 특정 쿼리가 느린 것을 확인

3. Logs 확인
   → 해당 시간대의 DB 로그 검색
   → "Lock wait timeout exceeded" 발견
   → 배치 작업이 테이블 락을 잡고 있었음

로그만으로는 “어디가 느린지” 알 수 없고, 메트릭만으로는 “왜 느린지” 알 수 없고, 트레이스만으로는 “전체적으로 어떤 상태인지” 알 수 없다. 셋이 함께 있어야 한다.


Spring Boot 4의 접근: OpenTelemetry

Spring Boot 4 이전에는 메트릭(Micrometer), 트레이스(Zipkin/Jaeger), 로그(Logback)를 각각 따로 설정해야 했다. Spring Boot 4에서 spring-boot-starter-opentelemetry가 추가되면서 하나의 의존성으로 통합되었다.

[Spring Boot 4 이전]                      [Spring Boot 4]

Metrics → Micrometer → Prometheus           Metrics ─┐
Traces  → Brave     → Zipkin                Traces  ─┼→ Micrometer → OTLP → 아무 백엔드
Logs    → Logback   → ELK                   Logs    ─┘

(각각 설정, 각각 다른 백엔드)              (하나의 프로토콜, 백엔드 자유 선택)

OTLP(OpenTelemetry Protocol)가 핵심이다. 이것은 표준 프로토콜이기 때문에 수집 라이브러리와 백엔드를 독립적으로 선택할 수 있다. Grafana를 쓰다가 Datadog으로 바꿔도, 애플리케이션 코드는 수정할 필요가 없다. OTLP export URL만 변경하면 된다.

의존성

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-opentelemetry")
}

이 두 줄이면 Logs, Metrics, Traces 세 축이 전부 활성화된다.

설정

spring:
  application:
    name: my-api   # 트레이스에 표시될 서비스 이름

management:
  otlp:
    tracing:
      export:
        url: http://localhost:4318/v1/traces    # OTLP 수신 서버 (Grafana Tempo, Jaeger 등)
    metrics:
      export:
        url: http://localhost:4318/v1/metrics   # OTLP 수신 서버 (Grafana Mimir, Prometheus 등)
        step: 30s                               # 메트릭 전송 주기
  tracing:
    sampling:
      probability: 1.0   # 개발: 1.0 (100%), 프로덕션: 0.1 (10%)

sampling.probability는 전체 요청 중 몇 퍼센트의 트레이스를 수집할지 결정한다. 프로덕션에서 100%로 수집하면 데이터 양이 폭증하므로 보통 10% 정도로 설정한다. 장애 조사 시 일시적으로 올릴 수 있다.


Traces - 요청 추적

실제 로그가 어떻게 바뀌는가

spring-boot-starter-opentelemetry를 추가하면 모든 로그에 traceId와 spanId가 자동으로 주입된다. 코드를 수정하거나 MDC 필터를 만들 필요가 없다.

Before (OpenTelemetry 없음)

2026-02-24 10:30:01.123 INFO  [nio-8080-exec-1] c.e.m.order.OrderController    : 주문 조회 요청: orderId=123
2026-02-24 10:30:01.125 INFO  [nio-8080-exec-1] c.e.m.order.OrderService       : DB 조회 시작
2026-02-24 10:30:01.140 INFO  [nio-8080-exec-1] c.e.m.payment.PaymentClient    : 결제 상태 조회
2026-02-24 10:30:01.390 INFO  [nio-8080-exec-1] c.e.m.order.OrderService       : 주문 조회 완료

스레드 이름(nio-8080-exec-1)만으로는 요청을 구분할 수 없다. Virtual Threads 환경에서는 스레드가 매번 새로 생성되므로 더더욱 추적이 불가능하다.

After (OpenTelemetry 적용)

2026-02-24 10:30:01.123 INFO  [my-api,traceId=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4,spanId=1a2b3c4d5e6f7a8b] c.e.m.order.OrderController    : 주문 조회 요청: orderId=123
2026-02-24 10:30:01.125 INFO  [my-api,traceId=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4,spanId=2b3c4d5e6f7a8b9c] c.e.m.order.OrderService       : DB 조회 시작
2026-02-24 10:30:01.140 INFO  [my-api,traceId=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4,spanId=3c4d5e6f7a8b9c0d] c.e.m.payment.PaymentClient    : 결제 상태 조회
2026-02-24 10:30:01.390 INFO  [my-api,traceId=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4,spanId=2b3c4d5e6f7a8b9c] c.e.m.order.OrderService       : 주문 조회 완료

달라진 부분을 뜯어보면:

[my-api, traceId=a1b2c3d4..., spanId=1a2b3c4d...]
 ──┬──  ─────────┬─────────  ────────┬────────
   │             │                   │
   │             │                   └─ Span ID: 하나의 "작업 단위" 식별자
   │             │                      (Controller, Service, DB 쿼리 각각 다른 spanId)
   │             │
   │             └─ Trace ID: 하나의 "요청 전체" 식별자
   │                (같은 요청에서 나온 모든 로그가 동일한 traceId)
   │
   └─ 서비스 이름 (spring.application.name)

traceId: 하나의 요청이 시작부터 끝까지 동일한 값을 갖는다. 이 값으로 검색하면 해당 요청의 모든 로그를 모을 수 있다.

spanId: 요청 내부의 개별 작업 단위다. Controller → Service → DB 쿼리 각각이 하나의 span이다. 부모-자식 관계가 있어서 트리 구조로 표현된다.

동시 요청이 섞여도 구분 가능

10:30:01 [traceId=aaa...] INFO OrderController   : 주문 조회: orderId=123
10:30:01 [traceId=bbb...] INFO UserController    : 사용자 조회: userId=42
10:30:01 [traceId=aaa...] INFO OrderService      : DB 조회 시작
10:30:01 [traceId=ccc...] INFO OrderController   : 주문 조회: orderId=456
10:30:01 [traceId=bbb...] INFO UserService       : 사용자 조회 완료
10:30:01 [traceId=aaa...] ERROR PaymentClient    : 결제 상태 조회 실패
10:30:01 [traceId=aaa...] ERROR OrderService     : 주문 조회 실패

traceId=aaa로 필터링하면:

10:30:01 [traceId=aaa...] INFO  OrderController : 주문 조회: orderId=123
10:30:01 [traceId=aaa...] INFO  OrderService    : DB 조회 시작
10:30:01 [traceId=aaa...] ERROR PaymentClient   : 결제 상태 조회 실패    ← 원인
10:30:01 [traceId=aaa...] ERROR OrderService    : 주문 조회 실패         ← 결과

자동 추적되는 것들

spring-boot-starter-opentelemetry를 추가하면 코드 변경 없이 다음이 자동으로 추적된다:

자동 추적되는 것들:
├── HTTP 요청/응답 (Spring MVC)
├── DB 쿼리 (JDBC)
├── RestClient / WebClient 외부 호출
├── @Async 비동기 메서드
└── 메시지 큐 (Kafka, RabbitMQ)

하나의 API 호출이 내부에서 어떤 경로를 거치는지 시각적으로 볼 수 있다:

GET /api/orders/123                                    총 350ms
├── OrderController.getOrder()                         350ms
│   ├── OrderService.findById()                        300ms
│   │   ├── DB SELECT orders WHERE id=123              15ms
│   │   ├── PaymentClient.getPaymentStatus()           250ms  ← 외부 API 호출이 병목
│   │   └── DB SELECT order_items WHERE order_id=123   20ms
│   └── OrderMapper.toDto()                            2ms
└── Response 직렬화                                     5ms

MSA 환경 - 서비스를 넘나드는 추적

OpenTelemetry의 진짜 힘은 서비스 간 호출에서 나온다. traceId가 HTTP 헤더(traceparent)를 통해 자동 전파된다.

[주문 서비스 로그]
10:30:01 [my-order,traceId=aaa...] INFO  OrderService   : 주문 생성 시작
10:30:01 [my-order,traceId=aaa...] INFO  PaymentClient  : 결제 서비스 호출

[결제 서비스 로그] ← 서비스가 다른데 traceId가 같다
10:30:01 [my-payment,traceId=aaa...] INFO  PaymentController : 결제 요청 수신
10:30:01 [my-payment,traceId=aaa...] INFO  PaymentService    : 카드사 API 호출
10:30:01 [my-payment,traceId=aaa...] INFO  PaymentService    : 결제 승인 완료

[주문 서비스 로그]
10:30:01 [my-order,traceId=aaa...] INFO  OrderService   : 주문 생성 완료

두 서비스의 로그를 traceId=aaa로 검색하면 전체 흐름이 시간순으로 합쳐진다. Grafana Tempo나 Jaeger 같은 트레이스 백엔드에서는 이것을 시각적으로 보여준다:

[Grafana Tempo / Jaeger UI에서 보이는 화면]

 traceId: aaa...                                           총 450ms
 ┌─────────────────────────────────────────────────────────────────┐
 │ my-order   OrderController.createOrder()                 450ms │
 │ ├── my-order   OrderService.createOrder()                440ms │
 │ │   ├── my-order   DB INSERT orders                       10ms │
 │ │   ├── my-payment  PaymentController.pay()              380ms │ ← 서비스 경계
 │ │   │   ├── my-payment  PaymentService.process()         370ms │
 │ │   │   │   ├── my-payment  HTTP POST 카드사 API         350ms │ ← 진짜 병목
 │ │   │   │   └── my-payment  DB INSERT payments            15ms │
 │ │   └── my-order   DB UPDATE orders SET status='PAID'      8ms │
 └─────────────────────────────────────────────────────────────────┘

이 화면 하나로 “어떤 서비스의 어떤 작업이 얼마나 걸렸는지”를 즉시 파악할 수 있다. 위 예시에서는 카드사 API 호출(350ms)이 전체 450ms의 대부분을 차지하는 것이 한눈에 보인다.

JSON 로그에서의 모습

프로덕션에서 구조화 로깅(JSON)을 사용하면 로그가 이렇게 출력된다:

{
  "timestamp": "2026-02-24T10:30:01.123Z",
  "level": "INFO",
  "logger": "com.example.myapi.order.OrderService",
  "message": "주문 조회 시작: orderId=123",
  "service.name": "my-api",
  "trace_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "span_id": "2b3c4d5e6f7a8b9c",
  "trace_flags": "01",
  "thread": "virtual-thread-42"
}

Grafana Loki 같은 로그 수집 시스템에서 trace_id로 필터링하거나, trace_id 클릭 한 번으로 Grafana Tempo의 트레이스 화면으로 바로 이동할 수 있다. 이것이 Logs → Traces 연계다.


Actuator 헬스체크

Spring Boot Actuator는 애플리케이션의 상태를 HTTP 엔드포인트로 노출한다.

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  endpoint:
    health:
      show-details: when_authorized   # 인증된 사용자에게만 상세 정보
      probes:
        enabled: true
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true

Spring Boot 4에서는 liveness/readiness probe가 기본 활성화되었다. Kubernetes 환경이라면 별도 설정 없이 바로 사용할 수 있다.

Liveness vs Readiness

이 둘의 차이를 이해하려면 Kubernetes가 장애를 처리하는 방식을 알아야 한다.

[Liveness Probe] - "이 서버가 살아있는가?"
  GET /actuator/health/liveness

  응답 200 → 정상
  응답 503 → 비정상 → Kubernetes가 Pod를 재시작

  예: 데드락에 빠져서 완전히 응답 불가 상태
      → 재시작하는 것이 맞다


[Readiness Probe] - "이 서버가 요청을 받을 준비가 되었는가?"
  GET /actuator/health/readiness

  응답 200 → 정상 → 로드밸런서가 트래픽을 보냄
  응답 503 → 미준비 → 로드밸런서에서 제외 (재시작 안 함)

  예: 서버는 살아있지만 DB 커넥션이 일시적으로 끊김
      → 재시작이 아니라 잠시 트래픽을 빼는 것이 맞다
      → DB 복구되면 자동으로 다시 트래픽 수신
[실제 동작 흐름]

Pod 시작
  │
  ├─ Liveness: 200 ✓ (살아있음)
  ├─ Readiness: 503 ✗ (DB 연결 중...)
  │   → 로드밸런서에서 제외, 트래픽 안 받음
  │
  ├─ DB 연결 완료
  ├─ Readiness: 200 ✓ (준비 완료)
  │   → 로드밸런서에 등록, 트래픽 수신 시작
  │
  ├─ ... 정상 운영 중 ...
  │
  ├─ DB 일시 장애 발생
  ├─ Readiness: 503 ✗
  │   → 로드밸런서에서 제외 (재시작 안 함, 기다림)
  │
  ├─ DB 복구
  ├─ Readiness: 200 ✓
  │   → 다시 트래픽 수신

Kubernetes 배포 설정 예시:

# k8s deployment.yaml
spec:
  containers:
  - name: my-api
    livenessProbe:
      httpGet:
        path: /actuator/health/liveness
        port: 8080
      initialDelaySeconds: 30    # 시작 후 30초 대기 (앱 부팅 시간)
      periodSeconds: 10          # 10초마다 체크
      failureThreshold: 3        # 3번 연속 실패 시 재시작
    readinessProbe:
      httpGet:
        path: /actuator/health/readiness
        port: 8080
      initialDelaySeconds: 10
      periodSeconds: 5
      failureThreshold: 3        # 3번 연속 실패 시 트래픽 제외

커스텀 Health Indicator

기본 제공되는 것(DB, 디스크, Redis 등) 외에 직접 만들 수도 있다.

@Component
class ExternalPaymentHealthIndicator(
    private val restClient: RestClient
) : HealthIndicator {

    override fun health(): Health {
        return try {
            val response = restClient.get()
                .uri("/health")
                .retrieve()
                .toBodilessEntity()

            if (response.statusCode.is2xxSuccessful) {
                Health.up()
                    .withDetail("payment-service", "정상")
                    .build()
            } else {
                Health.down()
                    .withDetail("payment-service", "응답 코드: ${response.statusCode}")
                    .build()
            }
        } catch (e: Exception) {
            Health.down(e)
                .withDetail("payment-service", "연결 불가")
                .build()
        }
    }
}

/actuator/health 응답에 자동으로 포함된다:

{
  "status": "UP",
  "components": {
    "db": {
      "status": "UP"
    },
    "diskSpace": {
      "status": "UP"
    },
    "externalPayment": {
      "status": "UP",
      "details": {
        "payment-service": "정상"
      }
    }
  }
}

Metrics - 숫자로 보는 서버 상태

자동 수집되는 메트릭

Micrometer가 자동으로 수집하는 메트릭이 상당히 많다.

자동 수집되는 메트릭:
├── JVM
│   ├── jvm.memory.used          (힙 메모리 사용량)
│   ├── jvm.gc.pause             (GC 멈춤 시간)
│   └── jvm.threads.live         (활성 스레드 수)
├── HTTP
│   ├── http.server.requests     (요청 수, 응답 시간, 상태 코드별)
│   └── http.client.requests     (RestClient/WebClient 외부 호출)
├── DB
│   ├── hikaricp.connections.active    (사용 중인 커넥션)
│   ├── hikaricp.connections.pending   (대기 중인 스레드)
│   └── hikaricp.connections.timeout   (타임아웃 횟수)
└── System
    ├── system.cpu.usage         (CPU 사용률)
    └── process.uptime           (서버 가동 시간)

별도 코드 없이 이 모든 것이 수집된다. /actuator/metrics에서 확인할 수 있다:

# 전체 메트릭 목록
curl http://localhost:8080/actuator/metrics

# 특정 메트릭 상세
curl http://localhost:8080/actuator/metrics/http.server.requests

응답 예시:

{
  "name": "http.server.requests",
  "description": "Duration of HTTP server request handling",
  "measurements": [
    {
      "statistic": "COUNT",
      "value": 1523
    },
    {
      "statistic": "TOTAL_TIME",
      "value": 187.432
    },
    {
      "statistic": "MAX",
      "value": 2.105
    }
  ],
  "availableTags": [
    {
      "tag": "uri",
      "values": [
        "/api/users",
        "/api/orders",
        "/api/orders/{id}"
      ]
    },
    {
      "tag": "method",
      "values": [
        "GET",
        "POST",
        "DELETE"
      ]
    },
    {
      "tag": "status",
      "values": [
        "200",
        "404",
        "500"
      ]
    }
  ]
}

태그로 필터링도 가능하다:

# /api/orders에서 500 에러가 몇 번 났는지
curl "http://localhost:8080/actuator/metrics/http.server.requests?tag=uri:/api/orders&tag=status:500"

커스텀 메트릭

비즈니스에 중요한 숫자는 직접 메트릭으로 등록한다.

@Service
class OrderService(
    private val meterRegistry: MeterRegistry
) {

    // Counter: 누적 횟수 (계속 증가만 하는 값)
    private val orderCounter = Counter.builder("orders.created")
        .description("생성된 주문 수")
        .register(meterRegistry)

    // Timer: 소요 시간 (평균, 최대, 분포를 자동 계산)
    private val orderTimer = Timer.builder("orders.processing.time")
        .description("주문 처리 소요 시간")
        .register(meterRegistry)

    fun createOrder(request: CreateOrderRequest): Order {
        return orderTimer.recordCallable {
            // 이 블록의 실행 시간이 자동으로 측정된다
            orderCounter.increment()
            // ... 주문 처리 로직
        }!!
    }
}

메트릭 종류 네 가지

타입 용도 예시 비유
Counter 누적 횟수 (증가만) 주문 수, 에러 수 자동차 주행거리계 (줄어들지 않음)
Gauge 현재 값 (오르내림) 활성 사용자 수, 큐 크기 자동차 속도계 (실시간 변동)
Timer 소요 시간 + 횟수 API 응답 시간 스톱워치
Distribution Summary 값의 분포 주문 금액 분포 히스토그램
// Counter - 로그인 성공/실패 횟수
Counter.builder("auth.login")
    .tag("result", "success")   // 태그로 성공/실패 구분
    .register(meterRegistry)
    .increment()

// Gauge - 현재 대기열 크기
Gauge.builder("queue.size") { queue.size }
    .register(meterRegistry)
// 값을 직접 설정하지 않고 람다로 현재 값을 읽는다

// Timer - DB 쿼리 시간
Timer.builder("db.query.time")
    .tag("table", "orders")
    .register(meterRegistry)
    .record { dsl.selectFrom(ORDERS).fetch() }

// Distribution Summary - 주문 금액 분포
DistributionSummary.builder("order.amount")
    .baseUnit("won")
    .register(meterRegistry)
    .record(order.totalAmount.toDouble())

프로덕션 모니터링 전략

RED Method (서비스 레벨)

서비스가 정상적으로 동작하고 있는지 판단하는 세 가지 지표:

[RED Method]
├── Rate:     초당 요청 수 (http.server.requests count)
├── Errors:   에러 비율 (http.server.requests에서 status=5xx 비율)
└── Duration: 응답 시간 (http.server.requests 평균/p99)
지표 정상 경고 위험
Rate 평소 수준 유지 급증 또는 급감 0에 가까움 (서비스 다운)
Errors 5xx < 0.1% 5xx > 1% 5xx > 5%
Duration (p99) < 500ms < 2s > 5s

USE Method (리소스 레벨)

서버 리소스가 한계에 도달하고 있는지 판단하는 세 가지 지표:

[USE Method]
├── Utilization: CPU 사용률, 메모리 사용률, 커넥션 풀 사용률
├── Saturation:  커넥션 풀 pending, 스레드 풀 대기열
└── Errors:      커넥션 타임아웃, OOM 발생 여부
리소스 주요 메트릭 위험 신호
CPU system.cpu.usage > 80% 지속
Memory jvm.memory.used / jvm.memory.max > 85%
커넥션 풀 hikaricp.connections.active / max > 80%
커넥션 대기 hikaricp.connections.pending > 0 지속
커넥션 타임아웃 hikaricp.connections.timeout > 0

알림 설정 기준

[즉시 대응 - PagerDuty/슬랙 알림]
- 5xx 에러율 > 5% (5분 지속)
- 응답 시간 p99 > 5초 (5분 지속)
- 커넥션 풀 타임아웃 발생
- Pod readiness 실패

[주의 관찰 - 슬랙 알림]
- 5xx 에러율 > 1% (10분 지속)
- 커넥션 풀 pending > 0 (10분 지속)
- CPU > 80% (15분 지속)
- 메모리 > 85% (15분 지속)

[트렌드 확인 - 주간 리뷰]
- 응답 시간 평균 증가 추세
- 에러율 증가 추세
- 리소스 사용률 증가 추세

이 메트릭들을 Grafana 대시보드에 구성하면, 장애가 발생하기 전에 징후를 포착할 수 있다. 예를 들어 커넥션 풀 pending이 서서히 증가하는 추세가 보이면, 타임아웃 장애가 나기 전에 대응할 수 있다.