한 번의 탭을 잃지 않으려고 — Versus 토스 미니앱에서 만든 '미투표' 상태와 fetch·인증의 디테일
한 번의 탭을 잃지 않으려고 — Versus 토스 미니앱에서 만든 '미투표' 상태와 fetch·인증의 디테일#
Versus는 매일 새로운 A vs B 밸런스 게임에 투표하고, 다른 사람들의 선택 비율을 확인하는 서비스입니다. 이번에 이 Versus를 Apps in Toss 미니앱으로 옮겼습니다.
지난번에 Typefy를 토스 인앱으로 옮긴 이야기는 이미 한 번 정리했습니다. 그래서 이번 글은 같은 이야기를 반복하기보다, Versus를 만들면서만 마주친 코드 레벨의 결정들에 집중했습니다. "왜 이 함수가 이렇게 생겼는가"를 중심으로 7가지 디테일을 풉니다.
대상은 세 부류입니다.
- Apps in Toss 미니앱을 직접 짜고 있는 분
- React Native + 외부 호스트 환경에서 fetch / 인증 / 라우팅을 다루는 분
- "투표 한 번"처럼 짧은 인터랙션을 잃지 않으려고 고민하는 분
1. 가장 먼저 걸린 문제는 "탭을 했는데 로그인이 없다"는 순간이었습니다#
Versus의 핵심 인터랙션은 단 하나입니다. 두 카드 중 하나를 누른다. 끝.
문제는 이 "한 번의 탭"이 두 가지 조건을 동시에 만족해야 한다는 것이었습니다.
- 로그인이 없어도 즉시 결과 비율로 넘어가야 합니다.
- 동시에 그 선택은 "내 기록"으로 저장될 수 있어야 합니다.
웹 Versus는 이 갈등을 비교적 쉽게 풀 수 있습니다. 로컬에 임시 답변을 넣고, 로그인 후 페이지가 다시 마운트될 때 복원하면 됩니다. 인앱은 다릅니다. 페이지 리로드 개념이 약하고, 같은 컴포넌트가 살아 있는 동안 로그인 모달이 열렸다가 닫힙니다. 상태가 끊기는 게 아니라, 살아 있는 채로 갈아끼워져야 합니다.
그래서 Versus 인앱은 PendingVote라는 별도 개념을 두었습니다. 핵심 정의는 단순합니다.
type PendingVote = {
gameSlug: string;
questionId: number;
optionId: number;
};
const PENDING_VOTE_STORAGE_KEY = "versus.pending.vote";
미로그인 상태에서 옵션을 누르면, 서버를 부르지 않습니다. 대신 이 객체를 만들어서 SDK Storage에 즉시 저장합니다.
if (!session) {
const nextPendingVote = {
gameSlug: selectedSlug ?? "",
questionId: selectedQuestion.id,
optionId,
} satisfies PendingVote;
setPendingVote(nextPendingVote);
setIsSavePromptVisible(true);
await Storage.setItem(PENDING_VOTE_STORAGE_KEY, JSON.stringify(nextPendingVote));
return;
}
이 작은 객체 하나가 다음 세 가지를 동시에 가능하게 했습니다.
- 미로그인 상태에서도 화면은 "선택 완료"로 즉시 전환됩니다.
- 앱이 종료되었다가 다시 열려도, Storage에서 복원해 같은 화면을 다시 보여줍니다.
- 로그인 직후, 같은 객체를 그대로 서버 vote API로 흘려보냅니다.
여기서 한 가지 주의점이 있습니다. 미로그인 시 보여주는 결과 비율은 서버 통계가 아니라, 사용자가 방금 누른 선택지를 "표시상으로만" 강조한 것입니다. 그래서 UI 분기에서 두 종류의 vote를 합쳐 다뤄야 했습니다.
const selectedVote = votes.find(v => v.questionId === selectedQuestion?.id) ?? null;
const pendingSelectedVote =
pendingVote && pendingVote.questionId === selectedQuestion?.id
? { questionId: pendingVote.questionId, selectedOptionId: pendingVote.optionId }
: null;
const effectiveVote = selectedVote ?? pendingSelectedVote;
const hasVoted = effectiveVote !== null;
const hasPendingVoteToSave = selectedVote === null && pendingSelectedVote !== null;
effectiveVote는 "이 사용자가 결과 화면을 볼 자격이 있는가"를 결정하고, hasPendingVoteToSave는 "이 선택은 아직 저장되지 않았다"를 결정합니다. 후자가 분리되어 있어야 다음 메시지가 가능해집니다.
"선택은 완료됐어요. 로그인하면 이 선택을 내 기록에 저장할 수 있어요."
이 한 줄은 별것 아니어 보이지만, 인앱 환경에서는 큰 차이를 만듭니다. 로그인은 "기능을 잠그는 게 아니라, 이미 한 행동을 보존해 주는 장치"로 보이기 때문입니다.
2. 로그인이 끝난 직후, 같은 컴포넌트가 살아 있는 채로 vote API를 부른다#
PendingVote가 의미를 가지려면, 로그인 직후의 흐름이 깔끔해야 합니다. 그래서 로그인 핸들러는 단순히 토큰을 저장하고 끝나지 않습니다.
const handleLogin = useCallback(async () => {
setScreenError(null);
setIsSavePromptVisible(false);
try {
const activeSession = await loginWithToss();
if (pendingVote) {
setIsSubmittingVote(true);
try {
await savePendingVote(activeSession, pendingVote);
} finally {
setIsSubmittingVote(false);
}
}
} catch (error) {
setScreenError(error instanceof Error ? error.message : "Toss 로그인에 실패했습니다.");
}
}, [loginWithToss, pendingVote, savePendingVote]);
savePendingVote는 로그인으로 막 발급된 세션으로 vote API를 호출하고, 성공하면 Storage에서 PendingVote를 비웁니다. 여기서 한 가지 작은 디테일이 있습니다. setSession 후 useEffect가 도는 걸 기다리지 않고, 로그인 결과로 받은 activeSession을 직접 인자로 넘긴다는 점입니다.
const stats = await castVersusVote(
{ questionId: vote.questionId, optionId: vote.optionId },
activeSession, // 직접 받은 세션
persistSession,
clearSession,
);
이게 왜 중요하냐면, React 상태 업데이트는 비동기이고 setState 직후의 다음 줄에서 새 세션을 읽을 수 없습니다. 인앱에서는 한 번의 사용자 흐름이 길어서, 이 한 단계의 끊김이 "로그인했더니 투표가 사라졌다"는 인상을 만들 수 있습니다. 그래서 세션은 상태로도 흐르고, 인자로도 흐르는 이중 경로를 갖게 했습니다.
3. 401은 한 번 더 시도한다 — 한 함수에 갱신 흐름을 모아 둔 이유#
미니앱 사용자는 한 번 로그인하면 며칠씩 다시 들어옵니다. 그동안 access token은 만료됩니다. 이걸 어떻게 다룰지가 인증 코드에서 가장 큰 분기점이었습니다.
선택지는 두 가지였습니다.
- 매 요청 전에 만료 시간을 검사해 사전 갱신한다.
- 일단 보내고, 401이 떨어지면 그때 갱신해서 한 번 더 보낸다.
Versus는 두 번째를 택했습니다. 이유는 단순합니다. 만료 시간 검사는 클라이언트 시계에 의존합니다. 인앱 사용자가 디바이스 시간을 바꿔두거나, 서버 시계와 미세하게 어긋나면 그 검사는 거짓이 됩니다. 차라리 서버에게 묻는 편이 낫습니다.
대신 이 "한 번 더"를 모든 호출이 공유하도록, authorizedJson 한 함수로 묶었습니다.
export const authorizedJson = async <T>(
path: string,
session: AuthSession,
persistSession: (next: AuthSession) => Promise<void>,
clearSession: () => Promise<void>,
init?: RequestInit,
): Promise<T> => {
const doRequest = async (activeSession: AuthSession) => {
const headers = {
...(normalizeHeaders(init?.headers) ?? {}),
Authorization: `Bearer ${activeSession.accessToken}`,
};
return safeFetch(buildUrl(path), { ...init, headers });
};
let response = await doRequest(session);
if (response.status === 401) {
try {
const nextSession = await refreshSession(session.refreshToken);
await persistSession(nextSession);
response = await doRequest(nextSession);
} catch {
await clearSession();
throw new ApiError("세션이 만료되었습니다. 다시 로그인해주세요.", 401);
}
}
// ...
};
이 함수 하나로 vote, comment 작성/삭제, me, 로그아웃이 모두 같은 갱신 흐름을 공유합니다. 갱신 후에도 401이면 즉시 clearSession을 부르는 부분이 핵심입니다. refresh 자체가 실패한 상황은 "토큰이 영구적으로 무효"가 된 상태이고, 이때 세션을 그대로 두면 다음 호출도 똑같이 실패합니다. 한 번 더 망설이지 않고 바로 비워야 사용자가 다시 로그인 버튼을 만나게 됩니다.
추가로 "재시도는 한 번"을 강제했다는 점이 중요합니다. 갱신 후 또 401이면 재귀적으로 시도하지 않고 끝냅니다. 인앱에서 무한 갱신 루프가 한 번 시작되면 사용자는 그저 "느린 앱"으로 인식합니다.
4. fetch는 한 번 더 닦았습니다 — RN의 fetch는 브라우저와 다르기 때문입니다#
가장 시간을 많이 쓴 부분은 의외로 fetch였습니다. 브라우저에서 멀쩡히 동작하던 RequestInit이 인앱에서는 미묘하게 거절되거나, 헤더가 안 실려나가는 경우가 있었습니다.
원인은 한 가지였습니다. Headers 객체와 일부 옵션이 React Native fetch와 폴리필 사이에서 일관되지 않게 처리된다는 점입니다. 이걸 매번 추적하는 건 비용이 컸습니다. 그래서 모든 fetch 직전에 RequestInit을 plain object로 정규화하기로 했습니다.
const normalizeHeaders = (headers?: HeadersInit) => {
if (!headers) return undefined;
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
if (Array.isArray(headers)) return Object.fromEntries(headers);
return { ...headers };
};
const normalizeRequestInit = (init?: RequestInit) => {
// method / headers / body / credentials / mode / cache / redirect ...
// 각 필드를 타입 검증한 뒤, plain object 키로만 옮긴다
};
이 한 단계를 거친 뒤부터, "분명히 헤더를 넣었는데 서버에는 안 들어갔다"는 종류의 디버깅이 사라졌습니다.
여기에 더해, fetch 호출 직전과 직후를 모두 로깅합니다.
logApi("request:normalized", {
url, method, hasHeaders: Boolean(init?.headers),
hasBody: typeof init?.body === "string" && init.body.length > 0,
init,
});
hasHeaders, hasBody 같은 boolean 플래그는 별것 아닌 것 같지만 인앱 디버깅에서 매우 유용합니다. 본문을 그대로 찍으면 토큰이 따라 찍힐 수 있고, 양이 너무 많으면 토스 개발자 도구에서 잘립니다. "있다/없다"만 빠르게 보고, 의심되는 요청만 init 전체를 본다는 두 단계 로깅을 처음부터 가져갔습니다.
추가로 예외도 무조건 잡아 형태를 정리해서 던집니다.
const toLoggableError = (error: unknown) => {
if (error instanceof Error) return { name, message, stack, cause };
if (error && typeof error === "object") return { stringified: String(error), keys: Object.keys(error), value: error };
return { value: error, stringified: String(error) };
};
RN fetch는 종종 [Object object] 같은 모양의 에러를 던집니다. 위 변환을 거치지 않으면 Sentry에 가서도 같은 에러로 보입니다. 같은 에러로 묶이면 우선순위가 안 잡히고, 우선순위가 안 잡히면 결국 안 고쳐집니다.
5. 한 페이지 안에서 모달처럼 움직이는 라우팅 — useBackEvent 활용#
Versus 인앱의 라우터 구성은 매우 단순합니다. 라우트는 / 하나뿐입니다.
export const Route = createRoute("/", {
component: Page,
});
목록 → 상세 전환은 라우터가 아니라 useState로 합니다.
const [tab, setTab] = useState<AppTab>("discover");
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
이렇게 하면 두 가지 이득이 생깁니다.
- 목록의 스크롤, 광고 슬롯, 데이터 캐시가 상세 진입 후에도 그대로 살아 있습니다.
- 상세에서 돌아올 때 "복원"을 따로 짤 필요가 없습니다.
문제는 호스트 앱의 백 동작이었습니다. 토스 안에서 사용자가 디바이스 백 버튼이나 스와이프 제스처를 쓰면, 기본 동작은 미니앱 자체를 종료시킵니다. 우리 입장에서는 "상세에서 백을 누르면 목록으로", "프로필에서 백을 누르면 게임 탭으로" 가야 합니다.
이걸 useBackEvent로 가로챘습니다.
useEffect(() => {
const shouldHandleBack = selectedSlug !== null || tab !== "discover";
if (!shouldHandleBack) return;
backEvent.addEventListener(handleHostBack);
return () => backEvent.removeEventListener(handleHostBack);
}, [backEvent, handleHostBack, selectedSlug, tab]);
핵심은 루트 상태(목록 탭 + 미선택)에서는 리스너를 등록하지 않는다는 점입니다. 그래야 호스트 기본 동작이 작동해 사용자가 자연스럽게 미니앱을 종료할 수 있습니다. 이걸 무조건 가로채면 사용자는 "닫으려고 하는데 안 닫히는 앱"을 만나게 됩니다.
handleHostBack 자체는 단순한 우선순위 트리입니다.
const handleHostBack = useCallback(() => {
if (selectedSlug) { closeDetail(); return; }
if (tab !== "discover") setTab("discover");
}, [closeDetail, selectedSlug, tab]);
상세가 떠 있으면 상세부터 닫고, 없으면 탭을 기본 탭으로 되돌립니다. 라우터를 쓰지 않은 대가로 "어디까지 열려 있는가"를 명시적으로 트리로 표현해야 했지만, 코드를 읽기는 더 쉬웠습니다.
6. InlineAd는 위치마다 다른 logPrefix를 줬습니다 — 무료로 얻은 노출 분석#
Versus 인앱에는 InlineAd가 세 군데 들어갑니다. 목록 상단, 목록 중간(N번째 게임마다), 상세 페이지의 질문 위와 결과 위. 같은 컴포넌트지만 의도가 다릅니다.
처음에는 모두 같은 핸들러로 묶었는데, 곧 후회했습니다. "어느 위치의 광고가 가장 많이 노출되고 있나"가 안 보였기 때문입니다.
그래서 위치마다 prefix만 다른 동일 컴포넌트로 분리했습니다.
function InlineBannerAd({ logPrefix }: { logPrefix: string }) {
return (
<InlineAd
adGroupId={VERSUS_AD_GROUP_ID}
onAdRendered={(p) => console.log(`${logPrefix}_rendered`, p)}
onAdImpression={(p) => console.log(`${logPrefix}_impression`, p)}
onAdClicked={(p) => console.log(`${logPrefix}_clicked`, p)}
onNoFill={(p) => console.log(`${logPrefix}_nofill`, p)}
onAdFailedToRender={(p) => console.log(`${logPrefix}_failed`, p)}
/>
);
}
호출부에서 prefix만 바꿉니다.
<InlineBannerAd logPrefix="versus_toss_list_top_banner" />
<InlineBannerAd logPrefix="versus_toss_list_middle_banner" />
<InlineBannerAd logPrefix="versus_toss_detail_question_banner" />
<InlineBannerAd logPrefix="versus_toss_detail_result_banner" />
여기서 두 가지를 의도했습니다.
- 5가지 라이프사이클 콜백을 모두 찍는다. rendered / impression / clicked / nofill / failed. nofill과 failed가 의외로 많은 정보를 줍니다. "이 위치는 광고가 안 떠서 빈 칸이 보였다"가 빠르게 잡힙니다.
- prefix는 위치 + 화면 + 슬롯으로 일관되게 짠다. 나중에 분석 단계에서 "list", "detail", "result" 같은 구간 키워드로 묶을 수 있습니다.
광고 ID도 빌드 타이밍으로 자동 분기시켰습니다.
const TEST_BANNER_AD_GROUP_ID = "ait-ad-test-banner-id";
const LIVE_BANNER_AD_GROUP_ID = "ait.v2.live.41acbf8ab4814edf";
const DEFAULT_BANNER_AD_GROUP_ID =
process.env.npm_lifecycle_event === "build" ? LIVE_BANNER_AD_GROUP_ID : TEST_BANNER_AD_GROUP_ID;
pnpm dev로 실행하면 테스트 ID, pnpm build로 번들을 만들면 라이브 ID가 자동으로 들어갑니다. .env로 덮어쓸 수도 있게 두었지만, 기본값이 빌드 명령과 맞춰져 있어야 "테스트 ID로 라이브 빌드"가 발생할 가능성이 줄어듭니다.
7. 투표 결과는 화면 전체가 아니라 "그 질문만" 갈아끼웁니다#
투표를 하면 서버는 그 질문에 대한 새 통계를 돌려줍니다. 가장 단순한 처리는 "게임 상세를 다시 GET 하기"입니다. 하지만 그러면 응답을 기다리는 동안 결과 카드가 깜빡입니다. 그래서 부분 머지를 택했습니다.
export const mergeVoteStatsIntoGame = (
game: VersusGameDetail | null,
stats: VersusVoteStatsResponse,
): VersusGameDetail | null => {
if (!game) return game;
return {
...game,
questions: game.questions.map((q) =>
q.id === stats.questionId
? { ...q, options: stats.options }
: q,
),
};
};
같은 게임 안에서 다른 질문의 댓글 카운트나 추천 게임 정보는 보존하면서, 방금 투표한 질문의 옵션만 새 통계로 갈아끼웁니다. 이게 별것 아닌 것 같지만 한 게임이 여러 문항을 가지는 케이스에서 사용자 흐름이 매끄러워졌습니다.
댓글 카운트도 비슷합니다. 댓글을 쓰면 서버 응답을 기다리지 않고 즉시 +1, 삭제하면 -1을 합니다. 단 한 가지 예외만 둡니다.
{ ...question, commentCount: Math.max(0, question.commentCount - 1) }
Math.max(0, …)는 단순한 방어 코드처럼 보이지만, 낙관적 업데이트가 두 번 연속 발생할 가능성을 막습니다. 빠른 탭이 두 번 들어와서 카운트가 음수가 되면 UI가 어색해지는데, 이걸 한 줄로 막을 수 있습니다.
8. 같은 백엔드를 쓰지만 미니앱끼리 출처는 분리합니다#
Versus 토스앱은 기존 위글플러스 백엔드와 같은 토큰 시스템을 씁니다. Typefy 토스앱도 마찬가지입니다. 그렇다고 두 미니앱이 같은 출처로 보이면 안 됩니다. 통계가 섞이고, 향후 미니앱별로 정책이 다른 기능(쿠폰, 캠페인, 알림)을 만들 때 추적이 어려워집니다.
그래서 토스 인가 코드를 백엔드로 보낼 때 service 식별자를 같이 보냅니다.
const TOSS_LOGIN_SERVICE = "versus";
return fetchJson<AuthSession>("/api/v1/auth/toss/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ authorizationCode, referrer, service: TOSS_LOGIN_SERVICE }),
});
상수 한 줄짜리지만, 이 한 줄이 백엔드 쪽 분석 쿼리, Sentry 태그, 향후 미니앱별 권한 정책의 진입점이 됩니다. **"같은 토큰을 발급받지만, 출처는 다르다"**라는 모델은 미니앱이 늘어날수록 가치가 커집니다.
부수적으로 Sentry 초기화에도 같은 의도가 들어가 있습니다.
Sentry.withScope((scope) => {
scope.setTag("service", "versus-service-tossapp");
scope.setTag("runtime", "apps-in-toss");
// ...
});
service와 runtime 두 개의 태그를 박는 이유는, 같은 versus라도 웹/인앱이 별개의 환경이기 때문입니다. 한 화면에서 두 환경의 에러를 동시에 보면 분류가 어렵고, 한 환경의 에러만 보면 비교가 어렵습니다. 두 태그가 동시에 붙어 있으면 둘 다 가능합니다.
정리하면, 이번 작업은 코드의 결을 바꾸는 일에 가까웠습니다#
기능 목록만 보면 Versus 웹과 인앱은 비슷합니다. 게임 목록, 상세, 투표, 댓글, AI 의견. 그런데 코드를 들여다보면 거의 모든 함수가 한 번씩 다듬어졌습니다.
- 사용자 한 번의 탭은 PendingVote와 effectiveVote로 두 종류의 vote가 합쳐졌습니다.
- 인증은
authorizedJson한 함수에 401 갱신과 영구 만료 분기가 묶였습니다. - fetch는 RequestInit을 정규화하고 두 단계 로깅을 거쳐 RN 환경에서도 안정적으로 동작합니다.
- 라우팅은 단일 라우트 안에서 useState 트리와
useBackEvent우선순위로 모달처럼 움직입니다. - InlineAd는 위치별 prefix로 다섯 가지 라이프사이클 이벤트를 모두 남깁니다.
- 결과 갱신은 게임 전체가 아니라 그 질문만 부분 머지합니다.
- 백엔드는 같지만, 출처는 service 식별자와 Sentry 태그로 분리됩니다.
각 디테일은 별것 아니어 보이지만, 일곱 가지가 함께 있을 때 인앱에서의 체감이 달라집니다. "잘 동작하는 미니앱"과 "한 번도 끊기지 않는 미니앱"의 차이는 큰 결정 하나가 아니라, 이런 작은 결정 일곱 개에서 나옵니다.
다음 단계는 데이터입니다. 위에서 깔아둔 logPrefix와 service 태그를 토대로, "어디서 사용자가 떨어지는가"를 노출 단위로 보려고 합니다. 미투표 → 투표 전환율, 광고 위치별 노출/클릭, 401 갱신 빈도, 서비스별 에러 비율. 이 데이터가 모이면 다음 미니앱은 더 빨리 만들 수 있을 거라고 보고 있습니다.
비슷한 작업을 하고 있다면, 글에서 본 패턴 중 어느 한 가지라도 그대로 가져다 쓰셔도 좋습니다. 특히 PendingVote 패턴과 authorizedJson 401 갱신 묶음은 어떤 미니앱이든 도움이 될 가능성이 높습니다.
지금 바로 Versus 미니앱을 토스 안에서 열어볼 수 있습니다. 앱을 따로 설치하지 않아도 토스에서 바로 쓸 수 있습니다. https://minion.toss.im/jLcxWtD6
WegglePlus의 다른 서비스도 보고 싶다면#
Typefy - 나를 발견하다: MBTI·여행 DNA·직업 성향 등 다양한 무료 심리 테스트로 나 자신을 탐색해 보세요. Nomad's - 세상을 탐색하다: 전 세계 디지털 노마드들이 추천하는 지역 리뷰와 실제 체류 경험을 확인해 보세요. Nomad'With - 일상을 나누다: 디지털 노마드들의 여행, 원격 근무, 한달살기 이야기를 피드에서 만나보세요. JobSkill - 역량을 키우다: 현직자가 직접 진행하는 직무 스킬 세미나로 실전 커리어 경쟁력을 높여보세요. ThingsInThing - 미디어 속 숨겨진 것들을 발견하세요: 책, TV 프로그램 속에 등장하는 숨겨진 것들을 발견하고 탐색하세요. staglit - 무대 위의 모든 순간: 국내외 공연과 페스티벌 정보를 한 곳에서 확인하세요. 별짓 - 오늘의 운명을 읽다: 별자리 일간 운세로 매일의 흐름을 확인하세요. 셀링페이퍼 - 독후감으로 수익 만들기: 책을 읽고 독후감을 작성하세요. 다른 사람이 내 독후감을 통해 책을 구매하면 수수료를 받을 수 있습니다. WordMakers - 단어를 문장으로 엮다: 랜덤 단어를 받아 한국어, 영어, 일본어 문장을 만들고 커뮤니티 반응으로 톤을 검증하세요.
비슷한 고민을 하고 계시거나 같이 실험해보고 싶은 분은 커피챗 하고 싶습니다 ☕️ 📩 ksy90101@gmail.com
함께 읽으면 좋은 글
랜딩페이지나 MVP를 빠르게 만들어야 하나요?
기획, 디자인, 개발을 한 흐름으로 정리해 실제 운영 가능한 결과물까지 빠르게 연결합니다.
강의, 특강, 멘토링이 필요한 팀과 조직을 위한 페이지
실무 경험을 바탕으로 주제 정리부터 진행 방식 제안까지 함께 연결합니다.
댓글
0의견을 남겨보세요. 로그인하면 닉네임이 자동으로 입력됩니다.
댓글을 불러오는 중...