배포와 인프라11 분 읽기

네트워크를 탓하는 에러 메시지 두 개를 열었습니다. 둘 다 네트워크가 아니었습니다

프로덕션 앱의 결제 화면은 '연결을 확인하라'고 했고, 다른 앱의 로그인 화면은 '문제가 발생했다'고 했습니다. 폰은 정상이었습니다. 진짜 원인은 빈 채로 컴파일된 시크릿 하나와, 콘솔에 등록되지 않은 서명 지문 하나였습니다.

#android#gotchas#build#auth#porting
두 패널 도식. 왼쪽은 앱 화면 두 장으로 각각 '연결을 확인하세요'와 '문제가 발생했습니다'라는 문구가 적혀 있고 그 아래 와이파이 아이콘에 초록 체크가 있어 네트워크가 정상임을 표시한다. 오른쪽은 같은 두 실패의 실제 원인으로, 위는 빈 문자열이 들어간 빌드 설정 필드, 아래는 콘솔 목록에 없는 서명 지문이며 각 항목이 왼쪽 화면으로 점선 화살표로 연결된다.
사용자에게 보인 문구는 원인이 아니라, 가장 흔한 원인에 대한 추측이었습니다.

당신 앱의 실패 화면은 원인을 말합니까, 아니면 가장 흔한 원인을 추측합니까?

어제 프로덕션 앱 두 개에서 실패 신고가 왔습니다. 화면에 뜬 문구는 둘 다 네트워크를 가리켰습니다. 폰은 둘 다 정상이었습니다. 실제 원인은 한 건은 빌드, 한 건은 콘솔 설정이었고, 두 번 모두 답은 화면이 아니라 폰 안 로그에 있었습니다.

첫 번째: "요금제를 불러올 수 없습니다. 연결을 확인하세요"

결제 화면에서 이 문구가 떴습니다. 와이파이는 붙어 있고 같은 폰에서 다른 앱은 잘 됩니다.

먼저 원인을 셋으로 갈랐습니다. 폰·계정 문제냐, 스토어 상품 설정 문제냐, 앱 빌드 문제냐. 같은 폰에 깔린 다른 앱(같은 결제 SDK를 쓰는)을 실행해 로그를 봤습니다.

Requesting products from the store with identifiers: ...pro.yearly, ...pro.monthly
Retrieved productDetailsList: ... "formattedPrice":"₩2,500" ...
Building offerings response with 2 products
Billing connected with country code: KR

상품 두 개가 가격까지 정상으로 내려옵니다. 폰·계정·네트워크는 무죄로 확정됐습니다.

그 다음 문제의 앱을 실행하고 로그를 봤습니다. 한 줄이 전부였습니다.

E ...Paywall: REVENUECAT_API_KEY 가 비어 있다 — 결제가 동작하지 않는다

앱이 이미 알고 있었습니다. 자기 가드가 정확한 원인을 찍고 있었고, 그 위에 화면은 "연결을 확인하세요"를 덮어씌웠습니다.

배포된 APK를 열어서 확인했습니다

로그만 믿지 않고 스토어에 올라간 파일 자체를 봤습니다. 폰에서 APK를 꺼내 결제 키 형식(goog_ 접두)을 그레프했습니다.

매치는 하나 나왔습니다. goog_1a2b3c4d5e6f7h. 실제 키가 아니라 결제 SDK가 오류 안내문에 쓰는 예시 문자열이었습니다("키는 이렇게 생겨야 합니다"라는 그 문장). 정상 동작하는 대조군 앱의 APK에는 예시 문자열과 실키가 둘 다 있었고, 문제의 앱에는 예시 문자열만 있었습니다.

즉 릴리스 빌드에 키가 빈 문자열로 컴파일돼 그대로 스토어까지 갔습니다.

왜 빌드가 통과했나

키를 빌드 시점에 로컬 설정 파일에서 읽어 상수로 심는 구조입니다. 이런 모양입니다.

buildConfigField("String", "REVENUECAT_API_KEY",
    "\"${localProps.getProperty("REVENUECAT_API_KEY", "")}\"")

두 번째 인자가 기본값 빈 문자열입니다. 빌드한 머신에 값이 없으면 경고 하나 없이 ""로 컴파일되고, 컴파일도 서명도 업로드도 심사도 전부 통과합니다. 결제가 안 되는 건 실제 사용자가 결제 화면을 열었을 때뿐입니다.

여기서 선택지가 갈립니다. 빌드 절차 문서에 "키 넣는 것 잊지 마세요"를 적겠습니까, 아니면 빈 값이면 빌드가 실패하게 만들겠습니까?

문서는 이미 있었습니다. 그래서 코드로 막았습니다.

gradle.taskGraph.whenReady {
    if (allTasks.any { it.name.startsWith("bundleRelease") } && rcKey.isBlank())
        error("REVENUECAT_API_KEY missing — release blocked")
}

규칙을 사람이 기억하는 구조는 한 번은 반드시 깨집니다. 검사할 수 있는 규칙이면 검사하는 코드로 바꾸는 게 유일하게 남는 방법입니다. 같은 결제 화면에서 로케일 하나만 안 고쳐져 없는 기능을 팔고 있던 적도 있습니다. 결제 화면은 아무도 매일 열어보지 않는 화면입니다.

두 번째: "문제가 발생했습니다. 다시 시도해 주세요"

다른 앱, 구글 로그인 버튼. 누르면 이 문구가 뜹니다. 이번엔 계정 선택기까지 정상으로 떴다가 닫히면서 실패했습니다. 앱 로그에는 이만큼만 남았습니다.

E AuthViewModel: Google sign-in failure
androidx.credentials.exceptions.GetCredentialCancellationException: [16] Account reauth failed.

에러 문구의 첫 줄을 원인으로 읽으면 엉뚱한 것을 고치게 됩니다. 여기서도 그랬습니다.

Cancellation, reauth failed. 문자 그대로 읽으면 "사용자가 취소했다" 또는 "계정 재인증이 필요하다"입니다. 둘 다 앱 잘못이 아닌 것처럼 읽힙니다. 앱 프로세스 로그만 보면 여기서 막힙니다.

그래서 필터를 앱에서 떼고 시스템 인증 서비스 쪽 로그를 같은 시간대로 훑었습니다. 앱이 아니라 구글 플레이 서비스가 남긴 줄에 원인이 있었습니다.

W Auth.Api.Credentials: [AccountReauth_flowRunner] Flow failed.
W Auth.Api.Credentials: cpwk: [8] Unknown error [status=UNREGISTERED_ON_API_CONSOLE].
W Auth.Api.Credentials: cpwk: [16] Account reauth failed.

UNREGISTERED_ON_API_CONSOLE. 이 앱의 패키지명 + 서명 지문 조합이 해당 클라우드 프로젝트에 등록돼 있지 않다는 뜻입니다. 앱이 본 [16]은 그 실패가 한 겹 위로 올라오며 번역된 결과였고, 화면의 "문제가 발생했습니다"는 다시 한 겹 뭉갠 것이었습니다.

폰에 깔린 그 빌드의 서명을 확인했습니다.

$ apksigner verify --print-certs base.apk
Signer #1 certificate DN: C=US, O=Android, CN=Android Debug
Signer #1 certificate SHA-1 digest: 92a3...7bb3

디버그 키로 서명된 빌드였습니다. 로그인 토큰 발급은 웹 클라이언트 ID만으로 되지 않고 호출한 앱의 패키지명과 서명 지문이 콘솔에 등록돼 있어야 합니다. 디버그 키는 보통 등록해두지 않습니다. 그러니 개발 중 테스트 빌드에서만 나는 실패이고, 스토어 빌드에서 같은 문구가 나면 등록이 필요한 지문은 플레이 앱 서명 키 쪽입니다.

지문은 세 개를 다 등록해야 합니다. 디버그 키, 업로드 키, 그리고 플레이가 재서명에 쓰는 앱 서명 키. 하나만 빠져도 그 경로로 설치된 빌드에서만 로그인이 죽습니다.

두 실패의 공통점

원인은 완전히 다릅니다. 하나는 빌드 산출물, 하나는 외부 콘솔 설정입니다. 공통점은 사용자에게 보인 문구가 진단을 반대 방향으로 끌고 갔다는 것입니다.

  • "연결을 확인하세요" → 진짜 원인은 컴파일 시점에 비어버린 상수
  • "문제가 발생했습니다" → 진짜 원인은 콘솔에 없는 서명 지문

둘 다 실패 지점에서는 정확한 원인 문자열이 존재했습니다. 하나는 앱 자신의 로그에, 하나는 시스템 서비스 로그에. 그 문자열이 화면까지 오는 길에서 지워졌을 뿐입니다.

자가진단 3개

  1. 릴리스 빌드가 빈 시크릿으로 통과합니까? 설정 파일을 잠시 비우고 릴리스 빌드를 걸어보세요. 성공하면 그 앱은 이미 그렇게 나갔을 수 있습니다.
  2. 배포된 산출물 안에 실제 값이 있습니까? 소스가 아니라 스토어에 올린 파일에서 그레프하세요. 소스가 맞다는 건 빌드가 맞았다는 증거가 아닙니다.
  3. 실패 화면이 원인을 구분합니까? 설정 오류·사용자 취소·진짜 네트워크 오류가 한 문구로 합쳐져 있으면, 다음 신고 때도 같은 시간을 씁니다.

솔직한 부분

이 두 건은 며칠 동안 그 상태로 살아 있었습니다. 실패 화면이 그럴듯하게 네트워크를 가리켰기 때문에, 신고를 받고도 "일시적인 것"으로 넘어갔습니다. 결제 키가 비어 있던 앱은 그 기간 동안 결제 시도가 구조적으로 불가능했습니다.

시간을 실제로 줄여준 건 추측을 하나씩 지우는 순서였습니다. 같은 폰의 다른 앱으로 네트워크·계정을 무죄 처리하고, 앱 로그를 보고, 그 다음 스토어에 올라간 파일과 서명을 직접 확인하는 것. 화면 문구는 증거가 아닙니다.

지금 딱 하나만 해보세요. 폰을 연결하고 adb logcat을 띄운 채 문제 화면을 한 번 여는 것. 두 번 다 답이 첫 화면 스크롤 안에 있었습니다.

관련 글