Spring Boot 3.x(Spring 6.x) 이상부터 새롭게 추가되었고, 공식적으로 사용을 권장하는 HttpInterface에 대해 알아보고자 한다.
HttpInterface란?
✔ HttpInterface는 Http 요청을 위한 로직을 전통적인 RestTemplate, WebClient를 깡으로 사용하여 개발하는 것이 아닌, 인터페이스와 어노테이션을 통해 정의할 수 있도록 지원한다. (Feign Client와 유사하다)
✔ 구현한 인터페이스를 프록시 객체로 생성하고, 이를 Bean으로 등록하여 사용하면 손쉽게 Http 요청을 보낼 수 있다.
implementation("org.springframework.boot:spring-boot-starter-webflux")
◼ 해당 Dependency를 적용해야 사용할 수 있다. (HttpInterface는 결국 WebClient로 동작하기 때문)
💬 WebClient는 Non-Blocking, Reactive Http Client이다.
✨ 이전 코드 (카카오 사용자 정보 요청 API 예시)
webClient
.get()
.uri("https://kapi.kakao.com/v2/user/me")
.headers {
it.contentType = MediaType.APPLICATION_FORM_URLENCODED
it.set(HttpHeaders.AUTHORIZATION, getBearerToken(kakaoLoginRequest.accessToken))
}
.retrieve()
.bodyToMono(KakaoUserInfo::class.java)
.block() ?: throw WebClientResponseNullException()
✨ HttpInterface 적용 코드
interface KakaoClient {
@GetExchange("/v2/user/me")
fun getKakaoUserInfo(@RequestHeader(HttpHeaders.AUTHORIZATION) accessToken: String): KakaoUserInfo
}
◼ 이처럼 어노테이션을 사용해서 간단하게 요청할 수 있다.
🛠 요청 어노테이션
@RequestHeader: 요청 헤더를 설정한다.
@RequestBody: 요청 바디를 설정한다.
@PathVariable: 요청 Path를 설정한다.
@RequestParam: 요청 파라미터를 설정한다.
@RequestPart: 요청 mutipart 데이터를 설정한다.
@CookieValue: 요청 쿠키를 설정한다.
💬 인터페이스를 만들었으면, 해당 인터페이스에 대한 프록시 구현체를 만들어 주어야 한다.
@Configuration
class ClientConfig {
@Bean
fun kakaoClient(): KakaoClient {
return HttpServiceProxyFactory
.builder(WebClientAdapter.forClient(getWebClient())).build()
.createClient(KakaoClient::class.java)
}
private fun getWebClient() = WebClient
.builder()
.baseUrl("https://kapi.kakao.com")
.clientConnector(ReactorClientHttpConnector(httpClient()))
.build()
private fun httpClient() = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, DEFAULT_TIME_OUT_MS.toInt())
.responseTimeout(Duration.ofMillis(DEFAULT_TIME_OUT_MS))
.doOnConnected { connection ->
connection
.addHandlerFirst(ReadTimeoutHandler(DEFAULT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
.addHandlerLast(WriteTimeoutHandler(DEFAULT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
}
}
◼ 해당 코드를 통해, WebClient를 생성하고 이를 바탕으로 인터페이스의 프록시 구현체를 생성하여 Bean으로 등록해 사용할 수 있다.
자동으로 Bean 등록하기
🤦♂️ 현재 HttpInterface는 자동으로 Bean 등록이 되지 않아 @Configuration 아래 @Bean으로 등록해서 사용해야 하는 번거로움이 있다. 따라서, 다음과 같이 자동으로 Bean에 등록될 수 있도록 설정해주어야 한다.
import org.springframework.http.HttpHeaders
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.service.annotation.GetExchange
import org.springframework.web.service.annotation.HttpExchange
@HttpExchange("https://kapi.kakao.com")
interface KakaoClient {
@GetExchange("/v2/user/me")
fun getKakaoUserInfo(@RequestHeader(HttpHeaders.AUTHORIZATION) accessToken: String): KakaoUserInfo
}
◼ 먼저, HttpExchange 어노테이션에 base Url을 설정한다.
class HttpInterfaceFactory {
fun <S> create(clientClass: Class<S>): S {
val httpExchange = (AnnotationUtils.findAnnotation(clientClass, HttpExchange::class.java)
?: throw IllegalStateException("HttpExchange annotation not found"))
require(StringUtils.hasText(httpExchange.url)) { "HttpExchange url is empty" }
val webClient = getWebClient(httpExchange.url)
return HttpServiceProxyFactory.builder(WebClientAdapter.forClient(webClient)).build().createClient(clientClass)
}
private fun getWebClient(baseUrl: String) = WebClient
.builder()
.baseUrl(baseUrl)
.clientConnector(ReactorClientHttpConnector(httpClient()))
.build()
private fun httpClient() = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, DEFAULT_TIME_OUT_MS.toInt())
.responseTimeout(Duration.ofMillis(DEFAULT_TIME_OUT_MS))
.doOnConnected { connection ->
connection
.addHandlerFirst(ReadTimeoutHandler(DEFAULT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
.addHandlerLast(WriteTimeoutHandler(DEFAULT_TIME_OUT_MS, TimeUnit.MILLISECONDS))
}
}
◼ HttpExchange가 적용된 Interface에 대해 프록시 구현체를 생성하는 코드이다.
class HttpInterfaceClassFinder {
fun findBeanDefinitions(environment: Environment): MutableSet<BeanDefinition> {
val scanner = object : ClassPathScanningCandidateComponentProvider(false, environment) {
override fun isCandidateComponent(beanDefinition: AnnotatedBeanDefinition): Boolean {
return beanDefinition.metadata.isInterface && beanDefinition.metadata.hasAnnotation(HttpExchange::class.java.name)
}
}
scanner.addIncludeFilter(AnnotationTypeFilter(HttpExchange::class.java))
return scanner.findCandidateComponents({메인 클래스 명}::class.java.`package`.name)
}
}
◼ 타켓 클래스를 인터페이스이면서, HttpExchange 어노테이션을 가지고 있는지 확인하고 scanner의 범위를 메인클래스 패키지를 기준으로 설정한다.
@Component
class HttpInterfaceFactoryBeanFactoryPostProcessor : BeanFactoryPostProcessor {
override fun postProcessBeanFactory(beanFactory: ConfigurableListableBeanFactory) {
val beanDefinitions =
HttpInterfaceClassFinder().findBeanDefinitions(beanFactory.getBean(Environment::class.java))
beanDefinitions.stream().filter {
StringUtils.hasText(it.beanClassName)
}.forEach {
findClassAndRegisterAsSingletonBean(beanFactory, HttpInterfaceFactory(), it)
}
}
private fun findClassAndRegisterAsSingletonBean(
beanFactory: ConfigurableListableBeanFactory,
factory: HttpInterfaceFactory,
beanDefinition: BeanDefinition
) {
val beanClassName = getBeanClassName(beanDefinition)
beanFactory.registerSingleton(
beanClassName,
factory.create(findHttpInterfaceClass(beanDefinition))
)
}
private fun findHttpInterfaceClass(beanDefinition: BeanDefinition): Class<*> {
try {
val beanClassName = getBeanClassName(beanDefinition)
return ClassUtils.forName(beanClassName, this::class.java.classLoader)
} catch (e: ClassNotFoundException) {
throw IllegalStateException(e)
}
}
private fun getBeanClassName(beanDefinition: BeanDefinition): String =
beanDefinition.beanClassName ?: throw IllegalStateException("beanClassName is null")
}
◼ 위 조건들에 맞으면, 싱글톤 빈으로써 등록한다.
📒 REF
https://mangkyu.tistory.com/291
'Backend > Spring' 카테고리의 다른 글
동시성 문제와 해결 방법들 - 1편 (Synchronized) (1) | 2023.09.06 |
---|---|
Spring Event 사용하기 (0) | 2023.06.29 |
Kotlin에서 Rest Docs 문서화 코드 개선하기 (0) | 2023.05.29 |
Kotest를 통한 DCI 패턴 적용 (0) | 2023.05.27 |
spring에서 flyway 사용하기 (0) | 2023.05.26 |