spring boot/actuator in spring boot

spring boot 3.x + actuator 파헤치기. 4. custom endpoint 생성

Hello World Study 2023. 3. 24. 15:08

https://youtu.be/FoC5h1GHkKA

spring boot 에서는  "이런게 기본제공 되지 않을까? " 혹은 "이런 기능이 있었으면 좋겠다!" 라고 생각할 만한 대부분의 범용 기능들이 기본으로 제공되고 있습니다. 즉 등록된 bean 리스트, cache 상태 등이 기본 제공됩니다. 그러나, 실무에서 사내 정책에 따라 사내 전용 endpoint 가 필요한 경우가 있습니다.

이번 포스팅에서는 custom endpoint 를 생성하는 방법에 대해 알아보겠습니다.

 

참고로 공식 가이드는 https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.implementing-custom 를 참고하면 됩니다.

 

이번에 만들 custom endpoint 는 

application에서 참조하는 라이브러리 이름과 버전 정보를 응답으로 내보내는 myLibraryInfo 라는 이름의 endpoint 를 만들어보도록 하겠습니다. 

 

기본 클래스 만들기

일반적으로 endpoint 는 application 의 특정 정보를 응답으로 리턴합니다. 즉 cpu usage 를 응답으로 내보내고, 등록된 bean 정보를 응답으로 내보냅니다. 

우리가 custom endpoint 를 만들더라도 결국 특정 정보를 응답으로 내보내는 클래스 구현은 반드시 필요합니다.

 

현재 app에서 사용중인 라이브러리들의 이름과 버전정보를 응답으로 리턴하는 custom endpoint 를 만들 필요가 있다고 가정해봅시다.

아래처럼 getLibraryInfos() 라는 메서드에 라이브러리 정보를 가져와서 list에 저장하고 return 하는 메서드를 구현하면 됩니다. 실제 로딩된 라이브러리 정보 가져오는 건 강의 범위에 벗어나므로 하드코딩으로 대체하였습니다. 

 

@Endpoint(id = "myLibraryInfo")  // endpoint id 지정. 필수!
public class MyLibraryInfoEndpoint {


    @ReadOperation    // read 요청에 대한 메서드라는 의미
    public List<LibraryInfo> getLibraryInfos() {
        // TODO: 라이브러리 정보를 읽어서 name, version을 가져오는 코드가 있어야 하나 하드코딩으로 대체함.
        LibraryInfo libraryInfo1 = new LibraryInfo();
        libraryInfo1.setName("logback");
        libraryInfo1.setVersion("1.0.0");

        LibraryInfo libraryInfo2 = new LibraryInfo();
        libraryInfo2.setName("jackson");
        libraryInfo2.setVersion("2.0.0");

        return Arrays.asList(libraryInfo1, libraryInfo2);
    }
}

위 코드에 사용된 LibraryInfo 라는 클래스는 아래처럼 제가 만든 DTO 클래스일뿐입니다. 

@Data
public class LibraryInfo {
    private String name;
    private String version;
}

유심히 봐야 할 부분은 아래 어노테이션입니다.

@Endpoint(id = "myLibraryInfo")

rest controller 구현시의  아래 코드 정도의 역할이라고 보면 됩니다.

@RestController
@RequestMapping("/api/myLibraryInfo")

 

@Endpoint 어노테이션에서 중요한 부분만 아래에 표시했습니다.

@Target(ElementType.TYPE)   <-- type 즉 클래스 위에 지정 가능한 어노테이션
@Retention(RetentionPolicy.RUNTIME)
public @interface Endpoint {	
	String id() default "";       <-- value()가 없고 id()만 있음. 
}

@Target 에 Type 으로 적혀있으므로 class 에만 지정이 가능한 어노테이션이며

value() 라는 필드가 없고 id() 필드만 존재하는게 특이합니다.

 

@XXX("value1")  <-- 이런식으로 어노테이션을 사용하면 value1 이라는 값이 어노테이션내의 value() 필드에 할당되는게 자바 스펙입니다. 

그런데 @Endpoint 는 value() 필드가 없으므로 @Endpoint("myLibraryInfo") 라고 적으면 오류가 납니다.

따라서 반드시 @Endpoint(id = "myLibraryInfo") 와 같이 필드명을 명확히 지정해줘야 합니다.

 

추가로 유심히 봐야할 어노테이션은 아래와 같습니다.

@ReadOperation

이건 rest controller 구현시 사용하는 아래 어노테이션과 유사하다고 보면 됩니다. 즉 HTTP GET 요청, 즉 읽기 요청을 뜻합니다. 직관적인 어노테이션이라서 어렵지 않죠?

@GetMapping

 

이제 마지막으로 할일은 위 클래스를 bean 으로 등록하면 됩니다.

저는 아래처럼 config 클래스를 만든 후 그곳에서 @Bean 을 이용해서 등록했습니다.

@Configuration
public class MyLibraryInfoEndpointConfig {

    @Bean
    MyLibraryInfoEndpoint myLibraryInfoEndpoint() {
        return new MyLibraryInfoEndpoint();
    }
}

bean 으로 등록되는 클래스가 우리가 만든 클래스이므로 @Bean 이 아닌 @Component 을 써도 됩니다.

즉 위 config 클래스를 삭제하고 아래처럼 @Endpoint 가 붙은 클래스에 @Component 를 적어도 됩니다. 그러나 설정관련된 bean 은 일반적으로 @Configuration와 @Bean 을 이용해서 등록하며, spring boot actuator에서도 이 방식으로 endpoint 들을 bean으로 등록해주고 있으므로 가급적 @Bean 을 이용해서 등록하도록 합시다. 

@Component    <-- bean 등록을 위해 이걸 추가해도 됨
@Endpoint(id = "myLibraryInfo")
public class MyLibraryInfoEndpoint {
  (생략)
}

이제 spring boot 를 재구동한 후 actuator 에 custom endpoint 가 보이는지 확인해봅시다.

http://127.0.0.1:8080/actuator/myLibraryInfo 라는 url이 추가로 보이며 해당 링크에 들어가니

우리가 하드코딩한 library 2개의 정보가 리스트로 보여지는걸 알 수 있습니다. 

HTTP Method

위 예제 에서는 HTTP GET 요청에 대한 endpoint 를 다루어 봤습니다. 단순 정보 조회이면 이걸로 충분하나 thread dump 를 수행하라, logger level 을 debug 나 info 레벨로 변경하라. 와 같이 조회가 아닌 뭔가 수행하라는 명령을 actuator 를 통해 수행할수도 있는데 이때는 HTTP GET 이 적절해보이지 않습니다.

actuator 에서는 이런 문제를 해결하기 위해 아래처럼 @WriteOperation, @DeleteOperation 이라는 추가 어노테이션이 제공되며, 각 어노테이션별 매핑되는 HTTP Method 는 아래와 같습니다. 상식적이기에 외울것도 없어보입니다.

https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.implementing-custom.web.method-predicates

@WriteOperation 을 이용한 예제는 아래 파라미터 수신 파트에서 보여드리겠습니다.

 

 

파라미터 수신방법

rest api 의 경우 당연히 파라미터를 수신할 수 있습니다.

spring mvc 에서는 @PathVariable, @RequestParameter, @RequestBody 와 같은 어노테이션을 통해 query string 이나 http body 의 내용을 파라미터로 수신할 수 있습니다.

endpoint 에서도 유사하게 파라미터를 수신하는 방법이 있습니다.

 

파라미터 수신방법1 - query string으로 수신하는 방법

아래처럼 기존 메서드에 수신하고 싶은 파라미터를 적어주면 됩니다. 필수값이 아니라면 @Nullable 을 함께 적어줍니다.

이게 전부입니다. 아래 메서드는 @ReadOperation 어노테이션이 붙어 있으므로 http GET 요청에 매핑되며, http GET 요청에서 파라미터를 넘기는 기본 방법은 query string 입니다. 그래서 아래처럼만 적어줘도 query string 의 값을 매핑시켜서 파라미터로 전달해줍니다.

import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.lang.Nullable;

import java.util.Arrays;
import java.util.List;

@Endpoint(id = "myLibraryInfo")
public class MyLibraryInfoEndpoint {


    @ReadOperation
    public List<LibraryInfo> getLibraryInfos(@Nullable String name, boolean includeVersion) {
        // TODO: 라이브러리 정보를 읽어서 name, version을 가져오는 코드가 있어야 하나 하드코딩으로 대체함.
        LibraryInfo libraryInfo1 = new LibraryInfo();
        libraryInfo1.setName("logback");
        libraryInfo1.setVersion("1.0.0");

        LibraryInfo libraryInfo2 = new LibraryInfo();
        libraryInfo2.setName("jackson");
        libraryInfo2.setVersion("2.0.0");

        List<LibraryInfo> resultList = Arrays.asList(libraryInfo1, libraryInfo2);

        if (name != null) {
            resultList = resultList.stream()
                    .filter(libraryInfo -> {
                        return libraryInfo.getName().equals(name);
                    })
                    .toList();
        }
        if (includeVersion == false) {
            resultList = resultList.stream()
                    .map(libraryInfo -> {
                        LibraryInfo simpleInfo = new LibraryInfo();
                        simpleInfo.setName(libraryInfo.getName());
                        // version 정보는 포함하지 않음.
                        return simpleInfo;
                    }).toList();
        }

        return resultList;
    }
}

기존 메서드 코드에서 if 문이 2개나 더 들어갔는데, name 으로 필터링 및 includeVersion 의 true, false 에 따라 version 정보를 포함할지를 구현해 놓은것 뿐이니 이해가 가지 않으면 그냥 넘어가도 됩니다.

이제 spring boot 재구동하고 http://127.0.0.1:8080/actuator/myLibraryInfo 를 웹브라우저에서 실행하면 아래처럼 400 status 코드가 리턴됩니다.

name과 includeVersion 이라는 파라미터를 적었으며, includeVersion 파라미터는 @Nullable 이 없으므로 필수 필드인데, 우린 아무런 query string 을 넣지 않았기에 400 bad request 에러를 리턴해주는 겁니다.

 

아래 영상처럼 query string 을 넣어주면 원하는대로 잘 동작하는걸 알 수있습니다.

파라미터 수신방법2 - body 수신방법

query string 외에 http body 의 정보를 파라미터로 수신해야 할때도 있습니다. 보통 HTTP POST 방식일때이겠죠.

 

기존 예제와 큰 차이는 없으나, HTTP POST 방식으로 매핑되기 위해 @WriteOperation 을 사용해야 하며, 수신하고 싶은 파라미터명을 적어주면 됩니다.

@Slf4j
@Endpoint(id = "myLibraryInfo")
public class MyLibraryInfoEndpoint {

    @WriteOperation
    public void changeSomething(String name, boolean enableSomething) {
        log.info("name: {}, enableSomething: {}", name, enableSomething);
    }
    
    (생략)
}

라이브러리 조회 endpoint 에서는 조작할 만한 아이템이 보이지 않기에 단순히 로그만 찍어서 파라미터가 잘 수신되는지만 확인하도록 하겠습니다.

spring mvc 로 개발시의 body 는 MemberDto, OrderDto 와 같이 DTO 클래스를 파라미터로 지정하는데, 위 예제에서는 DTO 객체가 아닌, 개별 파라미터를 하나씩 다 적어주는게 특이해 보입니다.

 

단순한 파라미터 타입만 지원되기에 DTO와 같이 여러 멤버변수를 가진 객체를 파라미터로 지정해주는건 지원되지 않는다고 가이드 되고 있습니다. (아래그림참조)

spring mvc로 비유하자면, 입력값을 java DTO 타입으로 변환이 안되므로 simple 한 argument resolver 가 지원된다고 볼 수 있습니다.

https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.implementing-custom.input

HTTP POST 방식으로 요청을 해야하므로 웹브라우저는 사용이 어려우며 postman 이나 insomnia 와 같은 HTTP client 프로그램을 이용해서 테스트를 해야 합니다.

 

저는 insomnia 라는 프로그램을 통해 아래처럼 json type의 body에 name 과 enableSomething 이라는 필드를 넣어줬으며 HTTP POST 로 method 를 지정했습니다. 

java 메서드의 리턴 타입이 void 이므로 별도의 응답 body는 없습니다. 그래서 응답 status 가 204 no content 라고 나오네요.

body 의 경우 DTO 로 수신할 수 없는게 다소 불편할 수 있으나, actuator 에 복잡한 DTO를 넘길일은 거의 없으니 문제는 없어 보입니다.

 

파라미터 수신방법3 - path 파라미터 수신방법

마지막으로 spring mvc 의 @PathVariable 에 해당하는 path 파라미터 수신 방법에 대해 알아보겠습니다.

@Selector 라는 path 파라미터 수신용 어노테이션을 사용하면 됩니다.

여기서도 로그를 통해 파라미터가 잘 들어오는지 확인만 하고, 해당 파라미터를 그대로 리턴하도록 했습니다.

@ReadOperation
public String getPathVariable(@Selector String path1) {
    log.info("path1: {}", path1);
    return path1;
}

아래처럼 /actuator/myLibraryInfo 하위에 myPathVar 라는 path 파라미터를 넣었습니다. 응답에 myPathVar 라고 path 파라미터로 넣은 값이 잘 리턴되는걸 알 수 있습니다. 

/actuator/myLibraryInfo/path1/path2/path3  와 같이 path가 여러개일때도 처리가 가능합니다.

Selector 어노테이션을 내부를 보면 아래처럼 match 라는 필드가 있으며, 해당 필드는 SINGLE, ALL_REMAINING 중 하나를 넣을 수 있습니다. 하이라이트한 부분을 읽어보면 알 수 있듯이 모든 path 부분을 캡쳐하고, path 구분을 위해 String[] 형태로 변환된다고 적혀있습니다.

 

@Selector 의 match 기본값이 Match.SINGLE 이므로 아래처럼 파라미터 부분을 변경해주면 됩니다.

@ReadOperation
public String getMultiPathVariable(@Selector(match = Selector.Match.ALL_REMAINING) String[] path) {
    log.info("path: {}", Arrays.asList(path));
    return Arrays.asList(path).toString();
}

아래처럼 path1/path2/path3.... 처럼 path를 여러개 넣어서 테스트해보니 입력받은 path 파라미터가 잘 수신되는걸 알 수 있습니다.

주의할 부분은 ALL_REMAINING 과 SINGLE 를 사용한 메서드를 각각 생성해 버리면,  path 갯수에 상관없이 

ALL_REMAINING 메서드만 호출됩니다. 어느 메서드가 호출되는지 애매하고 외우기도 어려우니 Endpoint 당 @Selector 는 한개만 사용하는게 좋아 보입니다.

 

web , jmx 선택

@Endpoint는 web 과 jmx 둘다 지원해주는 endpoint 입니다. 

만약 web 용으로만 endpoint 를 만들고 싶다면 

@WebEndpoint 를 이용하면 됩니다. 아래처럼 오직 HTTP 에만 노출되게 해줍니다.

jmx 용으로만 endpoint 를 만들고 싶다면 당연히 @JmxEndpoint 를 이용하면 됩니다.

 

앞서 배웠듯이 최종적으로 노출되는건 yml 에서 exposure.include 에 적힌 endpoint 가 외부로 노출됩니다. 즉 모든 조건이 만족되어야 외부로 노출됩니다. 

 

rest controller 와 다를게 없음

가만히 생각해보면 endpoint 들은 @RestController 어노테이션을 이용해서 우리가 자주 만들던 rest controller 와 다를게 없습니다. url 에 맞게 메서드 매핑을 해주고 json을 리턴하면 되니까요

 

네 맞습니다. 

아래 공식가이드에서도 @RestControllerEndpoint 라는 어노테이션을 이용하면 일반적인 rest controller 구현하듯이 @GetMapping , @PostMapping 등을 써서 endpoint 를 만들수 있다고 합니다. 다만 호환성을 위한 비용이 발생할 수 있으니 특별한 이유가 아니면 @EndPoint 나 @WebEndpoint 를 이용해서 구현하라고 권장하고 있습니다.

https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.implementing-custom.controller

 

DispatcherServlet -> controller 순으로 http 요청이 흘러가니 controller 앞단인 서블릿으로도 구현할 수 있지 않을까? 생각할 수 있습니다.

네 맞습니다. 

@ServletEndpoint 를 이용해서 구현할 수 있으나 동일하게 호환성을 위한 비용이 발생할 수 있으니 가급적 @Endpoint 를 이용하라고 권장하고 있습니다.

https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.implementing-custom.servlet

권장하는 방법이 아니므로 저도 위 방법대로 해본적이 없고 굳이 예제로 제공할 필요도 없어보입니다.

 


custom endpoint 설명하는데 꽤나 긴 글이 되었네요.  rest controller 에 익숙하다면 개념적으로 크게 어렵지 않을것으로 보입니다.

 

"결국 rest controller 를 만들면 되는거니 굳이 actuator 가 아닌 직접 rest controller 만들면 되지 않나? " 라고 생각할 수도 있습니다. 그러나 직접 rest controller 로 만들어버리면, prometheus 와 같은  actuator 와 호환이 되는 여러 라이브러리와 연동이 될수 없습니다. 즉 actuator 가 일종의 인터페이스 역할이므로 다른 라이브러리와의 연동을 위해 actuator 를 이용하는게 좋습니다. 

 

custom endpoint 관련 전체 소스코드는 아래에서 받을 수 있습니다.

https://github.com/ChunGeun-Yu/spring-actuator-study/tree/customEndpoint

 

 

다음에는 actuator가 제공하는 endpoint 중 health endpoint 에 대해 알아보겠습니다. 

 

아~ 이제 머리가 좀 아프네. 쉬었다 하자... 라고 생각하고 있나요? 이러면 결국 안본다는거 잘 알잖아요. 화이팅!