3초짜리 클래식 음원을 듣고 작품을 맞히는 4지선다 퀴즈를 만들었다. 100문제를 난이도 오름차순으로 푸는 단순한 게임이다. 원래 계획은 여기서 끝이었다. 토스 앱 안에서 도는 미니앱 하나. 그런데 "출시"라는 단어가 나를 게임물관리위원회 회원가입 창과 애플 심사 대기열과 흰 화면의 iOS 시뮬레이터 앞으로 차례차례 끌고 갔다.

이 글은 5월 말부터 6월 중순까지 약 한 달간 쓴 다섯 편의 개발기를 하나로 묶은 것이다. 따로 쓸 때는 보이지 않던 한 줄기가 묶고 나니 또렷해졌다. 처음부터 끝까지 같은 질문이었다. 경계를 어디에 긋는가. 네이티브 경계, 직접 만들 것과 플랫폼에 얹을 것의 경계, 내 코드와 툴체인의 경계, 그리고 선언을 채울 것과 원인을 없앨 것의 경계.

순서대로 가보자.


1. 토스 미니앱을 Granite 위에 올리다 (2026-05-30)

앱인토스(Apps in Toss) 미니앱은 Granite(예전 이름 Bedrock)라는 프레임워크 위에서 돈다. 속은 React Native다. 화면은 RN 컴포넌트로 그리지만, 게임 센터·광고·공유 같은 토스가 자체 제공하는 기능@apps-in-toss/framework 패키지가 브릿지로 열어 준다. 라우팅은 파일 시스템 기반이라 pages/ 아래 파일이 곧 화면이고, 각 파일이 자기 라우트를 선언한다.

import { createRoute } from '@granite-js/react-native';

export const Route = createRoute('/ranking', {
  component: RankingPage,
  screenOptions: { headerShown: false },
});

여기까지는 평범한 RN 개발과 다르지 않다. 차이는 토스가 주는 네이티브 기능을 붙이기 시작하면서 드러났다. 그 기능들은 하나같이 비동기이고, "지원하지 않는 환경"이 존재하며, 실패가 일상적이다. 토스 앱 버전이 낮으면 함수가 undefined를 돌려주고, 사용자가 광고를 중간에 닫고, 공유 시트를 취소한다. 이 불확실성을 페이지 곳곳에 풀어 놓으면 화면 코드가 금세 너덜너덜해진다.

네이티브를 페이지에서 직접 부르지 않는다

그래서 규칙을 하나 세웠다. @apps-in-toss/framework의 네이티브 호출은 페이지에서 직접 부르지 않는다. 기능별로 전용 모듈을 두고 그 안에만 가둔다. 게임 센터는 leaderboard.ts, 공유는 share.ts, 전면 광고는 useInterstitialAd.ts. 이 모듈들이 지키는 공통 규칙이 셋이다.

첫째, 결과를 판별 유니온으로 정규화한다. 네이티브가 돌려주는 undefined(버전 미달)나 상태 코드, 던지는 예외를 화면이 그대로 받게 두지 않는다. UI가 switch 하나로 처리할 수 있는 { kind: ... } 형태로 바꿔 내보낸다.

export type SubmitOutcome =
  | { kind: 'success' }
  | { kind: 'profileMissing' }     // 게임 프로필 미생성
  | { kind: 'leaderboardMissing' } // 미니앱 정보 미승인
  | { kind: 'unsupported' }        // 토스앱 버전 미달
  | { kind: 'error'; error: unknown };

export async function submitScore(score: number): Promise<SubmitOutcome> {
  try {
    const result = await submitGameCenterLeaderBoardScore({ score: toScore(score) });
    if (!result) return { kind: 'unsupported' };       // undefined → 버전 미달
    switch (result.statusCode) {
      case 'SUCCESS': return { kind: 'success' };
      case 'PROFILE_NOT_FOUND': return { kind: 'profileMissing' };
      case 'LEADERBOARD_NOT_FOUND': return { kind: 'leaderboardMissing' };
      default: return { kind: 'error', error: result.statusCode };
    }
  } catch (error) {
    return { kind: 'error', error };
  }
}

페이지는 이제 statusCode 문자열을 몰라도 된다. kind만 보고 안내 문구를 고른다. 네이티브의 세부사항이 화면으로 새지 않는다.

둘째, 이 모듈들은 React에 의존하지 않는다(훅은 예외). 순수 함수로 두면 @apps-in-toss/framework 하나만 모킹해 모든 분기를 검증할 수 있다.

셋째, 흐름을 절대 막지 않는다. 미지원·실패·취소는 전부 graceful degrade로 처리하고, 사용자 진행은 항상 이어진다. 이 원칙은 세 기능 모두에서 같은 모양의 결정을 낳았다.

  • 랭킹은 처음엔 직접 만들었다. 닉네임 폼, 리더보드 리스트, 기록 탭, 로컬 저장소까지. 그런데 앱인토스에는 점수 제출과 전역 리더보드를 토스가 호스팅하는 게임 센터가 있었다. 직접 만든 1,500여 줄을 걷어내고 게임 센터를 감싼 얇은 모듈로 갈아탔다. 코드는 줄었지만, 대신 플랫폼의 상태 모델(버전 게이팅·게임 카테고리 승인·프로필 의존)을 그대로 떠안았다. 위 세 가지가 전부 판별 유니온의 kind로 들어간 이유다.
  • 광고는 전면과 배너 둘을 붙였다. 핵심은 수익이 아니라 "광고가 게임 흐름을 절대 막지 않는다"였다. 전면 광고를 훅으로 감싸면서 한 가지를 보장하게 했다. 광고가 뜨든, 못 뜨든, 미지원이든 onDone은 정확히 한 번 호출된다.
const showThen = (onDone: () => void) => {
  let settled = false;
  const settle = () => { if (!settled) { settled = true; onDone(); } };

  if (!readyRef.current || !isSupported()) { settle(); return; } // 준비 안 됐으면 곧장 진행
  showFullScreenAd({
    options: { adGroupId },
    onEvent: (e) => {
      if (e.type === 'dismissed' || e.type === 'failedToShow') { settle(); preload(); }
    },
    onError: () => settle(),
  });
};

호출부는 interstitial.showThen(goResult) 한 줄이다. 광고 성공 여부와 무관하게 결과 화면 이동은 일어난다. 배너도 같은 철학이라, 노필(no-fill)이면 높이 0으로 렌더되어 빈 자리조차 남지 않는다. 배너는 체류가 긴 홈·결과에만 두고, 3초 클립을 듣는 퀴즈 화면에는 넣지 않았다.

광고에는 미묘한 운영 함정도 있었다. 검수 번들엔 운영 ID가, 개발 중 클릭엔 테스트 ID가 들어가야 한다. 운영 ID로 개발하며 누르면 부정 클릭 제재, 테스트 ID로 출시하면 정책 위반이다. RN 번들러가 빌드 시점에 박는 __DEV__ 상수 한 줄로 둘을 갈랐다.

const adIds = __DEV__ ? TEST_AD_IDS : PROD_AD_IDS; // 개발 서버=테스트, 배포 빌드=운영
  • 공유는 OS 공유 시트(share)와 미니앱 복귀 딥링크(getTossShareLink)를 묶었다. 여기 진짜 함정이 있었는데, intoss:// 딥링크는 미니앱이 정식 출시된 뒤에야 작동한다. 출시 전엔 업로드 때마다 발급되는 intoss-private://...?_deploymentId=... 테스트 스킴으로만 검증된다. 이걸 모르면 "공유 시트는 뜨는데 링크를 눌러도 앱이 안 열린다"에서 한참 헤맨다. 폴백을 넣어 둔 덕에 공유 시트 자체는 출시 전에도 정상 동작했다.

플랫폼 위에서 개발할 때 정작 발목을 잡는 건 화려한 기능이 아니라 이런 경계의 규칙들이었다. 그래서 이 패턴들을 코드만 남기지 않고 프로젝트 가이드 문서에 적어 뒀다. 그게 다음 한 달을 버티게 해 줄 줄은, 이때는 몰랐다.


2. 게관위라는 벽, 그리고 예정에 없던 전환 (2026-06-02)

앱은 다 만들었다. 이제 출시만 하면 된다. 그렇게 생각했다.

모바일 게임을 만들어 본 적도 없는 내가, 게임물 같지도 않은 간단한 음악 퀴즈 하나 내겠다고 사업자등록부터 시작했다. 국내에서 게임을 유통하려면 등급분류를 받아야 하고, 그러려면 게임물관리위원회(게관위)를 거쳐야 한다. 게관위 사이트에 회원가입을 하려는데 범용 공동인증서가 필요하단다. 발급받았다. 다시 신청하러 들어가니 이번엔 게임물제작업자등록증이 있어야 한단다.

음악 퀴즈 하나 내려고 제작업자 등록까지. 기가 차서 한동안 멍하니 화면만 봤다. 배보다 배꼽이 더 컸다. 여기서 출시도 못 하고 포기하나, 싶던 찰나에 한 줄기 빛이 들어왔다. 애플 앱스토어와 구글 플레이에 등록하면 자체등급분류로 게관위를 거치지 않고 통과할 수 있다는 것이다.

그래서 생각지도 않았던 iOS·안드로이드 개발로 방향을 틀었다. 우회로가 본선이 된 순간이다. 앱인토스용 앱은 다 만들어 놨는데, 또 갈 길이 멀었다.

다만 한 가지는 다행이었다. 1장에서 세운 규칙(네이티브 호출을 화면에서 직접 부르지 않고 전용 모듈에 가둔다) 덕분에, 이 전환이 "처음부터 다시"는 아닐 수 있었다. 위기가 기회가 될지 아닐지는, 그 경계를 얼마나 잘 그어 뒀느냐에 달려 있었다.


3. 포트/어댑터로 한 코드베이스, 세 플랫폼 (2026-06-03)

토스 미니앱은 독립 앱이 아니다. 토스 앱이라는 숙주 안에서 App-in-App으로 도는 JS 번들이고, 빌드 결과물은 .ipa가 아니라 토스 런타임이 읽는 번들이다. 애플은 우리 퀴즈의 존재를 모른다. "앱스토어 출시"는 배포 버튼 하나가 아니라 별도의 독립 앱으로 다시 빌드하는 작업이다.

문제는 이 앱의 기능 절반이 토스 전용 네이티브 API에 묶여 있다는 점이었다. 점수 저장, 전역 랭킹, 광고, 공유, 오디오 재생이 전부 토스 프레임워크 호출이라 토스 밖에선 그냥 죽는다. 반대로 퀴즈의 "두뇌"는 토스를 전혀 모른다. 100문제 시드, 출제 로직, 점수 계산, 4지선다 UI, 테마 토큰까지 전부 순수 RN이다. 손발만 토스에 묶여 있었다.

핵심 결정네이티브 경계를 포트/어댑터로 격리한다

할 일이 명확해졌다.

  1. 순수 두뇌 + 네이티브 경계 인터페이스(포트)를 공유 패키지로 분리한다.
  2. 토스 앱은 그 포트를 Granite로 구현(어댑터)한다.
  3. 새 Expo 앱은 같은 포트를 expo-audio·AsyncStorage 등으로 구현한다.

화면은 포트만 알고, 각 앱이 어댑터를 주입한다. "다시 만들기"가 아니라 얇은 어댑터 몇 개만 교체하는 작업으로 바뀐다. 전체를 한 번에 하기엔 커서 네 단계로 쪼갰고, 각 단계는 그 자체로 동작하는 소프트웨어를 내야 한다는 원칙을 세웠다.

Plan 1, 모노레포 재편 + 두뇌 분리. 저장소를 npm workspaces 모노레포로 바꿨다. packages/core(@classic/core)에 순수 두뇌와 포트 인터페이스를, 토스 미니앱은 루트에 그대로 뒀다(Granite 툴링이 루트 레이아웃을 강하게 가정한다). 가장 큰 미지수는 "Granite 번들러가 워크스페이스 심볼릭링크를 해석하는가"였다. 문서에 명시가 없어서, 큰 파일 이동 전에 @classic/core를 import해 번들이 통과하는지만 보는 스파이크를 첫 작업으로 박았다. 통과 → 진행.

Plan 2a, 화면을 코어로. 화면이 Granite 라우팅을 직접 쓰고 있어서 추상화를 둘로 나눴다. 앱-전역 서비스(저장·랭킹·공유·오디오·광고·포커스)는 React 컨텍스트(AppPorts)로, 라우트별 네비게이션·파라미터는 props(NavPort)로 주입. 화면 본문은 packages/core/src/screens/*로 옮기고, 토스 페이지는 얇은 래퍼만 남았다.

function QuizPage() {
  const navigation = Route.useNavigation();
  return <QuizScreen nav={toNavPort(navigation)} />; // 본문은 core가 소유
}

Plan 2b, 두 번째 앱 Expo. apps/native에 표준 Expo 앱을 세우고 같은 core 화면을 렌더하되 AppPorts만 Expo 어댑터로 채웠다.

포트 토스 어댑터 Expo 어댑터
저장 토스 Storage AsyncStorage
오디오 호스트 Granite 미디어 expo-audio
공유 토스 딥링크 RN Share
라우팅 Granite 라우터 expo-router
광고·전역 랭킹 토스 광고·게임센터 (당시엔 no-op 스텁)

가장 까다로운 건 오디오였다. 기존 호스트는 playToken이라는 단조 증가 값이 바뀌면 처음부터 재생하고, 소스가 아직 로드 안 됐으면 로드 완료까지 재생을 미뤄 레이스를 피하는 미묘한 프로토콜을 가졌다. 게다가 퀴즈 클립과 효과음은 분리된 두 채널이어야 한다(같은 채널을 쓰면 효과음 재생 중 재생 버튼이 헛돈다). 이 동작 계약을 그대로 expo-audio로 재현했다. (참고로 expo-av는 이제 deprecated다. 새로 시작한다면 처음부터 expo-audio를 쓰는 게 좋다.)

JS 게이트가 초록불이어도 네이티브는 깨진다

번들은 깨끗하게 통과했지만, 진짜 검증은 실기기 빌드에서 났다. 여기서 함정이 하나 드러났다.

루트 토스 앱은 Granite가 핀한 RN 0.84에 묶여 있고, Expo 앱은 SDK가 핀한 0.85.3을 쓴다. 두 RN 버전이 한 모노레포에 공존한다. npm 호이스팅은 가능하면 패키지를 루트로 끌어올리는데, 그래서 Expo의 expo-modules-core가 react-native를 찾을 때 앱의 0.85.3이 아니라 루트의 0.84로 해석되는 경로가 생겼다. 한 번들에 두 RN이 섞이면 네이티브 브리지가 깨진다(Cannot find native module 'ExpoAsset' 같은 에러).

고약한 건 이게 JS 번들 단계에선 안 잡힌다는 점이다. expo export는 JS만 묶으니 두 RN이 섞여도 조용히 통과한다. 깨지는 건 expo run:ios로 네이티브를 실제 빌드할 때다. metro.config.jsextraNodeModules로 react-native를 앱의 0.85.3 단일 사본에 못박아 호이스팅이 끼어들지 못하게 했다. 이 한 파일이 마지막 방벽이라 "삭제·수정 금지" 주석을 박아 뒀다.

추가로 토스에만 있던 기능 하나를 Expo에도 채웠다. 스플래시에서 한 번 흐르는 인트로 BGM이다. 이걸 core의 IntroChannel로 끌어올려 퀴즈 클립·효과음에 이은 세 번째 오디오 채널로 만들었다. 핵심은 새 포트 타입을 하나도 안 늘렸다는 점이다. 재생 프리미티브는 기존 오디오 호스트 어댑터가 맡고, core는 오케스트레이션(세션당 1회 재생 래치, 화면 전환 시 정지)만 소유한다.

일하는 방식계획 → 스파이크 → 독립 리뷰

코드만큼이나 방식이 이 작업을 굴러가게 했다. 단계마다 파일 구조·작업·검증 기준을 적은 계획을 먼저 문서로 고정했고, 가장 큰 미지수("Granite가 워크스페이스를 해석하나", "Expo가 react를 단일 사본으로 해석하나")는 추측으로 두지 않고 최소 실험으로 먼저 죽였다. 구현과 리뷰는 분리했다. 그 결과 세 단계 전부 "토스 앱 회귀 0"을 유지했다. 새 앱을 만드는 내내 기존 미니앱이 한 번도 깨지지 않았다는 뜻이다.

좋은 경계는 미래의 나에게 보내는 선물이라는 말을 이번에 한 번 더 실감했다. 1장에서 그어 둔 네이티브 경계가 없었다면, 게관위에 떠밀린 이 전환은 "처음부터 다시"가 됐을 것이다.


4. 구글 플레이의 선언 기계 (2026-06-11)

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

스텁 채우기AdMob과 크로스플랫폼 리더보드

광고는 react-native-google-mobile-ads로 배너·전면 어댑터를 만들었다. 여기서도 showThen(onDone)이 광고 성공/실패/미지원과 무관하게 정확히 한 번 호출되는 계약을 지켰다. 함정은 광고가 에러로 닫힐 때 다음 load를 거는 걸 빼먹기 쉽다는 것. CLOSED에서만 재load하면 한 번 에러난 뒤로 광고가 영영 안 나온다. CLOSED와 ERROR 양쪽, 그리고 show()가 비동기로 reject하는 경우까지 잡아야 "한 번 실패해도 다음 판엔 다시 나오는" 상태가 됐다.

전역 랭킹은 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 쪽은 activity가 없는 시점에 호출되면 promise가 reject도 안 하고 멈춰서, 모든 호출을 withTimeout(8초)으로 감쌌다. import 시점에 네이티브 모듈을 강제 로드하는 것도 lazy require로 격리했다.

화면 쪽은 아무것도 몰라도 됐다. 결과를 판별 유니온으로 정규화하는 포트 계약은 토스 게임센터 시절 그대로고, 어댑터만 두꺼워졌다. 1장의 경계가 또 한 번 값을 했다.

실기기에서만 보이는 버그

내부 테스트 트랙 첫 빌드를 실기기에서 돌리니, 시뮬레이터에선 절대 안 보이던 버그가 나왔다. Play Games 프로필이 없는 새 사용자의 점수 제출이 조용히 실패하는 것. 흐름이 이랬다: 제출 → 프로필 없음 → 생성 안내 → 사용자가 만들고 돌아옴 → 그런데 점수는 이미 버려졌다. 수정은 프로필 생성이 확인되면 보류한 점수를 자동 재제출하고, 그래도 실패하면 "점수 등록하기" 재시도 버튼을 주는 것. 교훈은 단순하다. 플랫폼 계정 상태는 시뮬레이션이 안 된다.

진짜 보스콘솔, 그리고 gstack의 손

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

이 방대한 콘솔 노동에서 gstack의 도움을 크게 받았다. gstack은 AI가 직접 모는 헤드리스 브라우저 도구 모음인데, 플레이 콘솔과 앱스토어 커넥트의 수십 개 폼을 함께 읽고 채우고, 선언들이 서로 어떤 순서로 걸려 있는지 추적하고, 빌드를 올릴 때마다 바뀌는 "주의 필요" 항목을 훑는 일을 사람이 클릭으로 일일이 하는 대신 브라우저 자동화로 통과했다. 콘솔 작업의 본질이 "정책 텍스트를 읽고 정확한 칸을 채우는 일"이라, 브라우저를 직접 모는 에이전트와 궁합이 좋았다.

몇 가지 배운 것.

  • 모든 길은 개인정보처리방침으로 통한다. 의존성 그래프의 뿌리에 방침 URL 하나가 있었다. 스토어 등록정보가, 데이터 안전이, OAuth 동의화면이 전부 요구한다. 한 URL이 세 시스템의 선결 조건이라 회사 사이트에 정적 페이지로 먼저 깔았다. 약관류에 흔한 표 대신 좁은 화면에서도 읽히는 스택형 카드로 풀고, 상단에 "한눈에 보기" 요약을 뒀다(주 독자는 데스크톱이 아니라 앱 안의 사용자다).
  • 데이터 안전은 SDK 공식 문서가 SSOT다. 감으로 채우면 과잉 신고 아니면 허위 신고(앱 삭제 사유)다. 구글은 자사 SDK가 뭘 수집하는지 공개 가이드를 운영한다. AdMob과 PGS 가이드를 그대로 대조해 여섯 유형을 신고했다.
데이터 유형 출처 목적
대략적 위치 (IP 기반) AdMob 광고
앱 상호작용 AdMob 광고·분석
진단 AdMob·PGS 분석
기기/기타 ID (광고 ID) AdMob 광고
사용자 ID (게이머 ID) PGS 앱 기능
기타 작업 (리더보드 점수) PGS 앱 기능

흥미로운 건 신고하지 않아도 되는 것이다. 로컬에만 저장하는 최고점수는 "수집"이 아니다. 설문 정의상 수집은 데이터가 기기 밖으로 전송되는 것이라, 기기를 안 떠나는 데이터는 대상이 아니다.

  • 선언은 체인으로 걸려 있다. 타겟층을 열면 "광고를 먼저 완료하라", 광고를 끝내면 "로그인 세부정보를 완료하라"… 결국 순서는 콘솔이 정해준다: 광고 선언 → 로그인 세부정보 → 타겟층 → (그제서야) 데이터 안전 제출.
  • 등급과 타겟층은 다른 질문이다. 콘텐츠 등급(IARC)은 "내용이 유해한가" → 클래식 퀴즈라 전 기관 전체이용가. 타겟층은 "누구를 대상으로 만들었나" → 여기에 13세 미만을 넣는 순간 가족(Families) 정책이 발동해 광고를 비개인화로 바꿔야 하고 추가 심사가 붙는다. 그래서 등급은 전체이용가, 타겟층은 13세 이상으로 갔다.

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

마지막 선언이 가장 재미있었다. 콘솔이 말했다. "당신 앱이 FOREGROUND_SERVICE_MEDIA_PLAYBACK 권한을 쓰니, 실사용을 보여주는 동영상을 제출하라." 그런데 이 앱은 백그라운드 재생이 없다. 권한은 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"로 박혀 누가 넣으려 해도 최종 매니페스트에서 빠진다. 새 빌드를 올리자 세 가지가 한 번에 일어났다. 포그라운드 서비스 선언 항목이 콘솔에서 자동 소멸했고(선언은 아티팩트 기준이다), 지원 기기가 16대 늘었으며, 마이크 권한이 사라져 데이터 안전 신고와의 정합도 깔끔해졌다. 선언을 채우는 대신 원인을 제거하면 이런 배당이 따라온다.


5. iOS 흰 화면의 범인 찾기 (2026-06-13)

안드로이드를 플레이 콘솔의 선언 체인까지 통과시키고 iOS로 넘어왔다. 화면 본문은 이미 공유 코어에 있고 어댑터도 채워뒀다. 시뮬레이터에 한 번 띄우는 일만 남았다. 그렇게 생각했다. 시뮬레이터는 흰 화면을 내놓았다. 크래시도, 빨간 에러 박스도, 로그 한 줄도 없이.

먼저 디버그 빌드가 No script URL provided로 터졌다. 앱이 JS 번들 위치를 못 찾았다는 뜻인데, localhost로 강제해도 Metro에 요청이 0건이었다. 접속을 시도조차 안 했다. 빈 프로젝트를 새로 만들어도 재현됐다. 이건 우리 설정이 아니라 머신의 툴체인(특정 Xcode + 특정 iOS 시뮬레이터 + 특정 RN) 조합 버그였다. 당장의 우회책은 Release 빌드(JS 번들을 앱에 박아 Metro 불필요)로 보였다. 이 가정이 나중에 발목을 잡는다.

(곁가지로 Release에서 Sentry CLI가 빌드를 깼다. RN 번들 스크립트가 번들링과 소스맵 자동 업로드를 함께 하는데, 로컬엔 인증 토큰이 없어 업로드가 거부되고 모든 출력에 error: 접두사를 붙이며 실패했다. 로컬 Release는 소스맵을 올릴 이유가 없으니 SENTRY_DISABLE_AUTO_UPLOAD=true로 끄면 됐다.)

그리고 진짜 문제가 드러났다. Release 앱이 떴는데 화면이 순백이었다. 스플래시 배경색조차 아니었다. React 트리가 아예 안 그려졌다. 크래시는 없었다(uncaught JS fatal이 있었다면 RN이 잡아 크래시시키고 로그를 남긴다). 번들이 예외 없이 실행되는데도 UI가 안 나온다는 뜻이다.

여기서 원칙을 세웠다. 추측으로 고치지 않는다. 용의자를 하나씩 직접 증거로 소거한다.

용의자 평결 증거
라우트 미등록 무죄 strings main.jsbundle에 라우트 파일명 존재
dev client 가로채기 무죄 launcher pod이 Release에 미컴파일
Hermes 바이트코드 불일치 무죄 양쪽 hermesc·런타임 모두 v98
New Architecture 심증 강제라 끌 수 없음
precompiled modules 무죄 소스 빌드도 흰 화면

가장 유력했던 건 Hermes였다. 두 RN 버전이 공존하니, 번들을 컴파일한 hermesc와 앱에 링크된 런타임이 다른 RN에서 왔다면 바이트코드 버전이 안 맞아 실행을 거부할 수 있다. 번들 헤더의 버전 바이트를 직접 봤다.

c61f bc03 c103 191f 6200 0000 ...
                    ^^ 오프셋 8: 버전 = 0x62 = 98

양쪽 RN의 hermesc로 컴파일한 결과도, 임베드 번들도, 런타임 기대값도 전부 98. 불일치가 없었다. 가장 유력한 용의자가 무죄로 풀렸다.

결정적 계측은 무조건 렌더되는 magenta 풀스크린 View를 루트에 박는 것이었다. magenta가 뜨면 루트는 렌더된다는 뜻이고, 그래도 흰색이면 React 루트뷰 자체가 화면에 안 붙는다는 뜻이다. 재빌드 후에도 여전히 순백. 무조건 렌더되는 magenta조차 안 나왔다. 이건 화면 컴포넌트 문제가 아니라 RN 루트뷰가 디바이스에 attach되지 않는다는 강력한 신호였다.

평결: 범인은 코드가 아니다. 앱·빌드설정 레벨 원인이 전부 직접 증거로 배제됐고, magenta조차 안 그려지고, 결정적으로 같은 코드가 안드로이드 에뮬레이터에선 멀쩡히 렌더된다. 남는 결론은 하나다. 특정 Xcode + 특정 iOS 시뮬레이터 + 강제된 New Architecture 조합이 RN 루트뷰를 화면에 못 붙인다. 처음의 "Release면 우회된다"는 가정은 틀렸다.

중요한 건 이게 출시를 막지 않는다는 점이다. 이건 이 머신의 시뮬레이터 렌더 경로 문제고, 스토어 빌드는 실기기를 대상으로 한다. 클라우드 빌드로 실기기에 설치하면 시뮬레이터를 통째로 회피한다. 로컬 시뮬레이터 한 대가 막혔을 뿐, 앱은 갈 곳으로 갈 수 있었다.

때로는 원인이 당신 코드 바깥에 있고, 그걸 증명하는 것 자체가 엔지니어링이다. 흰 화면 앞에서 "내가 뭘 잘못했지"를 다섯 번 물은 끝에, 답이 "아무것도"일 수 있다는 걸 받아들이는 가장 안전한 길이 소거법이었다.


마무리: 지금, 양쪽 모두 심사 대기 중 (2026-06-17)

음악 퀴즈 하나 내겠다고 시작한 일이, 사업자등록과 게관위 회원가입과 범용 공동인증서와 포트/어댑터 리팩터링과 RN 버전 함정과 흰 화면 디버깅과 두 스토어의 선언 기계를 지나, 지금 여기에 와 있다.

현재 상태: iOS와 Android 둘 다 빌드를 올려 심사 결과를 기다리는 중이다.

  • Android. 권한 정리판으로 내부 테스트를 통과하고, 개인정보처리방침·데이터 안전·콘텐츠 등급·타겟층·광고 선언 체인을 모두 채워 프로덕션 검토를 보냈다. PGS의 "SDK를 APK에 추가하여 API 사용" 항목은 실기기에서 리더보드 API가 실제 호출되자 콘솔이 자동으로 체크해 줬다. 콘솔이 앱의 행동을 지켜보고 있다는 뜻이라 묘하게 든든했다.
  • iOS. App ID와 Game Center, 리더보드를 만들고, 실기기에서 Game Center 닉네임 로드까지 확인했다. 등록정보(스크린샷·설명·키워드 ko/en), 카테고리, 연령등급, App Privacy, 가격까지 채워 프로덕션 빌드를 첨부하고 검토를 제출했다.

게관위의 벽 앞에서 포기했다면 여기까지 오지 못했다. 우회로가 본선이 됐고, 그 본선을 끝까지 걸을 수 있었던 건 첫날 그어 둔 경계 덕이었다. 네이티브 호출을 화면에서 떼어 전용 모듈에 가둔 그 규칙 하나가, 포트/어댑터를 가능케 했고, 한 코드베이스가 미니앱·iOS·안드로이드 셋을 떠받치게 했다.

한 달을 관통한 교훈을 다섯 줄로 줄이면 이렇다.

  1. 네이티브 경계는 페이지가 아니라 전용 모듈에서 흡수한다. undefined·상태 코드·예외를 판별 유니온으로 정규화하니, 화면은 깨끗하고 테스트는 모킹 하나로 끝났다.
  2. 빌드 환경이 정책과 동작을 가른다. __DEV__가 테스트/운영 광고를, metro.config.js가 두 RN의 혼입을 갈랐다.
  3. JS 게이트가 초록불이어도 네이티브는 깨질 수 있다. 검증 안 된 토대 위엔 검증 못 하는 코드를 쌓지 않는다. 그래서 실기기 한 판을 고집했다.
  4. 고치기 전에 소거하라. 원인 후보가 넓을 땐 감으로 찌르지 말고 각 가설을 직접 증거(번들 문자열, 바이트코드 버전, pod 목록, magenta 프로브)로 죽인다. 때론 범인이 내 코드 바깥에 있다.
  5. 출시의 후반전은 코드가 아니라 시스템을 읽는 일이다. 선언을 채우지 말고 원인을 제거하라. 콘솔 작업도 의존성 그래프이고, 그 방대한 클릭 노동은 브라우저를 직접 모는 도구(gstack)에 맡길 수 있다.

코드를 다 짜면 출시는 클릭 몇 번이라 믿던 시절이 있었다. 지금은 안다. 스토어 출시의 후반전에도 아키텍처처럼 의존성과 우회로와 함정이 있고, 읽는 법을 익히면 거기서도 엔지니어링을 할 수 있다는 걸. 이제 심사 결과를 기다린다. 통과하든 보완 요청이 오든, 음악 퀴즈 하나가 나를 여기까지 데려온 길은 그 자체로 충분히 멀고 재미있었다.