
데스크톱 웹, 모바일 웹, 네이티브 앱 WebView — 같은 "결제하기"인데 내부 동작은 전부 다릅니다.
이 글은 실제 B2C 서비스에 토스페이먼츠를 통합하면서 마주한 문제들과 해결 과정을 정리한 글입니다.
토스페이먼츠 연동은 공식 문서만 보면 간단해 보입니다. SDK를 로드하고, requestPayment()를 호출하면 끝입니다. 하지만 실제 서비스에 적용하면 곧바로 이런 질문들이 생깁니다.
이 글은 Next.js 14 (App Router) 기반 프로젝트에서 토스페이먼츠의 일반결제(Widgets) 와 브랜드페이(BrandPay) 를 모두 구현하면서, 이 질문들에 대한 답을 찾아간 과정입니다.
먼저 큰 그림부터 잡겠습니다. 사용자가 "결제하기" 버튼을 누르면 아래 흐름이 시작됩니다.
사용자: "결제하기" 클릭
│
▼
결제 타입 선택 (PaymentTypeBottomSheet)
│
├── 일반결제 ──→ GeneralPayBottomSheet
│ │
│ ▼
│ ┌─ 플랫폼 감지 ───────────────────────┐
│ │ │
│ │ 데스크톱? → Promise 방식 │
│ │ 모바일 웹? → Redirect + Draft 방식 │
│ │ 앱 WebView? → Modal + Polling 방식 │
│ └──────────────────────────────────────┘
│
└── 간편결제(BrandPay) ──→ PayBottomSheet
│
├─ 첫 사용? → 약관 동의 → 토큰 발급
│
├─ 결제수단 없음? → 카드 등록 (SDK)
│
└─ 결제수단 선택 → requestPayment()
│
▼
paymentKey 수신 → 주문 API 호출
결제 실패/취소 후:
/?paymentResume=<orderId>
│
▼
Draft 복원 (storage에서 폼 데이터 로드)
│
▼
지도·경로·금액·예약일·입력값 전부 되살림
│
▼
사용자가 다시 "결제하기" 클릭 → 새 orderId로 재시도
같은 결제 버튼이지만, 결제 타입과 실행 환경에 따라 완전히 다른 전략을 사용합니다. 왜 이렇게 해야 하는지는 각 섹션에서 자세히 설명하겠습니다.
토스페이먼츠의 Widgets SDK는 결제 수단 선택 UI와 약관 동의 UI를 자동으로 만들어 줍니다. 개발자는 SDK를 로드하고, DOM에 그릴 위치(selector)만 지정하면 됩니다.
# .env.development
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxx # 클라이언트 키 (브라우저 노출 OK)
TOSS_CLIENT_SECRET=test_sk_xxxxxxxxxxxx # 시크릿 키 (서버 전용, 절대 노출 금지)
NEXT_PUBLIC_ 접두사가 붙은 키만 브라우저에서 접근 가능합니다. 시크릿 키는 반드시 서버(API Route)에서만 사용해야 합니다.
import { ANONYMOUS, loadTossPayments } from '@tosspayments/tosspayments-sdk';
useEffect(() => {
if (!clientKey || !isOpen) return;
let cancelled = false;
(async () => {
// 1단계: SDK 로드
const tossPayments = await loadTossPayments(clientKey);
if (cancelled) return;
// 2단계: Widgets 인스턴스 생성
// - 회원이면 customerKey 전달, 비회원이면 ANONYMOUS
const widgets = tossPayments.widgets(
customerKey ? { customerKey } : { customerKey: ANONYMOUS }
);
setWidgets(widgets);
})();
return () => { cancelled = true; };
}, [clientKey, customerKey, isOpen]);
ANONYMOUS란?
비회원(비로그인) 결제 시 사용하는 토스 SDK의 특수 상수입니다. 회원 결제라면 서버에서 발급한 고유customerKey를 전달합니다.
위젯 인스턴스가 준비되면, 금액을 설정하고 결제 수단 UI를 렌더링합니다.
useEffect(() => {
if (!widgets || !isOpen) return;
let cancelled = false;
(async () => {
// 결제 금액 설정
await widgets.setAmount({ currency: 'KRW', value: amount });
// 결제 수단 선택 UI + 약관 동의 UI를 동시에 렌더링
await Promise.all([
widgets.renderPaymentMethods({
selector: '#general-payment-method',
variantKey: 'DEFAULT',
}),
widgets.renderAgreement({
selector: '#general-agreement',
variantKey: 'AGREEMENT',
}),
]);
if (!cancelled) setReady(true);
})();
return () => { cancelled = true; };
}, [widgets, isOpen, amount]);
JSX에서는 빈 div 두 개만 준비하면 됩니다. 토스 SDK가 알아서 내부를 채워줍니다.
<div className="space-y-4">
<div id="general-payment-method" /> {/* 카드/계좌 선택 UI가 여기에 */}
<div id="general-agreement" /> {/* 약관 동의 UI가 여기에 */}
</div>
여기까지는 공식 문서와 거의 동일합니다. 진짜 문제는 "결제하기"를 누른 이후부터 시작됩니다.
토스페이먼츠 SDK의 widgets.requestPayment()는 실행 환경에 따라 동작이 달라집니다.
| 환경 | requestPayment() 동작 |
|---|---|
| 데스크톱 웹 | Promise를 반환 → paymentKey를 바로 받을 수 있음 |
| 모바일 웹 | 토스 결제 페이지로 리다이렉트 → 현재 페이지 상태 소실 |
| 앱 WebView | 리다이렉트가 WebView 안에서 일어남 → 예측 불가능한 동작 |
이 차이 때문에 하나의 handleRequestPayment 함수 안에서 3가지 분기를 처리해야 합니다.
먼저 현재 환경이 무엇인지 정확히 판단해야 합니다.
// iPadOS는 User-Agent에 'iPad'가 나오지 않아서 별도 감지가 필요합니다.
const isIpadOS = () => {
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
};
// 데스크톱 판별: iPadOS와 Android를 제외하고, Win/Mac/Linux/CrOS만 허용
const isDesktopPlatform = () => {
if (isIpadOS()) return false;
if (/Android/i.test(navigator.userAgent)) return false;
return /Win|Mac|Linux|CrOS/i.test(navigator.platform);
};
앱 WebView 감지는 네이티브에서 주입하는 커스텀 User-Agent를 확인합니다.
// 네이티브 앱이 WebView에 주입하는 UA 문자열
const IOS_UA = 'CURV/1.0.0 (iOS; WebView)';
const AOS_UA = 'CURV/1.0.0 (Android; WebView)';
export function isCurvAppWebView() {
const ua = navigator.userAgent || '';
return ua.includes(IOS_UA) || ua.includes(AOS_UA);
}
가장 간단합니다. requestPayment()가 Promise를 반환하므로 결과를 그 자리에서 받아 처리합니다.
const handleRequestPayment = async () => {
const desktop = isDesktopPlatform();
if (desktop) {
if (!widgets) return;
// 위젯이 history를 쌓기 전 길이 스냅샷
const beforeLen = captureHistoryLengthForPaymentWidget();
try {
const res = await widgets.requestPayment({
orderId: formBody.orderId,
orderName: formBody.orderId,
});
if (res?.paymentKey) {
order({ ...formBody, paymentKey: res.paymentKey });
} else if (trimHistoryAfterPaymentWidget(beforeLen) > 0) {
router.refresh(); // 이상 종료 후 history 정리됨
}
} catch (err) {
trimHistoryAfterPaymentWidget(beforeLen);
showToast(err instanceof Error ? err.message : '결제 중 오류가 발생했어요.');
}
return;
}
// ...모바일/앱 처리
};
핵심: 페이지 이동이 없으므로 React 상태가 유지됩니다. formBody를 별도로 저장할 필요가 없습니다.
왜
captureHistoryLengthForPaymentWidget가 필요한가?토스 위젯은 결제창 내부 단계(카드 선택 → OTP → 완료)마다
history.pushState()를 쌓습니다. 결제가 정상 완료되면 문제없지만, 취소하거나 오류가 나면 이 항목들이 세션 히스토리에 그대로 남습니다. 이후 사용자가 뒤로가기를 눌렀을 때 결제 위젯의 빈 상태로 이동하는 현상이 발생합니다.결제 직전
history.length를 기록해두고, 종료 후 delta를 계산하여history.go(-delta)로 되감습니다. 단, 8 이상의 큰 delta는 처리하지 않아 의도치 않은 뒤로가기를 방지합니다.
// utils/trimPaymentWidgetHistory.ts
const MAX_TRIM_DELTA = 8;
export function captureHistoryLengthForPaymentWidget(): number | null {
if (typeof window === 'undefined') return null;
try { return window.history.length; } catch { return null; }
}
export function trimHistoryAfterPaymentWidget(beforeLen: number | null): number {
if (beforeLen == null || typeof window === 'undefined') return 0;
const delta = window.history.length - beforeLen;
if (delta <= 0 || delta > MAX_TRIM_DELTA) return 0;
try { window.history.go(-delta); } catch { return 0; }
return delta;
}
모바일에서는 requestPayment() 호출 시 브라우저가 통째로 토스 결제 페이지로 이동합니다. 결제가 끝나면 successUrl 또는 failUrl로 돌아옵니다.
문제: 페이지를 떠나는 순간 React 상태(사용자가 입력한 폼 데이터)가 전부 사라집니다.
해결: 결제 요청 직전에 폼 데이터를 브라우저 Storage에 저장(Draft) 해둡니다. 동시에 "결제 리다이렉트 진행 중"임을 알리는 플래그도 기록합니다.
const origin = window.location.origin;
// 1. 폼 데이터를 Storage에 임시 저장
saveGeneralPayWebDraft(formBody.orderId, formBody);
// 2. 뒤로가기/제스처 복귀 감지를 위한 플래그 저장
// - success/fail 페이지에 정상 도달하면 이 플래그가 제거됨
// - 제거되지 않은 채 홈으로 돌아오면 "중단된 결제"로 간주
paymentRedirectingRef.current = true;
try { sessionStorage.setItem('payment:redirecting', formBody.orderId); } catch {}
// 3. 토스 결제 페이지로 이동 (이 시점에 현재 페이지는 사라짐)
await widgets.requestPayment({
orderId: formBody.orderId,
orderName: formBody.orderId,
successUrl: `${origin}/payments/general/success?orderId=${formBody.orderId}`,
failUrl: `${origin}/payments/general/fail?orderId=${formBody.orderId}`,
});
결제 성공 후 successUrl로 돌아오면, 저장해뒀던 Draft를 복원하여 주문을 생성합니다. (5장에서 자세히 설명)
앱 안의 WebView에서는 토스 SDK 리다이렉트가 예측 불가능하게 동작합니다. 메인 WebView의 URL이 바뀌면 앱이 이를 가로채거나, 뒤로 가기 스택이 꼬이는 문제가 생깁니다.
해결: 메인 WebView는 그대로 두고, 새로운 모달 WebView를 열어서 결제를 진행합니다.
useEffect(() => {
if (!appWebView || !isOpen) return;
const orderId = formBody?.orderId;
// 1. 모달 WebView에서 읽을 수 있도록 결제 정보를 localStorage에 저장
const draft = { orderId, formBody, amount, clientKey, customerKey };
saveGeneralPayDraft(orderId, draft);
// 2. 네이티브 앱에게 "이 URL로 모달 WebView를 열어달라"고 요청
const url = `${origin}/payments/general/checkout?orderId=${orderId}&modal=1`;
openAppModalWebView({ url, title: '일반결제' });
// 3. 결과 폴링 시작 (모달이 결과를 localStorage에 쓸 때까지 대기)
setPendingModalOrderId(orderId);
// 4. 현재 바텀시트는 닫기 (모달이 대신 결제 UI를 보여줌)
onClose();
}, [appWebView, isOpen]);
모달 WebView와 부모 WebView는 직접 JS 통신이 불가능합니다. 대신 같은 도메인의 localStorage를 공유하는 특성을 활용하여, 300ms 간격으로 결과를 확인(polling)합니다. 폴링은 최대 5분 후 자동 종료됩니다.
useEffect(() => {
if (!pendingModalOrderId) return;
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5분 타임아웃
const startedAt = Date.now();
const tick = () => {
// 앱 강제 종료 등으로 모달 응답이 영원히 오지 않는 경우를 방어
if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
setPendingModalOrderId(null);
showToast('결제 응답 대기 시간이 초과되었어요. 주문 내역을 확인해 주세요.');
return;
}
const result = popGeneralPayResult(pendingModalOrderId);
if (result?.status === 'SUCCESS') {
window.location.href = `/mypage/service/detail/${result.detailId}`;
return;
}
if (result?.status === 'FAIL') {
showToast(result.message || '결제가 취소되었거나 실패했습니다.');
return;
}
pollRef.current = window.setTimeout(tick, 300);
};
pollRef.current = window.setTimeout(tick, 300);
return () => { if (pollRef.current) window.clearTimeout(pollRef.current); };
}, [pendingModalOrderId]);
전체 흐름을 정리하면 이렇습니다.
부모 WebView 모달 WebView
────────── ──────────
localStorage에 draft 저장
│
네이티브에게 모달 열기 요청 ────→ /payments/general/checkout 로드
│ │
결과 polling 시작 (최대 5분) localStorage에서 draft 읽기
│ │
(300ms 간격 반복) 토스 위젯 렌더링 + 결제
│ │
│ 결제 완료 → success 페이지
│ │
│ 주문 API 호출
│ │
│ 결과를 localStorage에 저장
│ │
결과 감지! ←───────────────────── 모달 자동 닫기
│
상세 페이지로 이동
결제 연동에서 가장 까다로운 부분 중 하나가 "리다이렉트 간 상태 보존" 입니다. 사용자가 정성껏 입력한 폼 데이터가 결제 리다이렉트 한 번에 증발하면 안 됩니다.
이를 위해 환경에 맞는 Draft Store를 만들었습니다.
Draft Store는 용도에 따라 두 개의 독립된 모듈로 분리되어 있습니다.
| 모듈 | 대상 | Storage | 키 패턴 |
|---|---|---|---|
generalPayWebDraft.ts | 모바일 웹 리다이렉트 | sessionStorage + localStorage 동시 쓰기 | order:formBody:<orderId> |
generalPayDraftStore.ts | 앱 WebView 모달 Draft + 결과 전달 | 앱→localStorage, 웹→sessionStorage | generalpay:draft:<orderId> |
두 모듈을 분리한 이유가 있습니다. 모바일 웹 리다이렉트는 "동일 탭에서 URL이 바뀌는 것" 이므로 sessionStorage가 살아남습니다. 반면 앱 모달 WebView는 부모 WebView와 별도 탭(컨텍스트)이라 sessionStorage를 공유하지 못합니다. localStorage만 공유 가능합니다.
// generalPayDraftStore.ts — 앱 모달 전용, Storage 분기
function getDraftStorage(): Storage | null {
if (typeof window === 'undefined') return null;
if (isCurvAppWebView()) return localStorage; // 앱 모달 → localStorage
return sessionStorage; // 웹 → sessionStorage
}
// generalPayWebDraft.ts — 모바일 웹 전용, 양쪽 동시 쓰기
export function saveGeneralPayWebDraft<T>(orderId: string, body: T) {
if (typeof window === 'undefined') return;
const wrap = JSON.stringify({ v: 1, ts: Date.now(), body });
// 1) sessionStorage 우선 (같은 탭 리다이렉트는 살아남음)
try { sessionStorage.setItem(`order:formBody:${orderId}`, wrap); } catch {}
// 2) localStorage 폴백 (일부 브라우저 대비)
try { localStorage.setItem(`order:formBody:${orderId}`, wrap); } catch {}
}
왜 둘 다 쓰나요? 특정 iOS 환경에서 리다이렉트 후 sessionStorage가 비워지는 엣지 케이스가 있었습니다. 동일 키로 양쪽에 저장해두고 읽을 때 sessionStorage → localStorage 순으로 시도합니다.
Draft는 영원히 남아있으면 안 됩니다. 다만 TTL이 너무 짧으면 카드 한도 확인 → 은행 앱 이동 → 한도 변경 → 복귀 같은 실제 사용자 시나리오에서 Draft가 이미 만료되어 복원에 실패합니다. 2시간을 기준으로 합니다.
두 모듈은 구조가 조금 다릅니다.
// generalPayWebDraft.ts — 저장 시각(ts) 기반 경과 시간 비교
type DraftWrap<T> = { v: 1; ts: number; body: T };
const MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2시간
// 읽을 때: Date.now() - parsed.ts > MAX_AGE_MS 이면 만료
// generalPayDraftStore.ts — 절대 만료 시각(expiresAt) 비교
type DraftEnvelope<T> = {
v: 1;
savedAt: number; // 저장 시각
expiresAt: number; // 만료 시각 (savedAt + TTL)
data: T;
};
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; // 2시간
export function saveGeneralPayDraft<T>(orderId: string, data: T, ttlMs = DEFAULT_TTL_MS) {
const storage = getDraftStorage();
if (!storage) return;
const now = Date.now();
try {
storage.setItem(
`generalpay:draft:${orderId}`,
JSON.stringify({ v: 1, savedAt: now, expiresAt: now + ttlMs, data })
);
} catch {}
}
읽을 때는 만료 여부를 반드시 확인합니다. 만료된 Draft는 삭제하고 null을 반환합니다.
// generalPayDraftStore.ts
export function loadGeneralPayDraft<T>(orderId: string): T | null {
const storage = getDraftStorage();
if (!storage) return null;
try {
const raw = storage.getItem(`generalpay:draft:${orderId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as DraftEnvelope<T>;
if (!parsed?.data) return null;
if (typeof parsed.expiresAt === 'number' && parsed.expiresAt < Date.now()) {
storage.removeItem(`generalpay:draft:${orderId}`);
return null;
}
return parsed.data;
} catch {
return null;
}
}
앱 WebView의 모달 → 부모 간 결과 전달에는 "쓰기 한 번, 읽기 한 번(Pop)" 패턴을 사용합니다. 읽는 즉시 삭제하여 중복 처리를 방지합니다.
export type GeneralPayResult =
| { status: 'SUCCESS'; detailId: string }
| { status: 'FAIL'; code?: string | null; message?: string | null };
// 모달이 결과를 씀
export function setGeneralPayResult(orderId: string, result: GeneralPayResult) {
localStorage.setItem(
`generalpay:result:${orderId}`,
JSON.stringify({ v: 1, at: Date.now(), result })
);
}
// 부모가 결과를 읽음 (읽으면 즉시 삭제)
export function popGeneralPayResult(orderId: string): GeneralPayResult | null {
const key = `generalpay:result:${orderId}`;
const raw = localStorage.getItem(key);
if (!raw) return null;
localStorage.removeItem(key); // Pop! 한 번 읽으면 사라짐
return JSON.parse(raw)?.result ?? null;
}
모바일 웹과 앱 WebView에서 토스 결제 완료 후 리다이렉트되는 페이지입니다.
/payments/general/success)이 페이지의 역할은 단순합니다: Draft를 복원하고, paymentKey와 합쳐서 주문을 생성합니다.
Success 페이지는 useEffect 안에서 주문 API를 호출합니다. React Strict Mode에서 effect가 두 번 실행되거나, 사용자가 F5를 눌러 페이지를 새로고침하면 동일한 paymentKey로 주문 생성 요청이 중복 발생할 수 있습니다. useRef 플래그 + sessionStorage를 조합한 이중 가드로 막습니다.
export default function GeneralPaySuccessPage() {
const orderId = searchParams.get('orderId');
const paymentKey = searchParams.get('paymentKey');
const orderInitiatedRef = useRef(false); // React effect 이중 실행 방어
// 성공/실패 페이지에 도달하면 payment:redirecting 플래그 정리
useEffect(() => {
try { sessionStorage.removeItem('payment:redirecting'); } catch {}
}, []);
useEffect(() => {
// (1) React Strict Mode / 빠른 재렌더링 방어
if (orderInitiatedRef.current) return;
if (!orderId || !paymentKey) {
goFail({ reason: 'MISSING_QUERY', message: '결제 성공 정보가 부족합니다.' });
return;
}
// (2) F5 새로고침 방어 — sessionStorage에 동일 paymentKey가 있으면 중복 요청
const deduplicateKey = `order:registering:${orderId}`;
try {
if (sessionStorage.getItem(deduplicateKey) === paymentKey) return;
} catch {}
// Draft 복원
let formBody = loadGeneralPayWebDraft(orderId);
if (!formBody) {
const modalDraft = loadGeneralPayDraft(orderId);
formBody = modalDraft?.formBody ?? null;
}
if (!formBody) {
goFail({ reason: 'FORM_BODY_MISSING', message: '예약 정보가 만료되었어요.' });
return;
}
orderInitiatedRef.current = true;
try { sessionStorage.setItem(deduplicateKey, paymentKey); } catch {}
order({ ...formBody, paymentKey });
}, [orderId, paymentKey, order, goFail]);
}
onSuccess에서는 Draft를 정리하고 중복 방지 키도 함께 제거합니다.
onSuccess: (res) => {
if (orderId) {
try { sessionStorage.removeItem(`order:registering:${orderId}`); } catch {}
clearGeneralPayWebDraft(orderId);
clearGeneralPayDraft(orderId);
}
if (modal && appWebView && orderId) {
setGeneralPayResult(orderId, { status: 'SUCCESS', detailId: String(res.data) });
closeSelfAppModalWebView();
return;
}
router.replace(`/mypage/service/detail/${res.data}`);
},
onError에서는 중복 방지 가드를 해제하여 사용자가 재시도할 수 있게 합니다.
onError: (err) => {
// 가드 해제 — fail 페이지에서 재시도 가능하도록
orderInitiatedRef.current = false;
try { sessionStorage.removeItem(`order:registering:${orderId}`); } catch {}
goFail({ reason: 'ORDER_CREATE_FAILED', message: err?.response?.data?.message });
},
/payments/general/fail)실패 페이지는 사용자 취소와 실제 오류를 구분하여 다른 메시지를 보여줍니다.
// 에러 코드 또는 메시지에 'CANCEL', '취소'가 포함되어 있으면 사용자 취소로 판단
const isUserCancel =
code.toUpperCase().includes('CANCEL') ||
message.includes('취소');
| 상황 | 보여주는 메시지 |
|---|---|
| 사용자 취소 | "결제가 취소되었어요" |
| 결제 오류 | "결제를 완료할 수 없었어요" + 에러 코드/사유 |
Fail 페이지 진입 시에도 payment:redirecting 플래그를 정리합니다. 정상적으로 실패/취소 경로를 타고 왔으므로 뒤로가기 복원이 필요 없습니다.
// 실패 페이지 마운트 시 payment:redirecting 정리
useEffect(() => {
try { sessionStorage.removeItem('payment:redirecting'); } catch {}
}, []);
앱 모달에서 fail로 왔다면, 결과를 localStorage에 저장하고 모달을 자동으로 닫습니다.
useEffect(() => {
if (!modal || !appWebView) return;
setGeneralPayResult(orderId, { status: 'FAIL', code: errorCode, message: errorMessage });
clearGeneralPayDraft(orderId);
setTimeout(() => closeSelfAppModalWebView(), 50);
}, [modal, appWebView]);
결제가 실패하면 단순히 fail 페이지를 보여주는 것으로 끝이 아닙니다. 사용자가 "이어서 결제하기"를 눌렀을 때, 이전에 입력했던 경로·서비스 타입·예약일·차량 정보·금액을 그대로 복원해야 불편함이 없습니다.
Fail 페이지에서 "이어서 결제하기"를 누르면 홈으로 리다이렉트하되, orderId를 쿼리 파라미터로 넘깁니다.
// fail/page.tsx
const goHome = () => {
if (main.orderId) {
router.replace(`/?paymentResume=${encodeURIComponent(main.orderId)}`);
return;
}
router.replace('/');
};
홈 페이지(page.tsx)는 마운트 시 paymentResume 파라미터를 감지하고, 해당 orderId로 Draft를 복원합니다.
const paymentResumeOrderId = searchParams.get('paymentResume')?.trim() ?? '';
useLayoutEffect(() => {
if (!paymentResumeOrderId) { /* ... */ return; }
const body = loadGeneralPayWebDraft<ReservationFormBody>(paymentResumeOrderId);
if (!body) {
showToast('저장된 예약 정보가 없어요. 경로를 다시 입력해 주세요.');
return;
}
const restored = restoreReservationUiFromFormBody(body);
// 복원된 값으로 모든 상태를 한 번에 세팅
resetCallerForm(restored.callerForm);
setStartCoord(restored.startCoord);
setEndCoord(restored.endCoord);
setReservedAt(restored.reservedAt);
setSelectedServiceType(restored.selectedServiceType);
setServicePriceMap(restored.servicePriceMap);
setOrderAmountVal(restored.orderAmountVal);
// ... 기타 상태들
}, [paymentResumeOrderId]);
여기서 심각한 문제가 발생합니다. 상태를 복원할 때 setSelectedServiceType, setStartCoord 등을 한꺼번에 호출하면, 이 상태들을 deps로 가진 수십 개의 useEffect가 동시에 깨어납니다. 그 중에는 이런 effect도 있습니다.
// 서비스 타입 변경 시 폼을 초기화하는 effect
useEffect(() => {
resetCallerForm(defaultValues); // ← 방금 복원한 차량명·메모를 덮어씀!
setReservedAt(null); // ← 방금 복원한 예약일시를 날림!
}, [selectedServiceType]);
복원 중에 이 effect가 실행되면 복원된 값이 모두 날아갑니다. 이를 막기 위해 "복원 진행 중" 플래그를 사용합니다.
const isRestoringPaymentRef = useRef(false);
// 복원 시작 직전에 플래그 ON
isRestoringPaymentRef.current = true;
resetCallerForm(restored.callerForm);
setSelectedServiceType(restored.selectedServiceType);
// ... 모든 상태 복원
// 모든 상태 세팅 완료 후, 현재 렌더 사이클의 effect들이 끝나면 플래그 OFF
// setTimeout(0)은 React batch가 flush된 이후에 실행되므로
// 동일 사이클에서 트리거된 모든 effect가 스킵된 후 해제됨
window.setTimeout(() => {
isRestoringPaymentRef.current = false;
setRestoreCompleteVersion(v => v + 1); // 복원 완료 알림
}, 0);
플래그를 확인하는 effect들:
// 서비스 타입 변경 effect — 복원 중이면 초기화 스킵
useEffect(() => {
if (isRestoringPaymentRef.current) return;
resetCallerForm(defaultValues);
setReservedAt(null);
}, [selectedServiceType]);
// 경로 변경 시 요금 리셋 effect — 복원 중이면 복원된 금액 유지
useEffect(() => {
if (isRestoringPaymentRef.current) return;
setServicePriceMap({ pickup: null, carrier: null, daeri: null });
setOrderAmountVal(null);
}, [startCoord, endCoord, viaSig, roundTrip]);
// 왕복/경유 변경 시 거리 재계산 — 복원 중이면 불필요한 API 호출 스킵
useEffect(() => {
if (isRestoringPaymentRef.current) return;
handleRecalcTotalDistance();
}, [roundTrip]);
복원 시 routeData를 null로 세팅합니다 (경로 API 응답 객체를 저장하지 않으므로). 그러면 요금 계산 effect가 routeData === null을 보고 실행을 건너뜁니다.
useEffect(() => {
if (routePriceVersion === 0) return;
if (totalKm === null || !routeData) return; // ← routeData가 null이면 스킵됨
// 요금 API 호출...
}, [routePriceVersion, totalKm, routeData]);
결과적으로 선택한 서비스의 금액은 복원되지만, 나머지 두 서비스의 요금은 "요금 계산 중"으로 남습니다. 이를 해소하기 위해 복원이 완전히 끝난 직후 거리+요금을 새로 계산하도록 트리거합니다.
const [restoreCompleteVersion, setRestoreCompleteVersion] = useState(0);
// 복원 완료 후 routeData가 없으면 Kakao API로 거리 재계산
// → recalcTotalDistance가 routeData를 채우고 routePriceVersion을 올림
// → 요금 API가 다시 실행되어 세 서비스 모두 金額 채워짐
useEffect(() => {
if (isRestoringPaymentRef.current) return;
if (restoreCompleteVersion === 0) return; // 최초 마운트에서는 실행 안 함
if (routeData !== null) return; // 이미 routeData 있으면 불필요
if (!startCoord || !endCoord) return;
if (!ready || totalKm === null) return;
void handleRecalcTotalDistance();
}, [restoreCompleteVersion]);
이 흐름을 정리하면 이렇습니다:
복원 완료 (`isRestoringPaymentRef = false`, `restoreCompleteVersion += 1`)
│
▼
"복원 후 재계산" effect 트리거
│
▼
handleRecalcTotalDistance() → Kakao API → routeData 세팅
│
▼
setRoutePriceVersion(v + 1)
│
▼
요금 조회 effect 트리거 → 3개 서비스 동시 요금 API 호출
│
▼
servicePriceMap 전체 채워짐 → "요금 계산 중" 해소
은행 앱 페이지에서 취소 버튼을 누르면 정상적으로 fail 페이지를 거쳐 복원이 됩니다. 문제는 브라우저 뒤로가기 동작(스와이프 제스처 포함) 을 사용할 때입니다.
이 경우 두 가지 시나리오가 있습니다.
Safari를 비롯한 최신 브라우저는 이전 페이지를 메모리에 그대로 얼려뒀다가 복원합니다(Back-Forward Cache). 이 경우 React 컴포넌트가 재마운트되지 않고, 토스 위젯 인스턴스만 stale(무효) 상태가 됩니다.
pageshow 이벤트의 persisted 플래그로 bfcache 복원을 감지합니다.
// GeneralPayBottomSheet.tsx
const paymentRedirectingRef = useRef(false);
const handleBfcacheRestore = useCallback((e: PageTransitionEvent) => {
if (!e.persisted) return; // bfcache 복원이 아니면 스킵
if (!paymentRedirectingRef.current) return; // 결제 중이 아니었으면 스킵
paymentRedirectingRef.current = false;
// stale 위젯 제거 + 바텀시트 닫기
setWidgets(null);
setReady(false);
onClose();
showToast('결제가 완료되지 않았어요. 다시 시도해 주세요.');
}, [onClose, showToast]);
useEffect(() => {
window.addEventListener('pageshow', handleBfcacheRestore);
return () => window.removeEventListener('pageshow', handleBfcacheRestore);
}, [handleBfcacheRestore]);
일부 브라우저나 상황에서는 뒤로가기 시 페이지가 완전히 새로 로드됩니다. 이 경우 payment:redirecting 플래그가 sessionStorage에 남아있으므로, 홈 진입 시 이를 감지하여 복원 flow를 자동으로 트리거합니다.
// page.tsx — useLayoutEffect
if (!paymentResumeOrderId) {
try {
const pendingOrderId = sessionStorage.getItem('payment:redirecting');
if (pendingOrderId) {
sessionStorage.removeItem('payment:redirecting');
// 기존 paymentResume 복원 flow를 그대로 재사용
router.replace(`/?paymentResume=${encodeURIComponent(pendingOrderId)}`);
}
} catch {}
return;
}
전체 뒤로가기 시나리오를 정리하면:
| 시나리오 | 처리 방법 |
|---|---|
| 정상 결제 성공 | success 페이지에서 플래그 정리. 복원 불필요 |
| 취소 버튼으로 fail 이동 | fail 페이지에서 플래그 정리. paymentResume으로 복원 |
| 뒤로가기 (bfcache) | pageshow + persisted 감지 → 위젯 리셋 + 시트 닫기 |
| 뒤로가기 (비bfcache) | payment:redirecting 고아 플래그 감지 → 자동 복원 |
일반결제가 "매번 카드 번호를 입력하는 결제"라면, 브랜드페이는 한 번 등록한 카드로 빠르게 결제하는 간편결제입니다.
| 일반결제 (Widgets) | 브랜드페이 (BrandPay) | |
|---|---|---|
| 결제 방식 | 매번 카드/계좌 정보 입력 | 미리 등록한 수단으로 원클릭 결제 |
| 사전 준비 | 없음 | 약관 동의 + 토큰 발급 + 카드 등록 |
| SDK | tossPayments.widgets() | tossPayments.brandpay() |
| 결과 수신 | 환경에 따라 Promise/Redirect | 항상 Promise |
| 적합한 상황 | 일회성 결제, 비회원 결제 | 재구매율 높은 서비스, 회원 전용 |
브랜드페이 SDK 인스턴스는 무거우므로, 한 번만 생성하고 재사용해야 합니다. useBrandPay 훅에서 싱글턴 패턴으로 관리합니다.
export function useBrandPay(opts: {
clientKey: string;
customerKey: string;
redirectPath?: string; // 기본값: '/api/brandpay/auth/callback'
}) {
const { clientKey, customerKey, redirectPath = '/api/brandpay/auth/callback' } = opts;
const router = useRouter();
const ref = useRef<any>(null);
const pendingRef = useRef<Promise<any>>(null);
// redirectUrl은 SSR 중에는 window가 없으므로 useMemo로 지연 계산
const redirectUrl = useMemo(() => {
if (typeof window === 'undefined') return '';
return `${window.location.origin}${redirectPath}`;
}, [redirectPath]);
const ensureBrandpay = useCallback(async () => {
if (ref.current) return ref.current;
if (pendingRef.current) return pendingRef.current;
const p = (async () => {
const tp = await loadTossPayments(clientKey);
const inst = tp.brandpay({ customerKey, redirectUrl });
ref.current = inst;
return inst;
})();
pendingRef.current = p;
return await p.finally(() => (pendingRef.current = null));
}, [clientKey, customerKey, redirectUrl]);
// ...
}
successPath/failPath같은 미사용 옵션은 인터페이스에 노출하지 않습니다. BrandPay SDK는 Promise 방식으로 결과를 반환하므로 redirect 경로가 필요 없고, 존재하지 않는 경로를 기본값으로 두면 향후 혼란을 초래합니다.
브랜드페이를 처음 사용하는 유저는 약관에 동의해야 합니다.
첫 사용자 흐름:
결제수단 조회 시도
│
├─ 401 또는 DEFINITION_ERROR 응답
│
▼
약관 목록 표시 (필수/선택)
│
▼
"동의" 버튼 클릭 → 백엔드 약관 동의 API → authCode 수신
│
▼
authCode → Access Token 교환 (서버 프록시 경유)
│
▼
결제수단 조회 → 카드 등록 가능 상태
async function agreeAndIssueToken() {
const termsId = terms.filter(t => agreeChecked[t.id]).map(t => t.id);
const r1 = await axios.post(API_URLS.BRAND_PAY.TERMS.URL, {
scope: ['REGISTER', 'CARD'],
termsId,
});
const code = r1.data.data.authCode;
// 시크릿 키가 필요하므로 서버(Next.js API Route)를 거침
await fetch('/api/brandpay/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerKey, code }),
});
await fetchMethods();
}
카드 등록 화면은 토스 SDK가 처리합니다. 다만 등록 후 복귀 시 React 컴포넌트가 재마운트되지 않아 목록이 자동으로 갱신되지 않는 문제가 있습니다.
sessionStorage 플래그 + pageshow/visibilitychange 조합으로 해결합니다.
// useBrandPay.ts — 카드 등록 시작 전에 플래그 세팅
const addPaymentMethod = useCallback(async () => {
return withLock(async () => {
const bp = await ensureBrandpay();
sessionStorage.setItem('bp:add:pending', '1');
try {
await bp.addPaymentMethod();
} catch (e) {
sessionStorage.removeItem('bp:add:pending');
throw e;
}
});
}, [ensureBrandpay, withLock]);
// PayBottomSheet.tsx — 페이지 복귀 시 자동 갱신
useEffect(() => {
const refreshIfPending = async () => {
if (sessionStorage.getItem('bp:add:pending') === '1') {
await fetchMethods();
sessionStorage.removeItem('bp:add:pending');
}
};
window.addEventListener('pageshow', refreshIfPending);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') refreshIfPending();
});
return () => {
window.removeEventListener('pageshow', refreshIfPending);
document.removeEventListener('visibilitychange', refreshIfPending);
};
}, []);
브랜드페이는 항상 Promise 방식으로 결과를 받으므로, 플랫폼 분기 없이 단순하게 처리합니다. 단, BrandPay SDK도 내부적으로 history를 쌓을 수 있으므로 결제 완료/실패 후 history 정리를 해줍니다.
const payWithMethodId = useCallback(
async (params: {
methodId: string;
orderName: string;
formBody: ReservationFormBody;
}) => {
await withLock(async () => {
const beforeLen = captureHistoryLengthForPaymentWidget();
try {
const bp = await ensureBrandpay();
const res = await bp.requestPayment({
amount: { currency: 'KRW', value: formBody.paymentAmount || 0 },
orderId: formBody.orderId,
orderName: params.orderName,
methodId: params.methodId,
});
if (res.paymentKey) {
order({ ...formBody, paymentKey: res.paymentKey });
} else if (trimHistoryAfterPaymentWidget(beforeLen) > 0) {
router.refresh();
}
} catch (e) {
trimHistoryAfterPaymentWidget(beforeLen);
throw e;
}
});
},
[ensureBrandpay, withLock, order, router],
);
사용자: "간편결제" 선택
│
▼
결제수단 조회 → 401/DEFINITION_ERROR → 약관 동의 → 토큰 발급
200 + 카드 없음 → 카드 등록
200 + 카드 있음 → 카드 선택
│
▼
"선택 완료" → payWithMethodId() → bp.requestPayment()
│
▼
paymentKey 수신 → order() → 상세 페이지 이동
앱 안의 WebView에서 네이티브 기능(모달 열기/닫기)을 호출하려면 JS Bridge가 필요합니다.
| 함수 | 누가 호출 | 동작 |
|---|---|---|
openAppModalWebView(url, title) | 부모 WebView | 새 모달 WebView를 지정 URL로 엶 |
closeAppModalWebView() | 부모 WebView | 열려 있는 모달 WebView를 닫음 |
closeSelfAppModalWebView() | 모달 WebView 내부 | 자기 자신(모달)을 닫도록 네이티브에 요청 |
closeAppModalWebView와 closeSelfAppModalWebView를 구분하는 이유: 모달 WebView 내부에서 부모 WebView를 닫는 것은 권한 밖입니다. 자기 자신을 닫는 메시지(TestJsBridge_closeSelfModalWebView)를 사용해야 네이티브가 올바른 WebView를 종료할 수 있습니다.
// 부모 WebView → 모달 열기
export function openAppModalWebView({ url, title = '' }: { url: string; title?: string }): boolean {
if (typeof window === 'undefined') return false;
if (isCurvIosWebView()) {
const handler = (window as any)?.webkit?.messageHandlers?.TestJsBridge_openModalWebView;
if (!handler?.postMessage) return false;
try { handler.postMessage({ url, title }); return true; } catch { return false; }
}
if (isCurvAndroidWebView()) {
const bridge = (window as any)?.TestJsBridge;
if (!bridge?.openModalWebView) return false;
try { bridge.openModalWebView(url, title); return true; } catch { return false; }
}
return false;
}
// 모달 WebView 내부 → 자기 자신 닫기
export function closeSelfAppModalWebView(): boolean {
if (typeof window === 'undefined') return false;
if (isCurvIosWebView()) {
const handler = (window as any)?.webkit?.messageHandlers?.TestJsBridge_closeSelfModalWebView;
if (!handler?.postMessage) return false;
try { handler.postMessage({}); return true; } catch { return false; }
}
if (isCurvAndroidWebView()) {
const bridge = (window as any)?.TestJsBridge;
if (!bridge?.closeSelfModalWebView) return false;
try { bridge.closeSelfModalWebView(); return true; } catch { return false; }
}
return false;
}
설계 포인트: 모든 브릿지 함수는
boolean을 반환합니다. 네이티브 인터페이스가 없는 환경에서 호출해도 에러 없이false를 반환합니다. 호출부에서false를 받으면 에러 토스트를 표시하거나 fallback URL로 이동합니다.
// 앱 모달 열기 실패 시 처리
const ok = openAppModalWebView({ url, title: '일반결제' });
if (!ok) {
showToast('결제 모달을 열 수 없어요. 앱 업데이트가 필요합니다.');
return;
}
결제가 성공하면 주문 상세 페이지로 이동합니다. 이때 뒤로가기 스택을 그냥 두면 이런 문제가 생깁니다.
[홈] → [결제 성공 페이지] → [주문 상세]
↑ 뒤로가기 시 여기로 돌아옴 (불필요)
결제 성공 페이지는 이미 처리가 끝난 중간 단계이므로, 사용자가 상세에서 뒤로가기를 누르면 홈으로 바로 돌아가야 합니다.
// 결제 성공 후 상세 이동 — 쿼리로 진입 경로 표시
router.replace(`/mypage/service/detail/${detailId}?fromPayment=1`);
// constants/paymentNavigation.ts
export const B2C_DETAIL_FROM_PAYMENT_STORAGE_KEY = 'b2c:detailNavFromPayment';
export const B2C_DETAIL_FROM_PAYMENT_SEARCH_PARAM = 'fromPayment';
export function buildMypageOrderDetailHref(detailId: string, opts?: { fromPayment?: boolean }) {
const base = `/mypage/service/detail/${detailId}`;
if (opts?.fromPayment) return `${base}?fromPayment=1`;
return base;
}
상세 페이지는 마운트 시 fromPayment=1 파라미터를 확인합니다. 있으면 sessionStorage에 플래그를 남기고 URL에서 파라미터만 제거(replace)합니다. 이후 뒤로가기 인터셉트 로직(또는 popstate 핸들러)에서 이 플래그를 확인하여 router.replace('/')로 홈으로 보냅니다.
결제 완료 후 히스토리:
before: ... → [홈] → [위젯 히스토리 × n] → [결제 성공 페이지]
after: ... → [홈] → [주문 상세] ← replace 이동
↑ 뒤로가기 시 홈으로 replace
결제 확인처럼 시크릿 키가 필요한 요청은 Next.js Route Handlers를 프록시로 활용합니다.
function basicAuth() {
const sk = process.env.TOSS_CLIENT_SECRET!;
return `Basic ${Buffer.from(`${sk}:`).toString('base64')}`;
}
| 경로 | 메서드 | 하는 일 |
|---|---|---|
/api/brandpay/confirm | POST | 결제 확인 (paymentKey 검증) |
/api/brandpay/methods | GET | 등록된 결제수단 조회 |
/api/brandpay/terms | GET | 약관 목록 조회 |
/api/brandpay/agree | POST | 약관 동의 처리 |
/api/brandpay/token | POST | Authorization Code → Access Token 교환 |
데스크톱 브라우저에서 DevTools 모바일 에뮬레이션을 켜면, 토스 SDK가 모바일로 인식하여 리다이렉트 방식으로 동작합니다. 이를 감지해서 안내 메시지를 띄웁니다.
const isDevtoolsMobileEmulation = () => {
const mm = window.matchMedia;
const narrow = mm('(max-width: 767px)').matches;
const coarse = mm('(pointer: coarse)').matches;
const hoverNone = mm('(hover: none)').matches;
return narrow && coarse && hoverNone;
};
if (isDesktopPlatform() && isNarrowWidth()) {
if (isDevtoolsMobileEmulation()) {
showToast('PC에서 개발자도구 모바일 모드로는 결제할 수 없어요.');
} else {
showToast('PC에서는 작은 화면에서 결제할 수 없어요. 창을 넓힌 뒤 다시 시도해 주세요.');
}
return;
}
useRef 기반 락 패턴으로 requestPayment() 중복 호출을 막습니다.
const lockRef = useRef(false);
const withLock = useCallback(async <T>(fn: () => Promise<T>): Promise<T | undefined> => {
if (lockRef.current) return;
lockRef.current = true;
try {
return await fn();
} finally {
lockRef.current = false;
}
}, []);
토스 BrandPay SDK가 카드 등록/변경 UI를 띄울 때 반투명 dimmer가 나타납니다. 이 dimmer가 떠 있어도 배경이 스크롤되는 문제가 있습니다. MutationObserver로 dimmer DOM을 실시간 감시하여 body에 스크롤 방지 클래스를 토글합니다.
/* globals.css */
body.brandpay-open {
overflow: hidden;
touch-action: none;
}
BFCache 동작으로 React 컴포넌트가 재마운트되지 않아 카드 목록이 갱신되지 않습니다.
sessionStorage 플래그 + pageshow/visibilitychange 조합으로 해결합니다. (7-3장 참고)
은행 앱/토스 결제창에서 취소 버튼 대신 브라우저/제스처 뒤로가기를 사용하면, fail 페이지를 거치지 않고 직접 홈으로 돌아옵니다. 이때 두 가지 케이스가 있습니다.
payment:redirecting 플래그가 남아있어 복원을 트리거하지 않으면 사용자 데이터가 날아갑니다.해결책:
requestPayment() 직전에 sessionStorage에 payment:redirecting 플래그를 저장pageshow + persisted 플래그로 감지 → 위젯 리셋 + 바텀시트 닫기useLayoutEffect에서 고아 플래그 감지 → paymentResume 복원 flow 재활용결제 실패 후 복원 시 routeData(Kakao 경로 API 응답)는 저장하지 않으므로 null로 복원됩니다. 요금 계산 effect가 routeData === null을 보고 실행되지 않아, 선택하지 않은 나머지 서비스의 요금이 "요금 계산 중"으로 영원히 남습니다.
restoreCompleteVersion 카운터를 도입하여 복원 완료 시점에 거리+요금 재계산을 명시적으로 트리거합니다. (6-3장 참고)
복원 과정에서 setSelectedServiceType, setStartCoord 등을 한꺼번에 호출하면, 이 값들을 deps로 가진 effect들이 모두 트리거됩니다. 특히 "서비스 타입 변경 시 폼 초기화" effect는 복원된 차량명·메모·예약일을 즉시 날려버리고, "경로 변경 시 요금 리셋" effect는 복원된 금액을 날립니다.
단순히 각 effect에 1회성 skip 플래그를 두면 React batch 처리로 인해 여러 effect가 연달아 실행될 때 skip 횟수가 부족합니다. isRestoringPaymentRef boolean 플래그를 setTimeout(0)으로 해제하면, 현재 렌더 사이클의 모든 effect가 단 하나의 플래그로 억제됩니다. (6-2장 참고)
success 페이지에서 사용자가 F5를 누르면 같은 paymentKey로 REGISTER_ORDER가 재호출되어 중복 주문이 생성될 수 있습니다. React Strict Mode에서도 effect가 두 번 실행됩니다.
useRef 플래그와 sessionStorage 이중 가드로 방지합니다. onError 시에는 두 가드를 모두 해제하여 실패 후 재시도는 허용합니다. (5-1장 참고)
데스크톱에서 widgets.requestPayment()를 호출하거나 BrandPay bp.requestPayment()를 실행하면, SDK가 내부적으로 history.pushState()를 여러 번 호출합니다. 결제 취소 또는 오류로 인해 위젯이 닫혀도 이 항목들은 세션 히스토리에 그대로 남습니다.
이후 사용자가 뒤로가기를 여러 번 눌러야 원래 페이지로 돌아가는 현상이 생깁니다. 더 심한 경우 Next.js 라우터의 레이어 쿼리파라미터(?layer=...)가 뒤로가기 한 번으로 안 빠지는 UX 문제로 이어집니다.
해결 방법은 간단합니다. 결제 직전 history.length 스냅샷 → 종료 후 delta 계산 → history.go(-delta)로 되감습니다. (3장 전략 1 참고)
apps/delivery-b2c/src/
│
├── app/
│ ├── (Layout)/
│ │ └── page.tsx ← 메인 주문 페이지 (지도 + 결제 상태 복원)
│ │
│ ├── api/brandpay/ ← 토스 API 서버 프록시
│ │ ├── confirm/route.ts
│ │ ├── methods/route.ts
│ │ ├── terms/route.ts
│ │ ├── agree/route.ts
│ │ └── token/route.ts
│ │
│ └── payments/general/ ← 결제 결과 처리 페이지
│ ├── checkout/page.tsx ← 앱 모달 전용 체크아웃
│ ├── success/page.tsx ← 결제 성공 → 주문 생성 (중복 방지 포함)
│ └── fail/page.tsx ← 결제 실패 → 에러 표시 + 복원 유도
│
├── components/test/
│ ├── GeneralPayBottomSheet.tsx ← 일반결제 (bfcache 뒤로가기 감지 포함)
│ ├── PayBottomSheet.tsx ← 브랜드페이 결제수단 선택
│ └── PaymentTypeBottomSheet.tsx ← 결제 타입 선택
│
├── constants/
│ └── paymentNavigation.ts ← buildMypageOrderDetailHref, fromPayment 상수
│
├── hooks/
│ ├── useBrandPay.ts ← 브랜드페이 SDK 싱글턴 래퍼 (redirectPath 옵션 포함)
│ └── useBrandPayScrollLock.tsx ← dimmer 스크롤 락
│
├── libs/
│ ├── generalPayDraftStore.ts ← 앱 모달용 Draft Store (TTL 2시간, DraftEnvelope)
│ ├── generalPayWebDraft.ts ← 모바일 웹용 Draft Store (TTL 2시간, DraftWrap, 양쪽 동시 쓰기)
│ └── appModalWebViewBridge.ts ← iOS/Android 네이티브 브릿지 (open/close/closeSelf)
│
└── utils/
├── trimPaymentWidgetHistory.ts ← 토스 위젯 history 오염 정리 유틸
└── restoreReservationFromFormBody.ts ← Draft → UI 상태 변환 유틸
결제 연동은 "API 하나 호출하면 끝"이 아닙니다. 일반결제에서는 플랫폼별 분기와 리다이렉트 상태 보존이, 브랜드페이에서는 약관-토큰-카드 등록이라는 사전 준비 체인이 각각의 난관입니다.
그리고 이 모든 구현이 끝난 뒤에도 "결제가 실패했을 때 사용자가 다시 시도하는 경험"이라는 추가 숙제가 남습니다. 결제 실패는 사용자 잘못이 아닙니다. 실패 후 재시도를 최대한 매끄럽게 만드는 것 — Draft 복원, React effect 사이드이펙트 억제, 뒤로가기 캡처, 요금 재계산 — 이 모든 것이 결국 "한 번 더 결제해주고 싶은" 경험을 만드는 과정입니다.
이 글에서 소개한 패턴들은 토스페이먼츠뿐 아니라 리다이렉트와 상태 복원이 필요한 모든 외부 결제/인증 연동에 동일하게 적용할 수 있습니다.