개요
사이드 프로젝트를 하며 OAuth 로그인, 회원가입 부분을 진즉 구현을 해두었지만,
공부를 추가적으로 하다보니 당시에 Rest Template을 이용한 구현이 현재는 Deprecated 되었다는 사실을 알게 되었고
리팩토링을 하기로 결정 했다.
- WebClient에 대해 더 알고 싶다면 다음 블로그를 참고하자 !
- https://velog.io/@yyong3519/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-WebClient
현재 구현되어있는 코드
@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로 매핑을 해주었다. - 매핑되는 객체
- 규격 : {"access_token" : "{accessToken}", "token_type" : "{tokenType}" , "refresh_token" : "{refreshToken}"}
@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
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로 통신 후 정상적으로 데이터가 받아와진 것을 확인 할 수 있다!
'💻Spring' 카테고리의 다른 글
MySQL , JPA 에서의 Batch Insert 적용기 (0) | 2023.08.08 |
---|---|
JPA 프록시 객체 equals의 Override (0) | 2023.08.08 |
Response 규격화 Swagger 처리 (1) | 2023.06.10 |
OAuth, 일반 이메일 회원가입시 중복관련 Trouble Shooting (0) | 2023.06.05 |
Jenkins 파이프라인을 이용한 SpringBoot 자동배포 2 (젠킨스 파이프라인 생성 및 깃허브 액션 연결) (0) | 2023.05.20 |