1. 개요

모바일 앱과 웹 앱의 한 끗 차이를 가르는 결정적인 경계선은 바로 **‘물리 뒤로가기 버튼과 스와이프 제스처의 작동 방식’**이더군요. DoEatFit v2.0 모바일 PWA 환경에서 실사용 테스트를 진행하던 중, 사용자가 모달 시트를 띄워 식단이나 운동 세트 로그를 땀 흘려 기입해 나가다 뒤로가기 제스처를 행했을 때 모달창만 가볍게 닫히는 대신 전체 브라우저 주소가 이전 페이지로 넘어가 버리며 기입 중이던 정보가 전량 증발하는 치명적인 UX 결함이 발생했습니다. 모바일 웹의 한계를 깨고 가상 히스토리를 정교하게 통제하여 네이티브 앱과 같은 사용성을 구현해 낸 과정을 꼼꼼히 정리합니다.


2. 핵심 내용

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

  • 현상:
    • 작성 중 데이터 증발: 전체 화면 모달에서 식단/운동 로그를 작성하던 유저가 본능적으로 스마트폰의 뒤로가기 제스처(iOS Swipe-back, Android Back Button)를 트리거하면 브라우저 페이지 전체가 뒤로 이동해 버려 입력값이 초기화되었습니다.
    • 명확한 이탈 경로 누락: 모달 우측 상단이나 하단에 명시적인 이탈/닫기(X) 버튼이 누락되어 있어, 제스처 오작동이 유발될 때 유저가 꼼짝없이 데이터 유실의 덫에 갇혔습니다.
  • 원인:
    • 브라우저는 기본적으로 화면 위에 임시 렌더링된 다이얼로그나 오버레이 모달 레이어의 활성화 상태를 인지하지 못합니다. 단지 “이전 페이지 URL로 주소를 전환하라”는 단순 윈도우 뒤로가기 명령으로 처리하기에 이와 같은 현상이 발생했습니다.

2-2. 💡 우아한 해결책 및 구현 코드

  • 해결 방안: URL 경로를 지저분하게 흔들고 전체 페이지 리렌더링 부하를 일으키는 URL 쿼리 제어방식(안티패턴)을 배제하고, 브라우저 URL 경로는 지키며 내부 히스토리 스택에만 가상의 식별자({overlay: id})를 안전하게 얹어 가로채는 ‘History API (pushState/popstate) 직접 조작’ 아키텍처를 도입했습니다. 중첩 오버레이가 차례로 닫힐 수 있도록 React의 useId 고유식별자 기반 가상 히스토리 제어 훅을 구현했습니다.
  • 코드 명세:
// 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)
 
    useEffect(() => {
        onCloseRef.current = onClose
    }, [onClose])
 
    useEffect(() => {
        if (!enabled) return
 
        let poppedByBackButton = false
        // 1. 현재 히스토리에 내 고유 ID를 가상 상태로 주입
        window.history.pushState({ overlay: id }, "")
 
        const handlePopState = () => {
            if (window.history.state?.overlay === id) return
 
            // 내 ID가 팝아웃되어 사라졌다는 것은 뒤로가기 제스처가 행해진 것!
            poppedByBackButton = true
            onCloseRef.current()
        }
 
        window.addEventListener("popstate", handlePopState)
 
        return () => {
            window.removeEventListener("popstate", handlePopState)
            // 'X' 버튼이나 빈 공간 바깥을 직접 터치해 수동으로 닫았을 때,
            // 히스토리 스택에 남은 내 가상 ID를 백오프하여 청소
            if (!poppedByBackButton && window.history.state?.overlay === id) {
                window.history.back()
            }
        }
    }, [enabled, id])
}
  • 공용 다이얼로그 플러그인 이식:
// dialog.tsx
useBackHandler(() => {
    if (onOpenChange) {
        onOpenChange(false)
    } else {
        // 비상 탈출을 위한 이탈 리스너 전파
        window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))
    }
})

2-3. ✅ 결과 검증 및 모던 오픈소스 라이브러리 검토

  • 최종 검증:
    • 제스처 뒤로가기 무결성: 아이폰 사파리 스와이프 백 또는 안드로이드 물리 백버튼 트리거 시, 페이지 튕김 현상 없이 오직 최상단 모달 서랍만 슥 부드럽게 닫히는 데 성공했습니다.
    • 다중 중첩 레이어 역순 소거: 운동 작성 모달 위에서 추가적인 세부 식품 검색 모달을 얹어 이중으로 띄운 복잡한 시나리오에서도, 뒤로가기를 1회 하면 자식 모달이 닫히고 2회 하면 부모 모달이 안전하게 닫히는 **중첩형 오버레이 역순 소거(Multi-Layer Stack Clear)**가 결함 없이 성공하는 것을 확인했습니다.
  • 추천 오픈소스 라이브러리 검토:
    1. Radix UI Dialog (Primitives):
      • 평가: 무장애 웹 접근성(WAI-ARIA) 규격을 완벽하게 따르며 포커스 가두기(Focus Trapping), 바깥 영역 터치 감지 등을 기본 내장하여 가상 히스토리 차단 시스템의 기반 뼈대로 활용하기에 압도적입니다.
    2. Vaul:
      • 평가: 모바일 네이티브 앱에서 널리 쓰이는 ‘아래에서 위로 스와이프하여 끌어내리는 바텀 드로어(Drawer)‘를 60fps 탄성 물리 스크롤 애니메이션과 함께 완벽 구현해주는 모바일 최적화 레이아웃 라이브러리로 강력 권장합니다.