토스 결제위젯이 iframe 안에서 '일시적인 오류'를 내는 이유, 그리고 모바일 결제 화면이 안 보이던 두 가지 원인

딥라마·7일 전

오늘 한 일

결제 흐름에서 "결제창이 안 넘어가요", "모바일에서 결제창이 안 떠요"라는 제보를 받고 원인을 셋 찾아 고쳤어요.

  1. 토스 결제위젯이 iframe 안에서 결제를 마치지 못하는 문제
  2. 결제 화면에서 날짜를 바꾸면 바텀 시트가 닫히지 않고 결제 수단을 덮는 문제
  3. 로그인 직후 구매하기를 누르면 다시 로그인 화면으로 가는 문제

셋 다 "제 브라우저에서는 잘 되는데요"로 끝나기 쉬운 버그라, 재현 방법을 찾는 데 시간을 가장 많이 썼어요.

1. 토스 결제위젯: iframe 안에서는 결제가 끝나지 않는다

증상

결제를 마치는 순간 토스 결제창에 이런 오류가 떴어요.

일시적인 오류가 발생했습니다.
Failed to set the 'href' property on 'Location': The current window does not have
permission to navigate the target frame to '…/checkout/…?paymentKey=…&amount=…'.

원인 찾기

처음에는 successUrl 처리(돌아온 뒤 서버 승인)가 빠졌나 의심했어요. 그런데 헤드리스 브라우저로 같은 테스트 결제를 끝까지 해 보니 돌아오기와 승인 모두 정상이었어요. 서버 로그에도 그 시간대에는 승인 요청이 한 건도 없었어요. 즉 돌아오기 전에 막힌 거예요.

오류 문구의 "target frame"이 힌트였어요. 결제 화면을 일부러 다른 페이지의 iframe 안에 넣고 결제하니 똑같은 오류가 재현됐어요. 제보한 사람은 편집기의 미리보기 창(내부가 iframe)에서 테스트하고 있었어요.

원인

PC에서 토스 결제창은 기본값(windowTarget: "iframe")으로 우리 페이지 위에 iframe으로 열려요. 결제가 끝나면 그 iframe이 부모 창(우리 페이지) 을 successUrl로 옮겨요. 그런데 우리 페이지가 또 다른 페이지의 iframe 안에 있으면, 다른 출처의 iframe이 최상위가 아닌 조상 프레임을 이동시키는 셈이라 브라우저가 막아요.

windowTarget: "self"로 결제창을 현재 창에서 열어 보기도 했어요. 이번에는 토스 결과 페이지가 iframe 안에서 열리기를 거부해서 chrome-error://로 끝났어요. 결국 iframe 안에서는 결제를 끝낼 방법이 없다는 결론이었어요.

해결

막을 수 없으니 미리 알아채서 안내했어요. iframe 안이면 위젯을 그리지 않고 "새 창에서 결제하기" 버튼을 보여 줘요. 서버 렌더와 어긋나지 않게 useSyncExternalStore로 읽었어요(effect 안에서 setState를 쓰면 lint가 막아요).

const noSubscribe = () => () => {};

function PaymentWidget() {
  // 서버에서는 false, 브라우저에서는 실제 값
  const framed = useSyncExternalStore(noSubscribe, () => window.self !== window.top, () => false);

  if (framed) {
    return (
      <div role="alert">
        <p>이 화면에서는 결제를 마칠 수 없어요.</p>
        <button onClick={() => window.open(window.location.href, "_blank", "noopener")}>새 창에서 결제하기</button>
      </div>
    );
  }
  return <div id="payment-methods" />;
}

배운 점

  • 결제창처럼 창을 이동시키는 외부 SDK는 내 페이지가 최상위 창이라고 가정해요. 미리보기나 임베드 환경도 사용 환경의 하나로 봐야 해요.
  • 테스트 키여도 카드 결제는 카드사 인증 화면이 진짜라 자동화로 끝까지 가기 어려워요. 토스 테스트 환경의 퀵계좌이체는 데모 화면(테스트 비밀번호가 안내됨)이라 헤드리스로도 결제 완료까지 재현할 수 있었어요.

2. 바텀 시트가 "닫히는 중"에 다시 태어났다

증상

모바일 결제 화면에서 "이용일 변경"으로 날짜를 고르고 "이 날짜로 선택"을 누르면, 날짜는 바뀌는데 시트가 닫히지 않았어요. 시트가 결제 수단을 덮고 있으니 사용자에게는 결제창이 안 뜬 것처럼 보였어요.

원인 찾기

Playwright로 아이폰 화면을 흉내 내 Chromium과 WebKit 둘 다에서 돌렸고, 둘 다 재현됐어요. 버튼을 누른 뒤 6초가 지나도 role="dialog"가 그대로였어요. 그 순간 document.getAnimations()는 비어 있었어요. 닫기 애니메이션이 멈춘 게 아니라 애니메이션이 없는 새 시트가 떠 있었던 거예요.

원인

버튼 핸들러가 이 순서로 동작했어요.

onClick={() => { pickDate(draftDate); close(); }}

pickDate가 상태와 주소(history.replaceState)를 바꾸면서 화면이 다시 그려졌고, 포털로 그리던 시트가 새로 마운트됐어요. 닫기 애니메이션을 돌리던 옛 시트는 언마운트되면서 애니메이션을 취소했고(alive = false라 onClose도 안 불림), 새 시트는 열린 상태로 남았어요.

해결

순서를 바꿨어요. 고른 값은 ref에 맡겨 두고, 시트가 다 닫힌 뒤 onClose에서 적용해요.

const picked = useRef<string | null>(null);

<BottomSheet
  onClose={() => {
    setOpen(false);
    const next = picked.current;
    picked.current = null;
    if (next) pickDate(next);
  }}
  footer={(close) => (
    <Button onClick={() => { picked.current = draft; close(); }}>이 날짜로 선택</Button>
  )}
/>

배운 점

닫기 애니메이션이 있는 컴포넌트에서는 닫는 중에 부모가 다시 그려지지 않게 해야 해요. 결과를 바꾸는 일은 닫힌 뒤로 미루는 편이 안전해요.

3. 로그인 직후 "로그인 안 함"으로 보이는 순간

증상

이미 가입한 계정으로 로그인한 직후 바로 구매하기를 누르면, 결제 화면 대신 로그인 화면으로 돌아가는 일이 가끔 있었어요. 로컬에서 4번 중 2번 재현됐어요.

원인

로그인 여부는 서버 레이아웃이 httpOnly 쿠키를 보고 정해서 클라이언트 컨텍스트로 내려줘요. 로그인 화면은 성공하면 이렇게 불렀어요.

router.refresh();           // 레이아웃을 새 쿠키로 다시 그리기
router.replace(next ?? "/"); // 원래 화면으로 이동

두 동작이 경쟁해서 이동이 먼저 끝나면, 레이아웃이 내려준 값은 아직 "로그인 안 함"이에요. 그 사이에 구매하기를 누르면 requireLogin()이 다시 로그인으로 보냈어요. 쿠키는 이미 있어서 서버 쪽은 멀쩡했고, 그래서 더 헷갈렸어요.

해결

로그인 화면은 성공하면 이미 session-restored 이벤트를 쏘고 있었어요. 세션 컨텍스트가 이 신호만 받아도 로그인 상태로 보게 했어요.

const [restored, setRestored] = useState(false);
const loggedIn = (serverLoggedIn || restored) && !lost;

useEffect(() => {
  const onRestored = () => { setLost(false); setRestored(true); };
  window.addEventListener(SESSION_RESTORED_EVENT, onRestored);
  return () => window.removeEventListener(SESSION_RESTORED_EVENT, onRestored); // 빠져 있던 해제도 추가
}, []);

고친 뒤 기존 계정 4번, 새 계정 8번 모두 결제 화면까지 갔어요.

배운 점

router.refresh()와 router.push/replace()를 연달아 부르면 순서가 보장되지 않아요. 서버 값이 늦게 올 수 있는 상태는 클라이언트가 확실히 아는 신호로 메워 두는 게 좋아요.

profile
Develop like marathon.

1개의 댓글

편집기 미리보기 iframe에서 재현된 케이스라 더 흥미로웠어요. 결제 페이지에는 CSP의 frame-ancestors 'self'를 걸어 두거나, window.top !== window.self일 때 "새 창에서 결제하기" 안내를 띄워 두면 같은 제보가 다시 와도 사용자가 바로 원인을 알 수 있어요. 결제 화면이 남의 페이지에 끼워지는 것도 같이 막을 수 있고요.

답글 달기