Spring에서 WebClient로 외부 API를 호출해 보자!(feat. Kotlin, Google OAuth)

sinryuji·2024년 12월 4일
post-thumbnail

이전 글에서 WebClient를 사용하도록 결정하였으니 이번 글에서는 WebClient의 사용법에 대해 알아보겠다. 먼저 WebClient 설정 방법을 알아보고 WebClient를 통해 Google의 OAuth API를 호출하는 방법을 알아보자!

WebClient 초기화 및 설정

implementation("org.springframework.boot:spring-boot-starter-webflux")

먼저 위와 같이 WebFlux 의존성을 추가해준다.


import org.springframework.web.reactive.function.client.WebClient;

WebClient.create("http://localhost:8080");

간단하게 위와 같이 create() 함수를 호출 하여 초기화를 할 수 있다. create()를 호출 하면 실질적으로 다음과 같이 DefaultWebClientBuilder()build()를 호출하게 된다.

    static WebClient create() {
        return (new DefaultWebClientBuilder()).build();
    }

하지만 사용을 할 때마다 객체를 초기화 해 줄 필요 없이 기본적인 설정을 해줌과 동시에 Bean으로 등록하여 싱글톤으로 재활용을 하는게 일반적인 사용법이다. 다음과 같이 말이다.

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.time.Duration


@Configuration
class WebClientConfig {
    @Bean
    fun webClient(): WebClient {
        return WebClient.builder()
            .clientConnector(
                ReactorClientHttpConnector(
                    HttpClient.newConnection().responseTimeout(Duration.ofSeconds(30)),
                ),
            )
            .build()
    }
}

위의 코드에선 responseTimeout만을 설정해주었는데 외에도 defaultCookie, defaultHeader, codec 등 다양한 설정이 가능하다. 그 중에서 responseTimeout은 필수로 설정을 해주는게 좋은데,

어떤 Http Client를 사용하냐에 따라 달라지겠지만 기본적으로 사용하는 Reactor Netty의 HttpClient의 경우에는 responseTimeout의 기본 값이 없다. 기본적으로는 응답이 올 때 까지 무한히 대기를 한다는 뜻이다. 하지만 그렇다고 실제로 무한히 대기를 하지는 않을 가능성이 높다. 외부 API의 프록시 서버에서 적절히 connection을 관리 하는 것이 정배(?)일 테니까 말이다. 그래도 혹시나 무한히 대기를 하게 된다면 동기든 비동기든 스레드 풀에 치명적일테니 responseTimeout 정도는 필수로 설정을 해주는 것이 좋아보인다.

Reactor Netty HttpClient란?

WebClient는 Reactor Netty의 HttpClient를 내부적으로 사용하여 HTTP 요청/응답을 처리한다. 즉, 실제로 내부의 Http 요청을 처리하는건 HttpClient이고 WebClient는 더 쉽고 간단하게 HttpClient를 다룰 수 있는 추상화 API, Wrapper라고 생각하면 된다.

참고로 RestTemplate나 RestClient 모두 WebClient와 같이 내부적으로 다른 Http Client를 이용하는 고수준 API들이다. RestTemplate는 기본적으로 Java HttpURLConnection를 사용하고 RestClient는 Apache HttpClient를 사용하며 각각 설정을 통해 다른 Http Client를 사용할 수 있다.

HTTP 요청하기

이번 글에서는 Google OAuth에서 승인 코드를 가지고 액세스 토큰을 요청하는 API를 구현해보겠다.

@Service
class AuthServiceImpl(
    private val webClient: WebClient,
) : AuthService {

앞서 WebClientBean으로 컨테이너에 등록을 해두었으므로 위와 같이 의존성을 주입할 수 있을 것이다.

요청 예시

POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded

code=4/P7q7W91a-oMsCeLvIaQm6bTrgtp7&
client_id=your_client_id&
client_secret=your_client_secret&
redirect_uri=http://127.0.0.1:9004&
grant_type=authorization_code

응답 예시

{
  "access_token": "1/fFAGRNJru1FTz70BzhT3Zg",
  "expires_in": 3920,
  "token_type": "Bearer",
  "scope": "https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/calendar.readonly",
  "refresh_token": "1//xEoDL4iW3cxlI7yDbSRFYNG01kVKM2C-259HOF2aQbI"
}

출처: https://developers.google.com/identity/protocols/oauth2/web-server?hl=ko

Google OAuth Document를 보면 위와 같이 6개의 매개변수가 필요한데 이 중 code_verifier는 필수가 아니다. 이는 PCKE라는 OAuth 2.0의 확장 사양인 인가 코드 검증 프로토콜을 사용할 때 필요한 값이다. 이 글의 메인 주제는 OAuth가 아니기에 다음 RFC 문서나 검색을 통해 자세히 알아보면 좋을 것 같다. 아무튼 요청 예시와 같이 code, client_id, client_secret, redirect_uri, grant_type을 Body에 담아 엔드포인트에 POST 요청을 보내면 된다!

    override fun getGoogleAccessToken(
        code: String,
        redirectUri: String,
    ): String {
        val response =
            webClient.post()
                .uri(googleTokenUri)
                .bodyValue(
                    mapOf(
                        "code" to code,
                        "client_id" to googleClientId,
                        "client_secret" to googleClientSecret,
                        "redirect_uri" to redirectUri,
                        "grant_type" to googleGrantType,
                    ),
                )
                .retrieve()
                .bodyToMono<Map<String, Any>>()
                .onErrorResume {
                    it.printStackTrace()
                    throw GlobalException(GlobalErrorCode.UNAUTHORIZED)
                }
                .block()

        return response?.get("access_token") as String
    }

액세스 토큰을 요청하는 함수는 위와 같다. 하나씩 자세히 살펴보자.

            webClient.post()
                .uri(googleTokenUri)
  • post()
    • 해당 메소드를 통해 POST 요청을 보낼 수 있다. GET이라면 get()을 호출 하면 된다.
  • uri()
    • 해당 메소드를 통해 요청을 보낼 엔드 포인트를 지정해 줄 수 있다.
                .bodyValue(
                    mapOf(
                        "code" to code,
                        "client_id" to googleClientId,
                        "client_secret" to googleClientSecret,
                        "redirect_uri" to redirectUri,
                        "grant_type" to googleGrantType,
                    ),
                )
                .retrieve()
  • bodyValue()
    • 해당 메소드를 통해 Request Body에 담을 값을 지정해 줄 수 있다. 해당 메소드의 매개변수 타입은 Java의 Object이다. 하지만 위의 API 요청 예시를 보면 Content-Type이 application/x-www-form-urlencoded이다. 이는 POST 요청의 가장 기본이 되는 Content-Type으로 key-value 형식으로 값을 담아주어야 한다. 그렇기에 mapOf()를 통해 Map 형식으로 값을 담아주었다.
  • retrieve()
    • WebClient에서는 응답을 retrieveexchange 2가지 방법으로 받을 수 있다. retrieve는 오직 응답의 본문 만이 필요한 경우, exchange는 응답의 상태 코드, 헤더 등을 모두 제어해야 할 경우 활용하면 된다. 나는 응답의 본문만이 필요하기에 retrieve를 사용했다.
                .bodyToMono<Map<String, Any>>()
                .onErrorResume {
                    it.printStackTrace()
                    throw GlobalException(GlobalErrorCode.UNAUTHORIZED)
                }
                .block()
  • bodyToMono<Map<String, Any>>()
    • 응답의 Body를 Mono로, 데이터 타입은 Map<String, Any>로 받는다. <>에는 데이터를 받을 타입을 지정해 줄 수 있다. DTO 클래스를 타입으로 주어 객체 형태로도 받을 수 있다. Map<String, Any>로 받는 이유는 Google OAuth가 Key-Value 형태로 값을 반환해주고, OAuth 마다 응답의 형태가 제각각이고 다른 응답값은 딱히 필요가 없고 액세스 토큰만이 필요했기에 굳이 따로 객체의 형태로 받을 이유가 없었기 때문이다.

      Mono란?

      WebFlux에서는 Mono와 Flux 두 개의 클래스로 데이터를 처리한다. Mono는 0개 또는 1개의 데이터를 처리할 때 사용되고 Flux는 0개 이상, N개의 데이터를 처리할 때 사용된다.
      여기서 착각하기 쉬운게 만약에 리스트 형태로 여러 데이터가 담겨오면 Flux로 받아야 하나? 생각할 수 있는데 이런 경우는 1개의 데이터에 해당한다. N개의 데이터라 함은 DB에서 여러 건의 조회가 일어났거나 실시간 데이터 스트리밍과 같은 케이스를 말한다.
      일반적으로 HTTP를 통한 API의 호출의 경우 한 번의 요청에 하나의 응답만이 오기 때문에 Mono를 사용하면 된다.

  • onErrorResume
    • HTTP 요청에서 에러가 발생했을때 에러 헨들링을 하는 부분이다. 에러 핸들링을 하지 않으면 WebClientResponseException을 던지게 된다.
  • block()
    • block()을 사용하면 요청을 동기로 처리하게 된다. 로그인의 경우에 요청이 엄청 자주 일어나지도 않고 무조건 단건으로 API가 호출되므로 굳이 비동기로 처리할 필요성을 느끼지 못하여 동기로 처리하였다.

정상적으로 API 호출이 이루어졌다면 Map<String, Any> 타입의 객체를 반환하게 된다. 그 중 액세스 토큰만이 필요하므로 get("access_token")로 해당 값을 가져와 반환한다.

profile
응애 개발자입니다.

0개의 댓글