'그 뭐더라'에서 '그' 를 설명하는 블로그
[spring boot] open API 연동하기 (한국관광공사) - REST 엔드포인트 호출 restClient 본문
개요
스프링은 REST 엔드포인트 호출을 위해 다음과 같은 선택 사항을 제공한다.
장점과 단점을 주로 기록하려고 한다. 실제로 단점을 느끼고 싶으면 코드를 찾아보면 좋다.
- RestTemplate
스프링 3.0에 추가된 HTTP 통신 템플릿이다.동기 방식으로 작동한다.
장점
- 오래된 스프링 버전에서 사용 가능
- 자유롭게 구성을 할 수 있음
단점 - 여러 메서드가 오버로드되어있어 사용하기 힘듦
- 고전적인 템플릿 패턴
- Non-blocking 환경에서 적합하지 않음
- WebClient
spring 5에 추가된 템플릿으로 비동기, 논블로킹을 지원하며 사용하고 싶다면 webflux 의존성이 필요하다.
장점
- 비동기 방식
- 직관적이고 유연한 API 제공
단점 - webflux 의존성 추가 필요 -> werClient만을 위해 해당 종속을 추가해야한다면....굳이 싶은...
- RestClient
- 블로킹 스타일의 유연한 API -> fluent API는 그대로 해서 RestTemplate 인프라와 함께 사용 가능 사용 방식은 webClient와 유사
- 종속성 없음
버전이 높아지고 기술이 나올수록 추상화 수준이 높아져서 사용하기 아주 편리하고 코드 가족성도 높다.
우선 가장 최신기술인 RestClient 를 채택하여 개발해보겠다.
개발
RestClientConfig
- 재시도 로직
RetryClientHttpRequestInterceptor
class RetryClientHttpRequestInterceptor : ClientHttpRequestInterceptor {
companion object {
private const val ATTEMPTS = 3
private const val ZERO = 0
}
private val log: Logger = LoggerFactory.getLogger(RetryClientHttpRequestInterceptor::class.java)
private val retryableStatus = setOf(HttpStatus.TOO_MANY_REQUESTS)
override fun intercept(
request: HttpRequest,
body: ByteArray,
execution: ClientHttpRequestExecution,
): ClientHttpResponse {
for (i in ZERO until ATTEMPTS) {
val response = execution.execute(request, body)
if (!retryableStatus.contains(response.statusCode)) {
// Todo. ES에 사용하기 위해 json 형태 필요
log.info("Successful attempt: $i")
return response
}
}
log.error("Retries exceeded")
throw RetriesExceededException()
}
}
- 타임아웃 설정
- 내부 라이브러리에서 사용하는 인코딩 제거 -> 커스텀하게 인코딩할 예정
-에러 핸들링
- 선언적 Http interface를 사용할것이기 때문에 HttpServiceProxyFactory
- 여러 api를 사용할 예정이기 때문에 제네릭하게 함수를 분리
전체 코드
package kr.weit.roadyfoody.global.client
import kr.weit.roadyfoody.common.exception.RestClientException
import kr.weit.roadyfoody.test.application.client.TestClientInterface
import kr.weit.roadyfoody.tourism.presentation.client.TourismClientInterface
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.web.client.ClientHttpRequestFactories
import org.springframework.boot.web.client.ClientHttpRequestFactorySettings
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpStatusCode
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.web.client.RestClient
import org.springframework.web.client.support.RestClientAdapter
import org.springframework.web.service.invoker.HttpServiceProxyFactory
import org.springframework.web.util.DefaultUriBuilderFactory
import java.net.http.HttpClient
import java.time.Duration
import java.util.concurrent.Executors
@Configuration
class RestClientConfig {
companion object {
private const val CONNECT_TIME = 1L
private const val READ_TIME = 5L
private const val TEST_URL = "https://jsonplaceholder.typicode.com"
private const val TOURISM_URL = "http://apis.data.go.kr/B551011/KorService1"
}
private val log: Logger = LoggerFactory.getLogger(RestClientConfig::class.java)
@Bean
fun tourismClientInterface(): TourismClientInterface {
return createClient(TOURISM_URL, TourismClientInterface::class.java)
}
private fun <T> createClient(
baseUrl: String,
clientClass: Class<T>,
): T {
val restClientBuilder =
RestClient.builder()
.uriBuilderFactory(defaultUriBuilderFactory(baseUrl))
restClientBuilder
.requestInterceptor(RetryClientHttpRequestInterceptor())
.requestFactory(clientHttpRequestFactory())
.defaultStatusHandler(HttpStatusCode::is4xxClientError) { _, response ->
log.error("Client Error Code={}", response.statusCode)
log.error("Client Error Message={}", String(response.body.readAllBytes()))
throw RestClientException()
}
.defaultStatusHandler(HttpStatusCode::is5xxServerError) { _, response ->
log.error("Server Error Code={}", response.statusCode)
log.error("Server Error Message={}", String(response.body.readAllBytes()))
throw RestClientException()
}
.build()
val restClient = restClientBuilder.build()
val adapter = RestClientAdapter.create(restClient)
val factory = HttpServiceProxyFactory.builderFor(adapter).build()
return factory.createClient(clientClass)
}
private fun clientHttpRequestFactory(): JdkClientHttpRequestFactory {
val requestSettings =
ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(CONNECT_TIME))
.withReadTimeout(Duration.ofSeconds(READ_TIME))
return ClientHttpRequestFactories.get(JdkClientHttpRequestFactory::class.java, requestSettings)
?: JdkClientHttpRequestFactory(
HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build(),
)
}
private fun defaultUriBuilderFactory(baseUrl: String): DefaultUriBuilderFactory {
return DefaultUriBuilderFactory(baseUrl).apply {
setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE)
}
}
}
TourismClientInterface
@ClientInterface
interface TourismClientInterface {
@GetExchange(
"/searchKeyword1?" +
"serviceKey={SERVICE_KEY}" +
"&MobileApp={MOBILE_APP}&MobileOS={MOBILE_OS}&pageNo=1&numOfRows={NUM_OF_ROWS}&listYN=Y" +
"&&arrange=C&contentTypeId={CONTENT_TYPE_ID}&keyword={KEYWORD}&_type=json",
)
fun searchTourismKeyword(
@PathVariable(name = "SERVICE_KEY") serviceKey: String,
@PathVariable(name = "MOBILE_APP") mobileApp: String,
@PathVariable(name = "MOBILE_OS") mobileOs: String,
@PathVariable(name = "NUM_OF_ROWS") numOfRows: Int,
@PathVariable(name = "KEYWORD") keyword: String,
@PathVariable(name = "CONTENT_TYPE_ID") contentTypeId: Int = 15,
): ResponseWrapper
}
TourismProperties
- application에 있는 설정값을 가져오기 위해 properties 클래스 생성
@ConfigurationProperties(prefix = "client.tour")
class TourismProperties(
val apiKey: String,
val mobileApp: String,
val mobileOs: String,
)
TourismService
- 한글 검색어와 서비스키 인코딩 후 인터페이스 호출
@Service
class TourismService(
private val tourismProperties: TourismProperties,
private val tourismClientInterface: TourismClientInterface,
) {
private val log: Logger = LoggerFactory.getLogger(TourismService::class.java)
fun searchTourism(
numOfRows: Int,
keyword: String,
): SearchResponses {
val encodedKeyword: String = UriEncoder.encode(keyword)
val encodedServiceKey: String = UriEncoder.encode(tourismProperties.apiKey)
return tourismClientInterface.searchTourismKeyword(
encodedServiceKey,
tourismProperties.mobileApp,
tourismProperties.mobileOs,
numOfRows,
encodedKeyword,
12,
)
}
출처: https://myvelop.tistory.com/217#4
스프링의 외부 API 호출, 그리고 RestClient
애플리케이션 외부 API 호출현업에서 외부 API를 호출해야하는 일이 많다. 다른 회사의 서비스(휴대전화 인증, 결제시스템)를 이용할 때 필수적이다. 물론 클라이언트 단에서 외부 API를 호출한다
myvelop.tistory.com
https://digma.ai/restclient-vs-webclient-vs-resttemplate/
RestClient vs. WebClient vs. RestTemplate
In this article, we will compare RestClient, WebClient, and RestTemplate for choosing the right library to call REST APIs in Spring Boot.
digma.ai
https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
REST Clients :: Spring Framework
WebClient is a non-blocking, reactive client to perform HTTP requests. It was introduced in 5.0 and offers an alternative to the RestTemplate, with support for synchronous, asynchronous, and streaming scenarios. WebClient supports the following: Non-blocki
docs.spring.io
'Spring' 카테고리의 다른 글
| [spring boot] open API 연동 - 가상스레드 사용하기 (0) | 2024.06.23 |
|---|---|
| [Spring Boot] TestContainer를 사용하여 test 하기 (0) | 2024.06.12 |