
결제 완료 후 사용자가 브라우저의 '뒤로가기' 버튼을 누르면, 결제 대행사 페이지로 되돌아가는 문제가 발생했습니다. 분명히 결제가 성공했는데 다시 결제 화면이 나타나면서 사용자 혼란이 컸습니다.
우리는 결제 대행사(Payment Gateway)를 통한 결제 후 다음과 같은 일반적인 흐름을 가집니다: 사용자가 결제 대행사 페이지에서 결제를 완료하면, 결제 대행사는 우리 서비스의 특정 리다이렉트 URL로 사용자 브라우저를 다시 보냅니다. 그리고 우리 서비스는 이 리다이렉트 URL에서 결제 결과를 처리한 뒤, 최종적으로 외부 구매 완료 페이지로 다시 한 번 사용자를 이동시킵니다. 그런데 여기서 문제가 발생했습니다. 사용자가 최종 구매 완료 페이지에서 '뒤로가기' 버튼을 눌렀을 때, 예상과 달리 우리 서비스 페이지를 건너뛰고 이전 단계인 결제 대행사 결제 페이지로 이동해버리는 현상이 나타났습니다.
분명히 우리 서비스 페이지를 거쳐 최종 구매 완료 페이지로 넘어왔음에도 불구하고, 브라우저 히스토리에는 우리 서비스 페이지의 기록이 남지 않는 듯했습니다. 이로 인해 사용자들은 "분명 성공했는데 왜 또 결제 페이지가 뜨지?" 하는 혼란을 겪게 되었습니다. 이 문제를 해결하려면 브라우저의 히스토리 관리 메커니즘과 프론트엔드 라우팅 방식에 대한 정확한 이해가 필요했습니다.
브라우저 히스토리와 라우팅, 그리고 예상치 못한 함정
결제 완료 후 뒤로가기 시 결제 대행사 페이지로 돌아가는 문제를 해결하기 위해서는 브라우저의 페이지 이동과 히스토리 관리가 어떻게 이루어지는지 정확히 이해하는 것이 중요했습니다.
브라우저의 '히스토리 조작 방지' 메커니즘
이 문제의 핵심은 바로 브라우저의 '히스토리 조작 방지(history manipulation intervention)' 정책 때문이었습니다. 모던 웹 브라우저는 사용자의 명확한 인터랙션(클릭 등) 없이 스크립트에 의해 history.pushState()가 남용되는 것을 제한합니다. 이는 악의적인 웹사이트가 사용자를 혼란스럽게 하거나 피싱 공격에 이용될 수 있는 '히스토리 스푸핑'을 방지하기 위함입니다. 사용자가 예상하지 못한 방식으로 히스토리 스택이 쌓이거나 변경되는 것을 막아 사용자 경험과 보안을 보호하는 정책입니다.
우리 서비스의 결제 완료 흐름에서 router.push()를 사용했을 때, 사용자 인터랙션(클릭) 없이 결제 대행사 리다이렉트 직후 바로 다음 페이지로 push되었기 때문에, 브라우저는 이 pushState를 '사용자 인터랙션 없는 스크립트 기반의 히스토리 조작'으로 간주합니다. 이 경우 히스토리 항목 자체는 생성되지만 "건너뛸 수 있는(skippable)" 상태로 표시되어, 사용자가 뒤로가기 버튼을 누르면 브라우저가 해당 항목을 자동으로 건너뜁니다. 그 결과, 최종 구매 완료 페이지에서 뒤로가기를 누르면 우리 서비스 페이지가 아닌 결제 대행사 페이지로 바로 돌아가는 현상이 발생했던 것입니다.
이번 문제 해결에서는 이러한 브라우저의 히스토리 조작 방지 메커니즘을 우회하고, 사용자의 명확한 인터랙션을 통해 페이지 이동을 유도함으로써 히스토리 스택을 올바르게 관리하는 것에 초점을 맞췄습니다.
문제 해결: 사용자 인터랙션과 window.location.href의 조합
앞서 설명한 브라우저의 히스토리 조작 방지 정책을 우회하기 위해, 사용자에게 결제 완료 사실을 알리는 모달을 띄우고, 사용자가 이 모달의 '확인' 버튼을 직접 클릭했을 때 최종 구매 완료 페이지로 이동하도록 로직을 변경했습니다. 이렇게 하면 페이지 이동이 스크립트에 의한 자동 이동이 아닌 '사용자의 명시적인 인터랙션'으로 간주되어 브라우저 히스토리 스택에 정상적으로 기록될 수 있습니다.
해결 방안은 두 가지 핵심 변경으로 요약됩니다:
- 사용자 인터랙션 유도: 모달을 통해 사용자가 직접 '확인' 버튼을 클릭하도록 만들어, 브라우저가 정상적인 사용자 행동으로 인식하게 합니다.
window.location.href사용:router.push()는 클라이언트 사이드 라우팅으로history.pushState()를 사용하는 반면,window.location.href는 브라우저의 기본 페이지 이동 메커니즘을 사용하여 전체 페이지를 새로 로드합니다. 사용자 인터랙션과 함께 사용하면 브라우저가 이를 정상적인 페이지 이동으로 인식하여 히스토리에 확실하게 기록합니다.
1. Before: 자동 리다이렉트 방식
결제 성공 시 useEffect 내부에서 바로 router.push를 호출하여 다음 페이지로 자동 이동했습니다.
useEffect(() => {
if (isSuccessful) {
const returnUrl = generateReturnUrl(/* ... */);
router.push(returnUrl); // 사용자 인터랙션 없이 자동 이동
}
}, [result]);문제점: 브라우저는 사용자 인터랙션 없는 스크립트 기반의 히스토리 조작을 제한하여, 우리 서비스 페이지가 히스토리 스택에 제대로 쌓이지 않았습니다.
2. After: 사용자 인터랙션 기반 이동
모달을 도입하여 사용자가 '확인' 버튼을 클릭한 후에 페이지 이동이 발생하도록 변경했습니다.
useEffect(() => {
if (isSuccessful) {
// 모달을 띄워서 사용자 인터랙션 유도
userInteractionModal.openModal({
open: true,
title: "결제 완료",
desc: "결제가 성공적으로 완료되었습니다.",
onClick: () => {
const returnUrl = generateReturnUrl(/* ... */);
window.location.href = returnUrl; // 사용자 클릭 후 이동
},
});
}
}, [result]);해결 방법: 사용자의 명시적인 클릭 인터랙션을 통해 페이지 이동을 트리거하고, window.location.href를 사용하여 브라우저가 이를 정상적인 페이지 이동으로 인식하게 했습니다.
결과 및 시사점
이번 개선 작업을 통해 결제 완료 후 사용자가 '뒤로가기' 버튼을 눌렀을 때 더 이상 결제 대행사 페이지로 이동하는 혼란이 발생하지 않게 되었습니다.
고객 불만 변화
가장 체감이 컸던 건 CS 문의 감소입니다. "결제했는데 왜 다시 결제 화면이 나오냐"는 문의가 주 평균 10~15건에서 1~2건으로, 거의 90% 가까이 줄었습니다. 결제 완료 페이지 이탈률도 25~30%에서 10% 안팎으로 떨어졌고, 중복 결제 시도는 사실상 사라지면서 운영팀의 수동 환불 처리 부담도 함께 해소되었습니다.
- 브라우저 히스토리 조작 방지 메커니즘: 단순히
router.push를 사용하는 것이 아니라, 브라우저가 스크립트 기반의 히스토리 조작에 대해 어떤 정책을 가지고 있는지 이해해야 합니다. 이는 사용자 경험과 보안을 동시에 고려한 브라우저의 설계 철학을 반영한 정책입니다. router.push와window.location.href의 차이: 두 방식 모두 페이지를 이동시키지만, 브라우저 히스토리 스택에 미치는 영향은 전혀 다릅니다. 사용자 인터랙션 여부가 핵심적인 판단 기준이 됩니다.- 사용자 인터랙션을 통한 히스토리 관리: 사용자의 명시적인 클릭을 통해 페이지 이동을 유도함으로써, 브라우저의 히스토리 조작 방지 정책을 우회하고 의도대로 히스토리를 쌓을 수 있었습니다.
- 결제 데이터의 철저한 서버 측 검증: 결제와 관련된 모든 콜백 데이터는 클라이언트에서 조작될 수 있으므로, 반드시 서버에서 무결성(PG사 서명 검증), 소유권, 상태, 타임스탬프 등을 포함한 철저한 유효성 검증을 수행해야 합니다.
브라우저의 히스토리 관리 정책은 결제 흐름뿐 아니라 리다이렉트가 포함된 모든 흐름에서 고려할 필요가 있습니다.
참고 자료
Related Posts

광고 차단 확장 프로그램이 이벤트 배너를 숨긴 이유
이벤트 배너가 특정 사용자에게만 보이지 않는 문제를 추적하며 광고 차단 확장 프로그램의 필터링 메커니즘을 이해하고, CSS 클래스명 변경으로 문제를 해결한 과정을 다룹니다.

웹 애플리케이션의 외부 서비스 연동 보안 강화: postMessage 데이터 검증 사례
브라우저 확장 프로그램으로 인한 불완전한 데이터 유입 문제를 발견하고, postMessage 통신의 데이터 검증 로직을 강화하여 외부 서비스 연동의 안정성을 향상시킨 과정을 다룹니다.

Nuxt 3.8 업데이트 후 구형 WebView에서 발생한 App Manifest 에러 해결기
Nuxt 3.8로 업데이트한 후 iOS WebView에서 발생한 #app-manifest import 오류의 원인을 파악하고, experimental.appManifest 설정을 통해 해결한 과정을 정리합니다.