Spring Boot Actuator: 헬스 체크와 메트릭

1. 개요

프로덕션 환경의 서비스는 두 가지 질문에 대답할 수 있어야 합니다: “여전히 살아 있는가?“와 “현재 어떻게 실행 중인가?”. Spring Boot Actuator는 미리 만들어진 엔드포인트를 통해 이 두 질문에 답하며, 주로 /actuator/health와 /actuator/metrics입니다.

이 글에서는 Actuator를 활성화하고, 사용자 정의 HealthIndicator를 작성하며, Kubernetes용 liveness/readiness probe를 설정합니다. 그 다음 Micrometer로 비즈니스 메트릭을 기록하고, Prometheus로 내보내며, 두 부분 모두에 대한 테스트를 작성합니다.

2. 설치

Actuator의 starter를 추가해야 합니다. 이 예에서는 HTTP를 통해 엔드포인트를 제공하기 위해 spring-boot-starter-web도 추가합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

애플리케이션을 시작한 후 시도해봅시다:

curl http://localhost:8080/actuator
{"_links":{"self":{...},"health":{...},"health-path":{...}}}

기본적으로 Spring Boot 3.x는 HTTP를 통해 health 엔드포인트만 노출합니다. 다른 엔드포인트는 여전히 존재하지만 외부에서 접근할 수 없습니다. 이는 의도적인 보안 선택입니다.

2.1. 추가 엔드포인트 노출

application.properties에서 노출하고 싶은 엔드포인트를 명확히 나열합니다:

management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always

첫 번째 줄은 health, info 및 metrics 세 개의 엔드포인트를 엽니다. 두 번째 줄은 /actuator/health가 전체 상태 하나만이 아닌 각 구성 요소의 세부 정보를 반환하도록 허용합니다.

주의: 프로덕션에서는 management.endpoints.web.exposure.include=*를 사용하지 마세요. env, heapdump, configprops 같은 엔드포인트는 비밀번호, 내부 구성 및 메모리 데이터를 노출할 수 있습니다.

3. 헬스 체크

3.1. 기본 엔드포인트

위의 구성으로 /actuator/health를 호출하면 다음과 같은 결과를 얻습니다:

{
  "status": "UP",
  "components": {
    "diskSpace": {
      "status": "UP",
      "details": { "total": 499963174912, "free": 213456789504, "threshold": 10485760 }
    },
    "ping": { "status": "UP" }
  }
}

Spring Boot는 classpath를 기반으로 HealthIndicator를 자동으로 등록합니다. DataSource가 있으면 db가 추가됩니다. Redis가 있으면 redis가 추가되고, 다른 구성 요소도 마찬가지입니다. 전체 상태는 병합된 결과입니다. 하나의 구성 요소가 DOWN이면 전체가 DOWN이고, HTTP 상태 코드는 503입니다.

3.2. 사용자 정의 HealthIndicator 작성

주문 서비스가 외부 결제 게이트웨이에 의존한다고 가정합시다. 먼저 간단한 클라이언트가 있습니다:

public interface PaymentGatewayClient {
    boolean ping();
}

그 다음 이 클라이언트를 확인하는 indicator를 작성합니다:

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {

    private final PaymentGatewayClient client;

    public PaymentGatewayHealthIndicator(PaymentGatewayClient client) {
        this.client = client;
    }

    @Override
    public Health health() {
        try {
            if (client.ping()) {
                return Health.up().withDetail("provider", "VNPay").build();
            }
            return Health.down().withDetail("reason", "ping이 false를 반환함").build();
        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}

주목할 몇 가지 사항:

  • Bean 이름은 paymentGatewayHealthIndicator입니다. Spring Boot는 HealthIndicator 접미사를 제거하므로 JSON의 키는 paymentGateway입니다.
  • withDetail(...)은 추가 정보를 추가하며, show-details가 허용할 때만 표시됩니다.
  • Health.down(e)는 자동으로 오류 메시지를 details.error 부분에 넣습니다.

결제 게이트웨이가 오류일 때의 결과:

{
  "status": "DOWN",
  "components": {
    "diskSpace": { "status": "UP", ... },
    "paymentGateway": {
      "status": "DOWN",
      "details": { "error": "java.net.ConnectException: Connection refused" }
    },
    "ping": { "status": "UP" }
  }
}

주의: health() 메서드는 엔드포인트가 호출될 때마다 실행됩니다. 타임아웃 없이 네트워크를 호출하면 느린 dependency가 /actuator/health를 정지시킵니다. health check에 사용되는 클라이언트에는 항상 짧은 타임아웃(예: 1–2초)을 설정해야 합니다.

3.3. Liveness와 Readiness Probe

Kubernetes는 두 종류의 probe를 구분합니다:

  • Liveness: 애플리케이션이 중단되었나요? 실패하면 pod이 재시작됩니다.
  • Readiness: 애플리케이션이 요청을 받을 준비가 되었나요? 실패하면 pod이 로드 밸런서에서 제거되지만 재시작되지 않습니다.

Spring Boot는 Kubernetes에서 실행 중임을 감지하면 이 두 그룹을 자동으로 활성화합니다. 로컬에서 실행할 때는 수동으로 활성화합니다:

management.endpoint.health.probes.enabled=true
management.endpoint.health.group.readiness.include=readinessState,paymentGateway

첫 번째 줄은 두 개의 엔드포인트 /actuator/health/liveness와 /actuator/health/readiness를 생성합니다. 두 번째 줄은 paymentGateway를 readiness 그룹에 추가합니다. 결제 게이트웨이가 다운되면 pod은 트래픽을 받지 않지만 불필요하게 재시작되지 않습니다.

curl http://localhost:8080/actuator/health/liveness
{"status":"UP"}

Kubernetes manifest의 해당 구성:

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080

주의: 외부 dependency(database, 결제 API)를 liveness 그룹에 넣지 마세요. 데이터베이스가 다운되면 모든 pod이 liveness에 실패하고 동시에 재시작됩니다. 재시작은 데이터베이스를 고칠 수 없으며 시작 시에만 더 많은 로드를 유발합니다.

4. Micrometer을 사용한 메트릭

Actuator는 메트릭의 추상화 계층으로 Micrometer를 사용합니다. 추가 구성이 필요하지 않으며, 이미 JVM, CPU, HTTP request, connection pool 등의 메트릭이 있습니다.

curl http://localhost:8080/actuator/metrics
{"names":["application.ready.time","http.server.requests","jvm.gc.pause","jvm.memory.used","process.cpu.usage", ...]}

특정 메트릭의 세부 정보를 보려면 이름을 URL에 추가합니다. tag 매개변수로 태그를 필터링할 수 있습니다:

curl "http://localhost:8080/actuator/metrics/http.server.requests?tag=uri:/orders"

4.1. 주문 수를 위한 Counter

시스템 메트릭만으로는 충분하지 않으며, 종종 비즈니스 메트릭도 필요합니다. 아래 예는 생성된 주문 수를 판매 채널별로 분류하여 계산합니다:

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final MeterRegistry registry;

    public OrderService(MeterRegistry registry) {
        this.registry = registry;
    }

    public Order createOrder(Customer customer, String channel) {
        Order order = new Order(customer.id(), channel);
        // ... 주문 저장
        Counter.builder("orders.created")
                .description("생성된 주문 수")
                .tag("channel", channel)
                .register(registry)
                .increment();
        return order;
    }
}
public record Customer(long id, String name) {}
public record Order(long customerId, String channel) {}

MeterRegistry는 Spring Boot에 의해 자동으로 구성되고 주입됩니다. 같은 이름과 태그로 register(registry)를 여러 번 호출하는 것은 안전합니다. Micrometer는 이미 있는 counter를 반환하기 때문입니다. web 채널을 통해 세 개의 주문을 만든 후:

curl http://localhost:8080/actuator/metrics/orders.created
{
  "name": "orders.created",
  "description": "생성된 주문 수",
  "measurements": [ { "statistic": "COUNT", "value": 3.0 } ],
  "availableTags": [ { "tag": "channel", "values": ["web"] } ]
}

4.2. 결제 처리 시간을 위한 Timer

Timer는 호출 횟수를 세는 동시에 총 시간과 최대 시간을 측정합니다:

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final Timer paymentTimer;

    public PaymentService(MeterRegistry registry) {
        this.paymentTimer = Timer.builder("payment.process")
                .description("결제 처리 시간")
                .register(registry);
    }

    public boolean pay(Order order) {
        return paymentTimer.record(() -> {
            // ... 결제 게이트웨이 호출
            return true;
        });
    }
}

Timer는 constructor에서 한 번 생성되고 재사용됩니다. 호출할 때마다 변경되는 태그가 없기 때문입니다. record(Supplier)는 lambda의 실행 시간을 측정하고 그 결과를 반환합니다. /actuator/metrics/payment.process 엔드포인트는 COUNT, TOTAL_TIME, MAX 세 가지 통계를 표시합니다.

4.3. 모든 메트릭을 위한 공통 태그

여러 서비스가 메트릭을 한 곳으로 푸시할 때 모든 메트릭에 애플리케이션 이름을 추가해야 합니다:

management.metrics.tags.application=order-service

주의: 큰 수 또는 무제한의 값을 태그로 사용하지 마세요(예: orderId, customerId, 이메일). 각 태그 조합은 별도의 time series를 생성합니다. 수백만 개의 주문은 수백만 개의 series를 생성하여 애플리케이션 메모리와 모니터링 시스템을 부풀립니다. 태그는 channel, status, region 같이 작고 고정된 값 집합만 포함해야 합니다.

5. Prometheus로 내보내기

/actuator/metrics 엔드포인트는 빠른 조회에 적합합니다. 장기 저장 및 그래프 작성을 위해 Prometheus 같은 시스템이 필요합니다. 해당 registry를 추가합니다:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

그 다음 prometheus 엔드포인트를 노출합니다:

management.endpoints.web.exposure.include=health,info,metrics,prometheus
curl http://localhost:8080/actuator/prometheus | grep -E "orders|payment"
# HELP orders_created_total 생성된 주문 수
# TYPE orders_created_total counter
orders_created_total{application="order-service",channel="web"} 3.0
payment_process_seconds_count{application="order-service"} 5.0
payment_process_seconds_sum{application="order-service"} 0.412
payment_process_seconds_max{application="order-service"} 0.153

Micrometer는 Prometheus 규칙에 따라 이름을 자동으로 변경합니다: 점을 언더스코어로, counter는 _total 접미사, timer는 초 단위. 나머지는 Prometheus가 /actuator/prometheus 경로를 스크래이프하도록 구성하는 것입니다.

6. 테스트

6.1. HealthIndicator 테스트

HealthIndicator는 일반 클래스이므로 Spring context를 시작하지 않고도 테스트할 수 있습니다:

import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.Status;

import static org.assertj.core.api.Assertions.assertThat;

class PaymentGatewayHealthIndicatorTest {

    @Test
    void whenPingSucceeds_thenStatusIsUp() {
        var indicator = new PaymentGatewayHealthIndicator(() -> true);

        Health health = indicator.health();

        assertThat(health.getStatus()).isEqualTo(Status.UP);
        assertThat(health.getDetails()).containsEntry("provider", "VNPay");
    }

    @Test
    void whenPingThrows_thenStatusIsDownWithError() {
        var indicator = new PaymentGatewayHealthIndicator(() -> {
            throw new IllegalStateException("timeout");
        });

        Health health = indicator.health();

        assertThat(health.getStatus()).isEqualTo(Status.DOWN);
        assertThat(health.getDetails().get("error").toString()).contains("timeout");
    }
}

PaymentGatewayClient는 하나의 method만 있으므로 mock 대신 lambda를 전달합니다.

6.2. 메트릭 테스트

Micrometer는 메모리에 메트릭을 저장하는 SimpleMeterRegistry를 제공하므로 단위 테스트에 매우 편합니다:

import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class OrderServiceTest {

    @Test
    void whenOrdersCreated_thenCounterIncrementsPerChannel() {
        var registry = new SimpleMeterRegistry();
        var service = new OrderService(registry);
        var customer = new Customer(1L, "An");

        service.createOrder(customer, "web");
        service.createOrder(customer, "web");
        service.createOrder(customer, "mobile");

        assertThat(registry.get("orders.created").tag("channel", "web").counter().count())
                .isEqualTo(2.0);
        assertThat(registry.get("orders.created").tag("channel", "mobile").counter().count())
                .isEqualTo(1.0);
    }
}

메트릭이 없으면 registry.get(...)은 MeterNotFoundException을 던집니다. 이 덕분에 테스트는 메트릭 이름의 오타도 포착합니다.

7. 보안 및 일반적인 오류

관리 포트 분리. Actuator를 별도의 포트에서 실행하고 내부 네트워크에서만 노출할 수 있습니다:

management.server.port=8081

이 경우 애플리케이션은 8080에서, /actuator/**는 8081에서만 제공됩니다. 방화벽 또는 ingress에서 이 포트를 차단하면 됩니다. Kubernetes에서는 probe 구성의 포트를 8081로 변경하는 것을 잊지 마세요.

헬스 세부 정보 제한. 프로덕션에서는 always 대신 show-details=when-authorized를 Spring Security와 함께 사용합니다. 그러면 익명 사용자는 {"status":"UP"}만 봅니다.

일반적인 오류:

  • /actuator/metrics 호출 시 404 수신: metrics를 exposure.include에 추가하지 않았습니다.
  • 헬스 체크가 무작위로 실패: indicator가 타임아웃 없이 네트워크를 호출하거나 probe 타임아웃보다 응답 시간이 깁니다.
  • 데이터베이스가 불안정할 때 pod이 계속 재시작: 데이터베이스가 liveness 그룹에 있습니다.
  • 시간이 지나면서 메모리가 점진적으로 증가: 메트릭이 무제한 개수의 값을 가진 태그를 사용합니다.
  • /actuator/prometheus 엔드포인트가 보이지 않음: micrometer-registry-prometheus가 없거나 엔드포인트를 노출하지 않았습니다.

8. 결론

Spring Boot Actuator는 거의 무료로 헬스 체크와 메트릭을 제공합니다. 당신의 일은 올바르게 구성하는 것입니다: 필요한 엔드포인트만 노출하고, 중요한 dependency에 대해 타임아웃이 있는 HealthIndicator를 작성하며, Kubernetes가 적절히 반응하도록 liveness와 readiness를 분리합니다.

메트릭의 경우 Micrometer는 비즈니스용 Counter와 Timer를 몇 줄로 기록하고 한 개의 dependency로 Prometheus로 내보낼 수 있게 해줍니다. 태그 값의 개수를 작게 유지하고 SimpleMeterRegistry로 테스트를 작성하여 refactor 후 메트릭이 조용히 깨지지 않도록 하세요.

spring-bootactuatormicrometermonitoringprometheus