@apps-in-toss/framework@2.0.5 — GameInitializer 반복 실행 버그 리포트

이 글의 성격은 무엇인가요?

질문 / 문제 해결

내용을 설명해주세요

@apps-in-toss/framework@2.0.5 — GameInitializer 반복 실행 버그 리포트
증상
appType: 'game’으로 설정된 미니앱에서 “xx님 반가워요!” 토스트가 페이지 이동할 때마다 반복
노출됩니다. 앱 진입 시 1회만 표시되어야 할 게임센터 프로필 인사 토스트가, 내부
네비게이션(예: 홈 → 게임 → 결과)할 때마다 매번 다시 표시됩니다.
토스 앱 심사에서 아래와 같이 반려되었습니다:
▎ “xx님 반가워요 문구가 모든 페이지 접근시 노출돼요.”

원인 분석
SDK 내부에서 GameInitializer 컴포넌트가 앱 레벨이 아닌 스크린(페이지) 레벨에 배치되어
있어, 페이지 이동 시마다 새 인스턴스가 mount되고 인사 플로우가 반복 실행됩니다.
호출 경로 (SDK 소스 기준)
registerApp() // src/core/registerApp.tsx
└─ router.screenContainer = AppsInTossScreenContainer ← (1) 페이지별 래퍼
└─ RNAppContainer // src/components/RNAppContainer.tsx
└─ (appType === ‘game’)
└─ GameAppContainer
└─ GameInitializer ← (2) 페이지마다 새 인스턴스 mount
└─ useEffect → fetchProfileData()
└─ useEffect → openGameProfileToast() ← (3) 토스트 반복
핵심 코드 4곳
(1) registerApp — screenContainer 등록
// src/core/registerApp.tsx (dist/index.js:1415-1416)
router: {
screenContainer: AppsInTossScreenContainer, // ← 각 스크린마다 이 컴포넌트로 래핑
}
(2) AppsInTossScreenContainer → RNAppContainer → GameAppContainer
// src/components/RNAppContainer.tsx (dist/index.js:1350-1373)
function RNAppContainer({ children }) {
switch (getAppsInTossGlobals().appType) {
case “game”:
return {children}; // ← 게임이면 여기로
}
}
function GameAppContainer({ children }) {
return <>
<RNNavigationBar.Game />
{getOperationalEnvironment() === “toss”
? {children} // ← 토스 환경에서 GameInitializer
래핑
: children}
</>;
}
(3) GameInitializer — 인사 플로우 실행
// src/components/GameInitializer.tsx (dist/index.js:551-601)
var GameInitializer = ({ children }) => {
const { openGameProfileToast, fetchProfileData, … } = useGameCenterProfile();
const isCompletedFlow = useRef(false); // ← 인스턴스별 ref → 페이지마다 false로 초기화
useEffect(() => { fetchProfileData(); }, ); // ← mount마다 프로필 API 호출
useEffect(() => {
if (!canShowBottomSheetOrToast) return;
if (isCompletedFlow.current) return; // ← 같은 인스턴스에서는 1회만 실행
isCompletedFlow.current = true;
if (profileData?.statusCode === “SUCCESS”) {
openGameProfileToast(profileData.nickname, profileData.profileImageUri); // ←
토스트!
}
}, [canShowBottomSheetOrToast, …]);
};
(4) openGameProfileToast — 토스트 UI
// src/components/GameProfileToast.tsx (dist/index.js:449-463)
openGameProfileToast = (nickname, profileImageUri) => {
overlay.open(({ isOpen, close, exit }) => (
<Toast
open={isOpen}
position=“top”
text={님 반가워요!} // ← 이 텍스트가 반복 노출
icon={<Asset.Image source={{ uri: profileImageUri }} />}
/>
));
};

왜 반복되는가
isCompletedFlow = useRef(false)는 컴포넌트 인스턴스별 값입니다. screenContainer에 포함된
GameInitializer는 React Navigation이 새 스크린을 push할 때마다 새 인스턴스가 생성되므로,
isCompletedFlow가 매번 false로 시작됩니다.
┌────────────────┬─────────────────────────┬────────────┐
│ 동작 │ isCompletedFlow │ 토스트 │
├────────────────┼─────────────────────────┼────────────┤
│ 홈 화면 mount │ 인스턴스A: false → true │ white_check_mark 표시 │
├────────────────┼─────────────────────────┼────────────┤
│ 게임 화면 push │ 인스턴스B: false → true │ white_check_mark 또 표시 │
├────────────────┼─────────────────────────┼────────────┤
│ 결과 화면 push │ 인스턴스C: false → true │ white_check_mark 또 표시 │
└────────────────┴─────────────────────────┴────────────┘

예상되는 의도 vs 실제 동작
┌──────────────────────┬─────────────────────┬────────────────────────────────────┐
│ │ 의도 (추정) │ 실제 동작 │
├──────────────────────┼─────────────────────┼────────────────────────────────────┤
│ GameInitializer 위치 │ 앱 레벨 (1회 mount) │ screenContainer (페이지마다 mount) │
├──────────────────────┼─────────────────────┼────────────────────────────────────┤
│ 인사 토스트 │ 앱 진입 시 1회 │ 페이지 이동마다 반복 │
├──────────────────────┼─────────────────────┼────────────────────────────────────┤
│ isCompletedFlow │ 앱 전체에서 공유 │ 인스턴스별 독립 (useRef) │
├──────────────────────┼─────────────────────┼────────────────────────────────────┤
│ fetchProfileData │ 앱 진입 시 1회 │ 페이지마다 API 재호출 │
└──────────────────────┴─────────────────────┴────────────────────────────────────┘

수정 제안
GameInitializer를 screenContainer 밖으로 이동하면 해결됩니다.
방법 A: AppsInTossContainer (앱 레벨)에서 GameInitializer를 래핑
// 현재: screenContainer 안에 GameInitializer
// 수정: AppsInTossContainer 안에 GameInitializer (앱 전체에서 1회만 mount)
function AppsInTossContainer(Container, { children, …initialProps }) {
return <>
{/* ← 여기서 1회 */}
{children}
</>;
}
방법 B: isCompletedFlow를 모듈 스코프 변수로 변경
// 현재: const isCompletedFlow = useRef(false); ← 인스턴스별
// 수정:
let _isCompletedFlow = false; // ← 모듈 스코프 (전역 1회)
var GameInitializer = ({ children }) => {
// …
useEffect(() => {
if (_isCompletedFlow) return;
_isCompletedFlow = true;
// …
});
};

현재 워크어라운드
SDK 수정 전까지 앱 측에서 아래 코드로 우회하고 있습니다:
// _app.tsx (모듈 스코프)
const _ait = (globalThis as any).__appsInToss;
if (_ait != null && _ait.appType === ‘game’) {
_ait.appType = ‘general’;
}
런타임에서 appType을 'general’로 전환하여 GameAppContainer → GeneralAppContainer로
우회합니다. 매니페스트의 appType: 'game’은 유지되므로 콘솔 등록에는 영향 없습니다.
단, 이 워크어라운드로 인해 GameAppContainer가 제공하는 iOS 스와이프 비활성화, 게임센터
프로필 미등록 시 자동 안내 등의 기능도 함께 비활성화됩니다.

재현 환경

  • @apps-in-toss/framework: 2.0.5
  • @apps-in-toss/plugins: 2.0.9
  • @apps-in-toss/native-modules: 2.0.5
  • appType: ‘game’ 설정 + 2개 이상 페이지가 있는 미니앱
  • 토스 앱 환경 (getOperationalEnvironment() === ‘toss’)에서 재현

appName (선택)

tap-tap-slide, tap-tap-challenge(둘다 위 버그로 인해 반려 먹었습니다)

왜 다른 개발자들은 안 걸렸는가?

┌───────────────┬─────────────────────────────────────────────────────────────────────┐
│ 이유 │ 설명 │
├───────────────┼─────────────────────────────────────────────────────────────────────┤
│ 개발 중 안 │ getOperationalEnvironment() !== “toss” → dev에서 GameInitializer │
│ 보임 │ 미실행 │
├───────────────┼─────────────────────────────────────────────────────────────────────┤
│ WebView 게임 │ type: ‘game’ WebView 기반 게임은 React Navigation 페이지 이동 없음 │
│ │ → 1회만 mount │
├───────────────┼─────────────────────────────────────────────────────────────────────┤
│ 2페이지 이하 │ 공식 예제도 TitlePage + GamePage 2개뿐 — 빠르게 넘어가면 못 알아챌 │
│ │ 수 있음 │
├───────────────┼─────────────────────────────────────────────────────────────────────┤
│ 아직 미발견 │ 같은 문제가 있지만 심사에서 지적 안 받았을 수 있음 │
└───────────────┴─────────────────────────────────────────────────────────────────────┘

SDK 업그레이드로 해결되나?

아니요. 최신 2.1.1까지 확인 완료 — 동일한 코드, 동일한 구조.

2.0.5 → GameInitializer in screenContainer, isCompletedFlow = useRef :cross_mark:
2.0.9 → 동일 :cross_mark:
2.1.0 → 동일 :cross_mark:
2.1.1 → 동일 :cross_mark:

결론

┌───────────────────────┬─────────────────────────────────────────────────────────────┐
│ 질문 │ 답 │
├───────────────────────┼─────────────────────────────────────────────────────────────┤
│ SDK 버그인가? │ 예 — 문서도 없고 제어 방법도 없는 내부 컴포넌트가 │
│ │ screenContainer에 잘못 배치됨 │
├───────────────────────┼─────────────────────────────────────────────────────────────┤
│ SDK 올리면 해결되나? │ 아니요 — 2.1.1까지 동일 │
├───────────────────────┼─────────────────────────────────────────────────────────────┤
│ 몽키패치 없이 해결 │ 아니요 — 공식 옵션 없음 │
│ 가능한가? │ │
├───────────────────────┼─────────────────────────────────────────────────────────────┤
│ 다른 앱도 같은 │ 예 — SDK 2.0.5+로 빌드+배포하면 전부 동일 │
│ 문제인가? │ │
└───────────────────────

이 문제때문에 아래와같이 처리하여 올리려고하는데 괜찮을까요?
수정 방식

_app.tsx: 런타임에서 appType을 'general’로 변경 → SDK의 GameAppContainer 대신
GeneralAppContainer 사용 → GameInitializer 자체가 실행 안 됨

각 페이지 5개: <PageNavbar preference={{ type: ‘none’ }} /> 추가 → GeneralAppContainer가
렌더하는 흰색 DefaultNavigationBar 숨김

토스트 동작

아닙니다. 홈 첫접속에서도 토스트가 안 뜹니다.

appType = 'general’로 바꾸면 GameInitializer 자체가 로드되지 않아서, “xx님 반가워요”
토스트가 0회 표시됩니다. 첫 페이지 포함 전부.

┌───────────┬──────────────────┬───────────────────┐
│ │ AS-IS (SDK 원본) │ TO-BE (현재 수정) │
├───────────┼──────────────────┼───────────────────┤
│ 홈 진입 │ 토스트 1회 :white_check_mark: │ 토스트 0회 │
├───────────┼──────────────────┼───────────────────┤
│ 게임 이동 │ 토스트 또 뜸 :cross_mark: │ 토스트 0회 │
├───────────┼──────────────────┼───────────────────┤
│ 결과 이동 │ 토스트 또 뜸 :cross_mark: │ 토스트 0회 │
└───────────┴──────────────────┴───────────────────┘

반려 사유(“모든 페이지 접근시 노출”)는 해결됩니다. 다만 원래 의도된 1회 인사 토스트도 안
뜨는 상태입니다.