간편 로그인(OAuth2)을 카카오·구글로 연동하면서, 중복되는 코드를 줄이고 프로바이더별 로직을 분리함
특히 탈퇴(연동 해제) 과정이 카카오와 구글이 달라서 이를 어떻게 추상화할지 고민함
(자세한 내용은 여기 참고)
code
발급 GET /oauth/{provider}/callback?code=…
로 code
수신 code
→ oauth 액세스 토큰 발급 interface OAuthLoginClient {
fun getOAuthProvider(): Member.OAuthProvider
fun getAccessToken(code: String): OAuthTokenInfo
fun getUserInfo(accessToken: String): OAuthUserInfo
}
@Service class GoogleOAuthLoginWebClient : OAuthLoginClient { … }
@Service class KakaoOAuthLoginWebClient : OAuthLoginClient { … }
@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
}
}
@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
)
...
}
}
“탈퇴”라고 해도, 내부 회원 삭제와는 별개로 외부 OAuth 제공자에 연동 해제를 먼저 해야 함.
카카오와 구글이 API 방식이 달라서 이를 중점적으로 서술해봄
interface OAuthWithdrawalUseCase {
fun getOAuthProvider(): Member.OAuthProvider
fun withdraw(member: Member)
}
@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)
}
}
제공자 | 처리 방식 |
---|---|
카카오 | 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)
}
}
@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)
}
}