뒤로가기 누르면 결제 두 번됨. 이런 식으로 막다

개바리바리·2025년 5월 23일

프로젝트 만들면서 페이지 라우팅을 이렇게 구성했다
/, /detail/:id, /confirm, /payment, /success
그리고 이 페이지는 꼭 적은 순서대로 진행되어야 한다

특히 결제페이지가 있어서 흐름이 깨지면 결제가 두번된다거나 하는 문제가 생긴다
그래서 흐름을 지키기 위해 상황 2개를 가정해 지키려고 한다

  1. url 직접 접근하는 경우
  2. 뒤로가기로 접근하는 경우

참고로 결제기능은 임시로 3초 진행됐다가 /success 로 넘어가게 구성했다


export default function Payment({ price }: PaymentProps) {
	const [loading, setLoading] = useState(true);
	const navigate = useNavigate();
	useEffect(() => {
		setTimeout(() => {
			setLoading(false);
			navigate("/success");
		}, 3000);
	}, []);

	return (
		<div className="p-5">
			<LoadingSpinner loading={loading} />
			<p>{price} 결제하는중</p>
		</div>
	);
}

❶ url 직접 접근 막기

우선 다른분들은 직접 접근을 어떻게 막으시는지 검색해봤다
보통 '로그인 안됐는데 마이페이지 들어가는 경우', '로그인 안됐는데 회원전용 페이지 들어가는 경우' 상황에서
토큰 존재 여부, 또는 로그인 인증 유무로 접근을 허용하거나 특정 페이지로 redirect 하는 방법으로 구현하셨다

기준 1. 전역 데이터

근데 나는 그냥 마트에서 주차정산 하는것처럼 로그인 없이 바로 숫자 4자리 누르면 되는건데
토큰 존재 여부? 로그인 상태 유무? 어떻게 기준을 세워야할지 감이 안왔다
생각해보니 숫자 4자리를 누르면 parkingInfo 데이터가 저장되는데 이걸 기준으로 잡아야 겠다는 생각을 했다

기준 2.

기준이 하나뿐인건 위험하니까 더 알아보기

document.referrer

처음으로 document.referrer 개념을 보게 되었다
이 속성은 사용자가 페이지를 이동했을 때 직전에 있었던 페이지의 URL을 반환한다

예를 들어, 네이버 메인화면에서 '네이버지도'를 클릭해 이동했다면
네이버지도 페이지에서 document.referrer 을 확인해볼때 네이버 메인화면의 URL이 출력된다
반면 사용자가 주소창에 URL을 직접 입력해 접근했다면 빈 문자열("")이 나온다

그래서 나는 이 점을 활용해 사용자가 직접 접근했는지, 이전 페이지를 통해 넘어온건지 판단하려고 했다
그래서 프로젝트 흐름을 따라가본후 마지막 페이지에서 document.referrer 값을 확인해봤는데 빈 문자열("")이 나왔다

분명 순서대로 따라가면 /, /detail/:id, /confirm, /payment, /success
/payment가 나와야 하는데 이게 무슨일일까

SPA

그 이유는 리액트는 SPA이기 때문이다
리액트는 싱글 페이지 앱으로 하나의 페이지로만 구성되어 있기 때문에
document.referrer 값이 업데이트되지 않고 빈 문자열로 남게 된것이다


SPA가 더 궁금하다면
SPA와 리액트에 대해 더 자세히 설명해 놓은 글이 있습니다 확인 해보세요 👉 리액트와 SPA

History API

SPA에서의 URL은 브라우저의 History API를 통해 관리된다
그래서 이 History API를 활용해보기로 했다

특히 window.history.state.idx 는 현재까지 히스토리가 몇 단계 쌓였는지를 나타내는 값인데
이 값을 통해 사용자의 이동 흐름을 파악할 수 있었고 나는 두번째 기준으로 삼았다

흐름에 따라 이동했을 경우 : 1 이상의 숫자
직접 접근 했을 경우 : 0

최종

if (window.history.state.idx == 0 && !parkingInfo){
	// ... 이제 막을 행동을 적기 
}

나는 직접 접근했을 경우 redirect 시켜서 이동시키는 것보단 '직접 접근은 안됩니다'란 메시지를 주고 싶었다
그래서 상태를 따로 만들고 조건부 렌더링으로 메시지를 보여줬다

export default function Payment({ price }: PaymentProps) {
	const [loading, setLoading] = useState(true);
	const navigate = useNavigate();
	const { parkingInfo } = useParkingInfoStore();
	const [blockAccess, setBlockAccess] = useState(false);
	useEffect(() => {
		if (!parkingInfo) {
			setBlockAccess(true);
			return;
		}

		setTimeout(() => {
			setLoading(false);
			navigate("/success");
		}, 3000);
	}, []);

	return (
		<div className="p-5">
			{blockAccess ? (
				<AccessBlocked /> // 직접 접근 경고 메시지를 담은 컴포넌트
			) : (
				<div className="p-5">
					<LoadingSpinner loading={loading} />
					<p className="text-center">{price} 결제하는중</p>
				</div>
			)}
		</div>
	);
}

메시지를 보여주는 것에서 그치지 않고 메인페이지로 이동할 수 있는 기능 추가하기

그리고 더 확실히 하기 위해 결제가 성공된 후 parkingInfo 데이터를 초기화했다

❷ 뒤로가기 처리

이제 뒤로 가기를 눌러 결제 페이지로 가는것을 막기
그 전에 확실히 이건 생각하고 가기

사용자의 뒤로가기를 막을 수 없다
이미 뒤로가기는 일어났고 내가 어떻게 처리하냐 싸움이다

그래서 뒤로가기를 누르면 한번은 새 히스토리를 만들어서 넣는 꼼수로 막고
뒤로가기를 실수로 누른 걸 수도 있으니 알림창으로 한번 더 묻게 했다
그래도 뒤로가기를 한다면 / 로 페이지 이동시키기

useEffect(() => {
	if (window.history.state.idx == 0 && !parkingInfo) setBlockAccess(true);

	const preventGoBack = () => {
    	if (confirm("페이지를 나가시겠습니까?")) {
        	navigate(`/`, { replace: true });
		} else {
        	history.pushState(null, "", location.href);
		}
	};

	history.pushState(null, "", location.href);
    window.addEventListener("popstate", preventGoBack);

	return () => {
    	window.removeEventListener("popstate", preventGoBack);
	};
}, []);

이슈 1.

근데 이 코드는 문제점이 있다
한번 뒤로가기를 눌러 '아니오'를 누른 후, 다시 뒤로가기를 누르면 알림창 없이 바로 이동해버린다
useEffect 의존성 배열이 비어 있어서 그런가 뭘 추가하려 해도
직접 접근을 막는 코드도 같이 들어가 있고 그렇다고 useEffect를 한 컴포넌트안에 두번 사용하는 것도 깨끗한 코드가 아닌것 같고 고민이 됐다

그래서 /confirm, /payment, /success 이 순서로 페이지가 이동할 때
히스토리 안남게 페이지가 대치되게 해야겠다 생각하고 navigatereplace 속성을 추가했다

이슈 2.

그런데 여기서 문제가 생겼다
결제 기능에 setTimeout으로 설정해둔 타이머가 페이지를 뒤로가도
계속 실행돼서 결국 다시 성공 페이지로 넘어가버리는 상황이 발생

이 문제는 컴포넌트가 언마운트될 때 clearTimeout을 사용해 타이머를 정리해주는 방식으로 해결했다

useEffect(() => {
	let timer = setTimeout(() => {
		setLoading(false);
		navigate("/success", { replace: true });
	}, 3000);

	return () => {
		clearTimeout(timer);
		console.log("Timeout cleared");
	};
}, []);

마무리

라우터 페이지 이동하는걸로 그동안은 가볍게 사용했는데
직접 접근이나 뒤로가기 이슈들 방어 해보는데 복잡했다

아이폰이나 키오스크 였다면 생각 안해도 되는 이슈들인데 역시 웹은 어려운 것 같다

profile
누군가에게 도움이 되는 글을 쓰자

1개의 댓글