5 분 소요

Spring boot 에서 REST client 를 사용하는 방법


Spring 공식 문서를 참고하였습니다.

REST api

스프링에서 @RestController 를 이용해 쉽게 REST api 를 만들어 줄 수 있었습니다. 리턴값으로 (직렬화 가능한) 객체를 줘도 알아서 json 포맷으로 만들어 주고, ResponseEntity<> 라는 Wrapper 로 좀 더 커스터마이즈가 가능했습니다. 예를 들면 이런 식으로 endpoint 를 만들어 줄 수 있었습니다.

1
2
3
4
5
6
7
8
9
10
@RestController
public class ApiController(){

  @RequestMapping(value = "people", method=RequestMethod.POST)
  public ResponseEntity<?> peoplePost(@Valide @RequestBody People people){
      :
      :
    return new ResponseEntity<>(body, header, HttpStatus);
  }
}

REST Client

REST api 를 제공할 뿐 아니라, 다른 서비스의 REST api 를 스프링에서 사용해야 할 때가 있습니다. 이전 포스트에서 다룬 오픈소스 프로젝트인 sagan 에서도 github 의 api 를 호출하고 있었지요. (github 를 guide 들을 저장하는 repository 로 사용하고 있었음.) 관련해서 스프링에선 RestClient, RestTemplate, WebClient 세가지 방법을 제시해 주고 있습니다. RestClient 는 동기방식, WebClient 는 비동기방식 (reactive spring) 이며, RestTemplateRestClient 와 같은데 template 형식으로 자주 사용하는 방법들을 축약시킨 형태입니다.

Normal thread, RestTemplate

Java 21 환경에서 기존 쓰레드와 RestTemplate 를 테스트 해보겠습니다. Spring 의 main method 에 조금 코드를 추가해서, noraml thread 인지 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
		Properties properties = new Properties();
		properties.setProperty("server.port", "8081");
		SpringApplication app = new SpringApplication(ApiDemoApplication.class);
		app.setDefaultProperties(properties);
		app.run(args);
		System.out.println(
				Thread.currentThread().isVirtual());
	}

Java21 의 새로운 기술인 virtual thread 를 사용하지 않는 경우, false 를 출력합니다. 이 어플리케이션에선 Client 역할을 수행하고, 8080번 포트에선 서버 역할을 하는 어플리케이션을 따로 띄웠습니다. 별건 없고 1초의 시간이 걸리는 REST api 를 테스트용으로 작성했습니다. 서버의 RestController 에서 적당히 만들어 줬습니다.

1
2
3
4
5
6
7
8
9
    @GetMapping("/test")
    public String testGet() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "hello world!";
    }

그리고 다시 클라이언트 어플리케이션에서, 테스트용 endpoint 를 만들고 curl 로 테스트 해봤습니다. test framework 를 사용하지 않아서 깔끔하다는 느낌은 없는데, 이 부분은 나중에 다뤄보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Controller part
@RestController
@RequestMapping("/api")
class ApplicationController{
	private Logger log = LoggerFactory.getLogger(this.getClass());
	private ClientTest clientTest;
	ApplicationController(ClientTest clientTest){
		this.clientTest = clientTest;
	}

	@GetMapping("/testclient")
	public String testClientGet(){
		String res = clientTest.callApi("/test");
		log.warn(res);
		return res;
	}
}


// Client part
@Component
public class RestClientTest implements ClientTest{

    RestTemplateBuilder restTemplateBuilder;
    RestTemplate client;

    public RestClientTest(RestTemplateBuilder restTemplateBuilder){
        this.restTemplateBuilder = restTemplateBuilder;
        this.client = restTemplateBuilder.rootUri("http://localhost:8080/api").build();
    }

    @Override
    public String callApi(String url) {
        String res = this.client.getForObject(url, String.class);
        return res;
    }
}

지금 개발환경은 windows 입니다만, linux 에 익숙해지기 위해서 WSL 을 이용하고 있습니다. linux bash 에서 curl 을 쓸건데, WSL 은 windows 네트워크와 분리되어 있어서 위의 localhost (127.0.0.1) 로 접근할 수 없습니다. (java application 도 WSL 상에서 돌리면 당연히 가능) WSL 에선 다음과 같은 명령어로 windows 의 주소를 가져올 수 있습니다.

$ ip route |grep default
default xxx.xxx.xxx.xxx dev eth0 proto kernel

$ curl xxx.xxx.xxx.xxx:8081/api/testclient
hello, world!

좀 더 심하게 load 를 주려면 이렇게 합니다.

$ seq 1 5000 | xarg -P5000 -I{} curl xxx.xxx.xxx.xxx:8081/api/testclient

이제 client application 의 출력을 보면 이런 식입니다.

2024-01-24T22:24:22.650+09:00  WARN 28280 --- [o-8081-exec-150] r.p.apidemo.ApplicationController        : hello world!
  :
  :
2024-01-24T22:24:47.637+09:00  WARN 28280 --- [o-8081-exec-128] r.p.apidemo.ApplicationController        : hello world!

5000 개의 요청이 한번에 들어와서 꽤 시간이 걸리고 있고, thread number 도 100을 넘어가는 걸 볼 수 있습니다. 시간이 20초 이상 걸리고 있습니다.

Virtual thread, RestTemplate

이제 client application 의 설정을 바꿔서, Java 21 의 virtual thread 를 사용하도록 하게 했습니다. 코드는 Baeldung 을 참고했습니다. 기존의 http 프로토콜을 처리해주는 객체를 virtual thread 를 사용하는 객체로 갈아끼우는 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@EnableAsync
@Configuration
@ConditionalOnProperty(
        value = "spring.thread-executor",
        havingValue = "virtual"
)
public class ThreadConfig {
    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }

    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

참고로 위 설정을 해도, main 메서드에 있던 currnt thread 는 virtual thread 가 아니라고 응답합니다. 하지만 Controller 의 메서드에선 virtual thread 를 사용하고 있습니다.

1
2
3
4
5
6
7
8
9
10
// in Controller

	@GetMapping("/thread")
	public String testThread(){
		return Thread.currentThread().isVirtual() + "";
	}

// in bash
$ curl xx.xx.xx.xx:8081/api/thread
true

위와 똑같이 curl 을 잔뜩 날려 테스트 해봤습니다. 근데 에러가 나버렸어요

2024-01-24T22:30:05.089+09:00 ERROR 8752 --- [               ] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8080/api/test": Connection refused: no further information] with root cause

java.net.ConnectException: Connection refused: no further information

connection refused 라면 client 보단 server 문제겠거니 생각했습니다. 확인해보니 Out-of-memory 가 떠있었습니다. 생각해보니 저번에 JVM option 으로 메모리 할당을 제한해놨었습니다. 해당 옵션을 지우고 다시 시도했습니다.

2024-01-24T22:34:37.954+09:00  WARN 8752 --- [               ] r.p.apidemo.ApplicationController        : hello world!
  :
  :
2024-01-24T22:35:03.403+09:00  WARN 8752 --- [               ] r.p.apidemo.ApplicationController        : hello world!

normal thread 때와 비슷하게 20초 이상 걸리고 있습니다. 조금 생각해보니 어차피 server application 에서 normal thread 를 사용하고 있으면 똑같이 병목현상이 일어날 것 같았습니다. 서버 어플리케이션에도 ThreadConfig 으로 virtual thread 를 사용하게끔 해 두고, 다시 테스트 해봤습니다.

2024-01-24T22:43:35.426+09:00  WARN 8752 --- [               ] r.p.apidemo.ApplicationController        : hello world!
2024-01-24T22:43:44.350+09:00  WARN 8752 --- [               ] r.p.apidemo.ApplicationController        : hello world!

걸리는 시간이 10 초 안으로 들어왔습니다.

Virtual Thread, WebClient

이번에는 WebClient 를 써봤습니다. ClinetTest 를 구현하는 새로운 client 를 아래와 같이 작성했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class WebClientTest implements ClientTest{
  
  WebClient client;

  public WebClientTest(){
    // 가장 간단히 생성하는 방법.
    // this.client = WebClient.builder().baseUrl("http://localhost:8080").build();

    // 더 상세히 설정하는 방법.
    ConnectionProvider connectionProvider = ConnectionProvider.builder("myConnectionPool")
            .pendingAcquireTimeout(Duration.ofSeconds(20))
            .maxConnections(1000)
            .pendingAcquireMaxCount(5000)
            .build();
    ReactorClientHttpConnector clientHttpConnector = new ReactorClientHttpConnector(HttpClient.create(connectionProvider));
    this.client = WebClient.builder().baseUrl("http://localhost:8080")
            .clientConnector(clientHttpConnector)
            .build();
  }
  
  @Override
  public String callApi(String url) {
    Mono<String> res = this.client.get().uri("/api/test").retrieve().bodyToMono(String.class);
      return res.block();
  }
}

…그리고 앞선 시도와 똑같이 load 를 주면

2024-01-24T23:08:11.384+09:00  WARN 26108 --- [               ] r.p.apidemo.ApplicationController        : hello world!
  :
2024-01-24T23:08:21.195+09:00  WARN 26108 --- [               ] r.p.apidemo.ApplicationController        : hello world!

Virtual thread 를 사용하는 RestTemplate 처럼 10초 내로 시간이 걸렸습니다.

정리

Virtual thread 를 사용할 때 단순한 (오래 걸리는) api call 의 경우, RestTemplateWebClient 가 비슷한 퍼포먼스를 보일 수 있습니다.

다만, 위 예시에선 reactive programming 의 문법을 깡그리 무시하고 있다는 점을 주의해야 합니다. 어떤 것도 publish, subscribe 하지 않고, block() 을 사용함으로써 WebClient 를 그냥 평범한 RestClient 처럼 사용하고 있는 상황입니다. 아마 non-blocking feature 는 virtual thread 만 사용함으로써 얻을 수 있고, Reactive programming 이 제공해주는 event-driven programming 같은 경우는 여전히 Reactor 및 WebClient 에 이점이 있지 않을까 싶습니다.