전략패턴을 이용한 OAuth 소셜 로그인

wellbeing-dough·2024년 5월 29일

시간이 남았다 객체지향의 사실과 오해를 읽고 추상화, 다형성에 관심이 많아졌고 내가 했던 플젝에서 적용할만한 곳이 있을까 둘러보다가 좋은 소스를 찾았다

우리 서비스는 카카오, 네이버 로그인을 지원한다
대부분의 소셜 로그인 써드파티 제공 업체는 회원가입(정보 이용 동의) -> 토큰 발급 -> 토큰을 기반으로 유저 정보 조회 -> 유저 정보 반환 형식이다
d

문제 상황

  1. 요구사항에서 소셜 로그인 업체를 추가한다면, 코드 너무 많이 변경이 일어난다 이것을 추상화로 해결 해 보자

문제 해결

    @Operation(summary = "소셜 로그인")
    @PostMapping("/signup/social")
    public ResponseEntity<TokenResponse> socialLogin(@Valid @RequestBody SocialLoginRequest request) {
        LoginToken loginToken = userService.signupByThirdParty(request.toDomain());
        return ResponseEntity.status(HttpStatus.OK).body(new TokenResponse(loginToken));
    }

presentation layer도 서드파티 싹다 통합하고 싶지만 이건 회사마다 다르다

@Data
public class SocialLoginRequest {

    @Schema(description = "Oauth 서버에서 받아온 인가코드", example = "인가코드")
    @NotBlank
    private String code;

    /**
     * redirectUrl 은 인가코드를 받아올 redirectUrl을 의미하며 여기서  redirectUrl은 로그인시 요청한 redirectUrl과 동일한 값으로 받아와야함
     * 리다이렉트 유알엘을 받는 이유는 로컬, 배포 , 테스트 환경에서 유동적으로 실행할수있게 하기 위함임
     */
    @Schema(description = "로그인후 리다이렉트 받을 주소 경로", example = "<http://localhost:3000/login/kakaoLoginProcess>")
    private String redirectUrl;
    
    private ProviderType type;

    @Schema(description = "소셜 로그인 타입", example = "KAKAO")
    @NotNull
    private ProviderType providerType;

    public ThirdPartySignupInfo toDomain() {
        Map<String, String> propertiesValues = new HashMap<>();
        propertiesValues.put("code", code);
        propertiesValues.put("redirectUrl", redirectUrl);
        return new ThirdPartySignupInfo(type, propertiesValues);
    }

}

여기서 ProviderType을 클라이언트에게 받는다 그리고 그것을

@Getter
public class ThirdPartySignupInfo {
    private final ProviderType providerType;
    private final Map<String, String> propertiesValues;

    public ThirdPartySignupInfo(ProviderType providerType, Map<String, String> propertiesValues) {
        this.providerType = providerType;
        this.propertiesValues = propertiesValues;
    }
}

해당 객체에 넣는다 이제 여기서부터 모든 서드파티가 통합이다 그래서 Map을 사용해서 각 서드파티에 필요한 요소값들을 넣어주었다 참고로 카카오는 redirect url이 필요하고 네이버 로그인은 state가 필요하다

이제 sevice layer로 가보자

    public LoginToken signupByThirdParty(ThirdPartySignupInfo request) {
        ThirdPartyUserInfo userInfo = requestProviderIdFromThirdParty(request);
        boolean isNewUser = userAuthManager.registerIfNeed(userInfo, request.getProviderType());
        return tokenGenerator.generate(userInfo.getProviderId(), isNewUser);
    }

    private ThirdPartyUserInfo requestProviderIdFromThirdParty(ThirdPartySignupInfo request) {
        ThirdPartyAuthorizer authorizer = thirdPartyAuthorizerProvider.get(request.getProviderType());
        String accessToken = authorizer.getAccessToken(request);
        return authorizer.getUserInfo(accessToken);
    }

여기서 이제 가장 중요한건 requestProviderIdFromThirdParty메서드 이다

@Component
@AllArgsConstructor
public class ThirdPartyAuthorizerProvider {
    private final List<ThirdPartyAuthorizer> thirdPartyAuthorizers;

    public ThirdPartyAuthorizer get(ProviderType providerType) {
        return thirdPartyAuthorizers.stream()
                .filter(authorizer -> authorizer.getProviderType() == providerType)
                .findFirst()
                .orElseThrow(() -> new RuntimeException("해당하는 제공자가 없습니다."));
    }
}
public interface ThirdPartyAuthorizer {
    String getAccessToken(ThirdPartySignupInfo signupInfo);

    ThirdPartyUserInfo getUserInfo(String accessToken);

    ProviderType getProviderType();

}

여기 보면 ThirdPartyAuthorizer 라는 인터페이스를 만들어서 소셜 로그인 제공자를 위한 공통된 메서드들을 정의했다 결국엔 모든 네이버, 카카오, 구글 등이 OAuth서버로부터 엑세스 토큰을 얻고, 엑세스 토큰을 사용하여 사용자 정보를 반환하는 구조기 때문이다 getProviderType으로 해당 타입 제공자가 카카오인지, 네이버인지, 구글인지 알 수 있게 해놨다

ThirdPartyAuthorizerProvider는 주어진 ProviderType에 맞는 적절한 ThirdPartyAuthorizer를 반환한다

예를 들어, ProviderType.KAKAO가 주어지면 KakaoAuthorizer 객체를 반환하면 된다

@Component
@RequiredArgsConstructor
public class KakaoAuthorizer implements ThirdPartyAuthorizer {

    private final KakaoAuthClient kakaoAuthClient;
    private final KakaoApiClient kakaoApiClient;
    @Value("${kakao.client-id}")
    private String clientId;
    @Value("${kakao.client-secret}")
    private String client_secret;

    @Override
    public String getAccessToken(ThirdPartySignupInfo signupInfo) {
        Map<String, String> propertiesValues = signupInfo.getPropertiesValues();

        KakaoTokenResponse response = kakaoAuthClient.generateToken(
                "authorization_code",
                clientId,
                propertiesValues.get("redirectUrl"),
                propertiesValues.get("code"),
                client_secret
        );

        return response.getAccessToken();
    }

    @Override
    public ThirdPartyUserInfo getUserInfo(String accessToken) {
        KakaoUserInfo kakaoUserInfo = kakaoApiClient.getUserInfo(new BearerAuthHeader(accessToken).getAuthorization());
        return new ThirdPartyUserInfo(kakaoUserInfo.getId().toString(), kakaoUserInfo.getName(),
                kakaoUserInfo.getNickName(), kakaoUserInfo.getProfileImage(), kakaoUserInfo.getEmail(),
                kakaoUserInfo.getPhoneNumber(), kakaoUserInfo.getGender(), kakaoUserInfo.getBirthDay());
    }

    @Override
    public ProviderType getProviderType() {
        return ProviderType.KAKAO;
    }

}

이렇게 하면 된다 그러면 프론트가 KAKAO값을 주면 적절한 ThridPartyAuthorizer 타입의 변수인 KakaoAuthorizer객체를 참조할 수 있다 이러면 구체적인 구현 클래스에 의존하지 않고 인터페이스 타입으로 객체를 다룰 수 있다

서비스 코드인

    private ThirdPartyUserInfo requestProviderIdFromThirdParty(ThirdPartySignupInfo request) {
        ThirdPartyAuthorizer authorizer = thirdPartyAuthorizerProvider.get(request.getProviderType());
        String accessToken = authorizer.getAccessToken(request);
        retrun authorizer.getUserInfo(accessToken);
    }

여기서!

다시말해 ThirdPartyAuthorizer 인터페이스의 KakaoAuthorizer와 같은 구현체는 ThirdPartyAuthorizer 타입으로 참조될 수 있으며, service layer에서는 이를 통해 다양한 소셜 로그인들을 처리할 수 있다 (리스코프 치환 원칙)

그러면 여기서 네이버 로그인 추가하면 어떻게 될까?

결국엔 카카오랑 똑같이 signupByThirdParty메서드만 호출하고 카카오랑 다른 요소값만 Map형식으로 보내주면 된다

그리고 ProviderType에

public enum ProviderType implements EnumModel {

    KAKAO("kakao"),
    NAVER("naver"),

    NORMAL("일반회원가입");

    private final String value;

    ProviderType(String value) {
        this.value = value;
    }

    @Override
    public String getKey() {
        return name();
    }

    @Override
    public String getValue() {
        return value;
    }
}

네이버 추가해주고
ThirdPartyAuthorizer를 구현하는

@Component
@RequiredArgsConstructor
public class NaverAuthorizer implements ThirdPartyAuthorizer {

    private final NaverAuthClient naverAuthClient;
    private final NaverApiClient naverApiClient;
    @Value("${spring.oauth2.client.registration.naver.client-id}")
    private String clientId;
    @Value("${spring.oauth2.client.registration.naver.client-secret}")
    private String client_secret;

    @Override
    public String getAccessToken(ThirdPartySignupInfo signupInfo) {

        Map<String, String> propertiesValues = signupInfo.getPropertiesValues();

        NaverTokenResponse response = naverAuthClient.generateToken(
                "authorization_code",
                clientId,
                client_secret,
                propertiesValues.get("code"),
                propertiesValues.get("state")
        );

        return response.getAccess_token();
    }

    @Override
    public ThirdPartyUserInfo getUserInfo(String accessToken) {

        NaverUserInfo naverUserInfo = naverApiClient.getUserInfo(new BearerAuthHeader(accessToken).getAuthorization());

        return new ThirdPartyUserInfo(naverUserInfo.getId().toString(), naverUserInfo.getName(),
                naverUserInfo.getNickName(), naverUserInfo.getProfileImage(), naverUserInfo.getEmail(),
                naverUserInfo.getPhoneNumber(), naverUserInfo.getGender(), naverUserInfo.getBirthDay());
    }

    @Override
    public ProviderType getProviderType() {
        return ProviderType.NAVER;
    }

}

이친구만 추가해주면 끝이다 구글도 같은 방식으로 처리하면 된다

이렇게 기획에서 소셜이 추가되더라도 객체지향적으로 코드를 작성하면 도메인과 서비스 레이어의 코드를 1도 건들이지 않고 시스템은 견고하면서 확장이 가능하다

근데 뭐 소셜 로그인 추가가 자주 일어날라나..... 어떻게 생각해보면 자주 일어날거같고 어떻게 생각해보면 자주 일어나지 않을 것 같다.

그리고 객체지향적으로 잘 짠다는 것은 결국엔 인터페이스 활용도가 아닌가 라는 생각도 들었다

그리고 역시 단순 독서보다 직접 활용하는게 나한텐 맞다

라고 정리했었는데 구글 로그인이 스펙에 추가 되었다

@Component
@RequiredArgsConstructor
public class GoogleAuthorizer implements ThirdPartyAuthorizer {

    private final GoogleAuthClient googleAuthClient;
    private final GoogleApiClient googleApiClient;

    @Value("${google.client-id}")
    private String clientId;
    @Value("${google.client-secret}")
    private String client_secret;

    @Override
    public String getAccessToken(final ThirdPartySignupInfo signupInfo) {
        Map<String, String> propertiesValues = signupInfo.getPropertiesValues();
        String code = propertiesValues.get("code");
        String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); // 구글 oauth 서버로부터 받은 인가코드는 디코딩 한번 해줘야함
        GoogleTokenRequest googleTokenRequest = new GoogleTokenRequest(
                "authorization_code",
                clientId,
                propertiesValues.get("redirectUrl"),
                decodedCode,
                client_secret);

        GoogleTokenResponse response = googleAuthClient.generateToken(googleTokenRequest);
        return response.accessToken();
    }

    @Override
    public ThirdPartyUserInfo getUserInfo(final String accessToken) {
        GoogleUserInfo googleUserInfo = googleApiClient.getUserInfo(new BearerAuthHeader(accessToken).getAuthorization());

        return new ThirdPartyUserInfo(googleUserInfo.getId().toString(), googleUserInfo.getName(),
                googleUserInfo.getNickName(), googleUserInfo.getProfileImage(), googleUserInfo.getEmail(),
                googleUserInfo.getPhoneNumber(), googleUserInfo.getGender(), googleUserInfo.getBirthDay());
    }

    @Override
    public ProviderType getProviderType() {
        return ProviderType.GOOGLE;
    }

}

정말 간단하게 구글 로그인을 추가할 수 있었다 굳굳

0개의 댓글