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으로 오버라이딩된다.)
- 이때 property를 따로 선언한다면
WebClientPair Class 생성
WebClient
와 WebClientProperties
를 한 쌍으로 묶어주기 위한 WebClientPair
라는 클래스를 새로 선언해준다.
class WebClientPair(
val webClient: WebClient,
val properties: WebClientProperties
)
개발을 하다 보면 WebClient 뿐만 아니라 property도 필요한 경우가 있으므로..
WebClientFactory Component 제작
이제 위에서 추가한 값들을 통해 자동으로 WebClientProperties
와 WebClientPair
를 Bean으로 등록해주는 Bean Factory를 구현한다.
이 Bean Factory는 yaml에서 선언한 services:
에 존재하는 모든 service 들을 WebClientPair
Bean으로 등록시켜준다.
본문의 예제에서는 google
과 github
이 존재했기 때문에 다음과 같은 이름을 가진 여러 개의 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
'Server > Spring Boot' 카테고리의 다른 글
Spring Webflux + Coroutine에서 Context 관리하기 (downstream으로 context 변경 전파하기) - 2편 (1) | 2022.09.18 |
---|---|
Spring Webflux + Coroutine에서 Context 관리하기 (downstream으로 context 변경 전파하기) - 1편 (0) | 2022.09.18 |