Android 앱에서 Google 계정으로 로그인을 구현하는 방법 알아보기

1. 시작하기 전에

이 Codelab에서는 Credential Manager를 사용하여 Android에서 Google로 로그인을 구현하는 방법을 알아봅니다.

기본 요건

  • Android 개발에 Kotlin을 사용하는 방법에 관한 기본적 이해
  • Jetpack Compose에 관한 기본 이해 (자세한 내용은 여기 참고)

학습할 내용

  • Google Cloud 프로젝트를 만드는 방법
  • Google Cloud 콘솔에서 OAuth 클라이언트를 만드는 방법
  • 하단 시트 흐름을 사용하여 Google 계정으로 로그인을 구현하는 방법
  • 버튼 흐름을 사용하여 Google 계정으로 로그인을 구현하는 방법

필요한 항목

2. Android 스튜디오 프로젝트 만들기

소요 시간 3:00~5:00

시작하려면 Android 스튜디오에서 새 프로젝트를 만들어야 합니다.

  1. Android 스튜디오 열기
  2. 새 프로젝트를 클릭합니다.Android 스튜디오 시작
  3. Phone and TabletEmpty Activity를 선택합니다.Android 스튜디오 프로젝트
  4. 다음을 클릭합니다.
  5. 이제 프로젝트를 몇 가지 설정할 차례입니다.
    • 이름: 프로젝트의 이름입니다.
    • 패키지 이름: 프로젝트 이름을 기반으로 자동 입력됩니다.
    • 저장 위치: Android 스튜디오에서 프로젝트를 저장하는 폴더가 기본값으로 설정되어야 합니다. 원하는 대로 변경할 수 있습니다.
    • 최소 SDK: 앱이 실행되도록 빌드된 Android SDK의 가장 낮은 버전입니다. 이 Codelab에서는 API 36 (Baklava)을 사용합니다.
    Android 스튜디오 설정 프로젝트
  6. Finish를 클릭합니다.
  7. Android 스튜디오에서 프로젝트를 만들고 기본 애플리케이션에 필요한 종속 항목을 다운로드합니다. 몇 분 정도 걸릴 수 있습니다. 이를 확인하려면 빌드 아이콘 Android 스튜디오 프로젝트 빌드을 클릭하세요.
  8. 완료되면 Android 스튜디오가 다음과 같이 표시됩니다.Android 스튜디오 프로젝트 빌드됨

3. Google Cloud 프로젝트 설정

Google Cloud 프로젝트 만들기

  1. Google Cloud 콘솔로 이동합니다.
  2. 프로젝트를 열거나 새 프로젝트를 만듭니다.GCP 새 프로젝트 만들기GCP 새 프로젝트 만들기 2GCP 새 프로젝트 만들기 3
  3. API 및 서비스를 클릭합니다.GCP API 및 서비스
  4. OAuth 동의 화면으로 이동합니다.GCP OAuth 동의 화면
  5. 계속하려면 개요의 필드를 작성해야 합니다. 시작하기를 클릭하여 다음 정보를 입력합니다.GCP 시작하기 버튼
    • 앱 이름: 이 앱의 이름입니다. Android 스튜디오에서 프로젝트를 만들 때 사용한 이름과 동일해야 합니다.
    • 사용자 지원 이메일: 로그인한 Google 계정과 관리하는 Google 그룹이 표시됩니다.
    GCP 앱 정보
    • 잠재고객:
      • 조직 내에서만 사용되는 앱의 경우 '내부'입니다. Google Cloud 프로젝트에 연결된 조직이 없으면 이 옵션을 선택할 수 없습니다.
      • 외부를 사용합니다.
    GCP 잠재고객
    • 연락처 정보: 애플리케이션의 연락처로 사용할 이메일 주소를 입력합니다.
    GCP 연락처 정보
    • Google API 서비스: 사용자 데이터 정책을 검토합니다.
  6. 사용자 데이터 정책을 검토하고 동의한 후 만들기를 클릭합니다.GCP 만들기

OAuth 클라이언트 설정

이제 Google Cloud 프로젝트가 설정되었으므로 클라이언트 ID를 사용하여 OAuth 백엔드 서버에 API 호출을 할 수 있도록 웹 클라이언트와 Android 클라이언트를 추가해야 합니다.

Android 웹 클라이언트의 경우 다음이 필요합니다.

  • 앱의 패키지 이름 (예: com.example.example)
  • 앱의 SHA-1 서명
    • SHA-1 서명이란 무엇인가요?
      • SHA-1 디지털 지문은 앱의 서명 키에서 생성된 암호화 해시입니다. 특정 앱의 서명 인증서의 고유 식별자 역할을 합니다. 앱의 디지털 '서명'과 같다고 생각하면 됩니다.
    • SHA-1 서명이 필요한 이유는 무엇인가요?
      • SHA-1 디지털 지문은 특정 서명 키로 서명된 앱만 OAuth 2.0 클라이언트 ID를 사용하여 액세스 토큰을 요청할 수 있도록 하여 다른 앱 (패키지 이름이 동일한 앱 포함)이 프로젝트의 리소스와 사용자 데이터에 액세스하지 못하도록 합니다.
      • 다음과 같이 생각해 보세요.
        • 앱의 서명 키는 앱의 '문'을 여는 실제 키와 같습니다. 앱의 내부 작동에 액세스할 수 있도록 허용하는 것입니다.
        • SHA-1 지문은 실제 키에 연결된 고유한 키 카드 ID와 같습니다. 특정 키를 식별하는 특정 코드입니다.
        • OAuth 2.0 클라이언트 ID는 특정 Google 리소스 또는 서비스 (예: Google 로그인)에 대한 입장 코드와 같습니다.
        • OAuth 클라이언트 설정 중에 SHA-1 디지털 지문을 제공하면 기본적으로 Google에 '이 특정 ID (SHA-1)가 있는 키카드만 이 액세스 코드 (클라이언트 ID)를 열 수 있습니다'라고 알리는 것입니다. 이렇게 하면 앱만 해당 입력 코드에 연결된 Google 서비스에 액세스할 수 있습니다.'

웹 클라이언트의 경우 콘솔에서 클라이언트를 식별하는 데 사용할 이름만 있으면 됩니다.

Android OAuth 2.0 클라이언트 만들기

  1. 클라이언트 페이지로 이동합니다.GCP 클라이언트
  2. 클라이언트 만들기를 클릭합니다.GCP 클라이언트 만들기
  3. 애플리케이션 유형으로 Android를 선택합니다.
  4. 앱의 패키지 이름을 지정해야 합니다.
  5. Android 스튜디오에서 앱의 SHA-1 서명을 가져와 여기에 복사/붙여넣어야 합니다.
    1. Android 스튜디오로 이동하여 터미널을 엽니다.
    2. 다음 명령어를 실행합니다.
      keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
      
      이 명령어는 키 저장소 내의 특정 항목 (별칭)의 세부정보를 나열하도록 설계되었습니다.
      • -list: 이 옵션은 keytool에 키 저장소의 콘텐츠를 나열하도록 지시합니다.
      • -v: 이 옵션은 상세 출력을 사용 설정하여 항목에 관한 자세한 정보를 제공합니다.
      • -keystore ~/.android/debug.keystore: 키 저장소 파일의 경로를 지정합니다.
      • -alias androiddebugkey: 검사하려는 키의 별칭 (항목 이름)을 지정합니다.
      • -storepass android: 키 저장소 파일의 비밀번호를 제공합니다.
      • -keypass android: 지정된 별칭의 비공개 키 비밀번호를 제공합니다.
    3. SHA-1 서명 값을 복사합니다.
    SHA 서명
    1. Google Cloud 창으로 돌아가 SHA-1 서명 값을 붙여넣습니다.
  6. 이제 화면이 다음과 같이 표시되며 만들기를 클릭할 수 있습니다.Android 클라이언트 세부정보Android 클라이언트

웹 OAuth 2.0 클라이언트 만들기

  1. 웹 애플리케이션 클라이언트 ID를 만들려면 Android 클라이언트 만들기 섹션의 1~2단계를 반복하고 애플리케이션 유형으로 웹 애플리케이션을 선택합니다.
  2. 클라이언트에 이름을 지정합니다 (OAuth 클라이언트가 됨). 웹 클라이언트 세부정보
  3. 만들기를 클릭합니다.웹 클라이언트
  4. 팝업 창에서 클라이언트 ID를 복사합니다. 나중에 필요합니다.클라이언트 ID 복사

이제 OAuth 클라이언트가 모두 설정되었으므로 Android 스튜디오로 돌아가 Google로 로그인 Android 앱을 만들 수 있습니다.

4. Android Virtual Device 설정

실제 Android 기기 없이 애플리케이션을 빠르게 테스트하려면 Android 스튜디오에서 앱을 빌드하고 즉시 실행할 수 있는 Android 가상 기기를 만들어야 합니다. 실제 Android 기기로 테스트하려면 Android 개발자 문서의 안내를 따르세요.

Android Virtual Device 만들기

  1. Android 스튜디오에서 기기 관리자를 엽니다.기기 관리자
  2. + 버튼 > Create Virtual Device를 클릭합니다.가상 기기 만들기
  3. 여기에서 프로젝트에 필요한 기기를 추가할 수 있습니다. 이 Codelab에서는 Medium Phone을 선택하고 Next를 클릭합니다.Medium Phone(중간 크기 휴대전화)
  4. 이제 고유한 이름을 지정하고, 기기에서 실행할 Android 버전을 선택하는 등 프로젝트에 맞게 기기를 구성할 수 있습니다. API가 API 36 'Baklava'; Android 16으로 설정되어 있는지 확인한 후 Finish를 클릭합니다.가상 기기 구성
  5. 기기 관리자에 새 기기가 표시됩니다. 기기가 실행되는지 확인하려면 방금 만든 기기 옆에 있는 기기 실행를 클릭합니다.기기 2 실행
  6. 이제 기기가 실행됩니다.실행 중인 기기

Android Virtual Device에 로그인

방금 만든 기기가 작동합니다. 이제 Google로 로그인 테스트 시 오류를 방지하려면 Google 계정으로 기기에 로그인해야 합니다.

  1. 설정으로 이동합니다.
    1. 가상 기기에서 화면 중앙을 클릭하고 위로 스와이프합니다.
    클릭 및 스와이프
    1. 설정 앱을 찾아 클릭합니다.
    설정 앱
  2. 설정에서 Google을 클릭합니다.Google 서비스 및 환경설정
  3. 로그인을 클릭하고 메시지에 따라 Google 계정에 로그인합니다.기기 로그인
  1. 이제 기기에 로그인되어 있어야 합니다.기기에 로그인됨

이제 가상 Android 기기를 테스트할 수 있습니다.

5. 종속 항목 추가

지속 시간 5:00

OAuth API 호출을 하려면 먼저 인증 요청을 하고 Google ID를 사용하여 이러한 요청을 할 수 있는 필요한 라이브러리를 통합해야 합니다.

  • libs.googleid
  • libs.play.services.auth
  1. File > Project Structure로 이동합니다.프로젝트 구조
  2. 그런 다음 종속 항목 > > '+' > 라이브러리 종속 항목으로 이동합니다.종속 항목
  3. 이제 라이브러리를 추가해야 합니다.
    1. 검색 대화상자에서 googleid를 입력하고 검색을 클릭합니다.
    2. 항목이 하나만 있어야 합니다. 항목을 선택하고 사용 가능한 가장 높은 버전을 선택합니다 (이 Codelab에서는 1.1.1).
    3. 확인을 클릭합니다.Google ID 패키지
    4. 1~3단계를 반복하되 'play-services-auth'를 대신 검색하고 그룹 ID가 'com.google.android.gms'이고 아티팩트 이름이 'play-services-auth'인 행을 선택합니다.Play 서비스 인증
  4. 확인을 클릭합니다.완료된 종속 항목

6. 하단 시트 흐름

하단 시트 흐름

하단 시트 흐름은 사용자가 Android에서 Google 계정을 사용하여 앱에 로그인하는 간소화된 방법을 위해 인증 관리자 API를 활용합니다. 특히 재방문 사용자를 위해 빠르고 편리하게 설계되었습니다. 이 흐름은 앱 실행 시 트리거되어야 합니다.

로그인 요청 빌드

  1. 시작하려면 MainActivity.kt에서 Greeting()GreetingPreview() 함수를 삭제하세요. 필요하지 않습니다.
  2. 이제 이 프로젝트에 필요한 패키지가 가져와졌는지 확인해야 합니다. 3번째 줄부터 시작하는 기존 문 뒤에 다음 import 문을 추가합니다.
    import android.content.ContentValues.TAG
    import android.content.Context
    import android.credentials.GetCredentialException
    import android.os.Build
    import android.util.Log
    import android.widget.Toast
    import androidx.annotation.RequiresApi
    import androidx.compose.foundation.clickable
    import androidx.compose.foundation.Image
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Column
    import androidx.compose.material3.MaterialTheme
    import androidx.compose.material3.Surface
    import androidx.compose.runtime.rememberCoroutineScope
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.platform.LocalContext
    import androidx.compose.ui.res.painterResource
    import androidx.credentials.CredentialManager
    import androidx.credentials.exceptions.GetCredentialCancellationException
    import androidx.credentials.exceptions.GetCredentialCustomException
    import androidx.credentials.exceptions.NoCredentialException
    import androidx.credentials.GetCredentialRequest
    import com.google.android.libraries.identity.googleid.GetGoogleIdOption
    import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
    import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
    import java.security.SecureRandom
    import java.util.Base64
    import kotlinx.coroutines.CoroutineScope
    import androidx.compose.runtime.LaunchedEffect
    import kotlinx.coroutines.delay
    import kotlinx.coroutines.launch
    
  3. 다음으로, 하단 시트 요청을 빌드하는 함수를 만들어야 합니다. MainActivity 클래스 아래에 이 코드를 붙여넣습니다.
   //This line is not needed for the project to build, but you will see errors if it is not present.
   //This code will not work on Android versions < UpsideDownCake
   @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
   @Composable
    fun BottomSheet(webClientId: String) {
        val context = LocalContext.current

        // LaunchedEffect is used to run a suspend function when the composable is first launched.
        LaunchedEffect(Unit) {
            // Create a Google ID option with filtering by authorized accounts enabled.
            val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder()
                .setFilterByAuthorizedAccounts(true)
                .setServerClientId(webClientId)
                .setNonce(generateSecureRandomNonce())
                .build()

            // Create a credential request with the Google ID option.
            val request: GetCredentialRequest = GetCredentialRequest.Builder()
                .addCredentialOption(googleIdOption)
                .build()

            // Attempt to sign in with the created request using an authorized account
            val e = signIn(request, context)
            // If the sign-in fails with NoCredentialException,  there are no authorized accounts.
            // In this case, we attempt to sign in again with filtering disabled.
            if (e is NoCredentialException) {
                val googleIdOptionFalse: GetGoogleIdOption = GetGoogleIdOption.Builder()
                    .setFilterByAuthorizedAccounts(false)
                    .setServerClientId(webClientId)
                    .setNonce(generateSecureRandomNonce())
                    .build()

                val requestFalse: GetCredentialRequest = GetCredentialRequest.Builder()
                    .addCredentialOption(googleIdOptionFalse)
                    .build()
                    
                //We will build out this function in a moment
                signIn(requestFalse, context)
            }
        }
    }

   //This function is used to generate a secure nonce to pass in with our request
   fun generateSecureRandomNonce(byteLength: Int = 32): String {
      val randomBytes = ByteArray(byteLength)
      SecureRandom.getInstanceStrong().nextBytes(randomBytes)
      return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes)
   }

이 코드의 역할을 살펴보겠습니다.

fun BottomSheet(webClientId: String) {...}: webClientid라는 문자열 인수를 하나 사용하는 BottomSheet라는 함수를 만듭니다.

  • val context = LocalContext.current: 현재 Android 컨텍스트를 가져옵니다. 이는 UI 구성요소 실행을 비롯한 다양한 작업에 필요합니다.
  • LaunchedEffect(Unit) { ... }: LaunchedEffect는 컴포저블의 수명 주기 내에서 정지 함수 (실행을 일시중지하고 재개할 수 있는 함수)를 실행할 수 있는 Jetpack Compose 컴포저블입니다. 단위를 키로 사용하면 컴포저블이 처음 실행될 때 이 효과가 한 번만 실행됩니다.
    • val googleIdOption: GetGoogleIdOption = ...: GetGoogleIdOption 객체를 만듭니다. 이 객체는 Google에서 요청되는 사용자 인증 정보의 유형을 구성합니다.
      • .Builder(): 빌더 패턴을 사용하여 옵션을 구성합니다.
      • .setFilterByAuthorizedAccounts(true): 사용자가 모든 Google 계정에서 선택할 수 있는지 아니면 이미 앱을 승인한 계정에서만 선택할 수 있는지를 지정합니다. 이 경우 true로 설정되어 있으므로 사용자가 이전에 이 앱과 함께 사용할 수 있도록 승인한 사용자 인증 정보가 있는 경우 해당 사용자 인증 정보를 사용하여 요청이 이루어집니다.
      • .setServerClientId(webClientId): 앱 백엔드의 고유 식별자인 서버 클라이언트 ID를 설정합니다. ID 토큰을 가져오려면 이 권한이 필요합니다.
      • .setNonce(generateSecureRandomNonce()): 재전송 공격을 방지하고 ID 토큰이 특정 요청과 연결되도록 nonce(임의 값)를 설정합니다.
      • .build(): 지정된 구성으로 GetGoogleIdOption 객체를 만듭니다.
    • val request: GetCredentialRequest = ...: GetCredentialRequest 객체를 만듭니다. 이 객체는 전체 사용자 인증 정보 요청을 캡슐화합니다.
      • .Builder(): 요청을 구성하기 위해 빌더 패턴을 시작합니다.
      • .addCredentialOption(googleIdOption): Google ID 토큰을 요청하려는 것을 지정하여 요청에 googleIdOption을 추가합니다.
      • .build(): GetCredentialRequest 객체를 만듭니다.
    • val e = signIn(request, context): 생성된 요청과 현재 컨텍스트로 사용자를 로그인하려고 시도합니다. signIn 함수의 결과는 e에 저장됩니다. 이 변수에는 성공한 결과 또는 예외가 포함됩니다.
    • if (e is NoCredentialException) { ... }: 조건부 확인입니다. signIn 함수가 NoCredentialException으로 실패하면 이전에 승인된 계정이 없다는 의미입니다.
      • val googleIdOptionFalse: GetGoogleIdOption = ...: 이전 signIn이 실패한 경우 이 부분에서는 새 GetGoogleIdOption를 만듭니다.
      • .setFilterByAuthorizedAccounts(false): 첫 번째 옵션과의 중요한 차이점입니다. 승인된 계정의 필터링이 사용 중지되므로 기기의 모든 Google 계정을 사용하여 로그인할 수 있습니다.
      • val requestFalse: GetCredentialRequest = ...: googleIdOptionFalse로 새 GetCredentialRequest가 생성됩니다.
      • signIn(requestFalse, context): 모든 계정을 사용할 수 있는 새 요청으로 사용자를 로그인하려고 시도합니다.

기본적으로 이 코드는 제공된 구성을 사용하여 사용자의 Google ID 토큰을 가져오기 위해 사용자 인증 정보 관리자 API에 대한 요청을 준비합니다. 그런 다음 GetCredentialRequest를 사용하여 사용자 인증 정보 관리자 UI를 실행할 수 있으며, 여기에서 사용자는 Google 계정을 선택하고 필요한 권한을 부여할 수 있습니다.

fun generateSecureRandomNonce(byteLength: Int = 32): String: generateSecureRandomNonce이라는 함수를 정의합니다. 바이트 단위로 원하는 nonce 길이를 지정하는 정수 인수 byteLength (기본값 32)를 허용합니다. 무작위 바이트의 Base64로 인코딩된 표현인 문자열을 반환합니다.

  • val randomBytes = ByteArray(byteLength): 지정된 byteLength의 바이트 배열을 만들어 임의의 바이트를 저장합니다.
  • SecureRandom.getInstanceStrong().nextBytes(randomBytes):
    • SecureRandom.getInstanceStrong(): 암호화적으로 강력한 난수 생성기를 가져옵니다. 이는 생성된 숫자가 예측할 수 없는 진정한 난수임을 보장하므로 보안에 매우 중요합니다. 시스템에서 사용 가능한 가장 강력한 엔트로피 소스를 사용합니다.
    • .nextBytes(randomBytes): SecureRandom 인스턴스에서 생성된 임의의 바이트로 randomBytes 배열을 채웁니다.
  • return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes):
    • Base64.getUrlEncoder(): URL 안전 알파벳 (+ 및 / 대신 - 및 _ 사용)을 사용하는 Base64 인코더를 가져옵니다. 결과 문자열을 추가 인코딩 없이 URL에서 안전하게 사용할 수 있으므로 중요합니다.
    • .withoutPadding(): Base64 인코딩 문자열에서 패딩 문자를 삭제합니다. 이렇게 하면 논스가 약간 더 짧고 간결해지는 경우가 많습니다.
    • .encodeToString(randomBytes): randomBytes를 Base64 문자열로 인코딩하고 반환합니다.

요약하자면 이 함수는 지정된 길이의 암호화 방식으로 강력한 무작위 nonce를 생성하고, URL 보안 Base64를 사용하여 인코딩하고, 결과 문자열을 반환합니다. 이는 보안에 민감한 컨텍스트에서 사용하기에 안전한 nonce를 생성하는 표준 방식입니다.

로그인 요청하기

이제 로그인 요청을 빌드할 수 있으므로 인증 관리자를 사용하여 로그인할 수 있습니다. 이를 위해 Credential Manager를 사용하여 로그인 요청을 전달하는 동시에 발생할 수 있는 일반적인 예외를 처리하는 함수를 만들어야 합니다.

BottomSheet() 함수 아래에 이 함수를 붙여넣어 이를 달성할 수 있습니다.

//This code will not work on Android versions < UPSIDE_DOWN_CAKE when GetCredentialException is
//is thrown.
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
suspend fun signIn(request: GetCredentialRequest, context: Context): Exception? {
    val credentialManager = CredentialManager.create(context)
    val failureMessage = "Sign in failed!"
    var e: Exception? = null
    //using delay() here helps prevent NoCredentialException when the BottomSheet Flow is triggered
    //on the initial running of our app
    delay(250)
    try {
        // The getCredential is called to request a credential from Credential Manager.
        val result = credentialManager.getCredential(
            request = request,
            context = context,
        )
        Log.i(TAG, result.toString())

        Toast.makeText(context, "Sign in successful!", Toast.LENGTH_SHORT).show()
        Log.i(TAG, "(☞゚ヮ゚)☞  Sign in Successful!  ☜(゚ヮ゚☜)")

    } catch (e: GetCredentialException) {
        Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": Failure getting credentials", e)
        
    } catch (e: GoogleIdTokenParsingException) {
        Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": Issue with parsing received GoogleIdToken", e)

    } catch (e: NoCredentialException) {
        Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": No credentials found", e)
        return e

    } catch (e: GetCredentialCustomException) {
        Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": Issue with custom credential request", e)

    } catch (e: GetCredentialCancellationException) {
        Toast.makeText(context, ": Sign-in cancelled", Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": Sign-in was cancelled", e)
    }
    return e
}

이제 코드가 여기서 하는 작업을 분석해 보겠습니다.

suspend fun signIn(request: GetCredentialRequest, context: Context): Exception?: signIn이라는 정지 함수를 정의합니다. 즉, 기본 스레드를 차단하지 않고 일시중지했다가 다시 시작할 수 있습니다.로그인에 성공하면 null을 반환하고 로그인에 실패하면 특정 예외를 반환하는 Exception?을 반환합니다.

다음 두 가지 매개변수를 사용합니다.

  • request: 검색할 사용자 인증 정보 유형의 구성이 포함된 GetCredentialRequest 객체입니다 (예: Google ID)를 사용합니다.
  • context: 시스템과 상호작용하는 데 필요한 Android 컨텍스트입니다.

함수 본문의 경우:

  • val credentialManager = CredentialManager.create(context): Credential Manager API와 상호작용하는 기본 인터페이스인 CredentialManager의 인스턴스를 만듭니다. 앱이 로그인 흐름을 시작하는 방법입니다.
  • val failureMessage = "Sign in failed!": 로그인 실패 시 토스트에 표시할 문자열 (failureMessage)을 정의합니다.
  • var e: Exception? = null: 이 줄은 프로세스 중에 발생할 수 있는 예외를 저장하기 위해 변수 e를 null로 시작하여 초기화합니다.
  • delay(250): 250밀리초 지연을 도입합니다. 이는 앱이 시작될 때, 특히 BottomSheet 흐름을 사용할 때 NoCredentialException이 즉시 발생할 수 있는 잠재적 문제를 해결하기 위한 해결 방법입니다. 이렇게 하면 시스템이 인증 관리자를 초기화할 시간이 주어집니다.
  • try { ... } catch (e: Exception) { ... }:try-catch 블록은 강력한 오류 처리에 사용됩니다. 이렇게 하면 로그인 프로세스 중에 오류가 발생해도 앱이 비정상 종료되지 않고 예외를 적절하게 처리할 수 있습니다.
    • val result = credentialManager.getCredential(request = request, context = context): 여기에서 Credential Manager API에 대한 실제 호출이 발생하고 사용자 인증 정보 검색 프로세스가 시작됩니다. 요청과 컨텍스트를 입력으로 사용하며 사용자에게 사용자 인증 정보를 선택할 수 있는 UI를 표시합니다. 성공하면 선택된 사용자 인증 정보가 포함된 결과가 반환됩니다. 이 작업의 결과인 GetCredentialResponseresult 변수에 저장됩니다.
    • Toast.makeText(context, "Sign in successful!", Toast.LENGTH_SHORT).show():로그인이 성공했음을 나타내는 짧은 토스트 메시지를 표시합니다.
    • Log.i(TAG, "Sign in Successful!"): 재미있고 성공적인 메시지를 Logcat에 로깅합니다.
    • catch (e: GetCredentialException): GetCredentialException 유형의 예외를 처리합니다. 이는 사용자 인증 정보 가져오기 프로세스 중에 발생할 수 있는 여러 특정 예외의 상위 클래스입니다.
    • catch (e: GoogleIdTokenParsingException): Google ID 토큰을 파싱할 때 오류가 발생할 때 발생하는 예외를 처리합니다.
    • catch (e: NoCredentialException): 사용자에게 사용할 수 있는 사용자 인증 정보가 없는 경우 (예: 저장된 사용자 인증 정보가 없거나 Google 계정이 없는 경우) 발생하는 NoCredentialException를 처리합니다.
      • 중요한 점은 이 함수가 e, NoCredentialException에 저장된 예외를 반환하여 호출자가 사용자 인증 정보가 없는 경우 특정 사례를 처리할 수 있다는 것입니다.
    • catch (e: GetCredentialCustomException): 인증 정보 제공업체에서 발생할 수 있는 맞춤 예외를 처리합니다.
    • catch (e: GetCredentialCancellationException): 사용자가 로그인 프로세스를 취소할 때 발생하는 GetCredentialCancellationException를 처리합니다.
    • Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show(): failureMessage를 사용하여 로그인이 실패했음을 나타내는 토스트 메시지를 표시합니다.
    • Log.e(TAG, "", e): 오류에 사용되는 Log.e를 사용하여 예외를 Android Logcat에 로깅합니다. 여기에는 디버깅에 도움이 되는 예외의 스택 추적이 포함됩니다. 재미를 위해 화난 이모티콘도 포함되어 있습니다.
  • return e: 예외가 포착된 경우 함수는 예외를 반환하고, 로그인이 성공한 경우 null을 반환합니다.

요약하자면 이 코드는 인증 관리자 API를 사용하여 사용자 로그인을 처리하고, 비동기 작업을 관리하고, 잠재적 오류를 처리하고, 오류 처리에 유머를 더하면서 토스트와 로그를 통해 사용자에게 의견을 제공하는 방법을 제공합니다.

앱에서 하단 시트 흐름 구현

이제 다음 코드와 Google Cloud 콘솔에서 이전에 복사한 웹 애플리케이션 클라이언트 ID를 사용하여 MainActivity 클래스에서 BottomSheet 흐름을 트리거하는 호출을 설정할 수 있습니다.

class MainActivity : ComponentActivity() {
    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        //replace with your own web client ID from Google Cloud Console
        val webClientId = "YOUR_CLIENT_ID_HERE"

        setContent {
            //ExampleTheme - this is derived from the name of the project not any added library
            //e.g. if this project was named "Testing" it would be generated as TestingTheme
            ExampleTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background,
                ) {
                    //This will trigger on launch
                    BottomSheet(webClientId)
                }
            }
        }
    }
}

이제 프로젝트를 저장하고 (파일 > 저장) 실행할 수 있습니다.

  1. 실행 버튼을 누릅니다.프로젝트 실행
  2. 앱이 에뮬레이터에서 실행되면 로그인 BottomSheet가 팝업됩니다. 계속을 클릭하여 로그인을 테스트합니다.하단 시트
  3. 로그인이 완료되었다는 토스트 메시지가 표시됩니다.하단 시트 성공

7. 버튼 흐름

버튼 흐름 GIF

Google 계정으로 로그인 버튼 흐름을 사용하면 사용자가 기존 Google 계정을 사용하여 Android 앱에 더 쉽게 가입하거나 로그인할 수 있습니다. 하단 시트를 닫거나 로그인 또는 가입 시 Google 계정을 명시적으로 사용하는 것을 선호하는 경우 이 버튼을 누릅니다. 개발자에게는 온보딩이 원활해지고 가입 시 문제가 줄어듭니다.

기본 제공 Jetpack Compose 버튼으로 이 작업을 할 수 있지만 Google 계정으로 로그인 브랜딩 가이드라인 페이지에서 사전 승인된 브랜드 아이콘을 사용합니다.

프로젝트에 브랜드 아이콘 추가

  1. 승인된 브랜드 아이콘의 ZIP 파일을 여기에서 다운로드하세요.
  2. 다운로드에서 signin-assest.zip을 압축 해제합니다 (이는 컴퓨터의 운영체제에 따라 다름). 이제 signin-assets 폴더를 열고 사용 가능한 아이콘을 살펴볼 수 있습니다. 이 Codelab에서는 signin-assets/Android/png@2x/neutral/android_neutral_sq_SI@2x.png을 사용합니다.
  3. 파일 복사
  4. drawable 폴더를 마우스 오른쪽 버튼으로 클릭하고 붙여넣기를 클릭하여 Android 스튜디오의 res > drawable 아래에 붙여넣습니다. res 폴더를 펼쳐야 표시될 수 있습니다.드로어블
  5. 파일 이름을 바꾸고 추가할 디렉터리를 확인하라는 메시지가 표시된 대화상자가 표시됩니다. 애셋 이름을 siwg_button.png로 바꾼 다음 확인을 클릭합니다.버튼 추가

버튼 흐름 코드

이 코드는 BottomSheet()에 사용되는 것과 동일한 signIn() 함수를 사용하지만, 이 흐름에서는 로그인 옵션을 표시하기 위해 기기에 저장된 사용자 인증 정보와 패스키를 활용하지 않으므로 GetGoogleIdOption 대신 GetSignInWithGoogleOption를 사용합니다. 다음은 BottomSheet() 함수 아래에 붙여넣을 수 있는 코드입니다.

@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
fun ButtonUI(webClientId: String) {
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()

    val onClick: () -> Unit = {
        val signInWithGoogleOption: GetSignInWithGoogleOption = GetSignInWithGoogleOption
            .Builder(serverClientId = webClientId)
            .setNonce(generateSecureRandomNonce())
            .build()

        val request: GetCredentialRequest = GetCredentialRequest.Builder()
            .addCredentialOption(signInWithGoogleOption)
            .build()

        coroutineScope.launch {
            signIn(request, context)
        }
    }
    Image(
        painter = painterResource(id = R.drawable.siwg_button),
        contentDescription = "",
        modifier = Modifier
            .fillMaxSize()
            .clickable(enabled = true, onClick = onClick)
    )
}

코드가 하는 작업을 분석하면 다음과 같습니다.

fun ButtonUI(webClientId: String): webClientId (Google Cloud 프로젝트의 클라이언트 ID)를 인수로 사용하는 ButtonUI이라는 함수를 선언합니다.

val context = LocalContext.current: 현재 Android 컨텍스트를 가져옵니다. 이는 UI 구성요소 실행을 비롯한 다양한 작업에 필요합니다.

val coroutineScope = rememberCoroutineScope(): 코루틴 범위를 만듭니다. 이는 비동기 작업을 관리하는 데 사용되며, 코드가 기본 스레드를 차단하지 않고 실행되도록 합니다. rememberCoroutineScope()는 컴포저블의 수명 주기에 연결된 범위를 제공하는 Jetpack Compose의 컴포저블 함수입니다.

val onClick: () -> Unit = { ... }: 버튼을 클릭할 때 실행되는 람다 함수를 만듭니다. 람다 함수는 다음을 실행합니다.

  • val signInWithGoogleOption: GetSignInWithGoogleOption = GetSignInWithGoogleOption.Builder(serverClientId = webClientId).setNonce(generateSecureRandomNonce()).build(): 이 부분은 GetSignInWithGoogleOption 객체를 만듭니다. 이 객체는 'Google 계정으로 로그인' 프로세스의 매개변수를 지정하는 데 사용되며 webClientId 및 nonce (보안에 사용되는 임의 문자열)가 필요합니다.
  • val request: GetCredentialRequest = GetCredentialRequest.Builder().addCredentialOption(signInWithGoogleOption).build(): GetCredentialRequest 객체를 빌드합니다. 이 요청은 인증 관리자를 사용하여 사용자 인증 정보를 가져오는 데 사용됩니다. GetCredentialRequest는 이전에 생성된 GetSignInWithGoogleOption을 옵션으로 추가하여 'Google 계정으로 로그인' 사용자 인증 정보를 요청합니다.
  • coroutineScope.launch { ... }: 비동기 작업 (코루틴 사용)을 관리하는 CoroutineScope입니다.
    • signIn(request, context): 이전에 정의한 signIn() 함수를 호출합니다.

Image(...): 이미지를 로드하는 painterResource를 사용하여 이미지를 렌더링합니다.R.drawable.siwg_button

  • Modifier.fillMaxSize().clickable(enabled = true, onClick = onClick):
    • fillMaxSize(): 이미지가 사용 가능한 공간을 채우도록 합니다.
    • clickable(enabled = true, onClick = onClick): 이미지를 클릭 가능하게 만들고 클릭하면 이전에 정의된 onClick 람다 함수를 실행합니다.

요약하자면 이 코드는 Jetpack Compose UI에서 'Google 계정으로 로그인' 버튼을 설정합니다. 버튼을 클릭하면 사용자 인증 정보 관리자를 실행하고 사용자가 Google 계정으로 로그인할 수 있도록 사용자 인증 정보 요청을 준비합니다.

이제 MainActivity 클래스를 업데이트하여 ButtonUI() 함수를 실행해야 합니다.

class MainActivity : ComponentActivity() {
    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        //replace with your own web client ID from Google Cloud Console
        val webClientId = "YOUR_CLIENT_ID_HERE"

        setContent {
            //ExampleTheme - this is derived from the name of the project not any added library
            //e.g. if this project was named "Testing" it would be generated as TestingTheme
            ExampleTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background,
                ) {
                    Column(
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally

                    ) {
                        //This will trigger on launch
                        BottomSheet(webClientId)

                        //This requires the user to press the button
                        ButtonUI(webClientId)
                    }
                }
            }
        }
    }
}

이제 프로젝트를 저장하고 (파일 > 저장) 실행할 수 있습니다.

  1. 실행 버튼을 누릅니다.프로젝트 실행
  2. 앱이 에뮬레이터에서 실행되면 BottomSheet가 표시됩니다. 바깥쪽을 클릭하여 닫습니다.여기를 탭하세요.
  3. 이제 앱에 생성한 버튼이 표시됩니다. 버튼을 클릭하여 로그인 대화상자를 확인하세요.로그인 대화상자
  4. 계정을 클릭하여 로그인하세요.

8. 결론

이 Codelab을 완료했습니다. Android에서 Google 계정으로 로그인에 관한 자세한 정보나 도움이 필요하면 아래의 자주 묻는 질문(FAQ) 섹션을 참고하세요.

자주 묻는 질문(FAQ)

전체 MainActivity.kt 코드

다음은 참고용 MainActivity.kt의 전체 코드입니다.

package com.example.example

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.example.ui.theme.ExampleTheme
import android.content.ContentValues.TAG
import android.content.Context
import android.credentials.GetCredentialException
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialCustomException
import androidx.credentials.exceptions.NoCredentialException
import androidx.credentials.GetCredentialRequest
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import java.security.SecureRandom
import java.util.Base64
import kotlinx.coroutines.CoroutineScope
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        //replace with your own web client ID from Google Cloud Console
        val webClientId = "YOUR_CLIENT_ID_HERE"

        setContent {
            //ExampleTheme - this is derived from the name of the project not any added library
            //e.g. if this project was named "Testing" it would be generated as TestingTheme
            ExampleTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background,
                ) {
                    Column(
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally

                    ) {
                        //This will trigger on launch
                        BottomSheet(webClientId)

                        //This requires the user to press the button
                        ButtonUI(webClientId)
                    }
                }
            }
        }
    }
}

//This line is not needed for the project to build, but you will see errors if it is not present.
//This code will not work on Android versions < UpsideDownCake
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
    fun BottomSheet(webClientId: String) {
        val context = LocalContext.current

        // LaunchedEffect is used to run a suspend function when the composable is first launched.
        LaunchedEffect(Unit) {
            // Create a Google ID option with filtering by authorized accounts enabled.
            val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder()
                .setFilterByAuthorizedAccounts(true)
                .setServerClientId(webClientId)
                .setNonce(generateSecureRandomNonce())
                .build()

            // Create a credential request with the Google ID option.
            val request: GetCredentialRequest = GetCredentialRequest.Builder()
                .addCredentialOption(googleIdOption)
                .build()

            // Attempt to sign in with the created request using an authorized account
            val e = signIn(request, context)
            // If the sign-in fails with NoCredentialException,  there are no authorized accounts.
            // In this case, we attempt to sign in again with filtering disabled.
            if (e is NoCredentialException) {
                val googleIdOptionFalse: GetGoogleIdOption = GetGoogleIdOption.Builder()
                    .setFilterByAuthorizedAccounts(false)
                    .setServerClientId(webClientId)
                    .setNonce(generateSecureRandomNonce())
                    .build()

                val requestFalse: GetCredentialRequest = GetCredentialRequest.Builder()
                    .addCredentialOption(googleIdOptionFalse)
                    .build()
                    
                signIn(requestFalse, context)
            }
        }
    }

@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
fun ButtonUI(webClientId: String) {
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()

    val onClick: () -> Unit = {
        val signInWithGoogleOption: GetSignInWithGoogleOption = GetSignInWithGoogleOption
            .Builder(serverClientId = webClientId)
            .setNonce(generateSecureRandomNonce())
            .build()

        val request: GetCredentialRequest = GetCredentialRequest.Builder()
            .addCredentialOption(signInWithGoogleOption)
            .build()

        signIn(coroutineScope, request, context)
    }
    Image(
        painter = painterResource(id = R.drawable.siwg_button),
        contentDescription = "",
        modifier = Modifier
            .fillMaxSize()
            .clickable(enabled = true, onClick = onClick)
    )
}

fun generateSecureRandomNonce(byteLength: Int = 32): String {
    val randomBytes = ByteArray(byteLength)
    SecureRandom.getInstanceStrong().nextBytes(randomBytes)
    return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes)
}

//This code will not work on Android versions < UPSIDE_DOWN_CAKE when GetCredentialException is
//is thrown.
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
suspend fun signIn(request: GetCredentialRequest, context: Context): Exception? {
    val credentialManager = CredentialManager.create(context)
    val failureMessage = "Sign in failed!"
    var e: Exception? = null
    //using delay() here helps prevent NoCredentialException when the BottomSheet Flow is triggered
    //on the initial running of our app
    delay(250)
    try {
        // The getCredential is called to request a credential from Credential Manager.
        val result = credentialManager.getCredential(
            request = request,
            context = context,
        )
        Log.i(TAG, result.toString())

        Toast.makeText(context, "Sign in successful!", Toast.LENGTH_SHORT).show()
        Log.i(TAG, "(☞゚ヮ゚)☞  Sign in Successful!  ☜(゚ヮ゚☜)")

    } catch (e: GetCredentialException) {
        Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": Failure getting credentials", e)
        
    } catch (e: GoogleIdTokenParsingException) {
        Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": Issue with parsing received GoogleIdToken", e)

    } catch (e: NoCredentialException) {
        Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": No credentials found", e)
        return e

    } catch (e: GetCredentialCustomException) {
        Toast.makeText(context, failureMessage, Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": Issue with custom credential request", e)

    } catch (e: GetCredentialCancellationException) {
        Toast.makeText(context, ": Sign-in cancelled", Toast.LENGTH_SHORT).show()
        Log.e(TAG, failureMessage + ": Sign-in was cancelled", e)
    }
    return e
}