1. 개요

이전에 모바일 웹 환경에서 네이티브 앱과 같은 사용성을 주고자 가상 히스토리 제어 훅(useBackHandler)을 도입한 바 있습니다. (참고: ISS-UX) 모바일 모달 뒤로가기 UX 개선)

하지만 실사용을 거듭하다 보니 치명적인 사용성 결함이 하나 숨어 있었습니다. 모달 창 안에서 식단이나 운동을 입력하며 땀을 흘리다 화면을 X 버튼으로 수동으로 닫았을 때, 겉으로는 화면이 정상적으로 원래 페이지로 돌아온 듯 보였으나 브라우저의 뒤로가기 버튼을 누르면 이전 페이지로 넘어가지 않고 수십 번을 헛도는 제자리걸음(루프) 현상에 빠져버리는 것이었습니다.

이 글에서는 프론트엔드의 상태 렌더링 생명주기와 브라우저의 DOM History API가 어긋날 때 발생하는 이 무한 증식 찌꺼기 버그를 어떻게 해결했는지 정리해 봅니다.


2. 핵심 내용

2-1. 🚨 문제 현상 및 원인 분석

  • 현상:
    • 유저가 모달 창 안에서 여러 동작(타이핑, 컴포넌트 토글 등)을 수행한 뒤 모달을 닫고 원래 페이지로 돌아옵니다.
    • 이 상태에서 디바이스의 물리적 뒤로가기를 누르면 화면이 바뀌지 않고 브라우저 히스토리 스택만 헛돌아갑니다. 이전 페이지로 가기 위해 수십 번 뒤로가기를 연타해야만 했습니다.
  • 원인 (pushState 무한 중첩):
    • 기존 useBackHandler 훅 내부에는 모달이 활성화될 때 window.history.pushState({overlay: id}, "")를 호출하여 가짜 히스토리를 1개 추가하는 로직이 있었습니다.
    • 문제는 React의 잦은 리렌더링(타이핑 등의 자잘한 상태 변화나 Strict Mode) 과정에서 이 useEffect가 재실행될 때마다 똑같은 가짜 히스토리 객체를 스택 위에 끝없이 겹겹이 쌓아 올리고 있었다는 점입니다.
    • 사용자가 X 버튼을 눌러 모달을 닫을 때 발동되는 cleanup 함수에서는 단 1번의 window.history.back()만 수행합니다. 즉, 밑에 깔린 수십 개의 찌꺼기 히스토리는 그대로 방치되었던 것입니다.

2-2. 💡 방어벽 구축 및 해결 코드

이 문제를 해결하기 위해 컴포넌트에 복잡한 상태(Context)를 추가하여 렌더링을 억제하려는 시도도 해 보았으나, 오히려 상태 복잡도만 높이고 버그의 근본 원인을 없애지는 못했습니다.

따라서 가장 깔끔하고 단단한 방어벽은 브라우저 히스토리 자체에 물어보는 것이었습니다. **“지금 스택의 최상단에 있는 가상 히스토리가 내가 만든 것이 맞다면, 굳이 또 렌더링되었다고 해서 새 히스토리를 얹지 않겠다”**는 명확한 조건을 추가했습니다.

// use-back-handler.ts
"use client"
import { useEffect, useId, useRef } from "react"
 
export function useBackHandler(onClose: () => void, enabled: boolean = true) {
    const id = useId()
    const onCloseRef = useRef(onClose)
    onCloseRef.current = onClose
 
    useEffect(() => {
        if (!enabled) return;
 
        let poppedByBackButton = false;
 
        // [버그 픽스] 현재 브라우저 히스토리 최상단이 내 overlay id가 아닐 때만 새 가상 히스토리를 추가한다.
        // 이 방어 코드로 인해 리렌더링이 백 번 일어나도 가상 히스토리는 무조건 딱 1개만 생성됨.
        if (window.history.state?.overlay !== id) {
            window.history.pushState({overlay: id}, "");
        }
 
        const handlePopState = () => {
            if (window.history.state?.overlay === id) return;
            poppedByBackButton = true;
            onCloseRef.current();
        };
 
        window.addEventListener("popstate", handlePopState);
 
        return () => {
            window.removeEventListener("popstate", handlePopState);
            // poppedByBackButton이 false일 때만(사용자가 X 버튼 등으로 명시적으로 닫았을 때) 뒤로가기 실행
            if (!poppedByBackButton && window.history.state?.overlay === id) {
                window.history.back();
            }
        };
    }, [enabled, id]); 
    // 의존성 배열을 최소화하여 불필요한 useEffect 재실행 차단
}

2-3. 시각화

sequenceDiagram
    participant React as React (useBackHandler)
    participant DOM as Browser History API
    
    React->>DOM: Mount (enabled=true)
    Note over DOM: Stack: [ Page A ]
    React->>DOM: pushState({overlay: "id1"})
    Note over DOM: Stack: [ Page A, {overlay: "id1"} ]
    
    React->>React: Typing & Re-rendering...
    React->>DOM: Check history.state?.overlay !== "id1"
    Note over DOM: 최상단이 이미 "id1"이므로 pushState 스킵!
    
    React->>React: User clicks "X" (Unmount)
    React->>DOM: history.back() (1회만 까임)
    Note over DOM: Stack: [ Page A ]
    
    DOM-->>React: 정상적인 이전 페이지 복귀 완료

3. 회고

“모달의 상태 제어를 URL 쿼리로 전면 개편해버릴까?” 하는 위험한 리팩토링의 유혹도 있었습니다. 하지만 만약 그렇게 했다면, 사용자가 모달을 닫고 진짜 뒤로가기를 눌렀을 때 아까 닫았던 모달이 URL 복원으로 인해 다시 튀어나오는 최악의 결함을 맞이했을 것입니다.

React의 선언적 UI와 브라우저의 절차적 DOM API 사이의 틈새에서 생기는 이런 기묘한 버그들을 잡으려면, 무리한 전체 공사보다는 DOM의 근본적인 상태(window.history.state)를 직접 들여다보는 현미경 같은 접근이 더 안전하고 강력함을 다시금 깨달았습니다.

연관 포스트 동시에 여러 유저가 편집하는 ‘잼(Jam)’ 세션에서 네트워크 비동기 지연으로 인해 일지 순서가 뒤죽박죽 섞이던 Race Condition 이슈 극복기는 다음 포스트를 참고해 주세요. 👉 ISS-Sync) 분산 환경에서 비동기 요청 순서 보장 (CRDT 타임스탬프)