앱인토스(Apps in Toss)에 미니앱을 하나 올렸다. 애니 성향 테스트, 이름은 ANIBTI다. 32개의 상황 질문으로 애니 감상 성향을 16유형으로 나눠주는 MBTI형 취향 테스트다.
원래는 애니바니라는 웹 서비스의 한 페이지였다. 이걸 토스 안에서도 돌아가게 만들었다. 그 과정에서 예상보다 많이 헤맸고, 밟은 지뢰가 꽤 됐다. 이 글은 그 기록이다.
앱인토스는 토스 앱 안에서 도는 미니앱 플랫폼이다. 별도 설치 없이 토스의 수천만 사용자에게 내 서비스를 노출할 수 있다.
두 갈래 길에서 헤맨 시작
앱인토스에는 프레임워크가 두 개 있다. 이걸 몰라서 초반에 시간을 버렸다.
@apps-in-toss/framework— React Native. Metro 번들러,<View>프리미티브.@apps-in-toss/web-framework— React DOM. Vite 번들러, 그냥<div>와 CSS.
내 애니바니는 React + Tailwind 웹앱이다. 당연히 web-framework를 써야 했다. 그래야 이미 만든 컴포넌트를 그대로 재사용한다.
문제는 프로젝트를 생성할 때 터졌다.
npx create-ait-app@latest anibti --inline --template react-ts --sample iaa이걸 돌리니 react-native, metro, @react-navigation deprecation 경고가 화면을 뒤덮었다. "아, RN 프로젝트구나" 하고 방향을 틀 뻔했다.
원인은 허무했다. 그 경고들은 앱인토스 CLI 툴링이 끌고 오는 transitive dependency였다. 정작 생성된 앱의 package.json을 열어보니 @apps-in-toss/web-framework와 react-dom이 들어 있었다. 앱 자체는 완전한 웹이었다.
교훈: 설치 로그의 RN 경고는 무시해도 된다. dependencies를 직접 확인하면 된다.
스캐폴딩부터 배포까지
방향을 잡고 나니 나머지는 빨랐다. create-ait-app이 만들어준 구조는 익숙한 Vite + React DOM 그 자체였다.
핵심 설정은 granite.config.ts 하나다.
import { defineConfig } from "@apps-in-toss/web-framework/config";
export default defineConfig({
appName: "anibti", // 스킴 식별자 → intoss://anibti
brand: {
displayName: "애니 성향 테스트",
primaryColor: "#FF6B6B",
icon: "https://static.toss.im/appsintoss/…/logo.png",
},
web: { commands: { dev: "vite dev", build: "vite build" } },
});빌드와 배포는 CLI가 처리한다.
npx ait build # → anibti.ait 아티팩트
npx ait deploy --api-key <KEY> # 업로드 → deploymentId + 테스트 스킴배포하면 intoss-private://anibti?_deploymentId=… 같은 테스트 스킴이 나온다. 이걸 토스 앱에서 열면 실제로 미니앱이 뜬다. granite dev는 토스 로그인 없이 로컬에서도 돈다.
여기서 또 한 번 막혔다. API 키를 .zshrc에 넣어뒀는데, ait deploy가 키를 못 찾았다.
원인은 셸이었다. 내가 명령을 돌리던 비대화형 셸은 .zshrc를 읽지 않는다. 그래서 대화형 셸로 감싸 키를 로드했다.
zsh -ic 'npx ait deploy --api-key "$AIT_API_KEY"'웹앱 컴포넌트를 그대로 재사용하기
web-framework가 React DOM이라는 건, 곧 내가 이미 만든 Tailwind 컴포넌트를 그대로 쓸 수 있다는 뜻이었다.
그래서 ANIBTI 코어(문항·채점 로직과 프레젠테이션 컴포넌트)를 packages/anibti-core로 분리했다. 웹앱과 미니앱이 path alias로 같은 소스를 공유하게 했다.
핵심은 alias 우선순위였다. @/lib/anibti 같은 구체 경로를 @보다 먼저 매칭시켜, 기존 웹앱 코드를 한 줄도 안 고치고 그 패키지를 쓰게 만들었다. 앱 결합부(analytics·toast·API 훅)만 미니앱용 shim으로 갈아끼웠다. 추천 API는 그대로 애니바니 백엔드를 호출하게 뒀다.
여기서 두 가지가 또 터졌다.
첫째, 추천 목록이 비었다. 미니앱은 anibti.apps.tossmini.com에서 도는데, 내 백엔드가 그 오리진에 CORS를 안 열어둔 탓이었다. 서버는 200을 주는데 브라우저가 응답을 막고 있었다. 백엔드에 토스 오리진을 허용하고 재배포하니 풀렸다.
둘째, 썸네일이 안 떴다. API가 imageUrl을 /uploads/x.jpg 같은 상대경로로 줬다. 미니앱 오리진 기준이라 404였다. 절대경로로 보정해 해결했다.
광고를 붙이다, 그리고 갇히다
앱인토스는 광고로 수익화할 수 있다. 나는 두 자리에 넣었다. 테스트 진행 화면 하단에 배너 광고, 결과를 보기 전에 전면(리워드) 광고 게이트.
전면 광고는 loadFullScreenAd / showFullScreenAd로 붙였다. 토스가 테스트 광고 ID를 줘서, 콘솔 실 ID 없이도 샌드박스에서 광고를 띄워볼 수 있었다.
여기까지는 순조로웠다. 문제는 실 광고 ID로 바꾼 뒤였다.
"광고 다 봤는데 결과가 안 보여요."
테스트 ID에선 멀쩡하던 게 실 ID에서 터졌다. 이 프로젝트에서 제일 애먹은 버그다.
원인은 이벤트 처리였다. 내 코드는 광고의 dismissed 이벤트만 기다려 결과를 열었다.
// 문제의 코드 — dismissed 만 기다린다
case "dismissed":
resolve({ rewarded });
break;
case "failedToShow":
resolve({ rewarded: false }); // 결과를 막아버린다
break;실 광고에선 dismissed가 안 오거나 failedToShow가 나는 경우가 있었다. 그러면 결과가 영영 안 열린다. 게다가 미니앱의 toast가 no-op이라, 사용자에겐 아무 반응 없이 멈춘 화면만 남았다.
해결은 fail-open이었다. 광고를 보든, 닫든, 실패하든 세션이 끝나면 무조건 결과를 연다. 로드와 노출에 타임아웃 안전망도 뒀다.
// 어떤 종료 이벤트든 결과를 공개한다
onEvent: (e) => {
if (["userEarnedReward", "dismissed", "failedToShow"].includes(e.type)) finish();
},
onError: finish,광고는 수익 수단이지, 사용자를 가두는 문이 아니다. 이 원칙을 코드로 옮기니 버그가 사라졌다.
검수, 그리고 반려
미니앱은 검수를 통과해야 공개된다. 나는 한 번 반려당했다. 사유는 두 개였다.
granite.config.ts의displayName이 콘솔에 등록한 앱 이름과 달랐다.brand.icon이 비어 있어 콘솔 아이콘과 불일치했다.
둘 다 "콘솔에 등록한 값과 정확히 같아야 한다"는 규칙이었다. 이름은 띄어쓰기까지 맞췄고, 아이콘은 콘솔이 준 static.toss.im URL을 그대로 넣었다.
스마트 메시지(광고성 푸시) 카피는 규칙이 더 빡빡했다.
- 제목: 공백 포함 7자 이내, 명사형(명령형·반말 금지)
- 본문: 25자 이내, 권유형 해요체, 마침표로 종료
애니덕후모여라는 반말 명령형이라 반려됐다. 애니 덕후는 서비스명과 겹치고 카테고리 설명이 아니라 또 반려됐다. 덕질 유형처럼 카테고리를 설명하는 명사구로 바꾸고 본문을 ~봐요.로 끝내니 통과했다.
작은 글자 수 안에 규칙을 다 욱여넣는 게, 코드보다 어려웠다.
끝맺음
웹앱 하나를 토스 미니앱으로 옮기는 일은, 생각보다 "코드"보다 "규칙"과의 싸움이었다.
프레임워크 두 갈래, 셸 환경, CORS, 검수 규칙, 광고 이벤트, 푸시 카피. 정작 React 컴포넌트는 거의 그대로 재사용됐고, 발목을 잡은 건 늘 그 주변부였다.
그래도 남은 건 분명하다. 같은 웹 코드로 토스 수천만 사용자 앞에 설 수 있는 창구가 하나 생겼다. 다음에 미니앱을 또 올린다면, 이 글의 지뢰 목록부터 펼쳐 놓고 시작할 것이다.
📌 이번에 배운 함정들은 따로 개발 노트로도 정리해뒀다. 같은 삽질을 두 번 하지 않기 위해서.
References
- 앱인토스 개발자센터 — https://developers-apps-in-toss.toss.im
- 앱인토스 예제 레포 — https://github.com/toss/apps-in-toss-examples