안녕하세요. 올리브영에서 Back-end 개발 업무를 맡은 달고나윤입니다.
현재 올리브영 전시파트는 기존 모놀로식 아키텍쳐에서 MSA 아키텍쳐로 이사가기 위해 열심히 준비 중에 있습니다.
이사라고 표현 하였지만, 보다 정확하게는 준비된 일부 화면 영역들이 조금씩 독립해나가는 과정으로 말씀드릴 수 있을 것 같아요.
저는 그중에서도 퀵 메뉴 영역에서 보실 수 있는 '프리미엄관'의 프론트/백엔드 의존성 분리를 위한 신규 API 구축 여정에 동행하게 되었습니다.
전시 Back-end는 Spring Boot 2.5.x + Kotlin을 기본 기술 스택을 사용하고 있는데요.
올리브영 사이트는 국내 대표 옴니채널 플랫폼이란 수식어에 걸맞게, 전시파트 영역이 단독 주인으로 보이는 화면에서도 다양한 스쿼드에서 개발하고 유지보수 하는 연계 서비스 들이 함께 존재하고 있었어요.
따라서, 전시 Back-end API는 여전히 기존 온라인몰에서 공통으로 개발&유지보수 되는 서비스 정보들을 받아오기 위한 통신도 빈번히 이루어져야 했습니다.
오늘은 신규 전시 서버에서 기존 온라인몰 서버와의 통신에서 사용한 WebClient 인터페이스에 대하여 포스팅 해보려고 합니다.
들어가기전에
스프링 어플리케이션에서 HTTP 요청에 쓰이는 인터페이스는 RestTemplate과 WebClient가 있습니다.
RestTemplate과 WebClient은 짧게 정리하면 아래와 같은 기본 특징들을 가지고 있습니다.
RestTemplate 특징
- Spring 3.0 부터 지원
- RESTful 형식을 지원
- 멀티 스레드 방식
- Blocking I/O기반의 동기 방식 API
- Spring 4.0에서 비동기 문제를 해결하고자 AsyncRestTemplate이 등장했으나, 현재 deprecated 됨
WebClient 특징
- Spring 5.0 부터 지원
- 싱글 스레드 방식
- Non-Blocking 방식, 동기/비동기 모두 지원
- Reactor 기반의 Functional API (Mono, Flux)
앞서 말씀드렸듯이 우리의 신규 프로젝트 기술 스택은 Spring Boot 2.5.x (Spring Framework 5.2) 이므로, WebClient를 사용해보기로 하였습니다.
아래에서는 WebClient 사용의 성능적인 이점을 설명드리며, 실제 사용을 위한 작성예시를 자세히 안내 드리겠습니다.
WebClient 이점?
WebClient는 동기, 비동기 호출을 모두 지원합니다.
WebClient는 Non-Blocking 방식으로, 호출된 시스템의 결과를 기다리지 않고 다른 작업을 처리할 수 있습니다.
Blocking 과 Non-Blocking의 차이를 설명하기 위해 저의 부족한 그림실력을 총동원하여 다음과 같은 Flow로 표현해 보았습니다.
Blocking 방식의 RestTemplate과 Non-Blocking 방식의 WebClient의 성능 비교
출처 : https://dzone.com/articles/raw-performance-numbers-spring-boot-2-webflux-vs-s
위의 그래프에서 Boot1은 RestTemplate을, Boot2는 WebClient를 사용합니다.
동시 사용자 수가 1,000명 미만일 경우에는 Boot1과 Boot2 모두 비슷한 응답속도를 나타내고 있습니다.
하지만, 동시 사용자 수가 늘어날 수록 속도 차이가 크게 벌어지는 것을 확인할 수 있습니다.
WebClient 사용
1.의존성 추가
Gradle Setting
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
}
2.WebClient 생성
WebClient 생성하는 방법은 단순 create()하는 방법과, option을 여러가지 추가할 수 있는 Builder를 활용한 방법이 존재합니다.
우리는 기본 option 설정들을 추가할 예정이므로 Builder를 활용한 방법을 안내하도록 하겠습니다.
2.1 MaxInMemorySize 설정
Spring Webflux는 어플리케이션 메모리 이슈를 방지하기 위해 코덱의 메모리 버퍼 사이즈를 디폴트 256KB로 제한하고 있습니다. 아래 설정을 추가하여 모든 디폴트 코덱의 최대 버퍼 사이즈를 조절할 수 있습니다.
WebClient.builder()
.codecs { configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 *1024) }
.build()
2.2 TimeOut 설정
val httpClient: HttpClient = HttpClient.create(connectionProvider)
.secure { t: SslProvider.SslContextSpec -> t.sslContext(sslContext) }
.resolver(DefaultAddressResolverGroup.INSTANCE)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10 * 1000)
.responseTimeout(Duration.ofMillis(60 * 1000L))
.doOnConnected { conn ->
conn.addHandlerLast(ReadTimeoutHandler(60 * 1000L, TimeUnit.MILLISECONDS))
.addHandlerLast(WriteTimeoutHandler(60 * 1000L, TimeUnit.MILLISECONDS))
}
WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.build()
2.3 default 설정 변경
한 번 빌드한 뒤부터는 WebClient는 immutable 합니다. 간혹 default setting을 다르게 사용해야할 경우가 생기는데요.
이럴땐 mutate()를 사용하여 기존 WebClient 인스턴스는 그대로 두고, 기존 WebClient 인스턴스를 복제해와서 설정을 변경할 수 있습니다.
// set baseUrl, defaultHeader (Content-Type), codecs, clientConnector
val webClient1 = WebClient.builder()
.baseUrl(url)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.codecs { configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 *1024) }
.clientConnector(ReactorClientHttpConnector(httpClient))
.build()
// set baseUrl, defaultHeader(Content-Type), codecs, clientConnector
// add set defaultHeader(X-Fingerprint-timestamp), defaultHeader(X-Fingerprint)
var webClient2 = webClient1.mutate()
.defaultHeader(HEADER_NAME_FINGERPRINT_TIMESTAMP, timeStamp.toString())
.defaultHeader(HEADER_NAME_FINGERPRINT, generateFingerprint(timeStamp))
.build()
3.Response
3.1 retrieve() - body를 바로 가져옵니다.
// Mono는 0-1개의 결과를 처리하는 객체
// GET /contents/{contentId}
webClient1.get()
.uri { builder -> builder.path("/contents/$contentId").queryParam("serviceId", serviceId).build() }
.retrieve()
.onStatus(HttpStatus::is4xxClientError) { ... }
.onStatus(HttpStatus::is5xxServerError) { ... }
.bodyToMono(GripResponseDto::class.java)
// Flux는 0-N개의 결과를 처리하는 객체
// GET /contents
webClient1.get()
.uri { builder -> builder.path("/contents").build() }
.retrieve()
.onStatus(HttpStatus::is4xxClientError) { ... }
.onStatus(HttpStatus::is5xxServerError) { ... }
.bodyToFlux(GripResponseDto::class.java)
3.2 exchange() - ClientResponse를 상태값 그리고 헤더와 함께 가져옵니다.
retrieve()와 다르게 모든 성공,오류 등의 시나리오에 대해 직접 response body를 컨슘해야 합니다.
// GET /contents/{contentId}
webClient1.get()
.uri { builder -> builder.path("/contents/$contentId").queryParam("serviceId", serviceId).build() }
.exchangeToMono { response ->
when (response.rawStatusCode()) {
HttpStatus.OK.value() -> response.bodyToMono(GripResponseDto::class.java)
HttpStatus.NOT_FOUND.value() -> Mono.just("Error response")
else -> Mono.just(response.createException())
}
}
추가적인 설정과 예시는 아래 공식문서를 참고 하여 보실 수 있습니다.
마무리
현재 올리브영 구성원들 모두 사용자에게 보다 빠르고 좋은 서비스를 제공하기 위해, 다양한 기술들을 학습하고 테스트하여 신규 서비스에 도입 중에 있는데요.
저 또한 한 명의 구성원으로서 크고 작은 새로운 내용들을 끊임없이 공부하고 모두에게 정보를 나눌 수 있도록 항상 노력하고자 합니다.
다음번에는 보다 더 유익한 추가 기술들을 하나씩 공유할 수 있기를 기약하며 마치도록 하겠습니다. 곧 또 만나요~!