대충 넘어가지 않는 습관을 위한 기록

FeignClient를 이용한 소셜 로그인 (카카오, 구글)

uhyvn 2024. 7. 11. 17:27

이번에 11월 런칭 목표인 새로운 서비스를 개발하게 됐다.

이메일 로그인은 MVP에서 제외된 기능이고, 우선은 소셜 로그인부터 구현해야 했다.

그동안 RestTemplate밖에 경험이 없었기에, 그 유명한 FeignClient를 사용해보기로 했다.

 

 

이전에 RestTemplate을 사용했을 때보다 훨씬 가독성이 좋아졌고,

인터페이스를 정의하고 어노테이션을 붙이는 것만으로 HTTP 클라이언트를 설정할 수 있어서 코드가 간결해졌다..!

 


 

 

 

의존성 추가 - build.gradle

implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.2'

 

당연하겠지만, 버전을 명시하지 않아도 동작한다.

우리는 다른 의존성과의 호환성 문제가 발생할 가능성을 생각해 버전을 명시해줬다.

 

 

 

 

@EnableFeignClients 추가 - FeignClientConfig

@Configuration
@EnableFeignClients(basePackages = {"패키지 지정 가능"})
public class FeignClientConfig {
}

 

 

 

 

FeignClient는 인터페이스 형식으로 정의하여 REST API 요청한다.

사용 예시 - KakaoAuthClient

@FeignClient(value = "kakao-auth", url = "${api.kakao.kauth}") // url은 프로퍼티로 지정함
public interface KakaoAuthClient {

    @PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    KakaoToken generateOAuthToken(@RequestParam(name = "grant_type") String grantType,
                                  @RequestParam(name = "client_id") String clientId,
                                  @RequestParam(name = "redirect_uri") String redirectUri,
                                  @RequestParam(name = "code") String code,
                                  @RequestParam(name = "client_secret") String clientSecret);
}

 

 

사용 예시 - KakaoApiClient

@FeignClient(value = "kakao-api", url = "${api.kakao.kapi}")
public interface KakaoApiClient {

    @GetMapping("/v2/user/me")
    KakaoUser getUserInfo(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken);
}

 

 

 

 

 

 


 

 

 

 

 

구글 로그인 구현

 

 

Controller

@Operation(summary = "구글 로그인")
    @PostMapping("/login/google")
    public ResponseEntity<CommonResponse<LoginResponse>> googleLogin(@Valid @RequestBody GoogleLoginRequest googleLoginRequest) {
        return CommonResponse.success(SuccessCode.SUCCESS, memberService.login(googleLoginRequest.toDto()));
    }

 

 

 

GoogleLoginRequest

public record GoogleLoginRequest(
    @NotBlank
    String code,
    
    @JsonProperty("redirectUri")
    String redirectUri
    
) {
    public OAuthLoginInfo toDto() {
        Map<String, String> propertyMap = Map.of(
            AuthConstants.CODE, code,
            AuthConstants.REDIRECT_URI, redirectUri
        );
        return new OAuthLoginInfo(Provider.GOOGLE, propertyMap);
    }
}

인가코드와 리다이렉트 Uri를 받아서, 맵으로 지정해준다.

Provider에는 GOOGLE, KAKAO와 같은 가입 경로들이 열거형으로 들어있다.

 

 

 

OAuthLoginInfo

public record OAuthLoginInfo(
        Provider provider,
        Map<String, String> propertyMap
) {
}

 

 

 

 

Service

@Transactional
    public LoginResponse login(OAuthLoginInfo authLoginInfo) {
        // 인가 코드를 이용해 액세스 토큰 발급 요청
        String accessToken = providerFactory.getAccessToken(
            authLoginInfo.provider(),
            authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI),
            authLoginInfo.propertyMap().get(AuthConstants.CODE)
        );

        // 액세스 토큰을 이용해 사용자 정보 가져오기
        OAuthUser oAuthUser = providerFactory.getOAuthUser(authLoginInfo.provider(), accessToken);

        // 회원가입 or 로그인 로직

        // 액세스 토큰 발급
        String token = jwtTokenProvider.createAccessToken(oAuthUser.email());

        return new LoginResponse(new Token(token, jwtTokenProvider.getExpirationByToken(token)));
    }

순서는 위와 같이,

1. 프론트에게 받은 인가 코드로 토큰을 발급 받는다.
2. 받은 토큰으로, 해당 사용자 정보를 가져온다.

3. 해당 사용자가 우리 db에 존재하는 회원인지 아닌 지에 따라 로직이 바뀐다. -> 회원가입 or 로그인

이는 서비스마다 구현 방식이 다를 것으로 예상된다.

4. 그 후에, 프론트에게 내어 줄 액세스 토큰을 발급하고 전달한다.

 

 

 

 

OAuthProviderFactory

    public String getAccessToken(Provider provider, String redirectUri, String code) {
        return getOAuthToken(provider, redirectUri, code).accessToken();
    }

    private OAuthToken getOAuthToken(Provider provider, String redirectUri, String code) {
        return getOAuthProvider(provider).getOAuthToken(redirectUri, code);
    }
    
    private OAuthProvider getOAuthProvider(Provider provider) {
        OAuthProvider oAuthProvider = OAuthProviderMap.get(provider);
        if (Objects.isNull(oAuthProvider)) {
            throw new InvalidParamException(ErrorCode.NOT_FOUND_OAUTH_PROVIDER);
        }

        return oAuthProvider;
    }

이해를 위해 일부 핵심 코드만 갖고 왔지만,

다른 코드는 Map을 init해주는 코드 말고는 없다..!

--> 여기서는 해당 Provider로 값을 찾아, Google이라면 GoogleProvider의 구현을 따르기로 한다.

 

 

 

 

OAuthProvider

public interface OAuthProvider {

    OAuthToken getOAuthToken(String redirectUri, String code);

    OAuthUser getOAuthUser(String accessToken);
}

이 OAuthProvider는 인터페이스로 작성하여, 각 소셜마다 다르게 구현할 수 있게 했다.

-아래에 GoogleToken 구현으로 볼 수 있다.

 

 

 

 

 

GoogleProvider

@Override
    public OAuthToken getOAuthToken(String redirectUri, String code) {
        String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); // 구글 oauth 서버로부터 받은 인가코드는 디코딩 해줘야 함
        return googleAuthClient.generateOAuthToken(
            decodedCode,
            clientId,
            clientSecret,
            redirectUri,
            grantType
        );
    }

위는 토큰을 받기 위한 구조로 보면 된다.

* api 문서에 있는 그대로 순서를 꼭 지켜야 하고, 구글은 카카오와 달리 인가 코드를 디코딩하여 보내줘야 한다..!

 

 

 

 

OAuthToken

public interface OAuthToken {
    String tokenType();
    String accessToken();
    int expiresIn();
    String refreshToken();
    String scope();
    int refreshTokenExpiresIn();
}

각 소셜마다 값이 다르게 내려올 수 있으므로, 위처럼 인터페이스로 정의한다.

 

 

 

 

GoogleAuthClient

@FeignClient(value = "google-auth", url = "${api.google.gauth}")
public interface GoogleAuthClient {

    @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    GoogleToken generateOAuthToken(@RequestParam(name = "code") String code,
                                   @RequestParam(name = "client_id") String clientId,
                                   @RequestParam(name = "client_secret") String clientSecret,
                                   @RequestParam(name = "redirect_uri") String redirectUri,
                                   @RequestParam(name = "grant_type") String grantType);

}

위와 같이 서버에 요청을 하면,

 

 

 

 

GoogleToken

public record GoogleToken(
    @JsonProperty("token_type") String tokenType,
    @JsonProperty("access_token") String accessToken,
    @JsonProperty("expires_in") int expiresIn,
    @JsonProperty("refresh_token") String refreshToken,
    @JsonProperty("scope") String scope,
    @JsonProperty("id_token") String idToken
) implements OAuthToken {
}

이렇게 따로 설정한 GoogleToken에 값이 전해진다.

 

 

 

 

 

이렇게 인가코드로 토큰을 받는 1번 과정은 끝이 났다.

다음으로 사용자 정보를 받는 2번 과정을 보자.

 

 

 

 

GoogleProvider

@Override
    public OAuthUser getOAuthUser(String accessToken) {
        return googleApiClient.getUserInfo(getBearerToken(accessToken));
    }

위에서 봤던 코드들은 생략했다.

ProviderFactory에서 해당 소셜 구현체로 getOAuthUser를 호출한다.

 

 

 

 

GoogleApiClient

@FeignClient(value = "google-api", url = "${api.google.gapi}")
public interface GoogleApiClient {

    @GetMapping("/userinfo/v2/me")
    GoogleUser getUserInfo(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken);

}

위와 같은 경로로 요청을 보내면, GoogleUser로 값을 받을 수 있다.

이 코드는 카카오와 같다!

 

 

 

 

GoogleUser

public record GoogleUser(
    @JsonProperty("id") String id,
    @JsonProperty("email") String email,
    @JsonProperty("verified_email") boolean verifiedEmail,
    @JsonProperty("picture") String picture
) implements OAuthUser{

}

위 4개 값은 필수 값이므로 꼭 받아주어야 에러가 나지 않는다.

 

 

 

 

 

이렇게 되면 사용자 정보를 받아오는 2번 과정까지 마쳤다.

이후에 토큰 발급과 같은 로직은 워낙 흔하게 포스팅되어 있으니, 넘어가겠다..!

 

 

 

 


 

 

 

 

주의사항

 

구글 로그인은 api 문서가 참 여기저기 흩어져 있다.

내가 참고한 문서 링크를 공유해놓겠다..!

 

https://cloud.google.com/identity-platform/docs/use-rest-api?hl=ko#section-refresh-token

 

REST API 사용  |  Identity Platform 문서  |  Google Cloud

의견 보내기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. REST API 사용 이 문서에서는 Identity Platform REST API를 사용하여 사용자 로그인 및 토큰 작업 등의 일

cloud.google.com

처음에는 이 문서를 보고 작업을 하다가..

url들이 어떤 블로그에도 나오지 않는 엔드포인트인 것이다........

그렇게 또 api 문서를 파 본 결과

 

 

https://developers.google.com/identity/protocols/oauth2/web-server?hl=ko#exchange-authorization-code

 

웹 서버 애플리케이션용 OAuth 2.0 사용  |  Authorization  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 의견 보내기 웹 서버 애플리케이션용 OAuth 2.0 사용 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이

developers.google.com

위 링크를 찾았다!

그렇게 요청 url과 요청 필수 값들을 넣는 것에는 성공했지만,

갑자기 id_token이 없다는 에러가 뜬다..?

분명 위 문서에는 응답 필드에

이렇게 다섯 가지만 존재한다.

더 구글링 해 본 결과

 

 

 

 

https://developers.google.com/identity/openid-connect/openid-connect?hl=ko#offlineaccess

 

OpenID Connect  |  Authentication  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 의견 보내기 OpenID Connect 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Google의 OAuth 2.0 API는 인증과 승

developers.google.com

이 문서를 찾을 수 있었다.

 

이렇게 id_token을 찾을 수 있었다.

뭐가 맞는 지 이젠 나도 모르겠다...

 

이렇게 token을 발급받는 문서는 찾았으나,

이틀 내내 관련 레퍼런스를 최대한 뒤져봐도 사용자 정보를 받아오는 올바른 url과 응답값을 소개하는 문서를 찾을 수가 없었다..

api 문서를 찾으면 바로 추가 포스팅하도록 하겠다.

 

---

 

아무리 찾아봐도 없고, 이전에 나와 같은 요청 url을 포스팅하신 분들의 글을 찾아보니
모두 내가 봤던 문서를 보셔서 나도 해당 문서를 다시 찾아봤다.

https://cloud.google.com/identity-platform/docs/use-rest-api?hl=ko#section-get-account-info

 

REST API 사용  |  Identity Platform 문서  |  Google Cloud

의견 보내기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. REST API 사용 이 문서에서는 Identity Platform REST API를 사용하여 사용자 로그인 및 토큰 작업 등의 일

cloud.google.com

위 링크인데, 아무리 찾아도 해당 url은 없다.

그래서 내가 내린 결론인데, 아무래도 구글에서 api 문서를 업데이트하고 이전 문서는 전부 비공개로 돌린 것 같다...

응답구조를 보고 싶은데, 새로운 api 문서를 따라서 새로 만들어야겠다..