지난 글에서 토스 미니앱을 포트/어댑터 구조로 독립 Expo 앱까지 만들었다. 광고와 전역 랭킹은 no-op 스텁으로 남겨둔 채였고, 남은 일 목록엔 이렇게 적혀 있었다. "남은 작업의 특징은, 코드보다 계정·빌드·심사 같은 외부 의존이 핵심이라는 점이다."

그 예고는 맞았다. 다만 절반만 맞았다. 스텁을 진짜 구현으로 바꾸는 코드 작업이 절반, 나머지 절반은 플레이 콘솔이라는 거대한 선언 기계를 통과하는 일이었다. 이번 글은 그 두 절반의 기록이다.

스텁 채우기 1AdMob

광고부터. react-native-google-mobile-ads로 배너·전면 어댑터를 만들어 스텁을 교체했다.

여기서 지킨 계약이 하나 있다. 전면 광고의 showThen(onDone)은 광고가 성공하든, 실패하든, 미지원이든 onDone을 정확히 한 번 호출한다. 게임 종료 → 결과 화면 전환 사이에 광고를 끼우는 구조라, 광고가 결과 화면 진입을 막는 순간 게임이 망가지기 때문이다. 광고는 어디까지나 손님이고, 흐름의 주인은 사용자다.

이 계약을 지키려다 보면 SDK의 빈틈이 보인다. 권장 패턴은 "load → show → 다음 load"인데, 처음 설계에선 광고가 에러로 닫힐 때 다음 load를 거는 걸 빼먹기 쉽다. CLOSED에서만 재load하면 한 번 에러난 뒤로 광고가 영영 안 나온다. CLOSED와 ERROR 양쪽에 재load를 걸고, show() 자체가 비동기로 reject하는 경우까지 잡아서야 "한 번 실패해도 다음 판엔 광고가 다시 나오는" 상태가 됐다.

스텁 채우기 2리더보드 — 크로스플랫폼 라이브러리는 없다

전역 랭킹이 진짜 문제였다. iOS는 Game Center, Android는 Google Play Games(PGS)로 가기로 했는데, 둘을 한 번에 덮는 살아있는 라이브러리가 없었다. 후보 하나는 수년째 동결이라 탈락. 결국 플랫폼별로 두 라이브러리를 조합했다.

조합하면 두 라이브러리의 사고방식 차이를 어댑터가 흡수해야 한다. 몇 가지가 기억에 남는다.

  • iOS 라이브러리는 config plugin이 깨져 있었다. 플러그인 매니페스트가 가리키는 app.plugin.js 파일이 패키지에 없어서, plugins 배열에 등록하면 prebuild가 죽는다. 우회는 플러그인을 포기하고 app.jsonios.entitlements에 Game Center 엔타이틀먼트를 직접 선언하는 것.
  • Android 라이브러리는 설정 위치가 특이했다. 옵션을 expo 설정 안이 아니라 app.json 최상위의 형제 키로 읽는다. 문서를 안 읽으면 절대 못 찾는 위치다.
  • Android 쪽은 promise가 영영 안 풀리는 경우가 있다. activity가 없는 시점에 호출되면 reject도 안 하고 그냥 멈춘다. 그래서 어댑터의 모든 호출을 withTimeout(8초)으로 감쌌다. 또 이 라이브러리는 import 시점에 네이티브 모듈을 강제 로드해서, 모듈이 없는 환경(시뮬레이터 등)에서는 import만으로 죽는다 — lazy require로 격리했다.

화면 쪽은 아무것도 몰라도 된다. 결과를 판별 유니온({ kind: 'ok' | 'unsupported' | ... })으로 정규화해서 돌려주는 포트 계약은 토스 게임센터 시절 그대로다. 어댑터만 두꺼워졌다.

실기기에서만 보이는 버그

내부 테스트 트랙에 첫 빌드를 올리고 실기기에서 돌려보니, 시뮬레이터에선 절대 안 보이던 버그가 나왔다. Play Games 프로필이 없는 새 사용자의 점수 제출이 조용히 실패하는 것이다.

흐름이 이렇다: 점수 제출 → 프로필 없음 에러 → 사용자에게 프로필 생성 안내 → 사용자가 만들고 돌아옴 → 그런데 점수는 이미 버려졌다. 수정은 프로필 생성이 확인되면 보류해둔 점수를 자동 재제출하고, 그래도 실패하면 "점수 등록하기" 재시도 버튼을 주는 것. 대화형 단계(프로필 생성 웹뷰)가 끼어들면 타임아웃도 그에 맞게 늘려야 했다.

교훈은 단순하다. 플랫폼 계정 상태는 시뮬레이션이 안 된다. 프로필 없음, 첫 로그인, 권한 미승인 — 이런 상태는 실기기 + 실계정에서만 나온다.

그리고 진짜 보스플레이 콘솔

빌드가 돌고 광고와 랭킹이 동작하면 끝인 줄 알았다. 콘솔의 "앱 설정" 체크리스트는 다르게 생각했다. 등록정보, 데이터 안전, 콘텐츠 등급, 타겟층, 광고 선언, 광고 ID 선언, 정부 앱, 금융 기능, 건강 앱… 항목 하나하나가 그 자체의 설문과 정책을 달고 있다.

모든 길은 개인정보처리방침으로 통한다

작업 순서를 짜다 보니 의존성 그래프의 뿌리에 개인정보처리방침 URL 하나가 있었다. 스토어 등록정보가 요구하고, 데이터 안전 섹션이 요구하고, OAuth 동의화면(리더보드의 Google 로그인용)이 또 요구한다. 한 URL이 세 시스템의 선결 조건이다.

그래서 회사 사이트에 정적 페이지로 방침을 먼저 깔았다. 사소하지만 마음에 든 결정 둘:

  • 앱 웹뷰 기준으로 디자인했다. 약관류 문서에 흔한 표(table)는 좁은 화면에서 가로 스크롤이 생긴다. 수집 항목을 표 대신 스택형 카드로 풀고, 상단에 "한눈에 보기" 세 줄 요약을 뒀다. 어차피 이 문서의 주 독자는 데스크톱이 아니라 앱 안의 사용자다.
  • 호스팅 특성을 미리 확인했다. 정적 export는 privacy.html을 만드는데, 목표 URL은 확장자 없는 /privacy다. 배포 전에 호스팅(Cloudflare Pages)이 .html을 clean URL로 자동 서빙하는 걸 확인해서, 설정 변경 없이 끝냈다.

데이터 안전 설문추측하지 말고 SDK 문서를 읽는다

데이터 안전 섹션은 "앱이 어떤 사용자 데이터를 수집·공유하는가"를 신고하는 5단계 설문이다. 여기서 흔한 실수가 감으로 채우는 것이다. 허위 신고는 앱 삭제 사유다.

다행히 구글은 자사 SDK가 뭘 수집하는지 공식 공개 가이드를 운영한다. AdMob(Google Mobile Ads SDK)과 Play Games Services SDK의 가이드를 그대로 대조해서 여섯 유형을 신고했다.

데이터 유형 출처 목적
대략적인 위치 (IP 기반) AdMob 광고
앱 상호작용 AdMob 광고·분석
진단 AdMob·PGS 분석
기기 또는 기타 ID (광고 ID) AdMob 광고
사용자 ID (게이머 ID) PGS 앱 기능
기타 작업 (리더보드 점수) PGS 앱 기능

흥미로운 건 신고하지 않아도 되는 것이다. 로컬에만 저장하는 최고점수는 "수집"이 아니다. 설문의 정의상 수집(collected)은 데이터가 기기 밖으로 전송되는 것이고, 기기를 떠나지 않는 데이터는 대상이 아니다. 이 정의를 모르면 과잉 신고를 하게 되고, 과잉 신고는 그것대로 방침 문서와의 불일치를 만든다.

선언은 체인으로 걸려 있다

콘솔 항목들은 독립적이지 않다. 타겟층 설문을 열었더니 "광고 섹션을 먼저 완료하라"고 했고, 광고를 끝내니 "로그인 세부정보 섹션을 완료하라"고 했다. 데이터 안전 설문은 다 작성해놓고도 "타겟층을 완료해야 제출할 수 있다"며 임시보관함에 묶였다. 결국 실행 순서는 콘솔이 정해준다: 광고 선언 → 로그인 세부정보 → 타겟층 → (그제서야) 데이터 안전 제출.

등급과 타겟층은 다른 것이다

콘텐츠 등급(IARC) 설문은 게임 카테고리에서 폭력·도박·선정성 등을 묻는다. 클래식 음악 퀴즈라 전 문항 "아니요", 결과는 전 기관 전체이용가(ESRB E, PEGI 3, GRAC 전체이용가).

그럼 타겟층도 "전체 연령"으로 해야 할까? 처음엔 그래야 자연스러워 보였다. 그런데 이 둘은 다른 질문이다.

  • 콘텐츠 등급 = 이 앱의 내용이 유해한가 → 전체이용가면 어린이도 다운로드할 수 있고 스토어에 그렇게 표시된다.
  • 타겟층 = 이 앱을 누구를 대상으로 만들었나 → 여기에 13세 미만을 포함하는 순간 가족(Families) 정책이 발동한다.

가족 정책이 발동하면 광고를 아동 대상 구성으로 바꿔야 하고(비개인화 광고 — 코드 수정에 수익 하락), 가족 정책 전용 심사가 추가되고, "만 14세 미만을 주 대상으로 하지 않는다"고 써둔 개인정보처리방침과도 모순이 생긴다. 그래서 등급은 전체이용가, 타겟층은 13세 이상으로 갔다. 캐주얼 게임 대부분이 택하는 조합이고, 어린이 다운로드가 막히는 것도 아니다.

덧붙여 IARC 설문에서 발견한 함정 하나: 설문을 제출한 뒤 개요 화면을 잘못 누르면 빈 새 설문지 초안이 생기는데, 이 초안에는 삭제 버튼이 없다. 한 번 생기면 완주해서 재제출하는 것만이 해소법이다. 콘솔에 "주의 필요"가 떠서 한참 찾았다.

데모 영상을 내라굽쇼선언 대신 권한 제거

마지막 선언이 가장 재미있었다. 콘솔이 말했다. "당신 앱이 FOREGROUND_SERVICE_MEDIA_PLAYBACK 권한을 쓰고 있으니, 이 권한의 실사용을 보여주는 동영상을 제출하라."

이 앱은 백그라운드 재생이 없다. 3초 클립도 인트로 BGM도 전부 화면 안에서만 돈다. 권한은 expo-audio를 셋업하면서 들어온 잔재였다. 그러니 보여줄 영상이 있을 리가 없다 — 실사용이 없는 권한이니까.

이럴 때 선언서를 어떻게든 채우는 건 답이 아니다. 실사용 없는 권한 선언은 허위 신고고, 심사에서 "권한은 있는데 사용처가 없음"으로 거부 사유가 된다. 정공법은 권한 자체를 제거하는 것이다.

들여다보니 제거할 게 하나 더 있었다. RECORD_AUDIO — 마이크 권한이다. 재생만 하는 앱에 녹음 권한이라니. 알고 보니 expo-audio의 config plugin이 recordAudioAndroid 옵션 기본값 true로 이 권한을 심는다. 녹음 API를 한 줄도 안 써도 들어온다.

{
  "android": {
    "permissions": ["android.permission.MODIFY_AUDIO_SETTINGS"],
    "blockedPermissions": [
      "android.permission.RECORD_AUDIO",
      "android.permission.FOREGROUND_SERVICE",
      "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
    ]
  },
  "plugins": [["expo-audio", { "recordAudioAndroid": false }]]
}

permissions 배열에서 빼는 것만으론 부족하다. 라이브러리 플러그인이 다시 주입할 수 있어서다. blockedPermissions는 매니페스트 머지 단계에서 tools:node="remove"로 박혀, 누가 넣으려 해도 최종 매니페스트에서 빠진다. 빌드 전에 expo config --type introspect로 생성될 매니페스트를 미리 확인할 수 있다.

새 빌드를 올리자 세 가지가 한 번에 일어났다. 포그라운드 서비스 선언 항목이 콘솔에서 자동 소멸했고(선언은 아티팩트 기준이다), 지원 기기가 16대 늘었으며(권한이 줄면 기기 요구사항도 준다), 마이크 권한이 사라져 데이터 안전 신고와의 정합도 깔끔해졌다. 선언을 채우는 대신 원인을 제거하면 이런 배당이 따라온다.

현재 상태

  • 내부 테스트 트랙에 권한 정리판이 올라가 테스터에게 제공 중이다. 앱 콘텐츠 "주의 필요"는 0개.
  • PGS 설정 체크리스트는 6개 중 5개 완료. "SDK를 APK에 추가하여 API 사용" 항목은 실기기에서 리더보드 API가 실제 호출되자 자동으로 체크됐다 — 콘솔이 앱의 행동을 지켜보고 있다는 뜻이라 묘하게 든든하다.
  • 남은 것: 대기 중인 선언 일괄 검토 전송, OAuth 동의화면·PGS 프로젝트의 프로덕션 게시, 그리고 프로덕션 트랙.

마무리출시의 후반전은 문서 작업이 아니라 시스템 작업이다

이번 구간에서 배운 것 셋.

  1. 선언을 채우지 말고 원인을 제거하라. 콘솔이 무언가를 증명하라고 요구할 때, 가장 좋은 답은 종종 "증명할 대상을 없애는 것"이다. 안 쓰는 권한, 안 쓰는 기능은 선언 비용 자체를 지운다.
  2. 데이터 안전의 SSOT는 SDK 공식 공개 문서다. 감으로 채우면 과잉 신고 아니면 허위 신고다. 구글 SDK는 구글이 뭘 수집하는지 문서로 말해준다. 그걸 옮겨 적으면 된다.
  3. 콘솔 작업도 의존성 그래프다. 개인정보처리방침 URL이 뿌리고, 광고 선언 → 로그인 세부정보 → 타겟층 → 데이터 안전이 체인으로 걸린다. 순서를 모르고 덤비면 임시보관함에 갇힌 설문만 쌓인다.

코드를 다 짜고 나면 출시는 클릭 몇 번이라고 생각했던 시절이 있었다. 지금은 안다. 스토어 출시의 후반전은 코드가 아니라 정책과 선언의 시스템을 읽는 일이고, 그 시스템에도 아키텍처처럼 의존성과 우회로와 함정이 있다. 읽는 법을 익히면, 거기서도 엔지니어링을 할 수 있다.