Spring Actuator 커스텀 엔드포인트

커스텀 Actuator 엔드포인트란?

Spring Boot Actuator는 /actuator/health, /actuator/metrics 등 내장 엔드포인트를 제공하지만, 실무에서는 비즈니스 특화 운영 엔드포인트가 필요합니다. @Endpoint 어노테이션으로 커스텀 엔드포인트를 만들면, Actuator의 보안·직렬화·JMX 노출 인프라를 그대로 활용하면서 도메인 전용 운영 API를 구축할 수 있습니다.

@Endpoint로 커스텀 엔드포인트 생성

@Endpoint는 Web(HTTP)과 JMX 양쪽에 동시 노출됩니다. @WebEndpoint는 HTTP 전용, @JmxEndpoint는 JMX 전용입니다:

@Component
@Endpoint(id = "features")  // → /actuator/features
public class FeatureToggleEndpoint {

    private final FeatureToggleService featureService;

    public FeatureToggleEndpoint(FeatureToggleService featureService) {
        this.featureService = featureService;
    }

    // GET /actuator/features → 전체 피처 플래그 조회
    @ReadOperation
    public Map<String, FeatureStatus> getAllFeatures() {
        return featureService.getAllFeatures();
    }

    // GET /actuator/features/{name} → 특정 피처 조회
    @ReadOperation
    public FeatureStatus getFeature(@Selector String name) {
        return featureService.getFeature(name)
            .orElseThrow(() -> new IllegalArgumentException(
                "Feature not found: " + name));
    }

    // POST /actuator/features/{name} → 피처 토글
    @WriteOperation
    public FeatureStatus toggleFeature(
            @Selector String name,
            boolean enabled) {
        return featureService.setFeature(name, enabled);
    }

    // DELETE /actuator/features/{name} → 피처 삭제
    @DeleteOperation
    public void deleteFeature(@Selector String name) {
        featureService.removeFeature(name);
    }
}

// DTO
public record FeatureStatus(
    String name,
    boolean enabled,
    Instant lastModified,
    String modifiedBy
) {}
# application.yml — 커스텀 엔드포인트 노출 설정
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,features,cache-ops
  endpoint:
    features:
      enabled: true

@ReadOperation · @WriteOperation · @DeleteOperation

어노테이션 HTTP 메서드 용도
@ReadOperation GET 상태 조회, 데이터 읽기
@WriteOperation POST 설정 변경, 데이터 수정
@DeleteOperation DELETE 리소스 삭제, 캐시 퍼지
@Selector 경로 변수 URL 세그먼트로 리소스 식별

실전 패턴 1: 캐시 운영 엔드포인트

운영 중 캐시를 조회하고 선택적으로 퍼지하는 엔드포인트입니다:

@Component
@Endpoint(id = "cache-ops")
public class CacheOperationsEndpoint {

    private final CacheManager cacheManager;

    public CacheOperationsEndpoint(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    // GET /actuator/cache-ops → 전체 캐시 현황
    @ReadOperation
    public Map<String, CacheInfo> getCacheStatus() {
        return cacheManager.getCacheNames().stream()
            .collect(Collectors.toMap(
                name -> name,
                name -> {
                    Cache cache = cacheManager.getCache(name);
                    var nativeCache = cache.getNativeCache();

                    if (nativeCache instanceof com.github.benmanes.caffeine.cache.Cache<?,?> caffeine) {
                        var stats = caffeine.stats();
                        return new CacheInfo(
                            caffeine.estimatedSize(),
                            stats.hitRate(),
                            stats.evictionCount(),
                            stats.missCount()
                        );
                    }
                    return new CacheInfo(-1, 0, 0, 0);
                }
            ));
    }

    // GET /actuator/cache-ops/{cacheName} → 특정 캐시 상세
    @ReadOperation
    public CacheInfo getCacheDetail(@Selector String cacheName) {
        Cache cache = cacheManager.getCache(cacheName);
        if (cache == null) {
            throw new IllegalArgumentException("Cache not found: " + cacheName);
        }
        // 상세 정보 반환
        var nativeCache = (com.github.benmanes.caffeine.cache.Cache<?,?>)
            cache.getNativeCache();
        var stats = nativeCache.stats();
        return new CacheInfo(
            nativeCache.estimatedSize(),
            stats.hitRate(),
            stats.evictionCount(),
            stats.missCount()
        );
    }

    // DELETE /actuator/cache-ops/{cacheName} → 특정 캐시 퍼지
    @DeleteOperation
    public Map<String, String> evictCache(@Selector String cacheName) {
        Cache cache = cacheManager.getCache(cacheName);
        if (cache != null) {
            cache.clear();
            return Map.of("status", "cleared", "cache", cacheName);
        }
        throw new IllegalArgumentException("Cache not found: " + cacheName);
    }

    // POST /actuator/cache-ops → 전체 캐시 초기화
    @WriteOperation
    public Map<String, Object> evictAllCaches() {
        List<String> cleared = new ArrayList<>();
        cacheManager.getCacheNames().forEach(name -> {
            Cache cache = cacheManager.getCache(name);
            if (cache != null) {
                cache.clear();
                cleared.add(name);
            }
        });
        return Map.of("status", "all_cleared", "caches", cleared);
    }
}

public record CacheInfo(
    long size,
    double hitRate,
    long evictionCount,
    long missCount
) {}

실전 패턴 2: 서킷 브레이커 대시보드

Resilience4j 서킷 브레이커 상태를 실시간으로 모니터링하고 제어하는 엔드포인트입니다:

@Component
@Endpoint(id = "circuit-breakers")
public class CircuitBreakerEndpoint {

    private final CircuitBreakerRegistry registry;

    public CircuitBreakerEndpoint(CircuitBreakerRegistry registry) {
        this.registry = registry;
    }

    // GET /actuator/circuit-breakers → 전체 서킷 상태
    @ReadOperation
    public Map<String, CircuitBreakerDetail> getAll() {
        return registry.getAllCircuitBreakers().stream()
            .collect(Collectors.toMap(
                CircuitBreaker::getName,
                cb -> new CircuitBreakerDetail(
                    cb.getState().name(),
                    cb.getMetrics().getFailureRate(),
                    cb.getMetrics().getSlowCallRate(),
                    cb.getMetrics().getNumberOfSuccessfulCalls(),
                    cb.getMetrics().getNumberOfFailedCalls(),
                    cb.getMetrics().getNumberOfNotPermittedCalls()
                )
            ));
    }

    // GET /actuator/circuit-breakers/{name}
    @ReadOperation
    public CircuitBreakerDetail getOne(@Selector String name) {
        CircuitBreaker cb = registry.circuitBreaker(name);
        return new CircuitBreakerDetail(
            cb.getState().name(),
            cb.getMetrics().getFailureRate(),
            cb.getMetrics().getSlowCallRate(),
            cb.getMetrics().getNumberOfSuccessfulCalls(),
            cb.getMetrics().getNumberOfFailedCalls(),
            cb.getMetrics().getNumberOfNotPermittedCalls()
        );
    }

    // POST /actuator/circuit-breakers/{name} → 강제 상태 전환
    @WriteOperation
    public Map<String, String> transition(
            @Selector String name,
            String state) {
        CircuitBreaker cb = registry.circuitBreaker(name);
        switch (state.toUpperCase()) {
            case "CLOSE", "CLOSED" -> cb.transitionToClosedState();
            case "OPEN" -> cb.transitionToOpenState();
            case "HALF_OPEN" -> cb.transitionToHalfOpenState();
            case "DISABLE", "DISABLED" -> cb.transitionToDisabledState();
            case "FORCE_OPEN" -> cb.transitionToForcedOpenState();
            default -> throw new IllegalArgumentException(
                "Invalid state: " + state);
        }
        return Map.of(
            "name", name,
            "previousState", cb.getState().name(),
            "newState", state.toUpperCase()
        );
    }
}

public record CircuitBreakerDetail(
    String state,
    float failureRate,
    float slowCallRate,
    long successfulCalls,
    long failedCalls,
    long notPermittedCalls
) {}

@WebEndpoint: HTTP 전용 엔드포인트

파일 다운로드, HTML 응답 등 HTTP에 특화된 기능은 @WebEndpointWebEndpointResponse를 사용합니다:

@Component
@WebEndpoint(id = "thread-dump-enhanced")
public class EnhancedThreadDumpEndpoint {

    // GET /actuator/thread-dump-enhanced → 향상된 스레드 덤프
    @ReadOperation(produces = "application/json")
    public WebEndpointResponse<Map<String, Object>> threadDump() {
        Map<Thread, StackTraceElement[]> threads = Thread.getAllStackTraces();

        // Virtual Thread 포함 분류
        var summary = threads.keySet().stream()
            .collect(Collectors.groupingBy(
                t -> t.isVirtual() ? "virtual" : "platform",
                Collectors.counting()
            ));

        // 데드락 감지
        ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
        long[] deadlocked = mxBean.findDeadlockedThreads();

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("timestamp", Instant.now());
        result.put("totalThreads", threads.size());
        result.put("summary", summary);
        result.put("deadlockedThreads",
            deadlocked != null ? deadlocked.length : 0);
        result.put("topCpuConsumers", getTopCpuThreads(5));

        return new WebEndpointResponse<>(result, 200);
    }

    // GET /actuator/thread-dump-enhanced/{threadName}
    @ReadOperation
    public WebEndpointResponse<Map<String, Object>> threadDetail(
            @Selector String threadName) {
        return Thread.getAllStackTraces().entrySet().stream()
            .filter(e -> e.getKey().getName().contains(threadName))
            .findFirst()
            .map(e -> {
                Map<String, Object> detail = Map.of(
                    "name", e.getKey().getName(),
                    "state", e.getKey().getState().name(),
                    "virtual", e.getKey().isVirtual(),
                    "daemon", e.getKey().isDaemon(),
                    "stackTrace", Arrays.stream(e.getValue())
                        .map(StackTraceElement::toString)
                        .toList()
                );
                return new WebEndpointResponse<>(detail, 200);
            })
            .orElse(new WebEndpointResponse<>(
                Map.of("error", "Thread not found"), 404));
    }
}

@EndpointExtension: 기존 엔드포인트 확장

내장 엔드포인트의 동작을 오버라이드하거나 확장할 수 있습니다:

// /actuator/health의 응답을 커스터마이즈
@Component
@EndpointWebExtension(endpoint = HealthEndpoint.class)
public class CustomHealthEndpointExtension {

    private final HealthEndpoint delegate;
    private final BuildProperties buildProperties;

    public CustomHealthEndpointExtension(
            HealthEndpoint delegate,
            BuildProperties buildProperties) {
        this.delegate = delegate;
        this.buildProperties = buildProperties;
    }

    @ReadOperation
    public WebEndpointResponse<Map<String, Object>> health() {
        HealthComponent health = delegate.health();

        Map<String, Object> enhanced = new LinkedHashMap<>();
        enhanced.put("status", health.getStatus().getCode());
        enhanced.put("version", buildProperties.getVersion());
        enhanced.put("buildTime", buildProperties.getTime());
        enhanced.put("uptime", ManagementFactory.getRuntimeMXBean()
            .getUptime() / 1000 + "s");
        enhanced.put("jvm", Map.of(
            "memory", getMemoryInfo(),
            "threads", Thread.activeCount(),
            "gc", getGcInfo()
        ));

        if (health instanceof CompositeHealth composite) {
            enhanced.put("components", composite.getComponents());
        }

        int status = health.getStatus().equals(Status.UP) ? 200 : 503;
        return new WebEndpointResponse<>(enhanced, status);
    }

    private Map<String, String> getMemoryInfo() {
        Runtime rt = Runtime.getRuntime();
        return Map.of(
            "max", (rt.maxMemory() / 1024 / 1024) + "MB",
            "used", ((rt.totalMemory() - rt.freeMemory()) / 1024 / 1024) + "MB",
            "free", (rt.freeMemory() / 1024 / 1024) + "MB"
        );
    }
}

보안 설정: 엔드포인트별 접근 제어

@Configuration
@EnableWebSecurity
public class ActuatorSecurityConfig {

    @Bean
    public SecurityFilterChain actuatorSecurity(HttpSecurity http)
            throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                // health, info는 공개
                .requestMatchers(
                    EndpointRequest.to("health", "info"))
                    .permitAll()
                // 읽기 전용 엔드포인트는 MONITOR 역할
                .requestMatchers(
                    EndpointRequest.to("metrics", "features", "cache-ops"))
                    .hasRole("MONITOR")
                // 쓰기/삭제 엔드포인트는 ADMIN 역할
                .requestMatchers(
                    EndpointRequest.to("circuit-breakers"))
                    .hasRole("ADMIN")
                .anyRequest().hasRole("ADMIN")
            )
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

실전 패턴 3: 동적 로그 레벨 제어

내장 loggers 엔드포인트보다 세밀한 로그 제어 엔드포인트입니다:

@Component
@Endpoint(id = "log-control")
public class LogControlEndpoint {

    private final LoggingSystem loggingSystem;
    private final Map<String, LogLevel> overrides = new ConcurrentHashMap<>();

    public LogControlEndpoint(LoggingSystem loggingSystem) {
        this.loggingSystem = loggingSystem;
    }

    @ReadOperation
    public Map<String, Object> getOverrides() {
        return Map.of(
            "activeOverrides", overrides,
            "hint", "POST with loggerName and level to override"
        );
    }

    // POST: 임시 로그 레벨 변경 (TTL 지원)
    @WriteOperation
    public Map<String, String> setLogLevel(
            String loggerName,
            String level,
            @Nullable Integer ttlMinutes) {
        LogLevel logLevel = LogLevel.valueOf(level.toUpperCase());
        LogLevel previous = loggingSystem
            .getLoggerConfiguration(loggerName)
            .getEffectiveLevel();

        loggingSystem.setLogLevel(loggerName, logLevel);
        overrides.put(loggerName, logLevel);

        // TTL 후 자동 복원
        if (ttlMinutes != null && ttlMinutes > 0) {
            CompletableFuture.delayedExecutor(
                ttlMinutes, TimeUnit.MINUTES
            ).execute(() -> {
                loggingSystem.setLogLevel(loggerName, previous);
                overrides.remove(loggerName);
            });
        }

        return Map.of(
            "logger", loggerName,
            "previous", previous.name(),
            "current", level.toUpperCase(),
            "ttl", ttlMinutes != null ? ttlMinutes + "min" : "permanent"
        );
    }

    // DELETE: 오버라이드 초기화
    @DeleteOperation
    public Map<String, String> resetLogLevel(@Selector String loggerName) {
        loggingSystem.setLogLevel(loggerName, null);
        overrides.remove(loggerName);
        return Map.of("status", "reset", "logger", loggerName);
    }
}

테스트 전략

@WebMvcTest
@Import(FeatureToggleEndpoint.class)
class FeatureToggleEndpointTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private FeatureToggleService featureService;

    @Test
    void shouldReturnAllFeatures() throws Exception {
        given(featureService.getAllFeatures())
            .willReturn(Map.of("dark-mode", new FeatureStatus(
                "dark-mode", true, Instant.now(), "admin")));

        mockMvc.perform(get("/actuator/features"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.dark-mode.enabled").value(true));
    }

    @Test
    void shouldToggleFeature() throws Exception {
        given(featureService.setFeature("dark-mode", false))
            .willReturn(new FeatureStatus(
                "dark-mode", false, Instant.now(), "admin"));

        mockMvc.perform(post("/actuator/features/dark-mode")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{"enabled": false}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.enabled").value(false));
    }
}

// 통합 테스트: 실제 Actuator 엔드포인트 호출
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ActuatorIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void cacheOpsEndpointShouldWork() {
        var response = restTemplate.getForEntity(
            "/actuator/cache-ops", Map.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

핵심 정리

어노테이션 용도
@Endpoint Web + JMX 동시 노출 커스텀 엔드포인트
@WebEndpoint HTTP 전용 (파일 다운로드, HTML 등)
@EndpointWebExtension 기존 내장 엔드포인트 응답 커스터마이즈
@Selector URL 경로 변수 바인딩
WebEndpointResponse HTTP 상태 코드 직접 제어

커스텀 Actuator 엔드포인트는 운영 도구를 Spring Boot 인프라 안에 통합하는 가장 깔끔한 방법입니다. 피처 플래그, 캐시 운영, 서킷 브레이커 제어 등 도메인 특화 운영 기능을 Actuator 보안·모니터링 체계 안에서 안전하게 제공할 수 있습니다. Spring Actuator 운영 심화Spring Resilience4j 서킷브레이커도 함께 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux