본문 바로가기

Backend/Spring

kotlin + HttpInterface 알아보기

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