커스텀 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에 특화된 기능은 @WebEndpoint와 WebEndpointResponse를 사용합니다:
@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 서킷브레이커도 함께 참고하세요.