← 블로그

Play Integrity를 켰는데도 App Check가 계속 실패한 이유

Play Integrity를 켰는데도 App Check가 계속 실패한 이유

Firebase App Check와 Play Integrity

운동 기록 앱을 하나 만들어서 플레이스토어에 올렸습니다. 오프라인으로도 쓸 수 있는 기록 앱인데, 여기에 “오늘 뭐 할지 추천해 주는” 기능을 하나 넣었습니다.

추천은 두 갈래입니다. 하나는 기기 안에서 계산하는 기기 추천이고, 다른 하나는 Firebase AI Logic으로 Gemini를 불러서 받는 AI 추천입니다. AI가 최근 운동 이력과 오늘 컨디션을 같이 보고 순서를 짜 줍니다.

개발할 때는 잘 됐습니다. 그런데 스토어에 올린 앱에서 AI 추천 버튼을 누르면, 결과 카드에 계속 “기기 추천” 칩이 붙어서 나왔습니다.

앱은 아무 말도 하지 않았다

가장 먼저 발목을 잡은 건 버그 자체가 아니라, 앱이 실패를 말해 주지 않는다는 점이었습니다.

AI 추천이 실패하면 기기 추천으로 넘어가도록 만들어 뒀습니다. 사용자가 버튼을 눌렀는데 아무것도 안 나오는 것보다는 낫다고 생각해서였습니다. 문제는 그 폴백이 너무 조용했다는 것입니다.

} catch (error, stack) {
  debugPrint('[AI recommend] failed: ${error.runtimeType}$error');
  if (local != null) _showResult(local, ai: false);   // 조용히 기기 추천으로
  if (mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(
          kDebugMode
              ? 'AI 추천 실패: $error'                  // 디버그에서만 진짜 원인이 보인다
              : context.l10n.recoAiFailedFallback,    // 릴리스에서는 일반 문구
        ),
      ),
    );
  }
}

디버그 빌드에서는 실패해도 에러 문구가 그대로 보입니다. 하지만 스토어에서 받은 앱에서는 “기본 추천으로 대체했어요” 정도의 문장만 뜹니다. 사용자에게 스택트레이스를 보여줄 수는 없으니 이렇게 만든 건데, 정작 개발자인 저도 원인을 볼 수 없게 됐습니다.

디버그에서는 왜 잘 됐을까

로그를 보니 실패 원인은 이거였습니다.

[firebase_app_check/unknown] 403 App attestation failed

Firebase AI Logic 호출은 App Check로 보호되고 있었습니다. App Check는 “지금 이 요청이 정말 내 앱에서 온 게 맞느냐”를 확인하는 장치입니다. 확인에 실패하면 Gemini 호출 자체가 403으로 막힙니다.

그런데 App Check는 빌드 종류에 따라 완전히 다른 방식으로 동작합니다.

await FirebaseAppCheck.instance.activate(
  providerAndroid: kDebugMode
      ? AndroidDebugProvider(debugToken: _appCheckDebugToken)  // 개발용
      : const AndroidPlayIntegrityProvider(),                  // 스토어 빌드
);

개발 중에는 디버그 프로바이더를 씁니다. 앱이 실행되면서 디버그 토큰을 하나 만들고, 그 토큰을 Firebase 콘솔에 등록해 두면 통과됩니다. 실제 기기 검증은 전혀 하지 않습니다.

반대로 릴리스 빌드는 Play Integrity로 갑니다. 구글이 “이 앱이 변조되지 않은, 플레이스토어에서 배포된 그 앱이 맞다”고 보증해 주는 방식입니다.

즉 개발할 때 잘 됐던 건 검증을 통과해서가 아니라, 검증을 안 했기 때문이었습니다. 스토어 빌드로 넘어가는 순간 처음으로 진짜 검증을 받게 된 것이었습니다.

여기서 한 가지 헷갈리기 쉬운 게 있습니다. 디버그 토큰은 앱 안에 박히는 값이 아니라 Firebase 콘솔에 등록되는 값입니다. 콘솔에 옛날 디버그 토큰이 남아 있어도 릴리스 빌드에는 아무 영향이 없습니다. 저도 처음에는 “테스트할 때 쓰던 토큰이 남아 있어서 그런가” 싶었는데, 관계없는 이야기였습니다.

Play Integrity를 켰는데도 그대로였다

원인을 알았으니 Firebase 콘솔에서 App Check에 Play Integrity 프로바이더를 등록했습니다. 이제 되겠지 싶었습니다.

여전히 기기 추천이 나왔습니다.

로그가 범인을 좁혀줬다

여기서 추측을 멈추고 실제 로그를 보기로 했습니다.

다행히 새 빌드를 만들 필요가 없었습니다. debugPrint는 릴리스 빌드에서도 출력됩니다. 스토어에서 받은 그 앱 그대로 폰을 USB로 연결하고 adb logcat만 띄우면 됩니다.

AI 추천을 누르는 동안 잡힌 로그는 이랬습니다.

I/PlayCore : IntegrityService : requestIntegrityToken(IntegrityTokenRequest{
             nonce=..., cloudProjectNumber=993010889228, network=null})
I/Finsky   : Integrity key attestation record generated successfully.
I/Finsky   : requestIntegrityToken() finished for com.KangJung.gymagaintoday.
I/flutter  : [AI recommend] generateContent failed: FirebaseException —
             [firebase_app_check/unknown] Error returned from API.
             code: 403 body: App attestation failed.

이 로그가 문제를 크게 좁혀줬습니다.

Play Integrity 자체는 성공했습니다. 기기가 무결성 토큰을 정상적으로 발급받았습니다. 그러니 Cloud 프로젝트 연결, Play Integrity API 활성화, 호출 할당량, Play가 앱을 인식하는지 여부는 전부 정상이었습니다. 이 후보들이 한 번에 다 지워졌습니다.

거절한 쪽은 Firebase 백엔드였습니다. 토큰은 만들어졌는데, 그걸 받아 검증하는 단계에서 403이 났습니다.

여기서 에러 문구를 구분하는 게 중요했습니다. App Check에 프로바이더가 아예 등록되지 않은 앱은 App Check token is invalid 쪽 메시지가 납니다. 반면 App attestation failed프로바이더는 등록됐고, 판정 내용을 대조하다가 거부했다는 뜻입니다.

그리고 이 단계에서 대조하는 값은 사실상 하나입니다. 서명 인증서의 SHA-256입니다.

업로드 키와 앱 서명 키는 다른 키다

문제는 서명 키였습니다.

App Check가 Play Integrity로 앱을 확인할 때는 설치된 앱의 서명 지문을 봅니다. 그래서 Firebase에 그 앱의 SHA-256 지문을 등록해 둬야 합니다. 저는 등록해 뒀습니다. 다만 업로드 키의 지문을 등록해 뒀습니다.

플레이스토어에 앱을 올릴 때는 키가 두 개 등장합니다.

  • 업로드 키: 내가 AAB에 서명해서 Play에 올릴 때 쓰는 키입니다. 내 PC의 keystore 파일에 들어 있습니다.
  • 앱 서명 키: Play가 사용자에게 앱을 내려줄 때 다시 서명하는 키입니다. Play App Signing을 쓰면 구글이 이 키를 보관합니다.

즉 사용자 폰에 실제로 설치되는 APK는 업로드 키가 아니라 앱 서명 키로 서명된 상태입니다. 내 손을 떠나는 순간 서명이 바뀌어 있는 셈입니다.

그러니 Firebase에 업로드 키 지문만 등록해 두면, Play Integrity가 보고하는 지문과 영원히 일치하지 않습니다. 프로바이더를 아무리 켜도 통과할 수가 없습니다.

앱 서명 키 지문은 Play Console에서 확인할 수 있습니다.

앱 서명 키

Play Console → Google Play로 보호됨 → Play 스토어 보호 → Play 앱 서명 관리 버튼 → 디지털 애셋 링크 JSON 영역의 sha256_cert_fingerprints 값
  └ "앱 서명 키 인증서" 섹션의 SHA-256 인증서 지문

인증서 파일을 내려받아 keytool로 뽑을 필요는 없습니다. 화면에 콜론으로 구분된 16진수 문자열로 그대로 표시되고, Firebase가 요구하는 형식과 같습니다. 복사해서 붙여넣기만 하면 됩니다.

같은 페이지의 “디지털 애셋 링크 JSON” 블록 안에 있는 sha256_cert_fingerprints 값도 동일한 지문입니다. 앱 링크 설정용으로 쓰는 그 JSON인데, 여기서 값을 가져와도 됩니다.

Firebase Console → 프로젝트 설정 → 내 앱 → (Android 앱 선택)
  → 디지털 지문 추가 → SHA-256 붙여넣기

업로드 키 지문은 지우지 말고 둘 다 남겨 두는 편이 낫습니다. 로컬에서 만든 릴리스 빌드를 확인할 일이 남아 있기 때문입니다.

참고로 이 지문은 google-services.json에 들어가지 않습니다. 지문을 추가했다고 해서 파일을 다시 내려받아 앱에 넣을 필요는 없습니다. App Check는 서버 쪽에서 대조합니다.

콘솔을 믿지 말고 설치본에서 직접 확인하기

콘솔 화면에서 값을 복사하다 보면 어느 인증서를 집었는지 헷갈립니다. 그럴 때는 폰에 실제로 설치된 APK에서 지문을 직접 뽑는 게 가장 확실합니다.

adb shell pm path com.example.app
adb pull /data/app/.../base.apk
apksigner verify --print-certs base.apk

apksigner는 Android SDK의 build-tools 안에 있습니다. keytool -printcert -jarfile로 시도하면 “Not a signed jar file”이 나는데, 요즘 APK는 v1(JAR) 서명 없이 v2/v3 서명만 쓰기 때문입니다.

출력은 이렇게 나옵니다.

Signer #1 certificate DN: CN=Android, OU=Android, O=Google Inc.,
                          L=Mountain View, ST=California, C=US
Signer #1 certificate SHA-256 digest: 1b4c6175047295d9cde7cbdf...

인증서 DN이 CN=Android, O=Google Inc.로 나오는 게 Play가 재서명했다는 증거입니다. 내가 만든 키라면 여기에 내가 입력한 이름이 나옵니다. 이 SHA-256 digest를 두 자리씩 콜론으로 끊어서 Firebase에 등록하면 됩니다.

같은 방법으로 앱이 정말 스토어에서 설치된 것인지도 확인할 수 있습니다.

adb shell pm list packages -i com.example.app
→ package:com.example.app  installer=com.android.vending

installer=com.android.vending이면 Play에서 설치된 것입니다. 로컬에서 APK를 밀어 넣었다면 다른 값이 나오고, 그러면 Play Integrity가 애초에 통과할 수 없습니다.

등록하고 나니 통과했다

앱 서명 키 지문을 추가하고 다시 눌러보니, 로그에서 App attestation failed가 사라졌습니다. 결과 카드에도 “기기 추천”이 아니라 “Gemini AI 추천”이 붙었고, 추천 이유도 로컬 엔진의 고정 문구가 아니라 Gemini가 쓴 문장으로 바뀌었습니다.

앱 데이터를 지우거나 재설치할 필요는 없었습니다. 실패가 캐시된 토큰 때문이 아니라, 매번 새로 발급받는 과정에서 나고 있었기 때문입니다.

그래도 안 될 때 볼 곳

제 경우는 서명 키가 원인이었지만, 로그가 다르게 나온다면 다른 곳을 봐야 합니다. 특히 requestIntegrityToken() finished 줄 자체가 안 보이거나 그 단계에서 에러가 난다면, 문제는 Firebase가 아니라 Play 쪽 설정입니다.

  • Firebase와 Google Play 계정 연결: Firebase Console → 프로젝트 설정 → 통합 → Google Play. 연결돼 있지 않으면 Firebase가 Play Integrity 결과를 받아올 수 없습니다.
  • Play Integrity API 활성화: Google Cloud Console에서 같은 프로젝트에 API가 켜져 있는지, 일일 호출 할당량이 남아 있는지 봅니다.
  • 설치 경로: 로컬에서 빌드한 릴리스 APK를 adb install로 넣고 테스트하고 있다면, Play가 그 앱을 인식하지 못해서 무조건 실패합니다. Play Console → 설정 → 라이선스 테스트에 본인 계정을 추가하면 로컬 빌드도 검증을 받을 수 있습니다.
  • 반영 시간: 지문을 추가하거나 프로바이더를 켠 직후에는 바로 반영되지 않습니다. 10~15분 정도 기다리고, 앱 데이터를 지워서 캐시된 App Check 토큰도 함께 비운 뒤에 다시 시도하는 게 확실합니다.

마무리

이번 일에서 시간을 가장 많이 잡아먹은 건 App Check 설정 자체가 아니라, 실패가 실패처럼 보이지 않았다는 점이었습니다.

앱은 정상적으로 동작했습니다. 버튼을 누르면 추천이 나왔고, 크래시도 없었고, 스토어 리뷰에서도 문제가 되지 않았을 겁니다. 다만 제가 의도한 기능이 아니라 폴백이 돌아가고 있었을 뿐입니다.

지난번 자동 발행 시스템을 만들 때도 비슷한 걸 느꼈습니다. CI는 초록불인데 글은 안 올라가 있었습니다. 그때는 상태값을 분리해서 해결했습니다.

이번에 저를 살린 건 debugPrint 한 줄이었습니다. 화면에는 안 보이지만 로그에는 남겨 뒀고, 그래서 폰을 연결하는 것만으로 원인을 찾을 수 있었습니다. 그게 없었다면 진단용 빌드를 새로 만들어 스토어에 다시 올려야 했을 겁니다.

폴백을 넣을 거라면, 폴백이 발동한 이유만큼은 어딘가에 남겨 둬야 한다는 걸 다시 배웠습니다. 사용자에게 보여줄 필요는 없지만, 적어도 내가 나중에 찾아볼 수는 있어야 합니다.