티스토리 뷰

로그인 UI

 

먼저 로그인 화면부터 구현할 것이다.

 

 

로그인 Flow

1. 로그인 진행
2. 로그인 시 유저 이메일과 자동 로그인 여부를 로컬 저장소에 저장
3. 저장된 값을 기반으로 초기 화면 라우팅 진행, 유저정보 가져오기

로그인 진행 시 이메일 값을 기반으로 유저 정보를 가져오도록 한다.

이메일을 키로 선택한 이유는 이메일은 계정의 고유한 값이기 때문이다.

 

 

의존성 추가

plugins {
    ...
    id("com.google.gms.google-services") version "4.3.8" apply false
}

프로젝트 수준의 그래들 파일에 위와 같은 의존성을 추가한다.

 

plugins {
	...
    id("com.google.gms.google-services")
}

dependencies {
    ...
    // Firebase
    implementation(platform("com.google.firebase:firebase-bom:33.4.0"))
    implementation("com.google.firebase:firebase-auth")
    implementation("com.google.firebase:firebase-firestore")

    // Google Login
    implementation("com.google.android.gms:play-services-auth:21.2.0")
    implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
    implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")

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

    // Naver Login
    implementation("com.navercorp.nid:oauth:5.10.0")
}

앱 수준의 그래들 파일에 위와 같은 의존성을 추가한다.

 

 

구글 로그인 설정

 

이 영상 4:09 ~ 6:12 보고 그대로 따라하면 된다.

 

 

 

웹 클라이언트 아이디는 복사해서 메모장 같은데 저장해놓도록 하자.

 

 

카카오 로그인 설정

이 영상 2:50 ~ 21:50 보고 따라하면 된다.

 

 

필자는 닉네임과, 이메일만 가져올 수 있도록 설정했다.

참고로 이메일 가져오려면 비즈앱 전환을 해야된다.

 

 

네이버 로그인 설정

 

[Android][Kotlin] 네이버 아이디 로그인(네아로) 연동(Naver Login)

안녕하세요~ 챠니입니다! :) 오늘은 네이버 아이디 로그인연동에 대해서 알아보겠습니다. 줄여서 "네아로"라고도 불리는데요 어렵지 않으니 하나씩 천천히 따라오시면 되겠습니다 :) Naver Developer

minchanyoun.tistory.com

Naver Developers 설정 파트까지만 참고해주자.

이것도 카카오 로그인과 마찬가지로 이름/이메일 값만 가져오도록 설정한다.

 

 

정식으로 사용하려면 네이버 로그인 검수요청을 보내고 승인받으면 되는데

일단 테스트부터 하고 싶으니까 테스트 아이디를 등록해주자.

 

 

SDK 초기화

 

안드로이드 스튜디오 Api키 관리하기

기존 방식app/src/main/java/com/hamond/escapeanchovy/constants/Secret.kt기존에는 .gitIgnore 파일에 깃허브에 업로드 하지 않을 상수 파일을 설정하고 object Secret { const val API_KEY = "asdfasdfasdf"}여기다가 Api 키를 작

tsi0511.tistory.com

이거 참고해서 Api키 설정 후 소셜 로그인 SDK를 초기화해주자.

 

 

FirebaseModule

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

    @Provides
    @Singleton
    fun provideFirebaseAuth(): FirebaseAuth {
        return FirebaseAuth.getInstance()
    }

    @Provides
    @Singleton
    fun provideFirebaseFirestore(): FirebaseFirestore {
        return FirebaseFirestore.getInstance()
    }
}

파이어베이스 서비스를 활용하기 위한 FirebaseModule을 작성해주자.

Hilt를 사용하여 FirebaseAuth와 FirebaseFirestore을 전역적으로 사용할 수 있도록 한다.

 

 

로그인 뷰모델 레파지토리

처음에 그냥 레파지토리 하나에 모든 데이터 관련 로직을

작성할까 생각했는데 그러면 레파지토리가 너무 커질 것 같아서

소셜로그인별로 레파지토리를 분리하기로 했다.

 

 

User 객체

data class User(
    val email: String,
    val name: String,
    val pw: String
)

일단 유저 객체에는 정말 필요한 필드 값만을 추가했다. 

 

이메일은 앞서 말했듯이 유저 식별에 사용되고,

비밀번호는 기본 로그인에, 이름은 이메일 찾기에 사용된다. 

 

소셜 로그인 같은 경우는 비밀번호 필드값이 공백에 해당한다.

 

 

GoogleLoginRepository

class GoogleLoginRepositoryImpl @Inject constructor(
    private val auth: FirebaseAuth,
) : GoogleLoginRepository {

    override fun createCredentialRequest(): GetCredentialRequest {
        val googleIdOption = GetGoogleIdOption.Builder()
            .setFilterByAuthorizedAccounts(false) // 디바이스의 모든 Google 계정 선택 가능
            .setAutoSelectEnabled(false) // Google 계정 자동선택 비활성화
            .setServerClientId(BuildConfig.GOOGLE_CLIENT_ID)
            .build()

        return GetCredentialRequest.Builder()
            .addCredentialOption(googleIdOption)
            .build()
    }

    override fun checkCredentialType(result: GetCredentialResponse): AuthCredential {
        return when (val credential = result.credential) {
            is CustomCredential -> {
                if (credential.type == TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
                    val googleIdTokenCredential =
                        GoogleIdTokenCredential.createFrom(credential.data)
                    val idToken = googleIdTokenCredential.idToken
                    GoogleAuthProvider.getCredential(idToken, null)
                } else {
                    throw Exception("Unsupported credential type")
                }
            }
            else -> {
                throw Exception("Invalid credential type")
            }
        }
    }

    override suspend fun loginWithCredential(firebaseCredential: AuthCredential): User {
        return try {
            val authResult = auth.signInWithCredential(firebaseCredential).await()
            val email = authResult.user?.email ?: ""
            val name = authResult.user?.displayName ?: ""
            User(email, name, "")
        } catch (e: Exception) {
            throw Exception(e.message)
        }
    }
}

구글 로그인 같은 경우는 기존 로그인 방식이 dispose되어

Credential Manger를 이용하여 구글 로그인을 구현하였다.

 

 

KakaoLoginRepository

class KakaoLoginRepositoryImpl @Inject constructor() : KakaoLoginRepository {

    override suspend fun loginWithKakaoAccount(context: Context) {
        return suspendCoroutine { continuation ->
            UserApiClient.instance.loginWithKakaoAccount(
                context,
                listOf(Prompt.SELECT_ACCOUNT)
            ) { _, error ->
                if (error != null) {
                    continuation.resumeWithException(Exception("로그인 실패: ${error.message}"))
                } else {
                    continuation.resume(Unit)
                }
            }
        }
    }

    override suspend fun getKakaoUser(): User {
        return suspendCoroutine { continuation ->
            UserApiClient.instance.me { user, error ->
                if (error != null) {
                    continuation.resumeWithException(Exception("사용자 정보 요청 실패: ${error.message}"))
                } else {
                    val email = user?.kakaoAccount?.email ?: ""
                    val name = user?.kakaoAccount?.profile?.nickname ?: ""
                    continuation.resume(User(email, name, ""))
                }
            }
        }
    }
}

필자는 카카오 계정 간편 로그인을 활용하였다.

 

카카오 Api는 기본적으로 콜백 방식이지만 콜백 지옥을 해결하기 위해

suspendCoroutine을 활용하여 카카오 로그인 함수를 작성하였다.

 

 

NaverLoginRepository

class NaverLoginRepositoryImpl @Inject constructor() : NaverLoginRepository {

    override suspend fun loginWithNaverAccount(context: Context): String? {
        return suspendCoroutine { continuation ->
            val oauthLoginCallback = object : OAuthLoginCallback {
                override fun onSuccess() {
                    val accessToken = NaverIdLoginSDK.getAccessToken()
                    continuation.resume(accessToken)
                }

                override fun onFailure(httpStatus: Int, message: String) {
                    val errorCode = NaverIdLoginSDK.getLastErrorCode().code
                    val errorDescription = NaverIdLoginSDK.getLastErrorDescription()
                    continuation.resumeWithException(
                        Exception("에러 코드:$errorCode, 에러 내용:$errorDescription")
                    )
                }

                override fun onError(errorCode: Int, message: String) {
                    onFailure(errorCode, message)
                }
            }

            NaverIdLoginSDK.authenticate(context, oauthLoginCallback)
        }
    }

    override suspend fun getNaverUser(accessToken: String?): User {
        return suspendCancellableCoroutine { continuation ->
            val userInfoUrl = "https://openapi.naver.com/v1/nid/me"
            val client = OkHttpClient()
            val request = Request.Builder()
                .url(userInfoUrl)
                .addHeader("Authorization", "Bearer $accessToken")
                .build()

            client.newCall(request).enqueue(object : Callback {
                override fun onResponse(call: Call, response: Response) {
                    val responseBody = response.body?.string()
                    responseBody?.let {
                        val jsonObject = JSONObject(responseBody)
                        if (jsonObject.getString("resultcode") == "00") {
                            val user = jsonObject.getJSONObject("response")
                            val email = user.getString("email")
                            val name = user.getString("name")
                            continuation.resume(User(email, name, ""))
                        }
                    }
                }

                override fun onFailure(call: Call, e: IOException) {
                    continuation.resumeWithException(Exception("네이버 유저 정보 가져오기 실패: ${e.message}"))
                }
            })

            continuation.invokeOnCancellation {
                client.dispatcher.cancelAll()
            }
        }
    }
}

카카오 로그인과 구성은 동일하나 유저정보를 가져오려면

okhttp3를 이용해 서버로 Api 요청을 보내야 했다.

 

 

StoreRepository

class StoreRepositoryImpl @Inject constructor(
    private val store: FirebaseFirestore
) : StoreRepository {
    ...
    override suspend fun saveAccountInfo(user: User) {
        try {
            store.collection(USER).document(user.email).set(user).await()
        } catch (e: Exception) {
            throw Exception("유저 정보 저장 에러: ${e.message}")
        }
    }

    override suspend fun isLoginSuccess(email: String, pw: String): Boolean {
        try {
            val query = store.collection(USER).whereEqualTo("email", email)
                .whereEqualTo("pw", hash(pw)).limit(1).get().await()
            return !query.isEmpty
        } catch (e: Exception) {
            throw Exception("기본 로그인 에러: ${e.message}")
        }
    }
}

마지막으로 유저정보 저장함수와 로그인 함수를 작성해주면 레파지토리 파트는 끝이다.

 

 

AccountDataSource

object AccountDataSource{
    private const val EMAIL = "email"
    private const val AUTO_LOGIN = "auto_login"

    fun saveUserEmail(context: Context, email:String?) {
        val prefs = context.getSharedPreferences(ACCOUNT_PREFS, 0)
        prefs.edit { putString(EMAIL, email) }
    }

    fun getUserEmail(context: Context): String? {
        val prefs = context.getSharedPreferences(ACCOUNT_PREFS, 0)
        return prefs.getString(EMAIL, null)
    }

    fun saveAutoLogin(context: Context, isAutoLogin: Boolean) {
        val prefs = context.getSharedPreferences(ACCOUNT_PREFS, 0)
        prefs.edit { putBoolean(AUTO_LOGIN, isAutoLogin) }
    }

    fun getAutoLogin(context: Context): Boolean {
        val prefs = context.getSharedPreferences(ACCOUNT_PREFS, 0)
        return prefs.getBoolean(AUTO_LOGIN, false)
    }
}

 계정 관련한 로컬 저장소 활용 함수를 작성해준다.

 

 

LoginState

sealed class LoginState {
    data object Init : LoginState()
    data class Success(val email: String) : LoginState()
    data object Failure : LoginState()
    data class Error(val error: String?) : LoginState()
}

뷰모델에서 사용할 LoginState를 정의한다.

로그인 성공 시에는 이메일을, 실패 시에는 에러를 반환한다.

 

 

LoginViewModel

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val googleLoginRepository: GoogleLoginRepository,
    private val kakaoLoginRepository: KakaoLoginRepository,
    private val naverLoginRepository: NaverLoginRepository,
    private val storeRepository: StoreRepository,
) : ViewModel() {

    private val _loginState = MutableStateFlow<LoginState>(LoginState.Init)
    val loginState: StateFlow<LoginState> = _loginState.asStateFlow()

    suspend fun login(email: String, pw: String) {
        try {
            val isLogin = storeRepository.isLoginSuccess(email, pw)
            if (isLogin) {
                _loginState.value = LoginState.Success(email)
            } else {
                _loginState.value = LoginState.Failure
            }
        } catch (e: Exception) {
            _loginState.value = LoginState.Error(e.message)
        }
    }

    suspend fun googleLogin(context: Context) {
        val credentialManager = CredentialManager.create(context)
        val request = googleLoginRepository.createCredentialRequest()

        runCatching {
            credentialManager.getCredential(request = request, context = context)
        }.onSuccess {
            performGoogleLogin(it)
        }.onFailure { error ->
            if (error is GetCredentialCancellationException) {
                // 로그인 취소 시에는 아무것도 하지 않음
            } else {
                openGoogleLoginScreen(context)
            }
        }
    }

    private suspend fun performGoogleLogin(result: GetCredentialResponse) {
        try {
            val credential = googleLoginRepository.checkCredentialType(result)
            val user = googleLoginRepository.loginWithCredential(credential)
            socialLoginSuccess(user)
        } catch (e: Exception) {
            _loginState.value = LoginState.Error(e.message)
        }
    }

    private fun openGoogleLoginScreen(context: Context) {
        val intent = Intent(ACTION_ADD_ACCOUNT)
        intent.putExtra(EXTRA_ACCOUNT_TYPES, arrayOf("com.google"))
        context.startActivity(intent)
    }

    suspend fun kakaoLogin(context: Context) {
        try {
            kakaoLoginRepository.loginWithKakaoAccount(context)
            val user = kakaoLoginRepository.getKakaoUser()
            socialLoginSuccess(user)
        } catch (e: Exception) {
            _loginState.value = LoginState.Error(e.message)
        }
    }

    suspend fun naverLogin(context: Context) {
        try {
            val accessToken = naverLoginRepository.loginWithNaverAccount(context)
            val user = naverLoginRepository.getNaverUser(accessToken)
            socialLoginSuccess(user)
        } catch (e: Exception) {
            _loginState.value = LoginState.Error(e.message)
        }
    }

    private suspend fun socialLoginSuccess(user: User) {
        storeRepository.saveAccountInfo(user)
        _loginState.value = LoginState.Success(user.email)
    }

    fun initLoginResult() {
        _loginState.value = LoginState.Init
    }
}

 

suspend 함수를 활용함으로써 에러처리를 일괄적으로 할 수 있게 되었다.

 

 

구글 로그인 함수만 유독 긴 이유

구글 로그인 같은 경우는 suspend 함수의 runCatching을 통해

분기처리를 해주지 않으면 앱이 강제종료가 되는 문제가 있었다.

 

보통 소셜 로그인 중에 예외가 발생하는 경우는 크게 2가지가 있는데

로그인을 도중에 취소하는 경우, 소셜 계정이 디바이스에 존재하지 않는 경우이다.

 

 Credential Manager를 활용한 구글 로그인은 이러한 예외처리를 해주지 않으므로

에러 코드를 로그에 찍어보면서 직접적으로 예외처리를 진행해주었다.

 

 

LoginScreen

@Composable
fun LoginScreen(navController: NavHostController) {

    // 화면 높이가 모자란 경우를 대비
    val scrollState = rememberScrollState()

    // 텍스트필드 사용 후 포커스 초기화를 위해 사용
    val focusManager = LocalFocusManager.current

    // 컴포즈 UI의 컨텍스트를 사용해야 뷰모델 함수가 오류 안남
    val context = LocalContext.current

    // 뷰모델 관련 변수 선언
    val loginViewModel = hiltViewModel<LoginViewModel>()
    val coroutineScope = rememberCoroutineScope()

    // 사용자 입력값들
    var email by remember { mutableStateOf("") }
    var pw by remember { mutableStateOf("") }

    // 자동 로그인 여부 컨트롤 변수
    var isSocialLogin by remember { mutableStateOf(true) }
    var isAutoLogin by remember { mutableStateOf(false) }

    // 로그인 버튼 활성화 조건 > 아이디 비번 입력
    val loginEnable = email.isNotEmpty() && pw.isNotEmpty()

    // 로그인 상태별 동작 정의
    LaunchedEffect(Unit) {
        loginViewModel.loginState.collect { loginState ->
            when (loginState) {

                // 초기 상태
                is LoginState.Init -> {
                    // (아무런 동작을 하지 않음)
                }

                // 로그인 성공 시
                is LoginState.Success -> {

                    // 자동 로그인 설정
                    if (isSocialLogin || isAutoLogin) {
                        saveAutoLogin(context, true)
                    }

                    // 사용자 이메일 저장
                    saveUserEmail(context, loginState.email)

                    // 로그인 상태 초기화
                    loginViewModel.initLoginResult()

                    // 홈 화면으로 라우팅
                    navController.navigate(HOME) {
                        popUpTo(LOGIN) { inclusive = true }
                    }
                }

                // 로그인 실패 시 (유저 정보 불일치)
                is LoginState.Failure -> {
                    // 토스트 메시지를 띄워 사용자에게 로그인 실패를 알림
                    showToast(context, "로그인 정보가 일치하지 않습니다.")
                    loginViewModel.initLoginResult()
                }

                // 비동기 작업 오류 발생 시
                is LoginState.Error -> {
                    // 디버깅을 위한 에러 로그 출력하기
                    Log.e("Login", "${loginState.error}")
                }
            }
        }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) }
            .verticalScroll(scrollState),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .fillMaxWidth()
                .padding(start = 48.dp, end = 48.dp),
        ) {
            Spacer(modifier = Modifier.height(60.dp))
            LoginAppTitle()
            Spacer(modifier = Modifier.height(48.dp))
            TextField(
                value = email,
                onValueChange = { email = it },
                drawableId = R.drawable.ic_email,
                hint = "이메일 입력",
            )
            Spacer(modifier = Modifier.height(16.dp))
            TextField(
                value = pw,
                onValueChange = { pw = it },
                drawableId = R.drawable.ic_pw,
                hint = "비밀번호 입력",
                isPassword = true,
                isLast = true,
                maxLength = 20
            )
            Spacer(modifier = Modifier.height(24.dp))
            Row(
                modifier = Modifier.fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                AutoLoginCheckbox(isChecked = isAutoLogin, onClick = { isAutoLogin = it })
                RecoveryScreenRouteText(onClick = { navController.navigate(RECOVERY) })
            }
            Spacer(modifier = Modifier.height(40.dp))
            Button(
                text = "로그인",
                onClick = {
                    isSocialLogin = false
                    coroutineScope.launch {
                        loginViewModel.login(email, pw)
                    }
                },
                color = CustomTheme.colors.skyBlue,
                enabled = loginEnable
            )
            Spacer(modifier = Modifier.height(20.dp))
            Button(
                text = "회원가입",
                color = CustomTheme.colors.orange,
                onClick = { navController.navigate(Routes.SIGN_UP) }
            )
        }
        Spacer(modifier = Modifier.height(60.dp))
        SocialLoginText()
        Spacer(modifier = Modifier.height(40.dp))
        Row(horizontalArrangement = Arrangement.SpaceBetween) {
            GoogleLoginButton(onClick = {
                coroutineScope.launch {
                    loginViewModel.googleLogin(context)
                }
            })
            Spacer(modifier = Modifier.width(40.dp))
            KakaoLoginButton(onClick = {
                coroutineScope.launch {
                    loginViewModel.kakaoLogin(context)
                }
            })
            Spacer(modifier = Modifier.width(40.dp))
            NaverLoginButton(onClick = {
                coroutineScope.launch {
                    loginViewModel.naverLogin(context)
                }
            })
        }
        Spacer(modifier = Modifier.height(40.dp))
    }
}

 

컴포저블 함수는 상태 변경이 일어날 때 화면을 다시 그리기에

UiState에 따른 동작을 정의할 때 LaunchedEffect를 활용해줘야한다.

 

UiState는 라우팅을 진행해도 자동적으로 초기값으로 초기화가 안되기에

라우팅이 포함되있는 로직에선 UiState를 초기값으로 초기화 해줘야한다.

 

사용자 편의성을 위해 소셜 로그인은 자동적으로 로그인이 되도록 설정했다.

 

 

UI 로직 분할

@Composable
fun LoginAppTitle() {
    val darkTheme = isSystemInDarkTheme()
    val logo = if (!darkTheme) R.drawable.logo else R.drawable.logo_dark

    Row(
        verticalAlignment = Alignment.CenterVertically
    ) {
        Svg(
            drawableId = logo,
            size = 92.dp
        )
        Spacer(modifier = Modifier.width(16.dp))
        Text(
            text = "ESCAPE\nANCHOVY",
            style = CustomTheme.typography.h1Bold,
            color = CustomTheme.colors.text
        )
        Spacer(modifier = Modifier.width(16.dp))
    }
}


@Composable
fun AutoLoginCheckbox(isChecked: Boolean, onClick: (Boolean) -> Unit) {
    Row(
        verticalAlignment = Alignment.CenterVertically
    ) {
        Checkbox(
            isChecked = isChecked,
            onClick = onClick,
        )
        Spacer(modifier = Modifier.width(8.dp))
        Text(
            text = "자동 로그인",
            color = CustomTheme.colors.subText
        )
    }
}

@Composable
fun RecoveryScreenRouteText(onClick: () -> Unit) {
    Column(horizontalAlignment = Alignment.End) {
        Spacer(modifier = Modifier.height(2.dp))
        Text(
            text = "이메일 찾기 / 비밀번호 재설정",
            modifier = Modifier.clickable { onClick.invoke() },
            color = CustomTheme.colors.subText
        )
        Spacer(modifier = Modifier.height(2.dp))
        Divider(width = 140.dp, color = CustomTheme.colors.subText)
    }
}

@Composable
fun SocialLoginText() {
    Row(
        Modifier
            .fillMaxWidth()
            .padding(horizontal = 40.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Divider(width = 82.dp, color = CustomTheme.colors.disabled)
        Row {
            Text(
                text = "SNS 계정",
                style = CustomTheme.typography.b3Bold,
            )
            Text(
                text = "으로 ",
                style = CustomTheme.typography.b3Regular,
            )
            Text(
                text = "간편 ",
                style = CustomTheme.typography.b3Bold,
            )
            Text(
                text = "로그인",
                style = CustomTheme.typography.b3Regular,
            )
        }
        Divider(width = 82.dp, color = CustomTheme.colors.disabled)
    }
}

@Composable
fun GoogleLoginButton(onClick: () -> Unit) {
    Svg(drawableId = R.drawable.ic_google, size = 50.dp, onClick = onClick)
}

@Composable
fun KakaoLoginButton(onClick: () -> Unit) {
    Svg(drawableId = R.drawable.ic_kakao, size = 50.dp, onClick = onClick)
}

@Composable
fun NaverLoginButton(onClick: () -> Unit) {
    Svg(drawableId = R.drawable.ic_naver, size = 50.dp, onClick = onClick)
}

상태관리가 필요없는 부분의 코드는 따로 분할하였다.

 

 

자동 로그인 설정

@Composable
fun MyApp() {
    val context = LocalContext.current
    val autoLogin = getAutoLogin(context)
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = if (autoLogin) HOME else LOGIN,
        exitTransition = { ExitTransition.None }
    ) {
        composable(route = LOGIN) { LoginScreen(navController) }
        composable(route = SIGN_UP) { SignUpScreen(navController) }
        composable(route = HOME) { HomeScreen(navController) }
    }
}

autoLogin값에 따라 시작 지점을 다르게 설정한다.

 

 

소셜 로그인 실행 화면

 

 

 

기본 로그인 실행 화면

 

 

 

회고

원래 소셜로그인 별로 개발일지를 쓸까 생각했는데,

너무 번거롭기도 하고 설정 관련 설명해놓은 글들이 워낙 많아서

관련 레퍼런스를 활용하는 방식으로 개발일지를 작성했다.

 

여담으로 구글 로그인 구현할 때 꽤나 애먹었는데

안드로이드 정책상 디바이스에 저장된 소셜 로그인 계정을 확인하는 것이 불가했기에

대체 어떻게 예외처리를 진행해야되나 한 3~4시간 정도 삽질하다가

최근에 작성된 티스토리 블로그 글을 보고 해결방법을 찾을 수 있었다.

 

역시 개발할 때는 최신순으로 작성된 레퍼런스부터 보는게 맞는 것 같다.

 

누군진 모르겠지만 고마워요 정말... 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함