[Spring Boot] OAuth2 간편 로그인·탈퇴 추상화

sarah·2025년 4월 28일
0

간편 로그인(OAuth2)을 카카오·구글로 연동하면서, 중복되는 코드를 줄이고 프로바이더별 로직을 분리함
특히 탈퇴(연동 해제) 과정이 카카오와 구글이 달라서 이를 어떻게 추상화할지 고민함


1. 간편 로그인 흐름

(자세한 내용은 여기 참고)

  1. 사용자가 앱에서 “카카오/구글 로그인” 버튼 클릭
  2. OAuth 서버에서 인증 후 code 발급
  3. 백엔드 GET /oauth/{provider}/callback?code=…code 수신
  4. 백엔드에서
    • code → oauth 액세스 토큰 발급
    • oauth 사용자 정보 가져오기
    • oauth token DB 에 저장

OAuth2 Flow


2. 로그인 연동: Strategy 패턴으로 공통 로직 묶기

OAuthLoginClient 인터페이스

interface OAuthLoginClient {
  fun getOAuthProvider(): Member.OAuthProvider
  fun getAccessToken(code: String): OAuthTokenInfo
  fun getUserInfo(accessToken: String): OAuthUserInfo
}
  • 카카오·구글용 WebClient 구현체가 이 인터페이스를 따릅니다.
  • 새 프로바이더를 추가할 때는 이 인터페이스를 구현한 클래스 추가
@Service class GoogleOAuthLoginWebClient : OAuthLoginClient {}
@Service class KakaoOAuthLoginWebClient  : OAuthLoginClient {}

OAuthCallbackService: 분기 없이 똑같은 처리

  • 로그인 플로우는 카카오/구글 동일함
  • oauth token을 DB 에 저장하는 이유 -> google 간편로그인 해제 시 이때 발급받은 refresh token이 필요함
@Service
class OAuthCallbackService(
    private val memberUseCase: MemberUseCase,
    private val tokenUseCase: OAuthTokenUseCase,
    private val clients: List<OAuthLoginClient>
): OAuthCallbackUseCase {

    override fun processOAuthCallback(provider: Member.OAuthProvider, code: String): Long {
        // 1) 클라이언트 조회
        val client = clients
            .firstOrNull { it.getOAuthProvider() == provider }
            ?: throw InvalidRequestException("지원하지 않는 provider: $provider")

        // 2) 코드로 토큰 발급
        val oauthTokenInfo = client.getAccessToken(code)

        // 3) 토큰으로 사용자 정보 조회
        val userInfo = client.getUserInfo(oauthTokenInfo.accessToken)

        // 4) DB에 회원 생성 또는 조회
        val member = memberUseCase.processOauthLogin(userInfo)

        // 5) 발급받은 토큰 DB에 저장
        tokenUseCase.createOAuthToken(member, oauthTokenInfo)

        return member.memberSeq
    }
}

3. 콜백 컨트롤러

@RestController
@RequestMapping("/oauth")
class OAuthCallbackController(
    private val callbackService: OAuthCallbackService,
	...
) {
    @GetMapping("/{provider}/callback")
    fun callback(
        @PathVariable provider: String,
        @RequestParam code: String,
        response: HttpServletResponse
    ) {
        val memberSeq = oAuthCallbackService.processOAuthCallback(
            provider = Member.OAuthProvider.fromPath(provider),
            code = code
        )
            ...
    }
}

4. 탈퇴(연동 해제) 처리: 프로바이더별로 다른 로직

“탈퇴”라고 해도, 내부 회원 삭제와는 별개로 외부 OAuth 제공자에 연동 해제를 먼저 해야 함.
카카오와 구글이 API 방식이 달라서 이를 중점적으로 서술해봄

4.1 공통 인터페이스

interface OAuthWithdrawalUseCase {
  fun getOAuthProvider(): Member.OAuthProvider
  fun withdraw(member: Member)
}

4.2 서비스

@Service
class OAuthWithdrawalService(
    private val cases: List<OAuthWithdrawalUseCase>
) {
    fun withdraw(member: Member) {
        val useCase = cases
            .firstOrNull { it.getOAuthProvider() == member.oauthProvider }
            ?: error("지원하지 않는 provider: ${member.oauthProvider}")
        useCase.withdraw(member)
    }
}

4.3 카카오 vs. 구글 플로우

제공자처리 방식
카카오POST /v1/user/unlink → Admin Key만 있으면 OK
구글1) DB에서 refreshToken 조회
2) refreshToken 유효 확인
3) oauth 토큰 재발급 → accessToken 얻기
4) POST /revoke?token=… 호출
@Service
class KakaoOAuthWithdrawalService(
    private val kakaoClient: KakaoOAuthWithdrawalClient
): OAuthWithdrawalUseCase {
    override fun getOAuthProvider() = Member.OAuthProvider.KAKAO
    override fun withdraw(member: Member) {
    	// oauth 탈퇴
        kakaoClient.withdraw(member.oauthId)
    }
}

@Service
class GoogleOAuthWithdrawalService(
    private val googleClient: GoogleOAuthWithdrawalClient,
    private val repo: OAuthTokenRepository
): OAuthWithdrawalUseCase {
    override fun getOAuthProvider() = Member.OAuthProvider.GOOGLE

    override fun withdraw(member: Member) {
    	// DB의 oauth 토큰 조회
        val token = repo.findLatest(member)
            ?: throw OAuthWithdrawalException("OAuth 정보 없음")
            
        // refresh token 유효한지 확인    
        if (token.refreshTokenExpiresAt <= LocalDateTime.now()) {
            throw OAuthWithdrawalException("만료된 refresh token, 재로그인 필요")
        }
        
        // oauth access Token 재발급
        val accessToken = googleClient.getAccessToken(token.refreshToken)
        
        // oauth 탈퇴
        googleClient.withdraw(accessToken)
    }
}

4.4 컨트롤러·서비스 연결

@RestController
@RequestMapping("/auth")
class AuthController(private val authUseCase: AuthUseCase) {
    @DeleteMapping("/withdraw")
    fun withdraw(
        @AuthenticationPrincipal member: Member,
        @RequestBody request: WithdrawalRequest
    ) {
        authUseCase.withdraw(member, request)
    }
}

@Service
class AuthService(
    private val oauthWithdrawalService: OAuthWithdrawalService,
    private val memberUseCase: MemberUseCase,
    private val redisTokenUseCase: RedisTokenUseCase
): AuthUseCase {
    override fun withdraw(member: Member, request: WithdrawalRequest) {
        // 1) 외부 연동 해제
        oauthWithdrawalService.withdraw(member)
        
        // 2) 내부 회원 탈퇴
        memberUseCase.withdraw(member)
        
        // 3) Redis에 저장된 refresh token 삭제
        redisTokenUseCase.deleteRefreshToken(member.memberSeq, request.deviceId)
    }
}

5. 마무리

  • 공통 부분을 추상화해서 로직을 하나로 합치고, 유지보수가 용이하도록 구현
  • 추상 인터페이스 + 구현체 리스트로 분기처리 하도록 함

0개의 댓글