[Spring Boot] 카카오, 구글 소셜 로그인 구현 (객체지향적 관점으로)

임원재·2024년 10월 25일
0

SpringBoot

목록 보기
7/18
post-thumbnail
  • 게시물을 올리는 간단한 프로젝트에서 소셜 로그인을 카카오와 구글 두 개로 진행하기로 결정했다.
  • 대략적인 로그인과정은 다음과 같다.
    소셜 로그인을 통해 이메일 정보를 인증 서버에서 받아오고,
    받아온 이메일로 유저의 존재 유무를 판단한 뒤,
    유저가 존재하면 그대로 로그인, 유저가 존재하지 않으면 해당 이메일로 회원가입하는 과정으로 이루어질 예정이다.

개요

  • 카카오와 구글의 OAuth2 프로토콜 소셜 로그인 과정은 다음과 같다.
  • 두 provider로부터 이메일 정보를 받아오기까지 모두 동일한 형태의 과정을 거쳐야 한다. 로그인을 진행하고 콜백하여 인가 코드를 받고,
    인가 코드로 토큰(accessToken)을 받으며,
    다시 액세스토큰으로 해당 사용자의 정보(이메일)을 받아옴으로써 소셜 로그인이 진행된다.
  • 해당 과정을 보고 두 개의 핸들러로 나누어 진다고 생각했다.

    1. 사용자가 소셜 로그인 버튼을 눌렀을 때 나타나는 로그인 창이다. 로그인을 진행하여 성공하면 미리 설정한 인증코드와 함께 리다이렉트 url로 서버를 호출한다.
    2. 리다이렉트 url로 서버를 호출하는 콜백이 발생하면, url안에 파라미터 형태로 들어있는 인증코드로 액세스토큰을 받고, 액세스토큰으로 사용자정보를 가져와 이메일을 가져올 수 있다.
  • 이를 컨트롤러로 작성하면 아래와 같다.

@Controller
@RequestMapping("/api/v1/oauth")
@RequiredArgsConstructor
public class SocialLoginController {
		
    private final KakaoSocialLoginService kakaoSocialLoginSercice;
    private final GoogleSocialLoginService googleSocialLoginService;

		
    @GetMapping("/kakao")
    public String kakaoLogin() {
        String redirectUrl = kakaoSocialLoginSercice.login();
        return "redirect:" + redirectUrl;
    }

    @GetMapping("/google")
    public String googleLogin() {
        String redirectUrl = googleSocialLoginService.login();
        return "redirect:" + redirectUrl;
    }
}

@RestController
@RequiredArgsConstructor
public class CallbackController {
	
    private final KakaoSocialLoginService kakaoSocialLoginSercice;
    private final GoogleSocialLoginService googleSocialLoginService; 

    @GetMapping("/oauth/kakao/callback")
    public ApiResponse<JwtInfoDto> kakaoCallback(String authCode) {
        return ApiResponse.onSuccess(kakaoLoginService.callback(authCode));
    }

    @GetMapping("/oauth/google/callback")
    public ApiResponse<JwtInfoDto> googleCallback(String authCode) {
        return ApiResponse.onSuccess(googleLoginService.callback(authCode));
    }
}
  • 컨트롤러에 하나의 책임만 부여하기 위해 소셜로그인을 맡는 SocialLoginController와 콜백을 맡는 CallbackController 두 개를 생성하였다.
  • SocialLoginController에서 호출하는 핸들러는 특정 url로 리다이렉트되며, provider서버에서 지정한 콜백 url로 CallbackController의 핸들러를 호출한다.
    즉, SocialLoginController 의 핸들러를 호출하면 자동으로 CallbackController의 핸들러가 호출됨을 확인할 수 있다.
  • 정리하자면 아래와 같다.
    • kakaoLogin(), googleLogin() : 사용자로 하여금 로그인 진행 + 로그인 성공하면 지정한 콜백 주소로 인증 코드와 함께 리다이렉트
    • kakaoCallback(String authCode), googleCallback(String authCode) : 리다이렉트되어 호출되는 핸들러 + 인증 코드로 액세스토큰을 받음 + 다시 액세스토큰으로 provider로부터 사용자 정보(이메일)을 받음
  • 이제 SocialLoginService, Callback의 메서드만 구현하면 소셜 로그인은 구현 가능하다.
  • kakaoCallback(String authCode)에서 JwtInfoDto를 반환하는 이유는 provider에서 받아온 이메일로 자체 JWT 액세스토큰을 생성하여 반환하기 때문이다.

  • 그러므로 위 그래프와 같이 볼 수 있다고 생각한다.
  • 해당 상황에서 다음과 같은 의문이 들었다

카카오와 구글 모두 거의 동일한 프로세스로 소셜로그인을 통해 사용자 정보를 가져오기 때문에 Service 계층에서 똑같은 메서드를 사용할텐데 이를 묶어서 하나의 인터페이스를 만들 수 있을까?

후에 네이버와 같은 다른 provider를 추가하거나 삭제할 가능성도 존재하기에 명세로 만든다면 이득이 있을거라고 생각했다.

첫 번째 리팩토링

  • 이에 SocialLoginService라는 인터페이스를 생성하였다.

public interface SocialLoginService {
		String login();
		
		JwtInfoDto callback(String authCode);
}

  • Service 계층은 위와 같이 정리되었다.
  • 하지만 Controller 계층이 문제였다. Service 계층을 하나의 인터페이스로 묶었더니 동일한 객체의 빈이 두개가 발생하는 것이다.
@Controller
@RequestMapping("/api/v1/oauth")
@RequiredArgsConstructor
public class SocialLoginController {

    private final SocialLoginService kakaoSocialLoginService;
    private final SocialLoginService googleSocialLoginService;

    @GetMapping("/kakao")
    public String kakaoLogin() {
        String redirectUrl = kakaoLoginService.login();
        return "redirect:" + redirectUrl;
    }

    @GetMapping("/google")
    public String googleLogin() {
        String redirectUrl = googleLoginService.login();
        return "redirect:" + redirectUrl;
    }
}
  • 해당 코드는 NoUniqueBeanDefinitionException예외가 발생한다.
  • 위 코드와 같이 KakaoSocialLoginService, GoogleSocialLoginService 모두 SocialLoginService를 상속했기 때문에 SocialLoginService라는 타입의 빈이 2개 존재하게 된다.
  • 즉, 스프링은 어느 빈을 주입해야 할지 모호해지는 상황이 발생한다.
    어떤 빈을 주입해야하는지 명시적으로 결정해야한다.

두 번쨰 리팩토링

  • Qualifier 라는 애노테이션을 통해 주입할 빈을 명시적으로 지정하였다.
@Controller
@RequestMapping("/api/v1/oauth")
@RequiredArgsConstructor
public class SocialLoginController {

    @Qualifier("kakaoLoginService")
    private final SocialLoginService kakaoSocialLoginService;
    @Qualifier("googleLoginService")
    private final SocialLoginService googleSocialLoginService;

    @GetMapping("/kakao")
    public String kakaoLogin() {
        String redirectUrl = kakaoLoginService.login();
        return "redirect:" + redirectUrl;
    }

    @GetMapping("/google")
    public String googleLogin() {
        String redirectUrl = googleLoginService.login();
        return "redirect:" + redirectUrl;
    }
}

@Service("googleLoginService")
@RequiredArgsConstructor
public class GoogleLoginService implements SocialLoginService{
	......
}

@Service("kakaoLoginService")
@RequiredArgsConstructor
public class KakaoLoginService implements SocialLoginService {
	......
}
  • 각 필드에 대해 구현체를 지정함으로써 주입할 빈이 모호해지는 문제를 해결하였다.

  • 하지만 해당 코드에도 문제는 있었다.

  • 위 컨트롤러는 카카오 로그인을 호출하던 구글 로그인을 호출하던 항상 두개의 빈을 주입받는 상황이다.
    어떤 provider의 로그인을 호출하는지에 대해 주입되는 의존관계를 동적으로 깔끔하게 정리하고 싶다는 생각이 들었다.

  • 그러면 카카오, 구글 나눠서 핸들러를 작성하지 않고 하나의 핸들러만 존재하는 좀 더 객체지향에 가까워지리라는 생각이었다.

세 번째 리팩토링

  • Map<String, SocialLoginService>타입을 의존관계로 설정하면 모든 SocialLoginService의 구현체를 자동으로 put하게 됨을 이용하였다. 이때 key는 구현체의 빈 이름, value는 해당 구현체의 인스턴스이다.
  • 이를 이용하여 모든 SocialLoginService의 구현체를 자동주입받고, 메소드를 실행할 때 provider를 받아와 해당 provider에 해당하는 구현체를 인스턴스로 가져오는 식으로 구현을 진행하였다.
@RestController
@RequiredArgsConstructor
public class CallbackController {

    private final Map<String, SocialLoginService> socialLoginServices;

    @GetMapping("/oauth/{provider}/callback")
    public ApiResponse<JwtInfoDto> callback(@PathVariable String provider,
                                            @RequestParam(name = "code") String authCode) {
        SocialLoginService socialLoginService = socialLoginServices.get(provider);
        if(socialLoginService == null) throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");

        return ApiResponse.onSuccess(socialLoginService.callback(authCode));
    }
}

@Service("google")
@RequiredArgsConstructor
public class GoogleLoginService implements SocialLoginService{
	......
}

@Service("kakao")
@RequiredArgsConstructor
public class KakaoLoginService implements SocialLoginService {
	......
}
  • 위처럼 provider를 @PathVariable을 사용해 바인딩하여 값을 받고 꼭 @RequestParam으로 파라미터를 받아야한다. 그렇지 않으면 파라미터를 인식하지 못한다. (아마 @PathVariable을 사용하기 때문일 것이다)
  • socialLoginServices.get(provider)로 호출한 provider 에 해당하는 구현체를 가져와 해당 구현체의 메서드를 호출함으로써 구현을 완료하였다.

  • 다음과 같이 카카오 구글 모두 JwtInfoDto가 잘 나오는 것을 확인할 수 있었다.
  • 새로운 provider를 추가하기 쉽게 객체지향적으로 설계를 해놓았으니 추후에 네이버, 깃허브같은 provider를 추가하는것도 고려해볼 예정이다.

0개의 댓글