kakao Developer, Naver Developer에 등록한 상태를 바탕으로 글을 작성하였습니다.
SNS로 로그인을 해서 얻은 결과로 백엔드와 통신하여 서버에
회원가입 및 로그인을 사용하는 방법을 보여주는 글 입니다
// Kakao Login
implementation("com.kakao.sdk:v2-user:2.19.0")
// Naver Login
implementation("com.navercorp.nid:oauth:5.1.0") // jdk 11
Kakao
Naver
localproperties
git에 안올리시는 분은 local.properties에 등록안하고 바로 사용해도 되겠지만
git에 올린다는 가정하에 보안을 위해 local.properties에 넣어서 사용을 했습니다
카카오 같은 경우는 네이티브 앱 키로 2개로 만들었는데
쌍 따움표 없는 것은 manifest에서 사용하기 위해서이고,
있는 것은 그외에 사용하기 위해서 입니다
plugins 밑에 아래와 같은 코드를 작성해준다
var properties: Properties = Properties()
properties.load(project.rootProject.file("local.properties").inputStream())
buildFeatures {
buildConfig = true
}
defaultConfig {
...
manifestPlaceholders["KAKAO_API_KEY"] = properties.getProperty("TEST_KAKAO_NATIVE_KEY")
buildConfigField("String", "KAKAO_API_KEY", properties.getProperty("TEST_KAKAO_API_KEY"))
buildConfigField("String", "NAVER_CLIENT_ID", properties.getProperty("NAVER_CLIENT_ID"))
buildConfigField("String", "NAVER_CLIENT_SECRET_KEY", properties.getProperty("NAVER_CLIENT_SECRET_KEY"))
}
<uses-permission android:name="android.permission.INTERNET" />
// 아래는 카카오 로그인만 해당합니다
<activity
android:name="com.kakao.sdk.auth.AuthCodeHandlerActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="oauth"
android:scheme="kakao${KAKAO_API_KEY}" />
</intent-filter>
</activity>
interface LoginDataSource {
suspend fun login(): String
}
sns 로그인을 하는 함수를 만들기 위해 interface로 선언해줍니다
class KakaoLoginDataSource @Inject constructor(@ApplicationContext val context: Context) :
LoginDataSource {
override suspend fun login(): String {
return suspendCancellableCoroutine {
// 카카오계정으로 로그인 공통 callback 구성
// 카카오톡으로 로그인 할 수 없어 카카오계정으로 로그인할 경우 사용됨
val callback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
if (error != null) {
Log.e(
"Login-kakao",
"카카오계정으로 로그인 실패", error
)
it.resume(null.toString(), {})
} else if (token != null) {
Log.d(
"Login-kakao",
"카카오계정으로 로그인 성공 ${token.accessToken}"
)
it.resume(token.accessToken, {})
}
}
if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) {
UserApiClient.instance.loginWithKakaoTalk(context) { token, error ->
if (error != null) {
Log.e(
"Login-kakao",
"카카오톡으로 로그인 실패", error
)
// 사용자가 카카오톡 설치 후 디바이스 권한 요청 화면에서 로그인을 취소한 경우,
// 의도적인 로그인 취소로 보고 카카오계정으로 로그인 시도 없이 로그인 취소로 처리 (예: 뒤로 가기)
if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
return@loginWithKakaoTalk
}
// 카카오톡에 연결된 카카오계정이 없는 경우, 카카오계정으로 로그인 시도
UserApiClient.instance.loginWithKakaoAccount(context, callback = callback)
} else if (token != null) {
Log.i(
"Login-kakao",
"카카오톡으로 로그인 성공 ${token.accessToken}"
)
it.resume(token.accessToken, {})
}
}
} else {
UserApiClient.instance.loginWithKakaoAccount(context, callback = callback)
}
}
}
}
context가 필요한 부분은 hilt를 통해 주입을 받습니다
상단의 그래프에서처럼 kakao에서 발급받은 accessToken을 통해
서버에 로그인을 해야하므로 return 타입을 String으로 하여
Token(kakao 에서 발급 해주는)을 return 해줍니다
class NaverLoginDataSource @Inject constructor(@ApplicationContext val context: Context) :
LoginDataSource {
override suspend fun login(): String {
return suspendCancellableCoroutine {
val oauthLoginCallback =
object : OAuthLoginCallback {
override fun onError(
errorCode: Int,
message: String,
) {
onFailure(errorCode, message)
it.resume(null.toString(), {})
}
override fun onFailure(
httpStatus: Int,
message: String,
) {
val errorCode = NaverIdLoginSDK.getLastErrorCode().code
val errorDescription = NaverIdLoginSDK.getLastErrorDescription()
Log.e(
"Login-naver",
"errorCode:$errorCode errorDescription:$errorDescription"
)
it.resume(null.toString(), {})
}
override fun onSuccess() {
Log.d("Login-naver", "로그인 성공")
it.resume(NaverIdLoginSDK.getAccessToken().toString(), {})
}
}
NaverIdLoginSDK.logout()
CoroutineScope(Dispatchers.Main).launch {
// UI 스레드에서 호출되어야 하는 작업
NaverIdLoginSDK.authenticate(context, oauthLoginCallback)
}
}
}
마지막쯤에 NaverIdLoginSDK.logout()
코드가 있는데
저는 없으면 로그인이 안되더라구요😢
없어도 되시는 분은 댓글 부탁드립니다 .. 🙇
data class LoginResponse (
val hasAdditionalInfo: Boolean,
val email: String,
val accessToken: String,
val refreshToken: String
)
data class LoginRequest(
val accessToken: String?
)
sns로 발급받은 accessToken을 통해 서버에 로그인을 시도 했을 때 받을 data입니다
서버에 로그인할 api
interface LoginApi {
@POST("members/oauth/{provider}/login")
suspend fun getAccessToken(
@Path("provider") provider: String,
@Body accessToken: LoginRequest,
): Response<LoginResponse>
}
object LoginRetrofitClient {
private const val BASE_URL = "사용하시는 URL"
private val logging =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
// cURL을 확인 하기 위해 사용
private val okHttpClient =
OkHttpClient.Builder()
.addInterceptor(logging)
.build()
private fun getRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(nullOnEmptyConverterFactory)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
// 비어있는 응답을 null로 처리
private val nullOnEmptyConverterFactory =
object : Converter.Factory() {
fun converterFactory() = this
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
) = object : Converter<ResponseBody, Any?> {
val nextResponseBodyConverter = retrofit.nextResponseBodyConverter<Any?>(converterFactory(), type, annotations)
override fun convert(value: ResponseBody) =
if (value.contentLength() != 0L) nextResponseBodyConverter.convert(value) else null
}
}
val loginApi: LoginApi = getRetrofit().create(LoginApi::class.java)
}
retrofit2 셋팅
interface LoginRepository {
suspend fun login(
provider: String,
): String
suspend fun loginApi(
provider: String,
accessToken: LoginRequest?,
): LoginResponse?
}
class LoginRepositoryImpl @Inject constructor(
val kakaoLoginDataSource: KakaoLoginDataSource,
val naverLoginDataSource: NaverLoginDataSource,
val loginRemoteDataSource: LoginRemoteDataSource,
) : LoginRepository {
override suspend fun login(provider: String): String {
var accessToken = ""
if (provider == "kakao") {
accessToken = kakaoLoginDataSource.login()
} else if (provider == "naver") {
accessToken = naverLoginDataSource.login()
}
return accessToken
}
override suspend fun loginApi(
provider: String,
accessToken: LoginRequest?,
): LoginResponse? {
return accessToken?.let { loginRemoteDataSource.loginApi(provider, it) }
}
}
class LoginRemoteDataSource @Inject constructor(
val loginApi: LoginApi) {
suspend fun loginApi(
provider: String,
accessToken: LoginRequest,
): LoginResponse? {
try {
val loginGetResponse = loginApi.getAccessToken(
provider,
accessToken,
)
if (loginGetResponse.code() != 200) {
val stringToJson = JSONObject(loginGetResponse.errorBody()?.string()!!)
Log.d("LoginGetFailure", loginGetResponse.code().toString())
Log.d("LoginGetFailure", "$stringToJson")
return null
}
Log.d("LoginGetSuccess", loginGetResponse.code().toString())
return loginGetResponse.body()
} catch (e: Exception) {
Log.e("LoginGetException", e.toString())
return null
}
}
}
성공했을 때만 반응하게 작동시켰습니다.
@Module
@InstallIn(SingletonComponent::class)
object LoginRepositoryModule {
@Provides
@Singleton
fun provideLoginRepository(loginRepositoryImpl: LoginRepositoryImpl): LoginRepository {
return loginRepositoryImpl
}
}
class LoginUsecase @Inject constructor(val loginRepository: LoginRepository,) {
suspend operator fun invoke(loginProvider: String, context: Context): LoginResponse? {
val sdkAccessTokenResult = loginRepository.login(loginProvider)
val apiResponseResult = loginRepository.loginApi(loginProvider, LoginRequest(sdkAccessTokenResult))
return apiResponseResult
}
}
sns 로그인을 통해 sns에서 발급받은 AccessToken을 sdkAccessTokenResult 에 대입 해주고,
발급받은 AccessToken으로 서버에 로그인을 해줍니다
@HiltViewModel
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUsecase,
) : ViewModel() {
private val _loginResponse = MutableLiveData<LoginResponse?>()
val loginResponse get() = _loginResponse
fun login(provider: String,context: Context) {
viewModelScope.launch {
val getAccessToken = loginUseCase(provider,context)
_loginResponse.value = getAccessToken
}
}
}
@AndroidEntryPoint
class LoginFragment : Fragment() {
private var _binding: FragmentLoginBinding? = null
private val binding get() = _binding!!
private val loginViewModel: LoginViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentLoginBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
binding.btnLoginKakao.setOnClickListener {
login("kakao")
}
binding.btnLoginNaver.setOnClickListener {
login("naver")
}
loginAfterMoveFragment()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun loginAfterMoveFragment() {
loginViewModel.loginResponse.observe(viewLifecycleOwner) { loginResponse ->
if (loginResponse != null) {
if (loginResponse.hasAdditionalInfo == true) {
findNavController().navigate(R.id.action_loginFragment_to_homeFragment)
} else {
findNavController().navigate(R.id.action_loginFragment_to_signupVeganTypeFragment)
}
}
}
}
private fun login(provider: String) {
context?.let { loginViewModel.login(provider, it) }
}
}
결과값에 따라서 회원정보가 비어있다면 회원가입 화면으로 이동하고
회원정보가 있다면 메인화면으로 이동하게 됩니다
SNS 로그인을 활용하여 서버와 통신하는 작업을 처음 진행하면서
Hilt, MVVM, 클린 아키텍처도 처음 적용해보았습니다
비록 미숙했지만 우여곡절 끝에 실행 가능한 상태로 완성했습니다이번 코드는 실행에만 중점을 두어서 완성도가 떨어질 수 있습니다
여러 사람의 도움을 받아 코드를 완성했지만, 혹시 저와 같은 어려움을 겪고 계신 분들에게 제 글이 약간의 도움이 되었으면 합니다
틀린 부분이 있거나 더 좋은 방법이 있다면 댓글로 알려주시면 감사하겠습니다 !