스프링부트

Spring Boot Actuator - 5. 사용자 정의 Endpoint 만들기

알쓸개잡 2025. 9. 27.

Spring Boot Actuator는 Endpoint 추상화를 중심으로 기능을 플러그인처럼 추가, 노출하는 구조다. 도메인 로직과 운영 로직을 분리하면서도 필요시 손쉽게 사용자 정의 Endpoint를 만들 수 있도록 설계되어 있다. 이번 포스팅에서는 점검 플래그를 활성화하여 수신되는 컨트롤러 트래픽을 차단하고 점검 플래그를 해제하여 컨트롤러 트래픽을 정상화하는 Endpoint를 애플리케이션에 추가하는 예시를 살펴보겠다.

 

Endpoint 구성요소

우선 Spring Boot Actuator Endpoint를 구성하는데 필요한 요소들은 다음과 같다.

 

@Endpoint(id = "<id>")

Endpoint 리소스를 선언하는 역할을 한다. id가 Endpoint 이름이 된다. (base-path가 /actuator인 경우 : /actuator/<id>)

 

Operation 어노테이션

  • @ReadOperation: 데이터 조회 (HTTP GET에 대응)
  • @WriteOperation: 상태 변경 (HTTP POST/PUT에 대응)
  • @DeleteOperation: 삭제/정리 (HTTP DELETE에 대응)

Selector 어노테이션

경로 일부를 파라미터로 바인딩한다. (PathVariable에 대응)

 

입출력 규칙

  • 입력: @Selector (경로), 메서드 파라미터(query/body)로 바인딩
  • 출력: 객체 -> Actuator 미디어 타입(JSON)으로 직렬화
    • 상태코드를 통제하려면 WebEndpointResponse<T>를 반환 (ResponseEntity<T>와 유사)

관련설정

  • 노출: management.endpoints.web.exposure.include에 Endpoint id를 설정
  • 접근: management.endpoint.<id>.access: 읽기, 쓰기 권한 제어
  • 캐시: management.endpoint.<id>.cache.time-to-live로 캐시 제어

Endpoint <-> MVC 컨트롤러 개념 매핑

Endpoint 구성 요소와 MVC 컨트롤러 개념을 다음과 같이 요약해 볼 수 있다.

Actuator Endpoint MVC 컨트롤러 비고
@Endpoint(id="...") @RestController + @RequestMapping 엔트포인트 단위 리소스 정의. MVC는 자유 경로. Actuator는 /<management.endpoints.web.base-path>/<id> 하위로 묶임
@ReadOperation @GetMapping 읽기 전용 오퍼레이션. 기본 200 OK
@WriteOperation @PostMapping / @PutMapping 상태변경 오퍼레이션.
@DeleteOperation @DeleteMapping 삭제/정리 동작
@Selector @PathVariable 경로 세그먼트 바인딩
WebEndpointResponse<T> ResponseEntity<T> 본문 + 상태코드 제어

 

Endpoint 만들기

Endpoint를 직접 만들어 보겠다.

운영 환경에서는 배포나 긴급 장애 대응처럼 외부 트래픽을 잠시 차단해야 하는 경우가 있다. 이때 애플리케이션을 강제로 중단하기보다는 점검 모드를 두어, 관리자는 빠르게 트래픽을 차단하고 사용자에게 명확한 상태를 알려줄 수 있다. 이와 같은 상황에 대해서 점검 모드 플래그를 Endpoint 호출로 활성화하고 점검 모드 플래그가 활성화 상태인 경우 트래픽을 차단하고 점검모드 플래그가 비활성화되면 다시 트래픽을 수신할 수 있도록 하는 예시를 들어 보겠다.

maintenance Endpoint

다음은 maintenance를 id로 한 Endpoint 클래스다.

@Component
@Endpoint( id = "maintenance" )
public class MaintenanceEndpoint {
    private final AtomicBoolean maintenance = new AtomicBoolean( false );
    private final AtomicReference<String> reason = new AtomicReference<>( "" );

    @ReadOperation( produces = MediaType.APPLICATION_JSON_VALUE )
    public WebEndpointResponse<Map<String, Object>> status() {
        return new WebEndpointResponse<>(
                Map.of(
                        "enabled", maintenance.get(),
                        "reason", reason.get()
                )
        );
    }

    @WriteOperation( produces = MediaType.APPLICATION_JSON_VALUE )
    public WebEndpointResponse<Map<String, Object>> enable( @Selector String reason ) {
        maintenance.set( true );
        this.reason.set( reason );
        return status();
    }

    @DeleteOperation( produces = MediaType.APPLICATION_JSON_VALUE )
    public WebEndpointResponse<Map<String, Object>> disable() {
        maintenance.set( false );
        reason.set( "" );
        return status();
    }

    public boolean isMaintenance() {
        return maintenance.get();
    }

    public String getReason() {
        return reason.get();
    }
}

Endpoint id는 maintenance 이다. management.endpoints.web.base-path로 지정된 URI + <endpoint id>로 호출을 한다.

ReadOperation (GET) 호출을 통해서 현재 maintenance 플래그 값과 reason 값을 응답한다.

WriteOperation (POST) 호출을 통해서 maintenance 플래그 값과 reason 값을 설정한다. reason은 body payload로 전달 받도록 할 수 있지만 편의상 @Selector (Path Variable)로 받도록 했다.

DeleteOperation (DELETE) 호출을 통해서 maintenance 플래그 값을 false로 변경하고 reason 값을 초기화한다.

 

maintenance access filter

Endpoint 호출을 통해서 트래픽을 차단하고 오픈하는 작업을 수행하므로 maintenance Endpoint 접근에 대한 제한 장치가 필요하다.

spring-boot-starter-security 의존성을 통해서 access 제한을 설정한다.

Endpoint 보안에 대한 내용은 아래 포스팅을 참고하기 바란다.

2025.09.23 - [스프링부트] - Spring Boot Actuator - 3. Actuator 보안

@Configuration
public class ActuatorSecurityConfig {

    private static final String ACTUATOR_ROLE = "ACTUATOR";
    @Value( "${app.security-password}" )
    private String actuatorPassword;

    @Value("${management.endpoints.web.base-path:/actuator}")
    private String actuatorBasePath;

    /*
    /metrics, metrics/**, 
    /env, /env/**, /maintenance, /maintenance/** 에 대한 인증 및 인가 적용
    /health, /info 엔드포인트는 기본 허용
    그 외 나머지는 연결 거부
     */
    @Bean
    public SecurityFilterChain securityFilterChain( HttpSecurity http ) throws Exception {
        http.securityMatcher( actuatorBasePath + "/**" )
                .authorizeHttpRequests( authorize -> authorize
                        .requestMatchers( actuatorBasePath + "/health", actuatorBasePath + "/info" ).permitAll()
                        .requestMatchers( actuatorBasePath + "/metrics", actuatorBasePath + "/metrics/**",
                                actuatorBasePath + "/env", actuatorBasePath + "/env/**",
                                actuatorBasePath + "/maintenance", actuatorBasePath + "/maintenance/**")
                        .hasRole( ACTUATOR_ROLE )
                        .anyRequest().denyAll() )
                .httpBasic( withDefaults() )
                .csrf( AbstractHttpConfigurer::disable );

        return http.build();
    }

    /*
    인증 처리에 필요한 비밀번호 Encoder 정의. 여기서는 bcrypt를 사용.
    빈을 생성하지 않은 경우 UserDetailsService 빈 생성시 password 에 prefix 지정을 해줘야 함.
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder( 10 );
    }

    /*
    UserDetailsService 빈이 spring security 처리 과정에서 적용된다.
    내부적으로 AuthenticationManager -> AuthenticationProvider를 통해서 인증이 일어난다.
    보통은 DB에 저장된 값 혹은 k8s 환경의 경우 secret에 저장된 값을 적용하지만 편의상 환경변수에 정의된 값을 사용하겠다.
     */
    @Bean
    public UserDetailsService userDetailsService() {
        return username -> User.withUsername( username )
                .password( passwordEncoder().encode( actuatorPassword ) )
                .roles( ACTUATOR_ROLE )
                .build();
    }
}

management.endpoints.web.base-path + /maintenance/** 에 대해서 인증/인가를 얻은 경우만을 허용하도록 하였다.

 

maintenance 활성화 시 API 호출 차단

<actuator base-path>/maintenance/@Selector(reason)을 호출하여 maintenance 모드를 활성화 후에 API Controller를 호출하면 트래픽을 차단하도록 하는 Filter 클래스를 작성하였다.

@Component
public class MaintenanceFilter extends OncePerRequestFilter {
    @Value( "${management.endpoints.web.base-path:/actuator}" )
    private String actuatorBasePath;

    @Value( "${server.port:8080}" )
    private Integer serverPort;

    private final MaintenanceEndpoint maintenanceEndpoint;
    private final ObjectMapper objectMapper;

    public MaintenanceFilter( MaintenanceEndpoint maintenanceEndpoint, ObjectMapper objectMapper ) {
        this.maintenanceEndpoint = maintenanceEndpoint;
        this.objectMapper = objectMapper;
    }

    @Override
    protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException {

        String requestURI = request.getRequestURI();
        int requestedServerPort = request.getServerPort();
        if ( maintenanceEndpoint.isMaintenance() &&
                requestedServerPort == serverPort &&
                !requestURI.startsWith( actuatorBasePath ) ) {
            response.setStatus( HttpServletResponse.SC_SERVICE_UNAVAILABLE );
            response.setContentType( MediaType.APPLICATION_JSON_VALUE );
            MaintenancePayload maintenancePayload = new MaintenancePayload( maintenanceEndpoint.getReason() );
            response.getWriter().write( objectMapper.writeValueAsString( maintenancePayload ) );
            return;
        }

        filterChain.doFilter( request, response );
    }

    @Getter
    private static class MaintenancePayload {
        private final String message = "Service under maintenance";
        private final String reason;

        private MaintenancePayload( String reason ) {
            this.reason = reason;
        }
    }
}

doFilterInternal() 메서드를 살펴보면 maintenance 플래그가 활성화(true) 상태이고 요청 서버 포트가 애플리케이션 서버 포트(actuator 포트와 분리된 경우 의미가 있다.)와 같고, 요청 URI가 actuator base-path로 시작하지 않는 경우(actuator Endpoint 호출이 아닌 경우) SC_SERVICE_UNAVAILABLE(503) 응답 코드와 함께 maintenance reason (점검 사유) 정보를 payload로 응답하도록 한다.

 

application.yml 설정

마지막으로 maintenance Endpoint를 위한 application.yml 설정이다.

server:
  port: 8080

app:
  security-password: ${SECURITY_PASSWORD}
  
management:
  server:
    port: 8081
  endpoints:
    web:
      exposure:
        include: maintenance
      base-path: /security-actuator
  endpoint:
    maintenance:
      access: unrestricted

maintenance Endpoint는 사용자 정의 Endpoint이므로 management.endpoint.maintenance.* 설정은 자동완성으로 제공되지 않는다. (Spring Boot에 정의되어 있지 않은 Endpoint 이므로)

하지만 Spring Boot는 Endpoint id를 제네릭 바인딩을 하기 때문에 런타임에 다음 속성들이 그대로 적용된다.

management.endpoint.<id>.*

ex) management.endpoint.maintenance.enabled, management.endpoint.maintenance.access...

다음 실행 절에서 management.endpoint.maintenance.access 설정 확인을 통해서 이를 살펴볼 것이다.

 

Endpoint 호출해 보자

위에 정의된 내용을 확인해 보자.

애플리케이션 실행 전에 인증 처리를 위해 SECURITY_PASSWORD 환경 변수를 'actuator-password'로 등록하였다.

 

@ReadOperation 호출

 ~/ curl -u "admin:actuator-password" \
> -i http://localhost:8081/security-actuator/maintenance

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 26 Sep 2025 15:39:35 GMT

{"enabled":false,"reason":""}

 현재 maintenance 상태와 reason 정보 확인.

 

@WriteOperation 호출

WriteOperation 호출을 통하여 maintenance 상태를 활성화하고 reason을 등록한다.

 ~/ curl -X POST -u "admin:actuator-password" \
-i http://localhost:8081/security-actuator/maintenance/for-test

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 26 Sep 2025 15:41:22 GMT

{"enabled":true,"reason":"for-test"}

POST 메서드를 호출하여 WriteOperation을 동작시켰다.

@Selector를 통하여 reason 정보를 전달받도록 하였으므로 path-variable 형태로 "for-test"를 전달하였다.

 

GET /hello API 호출 (MaintenanceFilter 클래스 동작 확인)

maintenance 플래그가 활성화되어 있는 상태에서 /hello 컨트롤러를 호출해 보자.

 ~/ curl -i "http://localhost:8080/hello"

HTTP/1.1 503
Content-Type: application/json;charset=ISO-8859-1
Content-Length: 59
Date: Fri, 26 Sep 2025 15:43:49 GMT
Connection: close

{"reason":"for-test","message":"Service under maintenance"}

SERVICE_UNAVAILABLE (503) 응답 코드와 함께 reason 정보를 payload에 담아 응답을 받는다.

 

@DeleteOperation 호출

DeleteOperation 호출을 통하여 maintenance 플래그를 해제(false) 하고 reason 값을 초기화한다.

 ~/ curl -X DELETE -u "admin:actuator-password" \
-i http://localhost:8081/security-actuator/maintenance

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 26 Sep 2025 15:46:15 GMT

{"enabled":false,"reason":""}

DELETE 메서드를 호출하여 @DeleteOperation을 동작시켰다.

maintenance를 비활성화(false) 하고 reason을 초기화했다.

 

이 상태에서 /hello 컨트롤러를 호출하면 다음과 같이 정상적으로 호출된다.

 ~/ curl -i "http://localhost:8080/hello"
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 11
Date: Fri, 26 Sep 2025 15:48:10 GMT

Hello World

 

management.endpoint.maintenance.access 설정 적용 여부 확인

마지막으로 직접 추가한 management.endpoint.maintenance.access 설정 적용 여부를 확인해 보자.

앞선 설정에서 management.endpoint.maintenance.access: unrestricted 상태였다. 

management.endpoint.maintenance.access를 read_only로 변경 후 확인해 보자.

management:
  endpoint:
    maintenance:
      access: read_only

 

이제 write 모드 동작 여부를 확인하기 위해 WriteOperation, DeleteOperation을 호출해 보자.

WriteOperation 호출

 ~/ curl -X POST -u "admin:actuator-password" \
-i http://localhost:8081/security-actuator/maintenance/for-test

HTTP/1.1 404
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 26 Sep 2025 15:52:28 GMT

{"timestamp":"2025-09-26T15:52:28.535+00:00","status":404,"error":"Not Found","path":"/security-actuator/maintenance/for-test"}

404 응답 코드로 WriteOperation이 동작하지 않는다.

 

DeleteOperation 호출

 ~/ curl -X DELETE -u "admin:actuator-password" \
-i http://localhost:8081/security-actuator/maintenance

HTTP/1.1 405
Allow: GET
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 26 Sep 2025 15:54:32 GMT

{"timestamp":"2025-09-26T15:54:32.650+00:00","status":405,"error":"Method Not Allowed","path":"/security-actuator/maintenance"}

405 응답 코드로 DeleteOperation이 동작하지 않는다.

WriteOperation과 DeleteOperation의 응답코드가 다른 이유
management.endpoint.maintenance.access: read_only로 지정되면
ReadOperation만 등록되고 WriteOperation과 DeleteOperation에 대한 라우트는 등록되지 않는다.
하지만 WriteOperation은 /<actuator-base-path>/maintenance/@Selector 경로로 애초에 경로가 등록되지 않으므로 404 Not Found가 발생하지만 DeleteOperation과 ReadOperation은 경로는 동일하지만 메서드만 다르기 때문에 DeleteOperation의 경우에는 404 Not Found가 아닌 405 Method Not Allowed가 발생하는 것이다.

 

끝.

 

댓글

💲 추천 글