Spring Boot Webflux에서는 다른 서버와 http 통신을 하기 위해 WebClient를 기본으로 제공하고 특별한 사유가 없다면 WebClient를 사용한다.

 

대부분 도메인마다 WebClient를 하나 만들어두고 사용하기 때문에 MSA로 이루어진 환경에서는 여러 endpoint 호출을 위해 여러 개의 WebClient를 생성하게 된다. 여러 WebClient를 사용하다 보면 필연적으로 중복 코드가 발생될 확률이 높으며 새로운 WebClient를 추가할 때마다 간단하지만 귀찮은 작업이 동반될 수 있다.

 

따라서 이 글에서는 WebClient를 생성하는 방법과 config 기반으로 여러개의 WebClient를 자동으로 생성해주는 방식에 대해 공유하고자 한다.

 


WebClientProperties 클래스 생성

WebClient 마다 필요한 property 설정을 다르게 해줄 수 있도록 필요한 property들을 묶어둔 data class를 하나 생성한다.

추후 우리는 default properties를 지정할 것이기 때문에 property를 overriding하는 메서드도 하나 추가해둔다.

 

data class WebClientProperties(
    var name: String = "webClient",
    var url: String = "",
    var maxConnections: Int? = null,
    var connectionTimeout: Int = 3000,
    var readTimeout: Long = 3000L,
    var writeTimeout: Long = 9000L,
    var maxIdleTime: Duration? = null,
    var maxRetry: Long = 3,
    var retryDelay: Duration = Duration.ofSeconds(1)
) {

    fun overrideProperties(name: String, other: WebClientProperties) {
        this.name = name
        if (maxConnections != other.maxConnections) {
            maxConnections = other.maxConnections
        }
        if (connectionTimeout != other.connectionTimeout) {
            connectionTimeout = other.connectionTimeout
        }
        if (readTimeout != other.readTimeout) {
            readTimeout = other.readTimeout
        }
        if (writeTimeout != other.writeTimeout) {
            writeTimeout = other.writeTimeout
        }
        if (maxIdleTime != other.maxIdleTime) {
            maxIdleTime = other.maxIdleTime
        }
        if (maxRetry != other.maxRetry) {
            maxRetry = other.maxRetry
        }
        if (retryDelay != other.retryDelay) {
            retryDelay = other.retryDelay
        }
    }
}

 


Application Config에 WebClient의 Property 정보 작성

 

webClient:
    defaultProperties:
        connectionTimeout: 6000
        readTimeout: 6000
        writeTimeout: 9000
        maxIdleTime: 10s
        maxRetry: 2
        retryDelay: 1s
    services:
        google:
            url: http://localhost:30001
            maxRetry: 0
        github:
            url: http://localhost:30002

 

config yaml 구조에 대해 설명해보자면,

  • config의 시작은 webClient 여야한다. (대소문자 구분 주의)
  • defaultProperties로 web client property의 기본값을 지정해줄 수 있다.
  • services에서는 호출하는 endpoint에 대한 이름을 key로 가지고 하위 항목에는 WebClientProperties에 추가한 property를 선언할 수 있다.
    • 이때 property를 따로 선언한다면 defaultProperties로 선언한 값에서 그 항목만 overriding 될 것이다. (예제에서는 maxRetry 값이 github client는 2지만 google client에서는 0으로 오버라이딩된다.)

 


WebClientPair Class 생성

WebClientWebClientProperties를 한 쌍으로 묶어주기 위한 WebClientPair라는 클래스를 새로 선언해준다.

 

class WebClientPair(
    val webClient: WebClient,
    val properties: WebClientProperties
)

개발을 하다 보면 WebClient 뿐만 아니라 property도 필요한 경우가 있으므로..


WebClientFactory Component 제작

이제 위에서 추가한 값들을 통해 자동으로 WebClientPropertiesWebClientPair를 Bean으로 등록해주는 Bean Factory를 구현한다.


이 Bean Factory는 yaml에서 선언한 services:에 존재하는 모든 service 들을 WebClientPair Bean으로 등록시켜준다.

 

본문의 예제에서는 googlegithub이 존재했기 때문에 다음과 같은 이름을 가진 여러 개의 Bean들을 자동으로 생성해준다.

  • googleWebClientProperties
  • googleWebClientPair
  • githubWebClientProperties
  • githubWebClientPair

services:에 있는 service들의 이름은 다음의 규칙으로 변환되게 된다.

Prefix Bean Name
test-app: testAppWebClientProperties
testAppWebClientPair
testApp:
test_app:

 

전체 코드는 다음과 같다.

 

@Component
@ConfigurationProperties(prefix = "web-client")
class WebClientFactory(
    val defaultProperties: WebClientProperties = WebClientProperties(),
    val services: Map<String, WebClientProperties>
) : BeanFactoryAware {

    private val log = logger()

    private lateinit var beanFactory: ConfigurableBeanFactory

    override fun setBeanFactory(beanFactory: BeanFactory) {
        Assert.state(beanFactory is ConfigurableBeanFactory, "wrong bean factory type")
        this.beanFactory = beanFactory as ConfigurableBeanFactory
    }

    @PostConstruct
    fun configure() {
        services.entries.forEach { (name, properties) ->
            properties.overrideProperties(name, defaultProperties)
            registerPropertyBean(name, properties)
            registerWebClientBean(name, properties)
        }
    }

    private fun registerPropertyBean(name: String, properties: WebClientProperties) {
        val propertyBeanName = "${name.convertToBeanName()}WebClientProperties"
        beanFactory.registerSingleton(propertyBeanName, properties)
        log.info(
            "create-webClient-property, name={}, beanName={}, properties={}",
            name,
            propertyBeanName,
            properties
        )
    }

    private fun registerWebClientBean(name: String, properties: WebClientProperties) {
        val webClientBeanName = "${name.convertToBeanName()}WebClientPair"
        val webClientPair = createWebClient(properties)
        beanFactory.registerSingleton(webClientBeanName, webClientPair)
        log.info("create-webClient, name={}, beanName={}", name, webClientBeanName)
    }

    fun createWebClient(
        properties: WebClientProperties,
        block: WebClient.Builder.() -> Unit = {}
    ): WebClientPair = WebClientPair(
        WebClient.builder()
            .clientConnector(properties.toConnector())
            .baseUrl(properties.url)
            .apply(block)
            .build(),
        properties
    )

    private fun WebClientProperties.toConnector(): ReactorClientHttpConnector {
        log.info("initial-web-client-properties, {}", this)

        val sslContext = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build()

        val provider = ConnectionProvider.builder("$name-provider")
            .apply {
                if (maxConnections != null) {
                    maxConnections(maxConnections!!)
                }
                if (maxIdleTime != null) {
                    maxIdleTime(maxIdleTime!!)
                }
            }
            .build()

        log.info("initial-web-client-properties, name={}, maxConnections={}", name, provider.maxConnections())

        val httpClient = HttpClient.create(provider)
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectionTimeout)
            .secure { it.sslContext(sslContext) }
            .doOnConnected {
                it
                    .addHandlerLast(ReadTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS))
                    .addHandlerLast(WriteTimeoutHandler(writeTimeout, TimeUnit.MILLISECONDS))
            }
        return ReactorClientHttpConnector(httpClient)
    }

    fun String.convertToBeanName(): String {
        val tokens = this.split("_", "-", " ")

        val capitalizing: String = tokens
            .drop(1)
            .joinToString("") { word ->
                word.replaceFirstChar { char ->
                    char.uppercaseChar()
                }
            }

        return tokens.first() + capitalizing
    }
}

 


WebClient 주입받기

위 작업까지 완료되면 이제 우리는 아래 코드와 같은 방식으로 WebClient를 주입받아 사용할 수 있을 것이다.

 

@Component
@DependsOn("webClientFactory")
class GoogleClient(
    @Qualifier("googleWebClientPair") webClientPair: WebClientPair
) {
    ...
}

 

WebClientFactory를 통해 생성되는 Bean들은 Spring에서 인지하지 못하므로 GoogleClient가 생성되기 전에 @DependsOn을 통해 반드시 WebClientFactory가 만들어진 뒤에 GoogleClient가 만들어지도록 해야 한다.


@Qualifier("googleWebClientPair") webClientPair: WebClientPair

@Qualifier를 사용해 자동으로 생성된 WebClientPair를 주입받는다.


private val webClient = webClientPair.webClient
private val retrySpec = Retry.backoff(
    webClientPair.properties.maxRetry, webClientPair.properties.retryDelay
)

WebClientPair에는 WebClient WebClientProperties가 담겨 있기 때문에 다음과 같이 추가해서 사용하면 된다.


따라서 전체 코드 예시는 아래와 같다.

 

@Component
@DependsOn("webClientFactory")
class GoogleClient(
    @Qualifier("googleWebClientPair") webClientPair: WebClientPair
) {
    private val webClient = webClientPair.webClient
    private val retrySpec = Retry.backoff(
        webClientPair.properties.maxRetry, webClientPair.properties.retryDelay
    )

    suspend fun getMainPage(): Map<*, *>? {
        return webClient
            .get()
            .uri("main")
            .retrieve()
            .bodyToMono(Map::class.java)
            .retryWhen(retrySpec)
            .awaitSingleOrNull()
    }
}

 

특정 WebClient에만 개별적인 Task 추가하기

하지만 경우에 따라서는 특정 서비스를 호출하는 WebClient에만 별도의 filter를 적용한다던가 하는 니즈가 생길 수 있다.
이런 경우에는 WebClient의 mutate()를 활용하면 된다.

 

@Component
@DependsOn("webClientFactory")
class GithubClient(
    @Qualifier("githubWebClientPair") webClientPair: WebClientPair
) {
    private val webClient = webClientPair.webClient
        .mutate()
        .filter { request, next ->
            next.exchange(
                ClientRequest.from(request)
                    .header("LOGGING-ID", UUID.randomUUID().toString())
                    .build()
            )
        }
        .build()

    ...
}

 


마무리

이제 우리는 WebClient 생성에 대한 로직을 한 곳으로 몰아넣고, 새로운 WebClient를 추가할 일이 생겨도 config에 url을 추가해주는 것 외에는 어떠한 작업도 필요 없게 되었다.

만약 다른 endpoint를 호출하는 WebClient가 필요해지면 아래와 같이 config만 추가한다면 모든 작업이 완료된 것이다.

 

webClient:
    ...
    services:
        google:
            url: http://localhost:30001
            maxRetry: 0
        github:
            url: http://localhost:30002
        naver:
            url: http://localhost:30003
@Component
@DependsOn("webClientFactory")
class NaverClient(
    @Qualifier("naverWebClientPair") webClientPair: WebClientPair
)

만약 여러 프로젝트에 걸쳐서 WebClient 생성에 대한 중복 코드를 제거하고 싶다면, 위 내용이 담긴 library를 제작하는 것도 하나의 방법이 될 수 있다. 다만 library화 하는 것에는 인젝션 순서 보장 방법, WebClient에 default task(codec, filter 등) 추가 등 약간의 공수가 추가될 수 있다.

 


 

전체 코드는 Github에서 확인할 수 있습니다.

https://github.com/pooi/web-client-example

 

GitHub - pooi/web-client-example: Spring Boot Webflux - WebClient example

Spring Boot Webflux - WebClient example. Contribute to pooi/web-client-example development by creating an account on GitHub.

github.com

 

 

복사했습니다!