[Android] SNS로 로그인 및 회원가입 하기

Sdoubleu·2024년 6월 25일
0

Android

목록 보기
9/16
post-thumbnail

SNS 회원가입 및 로그인

kakao Developer, Naver Developer에 등록한 상태를 바탕으로 글을 작성하였습니다.

SNS로 로그인을 해서 얻은 결과로 백엔드와 통신하여 서버에
회원가입 및 로그인을 사용하는 방법을 보여주는 글 입니다


build.gradle(:app)

    // Kakao Login
    implementation("com.kakao.sdk:v2-user:2.19.0")

    // Naver Login
    implementation("com.navercorp.nid:oauth:5.1.0") // jdk 11

local.properties

  • Kakao

  • Naver

  • localproperties

git에 안올리시는 분은 local.properties에 등록안하고 바로 사용해도 되겠지만
git에 올린다는 가정하에 보안을 위해 local.properties에 넣어서 사용을 했습니다

카카오 같은 경우는 네이티브 앱 키로 2개로 만들었는데
쌍 따움표 없는 것은 manifest에서 사용하기 위해서이고,
있는 것은 그외에 사용하기 위해서 입니다


build.gradle(:app)

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"))
}

manifests

<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>

code

loginDataSource.kt

interface LoginDataSource {
    suspend fun login(): String
}

sns 로그인을 하는 함수를 만들기 위해 interface로 선언해줍니다


kakaoDataSource.kt

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() 코드가 있는데
저는 없으면 로그인이 안되더라구요😢
없어도 되시는 분은 댓글 부탁드립니다 .. 🙇


LoginResponse.kt

data class LoginResponse (
    val hasAdditionalInfo: Boolean,
    val email: String,
    val accessToken: String,
    val refreshToken: String
)

data class LoginRequest(
    val accessToken: String?
)

sns로 발급받은 accessToken을 통해 서버에 로그인을 시도 했을 때 받을 data입니다


loginApi.kt

서버에 로그인할 api

interface LoginApi {
    @POST("members/oauth/{provider}/login")
    suspend fun getAccessToken(
        @Path("provider") provider: String,
        @Body accessToken: LoginRequest,
    ): Response<LoginResponse>
}

LoginRetrofitClient.kt

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 셋팅


loginRepository.kt

interface LoginRepository {
    suspend fun login(
        provider: String,
    ): String
    
    suspend fun loginApi(
        provider: String,
        accessToken: LoginRequest?,
    ): LoginResponse?
}
  • loginApi는 서버와 로그인하는 함수
  • login은 sns에 로그인을 할 함수
    kakao 인지 naver인지 구분하기 위해 매개변수 provider

loginRepositoryImpl.kt

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) }
    }
}

LoginRemoteDataSource.kt

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.kt

@Module
@InstallIn(SingletonComponent::class)
object LoginRepositoryModule {

    @Provides
    @Singleton
    fun provideLoginRepository(loginRepositoryImpl: LoginRepositoryImpl): LoginRepository {
        return loginRepositoryImpl
    }
}

usecase.kt

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으로 서버에 로그인을 해줍니다


LoginViewModel.kt

@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
        }
    }
}

LoginFragment.kt

@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, 클린 아키텍처도 처음 적용해보았습니다
비록 미숙했지만 우여곡절 끝에 실행 가능한 상태로 완성했습니다

이번 코드는 실행에만 중점을 두어서 완성도가 떨어질 수 있습니다
여러 사람의 도움을 받아 코드를 완성했지만, 혹시 저와 같은 어려움을 겪고 계신 분들에게 제 글이 약간의 도움이 되었으면 합니다

틀린 부분이 있거나 더 좋은 방법이 있다면 댓글로 알려주시면 감사하겠습니다 !

profile
개발자희망자

0개의 댓글

관련 채용 정보