본문 바로가기

💻Spring

Spring OAuth 로그인 및 회원가입 RestTemplate > WebClient 리팩토링

반응형

개요

사이드 프로젝트를 하며  OAuth 로그인, 회원가입 부분을 진즉 구현을 해두었지만,

공부를 추가적으로 하다보니 당시에 Rest Template을 이용한 구현이 현재는 Deprecated 되었다는 사실을 알게 되었고

리팩토링을 하기로 결정 했다. 

 

 

SpringBoot WebClient

제목: Spring WebClient 쉽게 이해하기작성자: tistory"happycloud-lee"작성자 수정일: 2021년 2월 20일링크: https://happycloud-lee.tistory.com/220작성: 2022년 2월 21일

velog.io

 

현재 구현되어있는 코드

@Operation(summary = "OAuth 로그인 메서드", description = "OAuth 로그인 메서드입니다.")
@ApiResponses(value = {
        @ApiResponse(responseCode = "307", description = "로그인 및 회원가입 성공, redirect url로 응답"),
        @ApiResponse(responseCode = "400", description = "OAuth code값 없음", content = @Content(schema = @Schema(implementation = ApiErrorResponse.class)))
})
@GetMapping("/{client}/login/{provider}")
@ResponseBody
public ResponseEntity<?> oAuthLogin(@PathVariable String client, @PathVariable String provider, String code){
    /**
     *- 기존 회원인 경우
     *  - respal의 accessToken과 refreshToken을 응답해준다.
     *
     * - 신규 회원인 경우
     *   1. 서버 - oauth 인증을 받으면 oauth의 accessToken과 provider(socialType) 정보와 redirectUrl을 보내준다.
     *   2. 클라이언트 - 회원가입 폼에서 닉네임, 비밀번호를 설정 후(email과 profileImage는 oauth에서 받아옴) redirectUrl로 Post 요청을 한다.
     *   3. 서버 - 해당 정보를 db에 저장 후 respal의 accessToken과 refreshToken을 응답해준다.
     */
    ProviderConverter providerConverter = new ProviderConverter();
    Provider providerType = providerConverter.convertToEntityAttribute(provider);

    OAuthToken oAuthToken = oAuthService.getAccessToken(providerType, code, client);
    UserInfo userInfo = oAuthService.getUserInfo(providerType, oAuthToken.getAccessToken());
    Token token = oAuthService.login(providerType, userInfo);
    URI redirectUrl = oAuthService.getRedirectUrl(providerType,userInfo,token, client);

    return ResponseEntity.status(HttpStatus.FOUND).location(redirectUrl).build();
}

OAuth login을 담당하는 Redirect API이다.

 

로직

  • 웹과 앱쪽에서 해당 API로 Redirect 시키며 code를 준다.
  • OAuthService의 getAccessToken을 통해 OAuth 서버(Google, Kakao, Github)로 code를 통해 각 OAuth 서버의 accessToken을 가져온다.
  • 가져온 accessToken으로 getUserInfo 메서드를 통해 각 OAuth 서버의 규격에 맞는 Response의 UserInfo를 받아온다.
  • 가져온 userInfo와 providerType(OAuth 타입)으로 login을 하여 우리 서버의 AccessToken과 RefreshToken을 받아온다.
  • 마지막으로 providerType에 맞는 redirectUrl을 받아와 Redirect를 시켜준다.
    • 앱은 커스텀 스킴 방식을 적용했다.
    • 웹은 dev, staging live의 운영 전략을 사용중이기 때문에 각각 domain을 다르게 설정을 해주었다.

이 중 Rest Template을 사용하는곳은 당연히 OAuth 서버와 통신을 하는 곳 이기 때문에 오늘의 리팩토링 메서드는 

OAuthService의 getAccessToken과 getUserInfo 메서드 이다.

 

나는 OAuthService를 Interface로 정의하였고, 구현체로 GoogleService, KakaoService, GithubService로 인터페이스를 Implements 시켰기 때문에 GoogleService만 예제로 작성하도록 하겠다.

 

@Override
public OAuthToken getAccessToken(String code, String client) {
    RestTemplate restTemplate = new RestTemplate();

    // 헤더 설정
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
    HttpEntity request = new HttpEntity(headers);

    /*
     https://accounts.google.com/o/oauth2/v2/auth?scope=profile&response_type=code
     &client_id="할당받은 id"&redirect_uri="access token 처리")
     로 Redirect URL을 생성하는 로직을 구성
     */
    // Uri 빌더 사용

    String redirectUri = null;
    if(Client.WEB_DEV.getValue().equals(client)){
        redirectUri = oAuthConfig.getGoogle().getWebDevRedirectUri();
    }else if(Client.WEB_STAGING.getValue().equals(client)){
        redirectUri = oAuthConfig.getGoogle().getWebStgRedirectUri();
    }else if(Client.WEB_LIVE.getValue().equals(client)){
        redirectUri = oAuthConfig.getGoogle().getWebLiveRedirectUri();
    }else if(Client.APP.getValue().equals(client)){
        redirectUri = oAuthConfig.getGoogle().getAppRedirectUri();
    }

    UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(oAuthConfig.getGoogle().getTokenUrl())
            .queryParam("grant_type", oAuthConfig.getGoogle().getGrantType())
            .queryParam("client_id",  oAuthConfig.getGoogle().getClientId())
            .queryParam("client_secret",  oAuthConfig.getGoogle().getClientSecret())
            .queryParam("redirect_uri",  redirectUri)
            .queryParam("code", code);

    ResponseEntity<String> response = restTemplate.exchange(
            uriComponentsBuilder.toUriString(),
            HttpMethod.POST,
            request,
            String.class
    );

    // UnderScoreCase To Camel GsonBuilder,, googleOAuth2Token 객체에 매핑
    Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
    OAuthToken oAuthToken = gson.fromJson(response.getBody(), OAuthToken.class);
    log.info("구글 액세스 토큰 : " + oAuthToken.getAccessToken());

    return oAuthToken;
}

getAccessToken 메서드 분석

  • RestTemplate 객체 생성
  • 헤더 설정
    • Content-Type 설정
  • 요청한 client에 따라 redirect 분기처리
  • UriComponent를 이용해 URI 생성
    • fromHttpUrl : OAuth 서버의 AccessToken을 받아올 URL 설정 (요청 할 API의 URL이라고 생각하면 된다)
    • queryParam : 쿼리 스트링 설정
      • grant_type : 클라이언트가 백엔드를 제공하는 웹 애플리케이션이면 authorization_code 인증을 사용한다. 
      • client_id : Google Cloud의 프로젝트 설정에서 받은 Client Id를 설정한다.
      • client_secret : 마찬가지로 Google Cloud 프로젝트 설정에서 받은 Client Secret을 설정한다.
      • redirect_uri : 위 메서드를 호출 할 때 설정했던 redirect uri로 설정한다.
      • code : OAuth 서버에서 위 redirect_uri로 전송 될 때 Query Param으로 들어오는 code값을 설정해준다. 
  • 위 설정들을 생성했던 RestTemplate 객체에 exchange 메서드를 통해 설정을 해주고, response를 받아 OAuth Token 객체에 매핑해준다.
    • 규격 : {"access_token" : "{accessToken}", "token_type" : "{tokenType}" , "refresh_token" : "{refreshToken}"} 
      이런식으로 넘어오기 때문에 Gson의 Lower_case_with_underscores로 매핑을 해주었다.
    • 매핑되는 객체
@Getter
@Setter
@Builder
public class OAuthToken {
    private String accessToken;
    private String tokenType;
    private String refreshToken;
    private int expiresIn;
    private String scope;
    private int refreshTokenExpiresIn;
}

 

 

getUserInfo 메서드 분석

@Override
public UserInfo getUserInfo(String accessToken) {
    RestTemplate restTemplate = new RestTemplate();

    // 헤더 설정
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
    headers.add("Authorization","Bearer "+accessToken);

    HttpEntity request = new HttpEntity(headers);

    ResponseEntity<String> response = restTemplate.exchange(
            oAuthConfig.getGoogle().getInfoUrl(),
            HttpMethod.GET,
            request, // 요청시 보낼 데이터
            String.class // 요청시 반환 데이터 타입
    );

    Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
    GoogleUserInfo googleUserInfo = gson.fromJson(response.getBody(), GoogleUserInfo.class);

    UserInfo oAuthUserInfoResponseDto = UserInfo.builder()
            .id(googleUserInfo.getId())
            .email(googleUserInfo.getEmail())
            .image(googleUserInfo.getPicture())
            .nickname(googleUserInfo.getName())
            .build();

    return oAuthUserInfoResponseDto;
}

마찬가지로 설정들을 하되,

이번엔 headers의 Authorization에 Bearer Type 과 앞전에 받은 OAuth서버의 accessToken이 추가되었고,

Get Method로 요청을 해야 한다.

 

위에서 작성했던 것 처럼 응답값의 json property는 스네이크 케이스를 사용하기 때문에 Gson으로 매핑을 했다.   

다음은 GoogleUserInfo이다.

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class GoogleUserInfo {
    private String id;
    private String email;
    private String picture;
    private String name;
}

 

 

리팩토링

 

의존성 추가

우선 Gradle에 WebClient의 의존성을 추가해주자.

implementation 'org.springframework.boot:spring-boot-starter-webflux'

 

WebClient의 인스턴스 생성은 create()를 이용한 방식(NoArgs)과 builder()를 이용한 방식으로 나뉜다. 

(인터페이스 까보니까 둘다 DefaultBuilder()를 이용한 방식인데, build()를 하냐 안하냐 차이)

 

빌더를 통해 다음과 같은 필드들을 지정 할 수 있다.

  • uriBuilderFactory: Customized UriBuilderFactory to use as a base URL.
  • defaultUriVariables: default values to use when expanding URI templates.
  • defaultHeader: Headers for every request.
  • defaultCookie: Cookies for every request.
  • defaultRequest: Consumer to customize every request.
  • filter: Client filter for every request.
  • exchangeStrategies: HTTP message reader/writer customizations.
  • clientConnector: HTTP client library settings.

URL과 헤더, 쿠키등을 설정 하는 옵션들이다. 

 

맨 첫번째 옵션인 uriBuilderFactory에 설정은 다음과 같다.

  • TEMPLATE_AND_VALUES: URI 템플릿을 먼저 인코딩 하고 URI 변수를 적용할 때 인코딩한다.
  • VALUES_ONLY: URI 템플릿을 인코딩하지 않고 URI 변수를 템플릿에 적용하기 전에 엄격히 인코딩한다.
  • URI_COMPONENT: URI 변수를 적용한 후에 URI 컴포넌트를 인코딩한다.
  • NONE: 인코딩을 적용하지 않는다.
  • 참고 : https://wpioneer.tistory.com/222
 

[Spring Boot] WebClient 파라미터 인코딩 하는법

WebClient를 사용해서 그냥 호출하게 되면 인코딩을 하지 않아 API 키가 달라지는 경우가 생길수가 있다. 나같은 경우에 그 문제 때문에 골머리를 앓았는데 아래와 같은 방법으로 해결했다. 일단 Uri

wpioneer.tistory.com

 

 

getAccessToken 메서드 리팩토링을 진행하였고, 내용은 주석에 작성해두었다.

@Override
public OAuthToken getAccessToken(String code, String client) {
    /*
     https://accounts.google.com/o/oauth2/v2/auth?scope=profile&response_type=code
     &client_id="할당받은 id"&redirect_uri="access token 처리")
     로 Redirect URL을 생성하는 로직을 구성
     */
    String redirectUri;
    if(Client.WEB_DEV.getValue().equals(client)){
        redirectUri = oAuthConfig.getGoogle().getWebDevRedirectUri();
    }else if(Client.WEB_STAGING.getValue().equals(client)){
        redirectUri = oAuthConfig.getGoogle().getWebStgRedirectUri();
    }else if(Client.WEB_LIVE.getValue().equals(client)){
        redirectUri = oAuthConfig.getGoogle().getWebLiveRedirectUri();
    }else if(Client.APP.getValue().equals(client)){
        redirectUri = oAuthConfig.getGoogle().getAppRedirectUri();
    } else {
        redirectUri = null;
    }

    WebClient webClient = WebClient.builder()
            .baseUrl(oAuthConfig.getGoogle().getTokenUrl()) // 요청 할 API Url
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) // 헤더 설정
            .build();

    String response = webClient.post()
            .uri(uriBuilder -> uriBuilder
                    .queryParam("grant_type", oAuthConfig.getGoogle().getGrantType())
                    .queryParam("client_id", oAuthConfig.getGoogle().getClientId())
                    .queryParam("client_secret", oAuthConfig.getGoogle().getClientSecret())
                    .queryParam("redirect_uri", redirectUri)
                    .queryParam("code", code)
                    .build())
            .retrieve() // 데이터 받는 방식, 스프링에서는 exchange는 메모리 누수 가능성 때문에 retrieve 권장
            .bodyToMono(String.class) // Mono 객체로 데이터를 받음 , Mono는 단일 데이터, Flux는 복수 데이터
            .block();// 비동기 방식으로 데이터를 받아옴

    // UnderScoreCase To Camel GsonBuilder,, googleOAuth2Token 객체에 매핑
    Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
    OAuthToken oAuthToken = gson.fromJson(response, OAuthToken.class);
    log.info("구글 액세스 토큰 : " + oAuthToken.getAccessToken());

    return oAuthToken;
}

 

 다음은 getUserInfo를 리팩토링을 진행하였다.

@Override
public UserInfo getUserInfo(String accessToken) {
    
    WebClient webClient = WebClient.builder()
            .baseUrl(oAuthConfig.getGoogle().getInfoUrl()) // 요청 할 API Url
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) // 헤더 설정
            .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer"+accessToken)
            .build();

    String response = webClient.get()
            .retrieve() // 데이터 받는 방식, 스프링에서는 exchange는 메모리 누수 가능성 때문에 retrieve 권장
            .bodyToMono(String.class) // Mono 객체로 데이터를 받음 , Mono는 단일 데이터, Flux는 복수 데이터
            .block();// 비동기 방식으로 데이터를 받아옴

    Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
    GoogleUserInfo googleUserInfo = gson.fromJson(response , GoogleUserInfo.class);

    UserInfo oAuthUserInfoResponseDto = UserInfo.builder()
            .id(googleUserInfo.getId())
            .email(googleUserInfo.getEmail())
            .image(googleUserInfo.getPicture())
            .nickname(googleUserInfo.getName())
            .build();

    return oAuthUserInfoResponseDto;
}

 

 

결과

OAuth용 Code와 AccessToken 때문에 어쩔 수 없이 controller와 연동하여 통합테스트로 진행하였다.

다음과 같이 WebClient로 통신 후 정상적으로 데이터가 받아와진 것을 확인 할 수 있다!

반응형