3초짜리 클래식 음원을 듣고 작품을 맞히는 퀴즈 앱이 있다. 토스의 React Native 플랫폼인 Granite 위에서 도는 미니앱이다. 어느 날 질문이 하나 들어왔다. "이거 앱스토어에 올리려면 어떻게 해?"

답은 생각보다 단호하다. 지금 코드 그대로는 못 올린다.

토스 미니앱은 앱스토어에 못 올린다

미니앱은 독립 앱이 아니다. 토스 앱이라는 숙주 안에서 App-in-App으로 도는 JS 번들이다. 빌드 결과물은 애플이 요구하는 .ipa가 아니라 토스 런타임이 읽는 번들이고, 앱스토어에 등록된 네이티브 앱은 "토스 앱" 하나뿐이다. 애플은 우리 퀴즈의 존재를 모른다.

그래서 "앱스토어 출시"는 배포 버튼 하나가 아니라, 별도의 독립 앱으로 다시 빌드(포팅)하는 작업이다. 문제는 이 앱의 기능 절반이 토스 전용 네이티브 API에 묶여 있다는 점이었다. 점수 저장, 전역 랭킹(게임 센터), 광고, 공유, 오디오 재생이 전부 토스 프레임워크 호출이다. 토스 밖에선 그 API가 존재하지 않아 그냥 죽는다.

반대로, 퀴즈의 "두뇌"는 토스를 전혀 모른다. 100문제 시드 데이터, 출제 로직, 점수 계산, 4지선다 UI 컴포넌트, 테마 토큰까지 전부 순수 React Native다. 손발(저장·랭킹·광고·공유·오디오)만 토스에 묶여 있었다.

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

다행히 이 코드베이스는 처음부터 한 가지 규율을 지키고 있었다. 네이티브 호출을 화면에서 직접 부르지 않고 전용 모듈 한 곳에 가둔다. 게임센터·공유·광고가 각각 모듈로 분리돼 있었고, 결과를 판별 유니온({ kind: ... })으로 정규화해 UI가 native 세부사항을 모르게 했다.

이 규율이 포팅의 순간을 위해 값을 했다. 할 일은 명확했다.

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

화면은 포트만 알고, 각 앱이 어댑터를 주입한다. "다시 만들기"가 아니라 얇은 어댑터 몇 개만 교체하는 작업으로 바뀐다.

전체를 한 번에 하기엔 컸다. 그래서 네 단계로 쪼갰다. 각 단계는 그 자체로 동작하는 소프트웨어를 내야 한다는 원칙을 세웠다.

Plan 1모노레포로 재편하고 두뇌를 분리

첫 단계는 저장소를 npm workspaces 모노레포로 재편하는 일이었다.

  • packages/core (@classic/core): 순수 퀴즈 두뇌 + 네이티브 경계 포트 인터페이스.
  • 토스 미니앱: 루트에 그대로 둔다. Granite 툴링이 루트 레이아웃(granite.config.ts, pages/, 진입점)을 강하게 가정하기 때문이다.

여기서 가장 큰 미지수는 "Granite 번들러가 워크스페이스 패키지 심볼릭링크를 해석하는가"였다. 토스 공식 문서에 모노레포 지원이 명시돼 있지 않았다. 그래서 큰 파일 이동을 하기 전에, @classic/core 패키지 하나를 import하고 번들이 통과하는지만 보는 스파이크를 첫 작업으로 박았다. 통과하면 진행, 실패하면 미리 정해둔 폴백 레이아웃으로 전환.

스파이크는 통과했다. 그 뒤 순수 모듈(시드·문제·점수·훅·컴포넌트·테마)을 git mv로 옮기고, 토스 어댑터들이 core 포트 인터페이스를 구현하도록 타입을 정렬했다. 동작은 한 줄도 바꾸지 않았다. 검증 기준은 단순했다. 토스 앱 회귀 0, 즉 타입체크·린트·테스트·번들 빌드 전부 통과다.

Plan 2a화면을 코어로 끌어올리다

두뇌는 옮겼지만 화면(스플래시·홈·퀴즈·결과·404)은 아직 토스 페이지 안에 있었다. 이걸 두 앱이 공유하려면 화면 본문도 core로 가야 한다.

문제는 화면이 Granite 라우팅(createRoute, useNavigation, useParams)과 경계 모듈을 직접 쓰고 있었다는 점이다. 그래서 추상화를 둘로 나눴다.

  • 앱-전역 서비스(저장·랭킹·공유·오디오 호스트·배너·광고·포커스)는 React 컨텍스트(AppPorts)로 주입.
  • 라우트별 네비게이션·파라미터는 props(NavPort)로 주입.

화면 본문은 packages/core/src/screens/*로 옮기고, 토스 페이지는 "createRoute + 네비게이션 변환 + core 화면 렌더"만 하는 얇은 래퍼가 됐다.

// 토스 래퍼 — 본문은 core가 소유한다
function QuizPage() {
  const navigation = Route.useNavigation();
  const nav = toNavPort(navigation);
  return <QuizScreen nav={nav} />;
}

이 단계 역시 토스 앱 안에서 회귀 0으로 검증했다. Expo는 한 줄도 들어가지 않았다. 토스가 모든 포트를 실제로 행사하므로, "포트 표면이 완전한가"를 실사용처로 먼저 증명한 셈이다.

Plan 2b두 번째 앱, Expo

이제 진짜 새 앱이다. apps/native에 표준 Expo 앱을 세우고, 같은 core 화면을 렌더하되 AppPorts만 Expo 어댑터로 채운다.

가장 큰 리스크는 React Native 버전 불일치였다. 루트 토스 앱은 Granite가 핀한 RN 0.84에 묶여 있는데, 이 버전을 핀하는 Expo SDK가 없었다(SDK는 0.85를 핀했다). 두 앱이 서로 다른 RN 버전으로 도는 모노레포다. 여기서 react가 중복 번들되면 그 악명 높은 "Invalid hook call"이 터진다.

Plan 1과 똑같이 스파이크부터 했다. 빈 화면 하나를 Expo에서 렌더해보고, react가 단일 사본으로 해석되는지 확인했다. 운 좋게 react 버전은 양쪽이 동일해서 호이스팅으로 단일 사본이 됐고(이게 중복 크래시를 막는다), react-native만 앱별로 갈렸다. core 패키지가 react/react-native를 peerDependencies로 선언하게 한 것도 이 격리에 한몫했다. 번들은 깨끗하게 통과했다.

그 위에 어댑터를 하나씩 얹었다.

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

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

(참고로 expo-av는 이제 deprecated다. expo-audio로 갈렸으니 새로 시작한다면 처음부터 후자를 쓰는 게 좋다.)

광고와 전역 랭킹은 네이티브 모듈이라 Expo Go에서 검증할 수 없다. 그래서 Plan 2b에서는 의도적으로 no-op 스텁으로 두고(흐름을 막지 않는 형태), 실제 구현은 다음 단계로 미뤘다.

Plan 2b 이후숨어 있던 함정과 경계 다듬기

번들은 깨끗하게 통과했지만, 진짜 검증은 실기기 빌드에서 났다. 그리고 거기서 위의 "운 좋게 단일 사본이 됐다"던 RN 이야기가 실은 함정을 하나 숨기고 있었다는 게 드러났다.

문제는 react-native만 앱별로 갈렸다는 바로 그 한 줄이었다. npm 호이스팅은 가능하면 패키지를 루트로 끌어올린다. 그래서 Expo의 expo-modules-core가 react-native를 찾을 때, 앱의 0.85.3이 아니라 루트의 0.84(토스가 핀한 버전)로 해석되는 경로가 생겼다. 한 번들 안에 RN 0.84와 0.85.3이 섞이면 네이티브 브리지가 깨진다. Cannot find native module 'ExpoAsset' 같은 에러로 터진다.

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

교훈은 글 끝의 원칙과 정확히 맞물린다. JS 게이트가 초록불이어도 네이티브는 깨질 수 있다. 검증 안 된 토대 위에 네이티브 모듈을 쌓으면, 크래시가 났을 때 범인이 토대인지 신규 코드인지 분리가 안 된다. Plan 3로 넘어가기 전에 실기기 한 판을 고집하는 이유다.

인트로 BGM세 번째 오디오 채널

토대를 다지면서, 토스에만 있던 기능 하나가 Expo에 빠져 있는 걸 마저 채웠다. 스플래시에서 한 번 흐르는 인트로 BGM이다. 원래 이건 토스 전용으로 Granite 미디어를 직접 부르는 컴포넌트에 갇혀 있었다.

이걸 core의 IntroChannel로 끌어올려, 퀴즈 클립·효과음에 이은 세 번째 오디오 채널로 만들었다. 핵심은 새 포트 타입을 하나도 안 늘렸다는 점이다. 재생 프리미티브는 이미 있던 오디오 호스트 어댑터(토스=Granite, Expo=expo-audio)가 그대로 맡고, core는 오케스트레이션만 소유한다. 세션당 1회만 재생, 라우터 위에 상주해 스플래시→홈 전환에도 안 끊김, 퀴즈처럼 자체 오디오가 있는 화면에 들어가면 정지. "1회 재생" 래치는 claimIntroPlayback이라는 순수 함수로 떼어내 단위 테스트로 박았다. 결과적으로 Expo 앱도 스플래시 인트로를 얻었다. 어댑터는 한 줄도 새로 안 만들고, 오케스트레이션 컴포넌트 하나만 더해서.

미지원 안내문은 누가 소유하나

마지막은 작은 경계 결정이었다. 리더보드가 "지원 안 됨"일 때 보여줄 안내 문구를 누가 갖고 있어야 하나?

처음엔 이 문구가 화면(core) 안에 토스 게임센터를 전제로 박혀 있었다. 그런데 iOS·Android에는 "게임센터"라는 토스 개념이 없다. 플랫폼마다 부를 이름도, 안내도 다르다. 그래서 안내 문구를 LeaderboardPort로 내렸다. 각 어댑터가 자기 플랫폼에 맞는 서비스 이름과 미지원 카피를 들고 오고, 화면은 그저 포트가 준 문구를 읽기만 한다. "플랫폼마다 달라지는 건 어댑터가, 공통인 건 core가"라는 경계가 한 번 더 또렷해졌다.

일하는 방식계획 → 스파이크 → 서브에이전트 리뷰

코드만큼이나 방식이 이 작업을 굴러가게 했다.

  • 계획을 먼저 문서로 고정했다. 각 단계마다 파일 구조·작업·검증 기준을 bite-sized로 적은 구현 계획을 썼다.
  • 가장 큰 미지수는 스파이크로 먼저 죽였다. "Granite가 워크스페이스를 해석하나", "Expo가 react를 단일 사본으로 해석하나" 같은 질문을 추측으로 두지 않고, 최소 실험으로 실증한 뒤에야 본 작업을 쌓았다.
  • 작업마다 독립 검증을 거쳤다. 구현과 리뷰를 분리해서, 변경이 스펙대로인지 + 코드 품질이 괜찮은지를 별도로 확인했다. 이 과정에서 스테일 테스트, 잘못 적힌 문서 경로, 포트 시그니처 누락 같은 걸 여러 번 잡았다.

지루해 보이지만, 결과적으로 세 단계 전부 "토스 앱 회귀 0"을 유지했다. 새 앱을 만드는 내내 기존 앱이 한 번도 깨지지 않았다는 뜻이다.

지금까지 / 앞으로

지금 상태:

  • Plan 1: 모노레포 재편 + 순수 두뇌를 공유 패키지로 추출
  • Plan 2a: 5개 화면을 코어로 추출 + AppPorts 컨텍스트
  • Plan 2b: Expo 앱 골격 + 저장·오디오·공유 어댑터 (광고·랭킹은 스텁)
  • Plan 2b 다듬기: RN 단일화로 네이티브 브리지 함정 차단, 인트로 BGM 3채널 패러티, 리더보드 안내문 포트 이관
  • 출시 전 prep: 앱 아이콘/스플래시 브랜딩(임시), 개인정보처리방침 초안

남은 일(Plan 3):

  • 광고: AdMob 배너/전면으로 스텁 교체
  • 전역 랭킹: iOS Game Center / Android Google Play Games로 스텁 교체
  • EAS 프로덕션 빌드 + 양 스토어 제출·심사
  • 디자이너 자산(네이티브 해상도 아이콘 등) + 개발자 계정 준비

남은 작업의 특징은, 코드보다 계정·빌드·심사 같은 외부 의존이 핵심이라는 점이다. 광고·랭킹은 네이티브 모듈이라 일반 개발 클라이언트로는 검증되지 않고 실제 빌드가 필요하다. 그래서 "검증 안 된 토대 위에 검증 못 하는 코드를 쌓지 않는다"는 원칙에 따라, Expo 앱을 실기기에서 한 번 돌려 오디오까지 확인한 뒤에 Plan 3로 넘어가기로 했다.

마무리

핵심은 처음의 한 결정이었다. 네이티브 경계를 인터페이스로 격리한다. 그 덕에 화면도 퀴즈 로직도 다시 쓰지 않고, 손발만 갈아끼워 한 코드베이스가 두 플랫폼(미니앱 + 독립 앱)에서 돌게 됐다. 좋은 경계는 미래의 나에게 보내는 선물이라는 말을, 이번에 한 번 더 실감했다.