ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • WebView에서 iOS처럼 스와이프 뒤로가기 넣기 — Next.js App Router 실전기
    개발 이야기 2026. 6. 19. 08:58

    앱 내부의 스크린 WebView를 만들면서 router.back() 하나로는 부족하다는 걸 금방 알았습니다. 버튼은 눌리는데, 손가락으로 왼쪽 끝을 끌었을 때 이전 화면이 옆에서 따라오는 느낌이 없었거든요. 네이티브 앱에 익숙한 사용자 입장에서는 “뒤로가기가 안 되는 것 같다”는 인상으로 이어질 수 있습니다.

    그래서 직접 구현했습니다. 엣지 스와이프, 이전 화면 미리보기, 스크롤 위치 복원까지 한 흐름으로 묶은 이야기입니다.


    들어가며 — router.back()만으로는 부족했던 이유

    처음엔 단순했습니다 헤더 뒤로가기 버튼에 router.back() 연결 -> 끝

    하지만 쓰다 보니 문제가 세 가지 문제가 발생했습니다.

    첫째, 미리보기가 없습니다. iOS는 스와이프하는 동안 이전 화면이 살짝 보입니다. router.back()은 그냥 화면이 바뀝니다.

    둘 째, 스크롤이 맨 위로 튑니다 게시판을 200번째 글까지 내려갔다가 상세 보고 돌아오면, 다시 맨 위부터 스크롤해야 합니다.

    셋 째, 스택이 꼬입니다. 탭 전환은 replace로 하고, 상세 진입은 push로 하는데, Next App Router 히스토리만 믿으면 “어디서 왔는지”가 애매해집니다. 투표·설문은 탭 쿼리까지 살려야 하고요.

    결국 역할을 나눠야 했습니다.

    • 터치·애니메이션 — 손가락 따라 화면이 움직이는 부분
    • 스냅샷 스택 — 이전 화면을 미리 보여줄 재료
    • 뒤로가기 규칙 — 실제로 어디로 갈지

    전체 그림 — 세 덩어리로 나눴다

    레이아웃은 단순하게 구성했습니다.

    PagesLayoutContent

    └── NavigationProvider ← goBack, canGoBack 정의

    └── SwipeBackGesture ← 터치 감지 + 2겹 화면

    └── 실제 페이지

    NavigationProvider는 “어디로 갈지”만 정합니다.
    SwipeBackGesture는 “손가락 움직임에 화면이 어떻게 반응할지” 담당합니다.
    navigationStack은 그 사이에서 이전 화면 DOM 복사본과 스크롤 위치를 들고 있습니다.

    버튼 뒤로가기든, 엣지 스와이프든, 결국 같은 goBack()을 호출합니다.
    진입로는 달라도 목적지는 같게 맞췄습니다.


    1단계 — 터치 이벤트로 스와이프 감지하기

    React onTouch 대신 document에 네이티브 TouchEvent를 붙였습니다. touchmove에서 preventDefault()로 스크롤과 싸워야 하기때문이죠.

    언제 제스처로 볼 것인가

    touchstart에서 조건을 걸러냅니다.

    • canGoBack이 true일 때만
    • 손가락 하나
    • 화면 왼쪽 엣지 24px(safe-area 포함) 안에서 시작
    • input, 모달, 가로 스크롤 영역은 제외

    touchmove에서는 방향을 바라봅니다. 가로로 8px 이상 움직이고, 세로보다 가로가 크면 “이건 스와이프다”인지하고 잠급니다.
    세로로 많이 움직이거나 왼쪽으로 밀면 취소합니다.

    잠기면 배경 스크롤을 막고, dragX만큼 현재 화면을 오른쪽으로 밉니다.

    언제 뒤로 갈 것인가

    손을 뗄 때 드래그 거리를 봅니다.

    • 80px 이상이거나
    • 화면 너비의 33% 이상이면 → 뒤로가기
    • 아니면 → 원래 자리로 되돌리는 스냅백

    임계값은 edgeSwipeBack.ts에 모아 두었습니다. 기기마다 손맛이 다르니 숫자만 바꿔 튜닝하기 쉽게요.


    2단계 — 두 겹 화면으로 이전 페이지 미리보기

    스와이프 중에는 화면이 두 층입니다.

    • 아래: 이전 화면(스냅샷 복제본)
    • 위: 지금 보고 있는 화면(실제 React 트리)

    현재 화면은 translateX(dragX)로 밀고, 이전 화면은 translateX(dragX - 화면너비)로 같이 끌려 옵니다. 손가락을 따라 이전 페이지가 옆에서 나오는 느낌이 납니다.

    이전 화면은 실시간 렌더가 아닙니다. 이동하기 직전에 저장해 둔 DOM clone입니다. cloneNode(true)로 복사하고, script와 id는 제거합니다.

    여기서 함정이 하나 있습니다. 스냅샷이 없는데 현재 화면만 숨기면 회색 빈 화면이 보입니다. 그래서 스냅샷이 있을 때만 패널을 투명 처리합니다.


    3단계 — History를 패치해서 스냅샷 스택 쌓기

    미리보기 재료는 navigationStack.ts가 관리합니다. history.pushState, replaceState, popstate를 패치합니다.

    상황 동작

    pushState (새 화면 진입) 현재 화면 스냅샷 push + scrollY 저장
    replaceState (탭 전환 등) scrollY만 저장, 스택은 유지
    popstate (뒤로가기) 스택 pop + 복원할 scrollY 기록

    탭 전환은 반드시 replace입니다. push로 쌓으면 스와이프할 때 탭 화면이 한 번 더 지나갑니다. 투표·설문 목록에서 탭 바꿀 때 router.replace를 쓰는 이유가 이것입니다.

    게시글 상세처럼 “목록에서 왔다”는 맥락이 중요한 경우, 링크 클릭 직전에 captureNavigationSnapshotNow()로 스냅샷을 확실히 쌓습니다. Next 내부 history 타이밍과 어긋나도 안전합니다.

    스크롤은 scroll 이벤트로 경로별 저장하고, sessionStorage에도 넣습니다. popstate 타이밍이 밀려도 복원할 수 있게 이중으로요.


    4단계 — 경로마다 다른 뒤로가기 규칙

    스와이프가 끝나면 goBack()이 호출됩니다. 실제 이동은 resolveBackTarget()이 정합니다.

    • /community — 허브라 뒤로가기 없음
    • 게시판·관리비 등 메뉴 진입점 — /community로 replace
    • 게시글·투표·설문 상세(스택 있음) — history.back() (탭·필터 쿼리 유지)
    • 게시글(진입 URL 저장됨) — 저장된 목록/검색 URL로 replace

    “스택이 있으면 history.back(), 없으면 상위 경로로 replace”가 기본 흐름입니다. 화면마다 예외가 있어서 규칙 테이블로 관리합니다.


    5단계 — 스크롤 복원, 맨 위로 튀는 문제

    뒤로 온 뒤 useLayoutEffect에서 저장해 둔 scrollY를 꺼내 복원합니다.

    여기서도 레이스가 있습니다. React가 다시 그리는 순간 스크롤이 0으로 튀는 경우가 있어서, swipeBackScrollLock으로 복원 직후 잠깐 scroll guard를 겹쳤습니다. 사소해 보이는데 체감 차이가 큽니다.


    막아 둔 곳들

    다 괜찮은 건 아닙니다.

    • 소방세대점검 폼 — 입력·서명과 스와이프가 겹쳐서 비활성
    • input, textarea, dialog — 입력 중 스와이프 방지
    • /community — 앱 허브, 뒤로가기 없음
    • 가로 스크롤 영역 — 캐러셀·가로 리스트와 충돌 방지

    data-swipe-back-disabled 속성으로 페이지 단위로 끌 수도 있습니다.


    마무리

    정리하면 이렇습니다.

    왼쪽 엣지에서 터치 → 드래그 거리 측정 → 스냅샷으로 이전 화면 미리보기 → 임계값 넘으면 goBack() → 저장해 둔 scrollY로 위치 복원

    네이티브에 가까운 뒤로가기는 브라우저 기본 API만으로는 부족했습니다. 터치 제스처, DOM 스냅샷, History 패치, 경로별 규칙을 조합해야 합니다.

    비슷한 WebView·SPA에서 엣지 스와이프 넣는다면, 제스처 레이어와 라우팅 레이어를 분리하는 쪽을 추천합니다.

Designed by Tistory.