Skip to content

화면 스택 계약 (navigation stack)

2026-09-08 이슈 #1393(좌우 슬라이드 화면 이동 규칙 정리, 오너 규칙 6개 + 결정 D1=a) 산출물. 모바일에서 "깊이 들어가는 화면"이 어떻게 열리고 닫히는지, 뒤로가기 4경로와 탭바가 무엇을 하는지를 한 곳에 적는다. 구조 변경은 이 계약 개정이 선행한다.

0. 한 줄 원칙

깊이 화면의 열림 상태는 내비게이션 저장소의 화면 스택 하나가 갖는다. 열기 = push(오른쪽 슬라이드 인 + 브라우저 히스토리 1칸), 닫기 = pop(슬라이드 아웃 + 히스토리 1칸). 뒤로가기 4경로가 전부 같은 pop을 부르고, 탭바 버튼은 스택을 비우고 첫 화면으로 간다.

iOS의 탭마다 하나씩 있는 내비게이션 스택과 같은 모델이다. 유저는 어느 화면에서 어떻게 뒤로 가든 한 겹씩 돌아오고, 탭을 누르면 언제나 그 탭의 첫 화면을 본다.

1. 오너 규칙 (2026-09-08)

  1. 모든 탭의 첫 화면은 가장 위 화면이다. 첫 화면에서는 좌우 스와이프로 화면 이동이 일어나지 않는다.
  2. 탭에서 깊이 들어간 화면은 한 겹씩 뒤로 가면서 이전 화면으로 돌아온다.
  3. 슬라이드 화면은 전부 오른쪽에서 왼쪽으로 들어온다.
  4. 탭바 버튼을 누르는 순간 그 탭의 첫 화면으로 가고, 이전 깊이 스택은 초기화된다. 이미 그 탭이어도 같다.
  5. 다른 탭에서 연 화면은 "연 탭의 스택" 위에 쌓인다. 탭 값을 바꾸지 않는다.
  6. 슬라이드 화면 안에는 내용 넘김용 좌우 스와이프를 두지 않는다. 첫 화면의 내용 넘김 스와이프(홈 기간 카드, 일지 달력 월 이동)는 그대로 둔다.

D1(오너 결정 2026-09-08): 드로워에서 여는 도구 화면 3종(나의 주요 종목·1RM 직접 입력·커스텀 종목 관리)도 스택 화면이다. 오른쪽 슬라이드 진입, 뒤로가기 4경로.

2. 용어

용어
첫 화면탭 값 하나가 가리키는 판(홈·일지·피드·그룹·리포트·검색·내 정보). 스택의 바닥. keep-alive로 숨겨질 뿐 언마운트되지 않는다
깊이 화면첫 화면 위에 오른쪽에서 슬라이드되어 쌓이는 전체 화면. 스택 항목 하나
스택 항목{ id, kind, tab, params, closing }. tab은 소유 탭(전역 항목은 null), closing은 닫히는 중 표시
전역 항목탭에 속하지 않는 층 — 메뉴 드로워(menu)·운동 플로우(workout)·법률 문서(legal). 같은 배열에 tab: null로 둔다
뒤로가기 4경로① 화면 안 ‹ 버튼 ② 앱 안 스와이프(왼→오) ③ iOS 가장자리 스와이프(WebView 히스토리 back) ④ Android 뒤로 버튼(셸이 history.back() 호출)
등록표kind마다 그리기·닫기 콜백·스크롤 복원 여부·하단 크롬 신고·애니를 한 줄로 적은 표. 새 화면은 여기 한 곳에만 쓴다

3. 저장소와 히스토리

  • 스택은 navigationStore의 필드 screens다. 조립 루트는 이 필드를 읽지 않는다. 각 탭 판 안의 호스트 컴포넌트가 screensForTab(tab)만 구독하고, 저장소는 바뀐 탭의 배열만 새 참조로 만든다(렌더 입자성: 탭 전환 시 조립 루트 커밋 0 유지). 전역 항목의 호스트는 app.tsx 층에 둔다(법률 문서는 로그인 전 온보딩에서도 열린다).
  • 열림 상태의 정본은 스택 하나다. groupStore·friendScopeStore는 데이터만 갖고 "무엇이 열려 있는가"는 갖지 않는다. 계정 전환·관리자 유저 열람의 리셋 리터럴(ownerChangeNavigationState)에 screens: []가 포함된다.
  • push(kind, params): 배열 push + history.pushState({ lg: kind, sid: id }). 같은 kind·같은 params가 연속이면 replaceState. Android 셸은 history.state.lg"root"가 아닌 문자열일 때만 history.back()을 보내고 아니면 앱을 백그라운드로 보내므로, 루트 항목은 정확히 { lg: "root" }, 나머지는 "root"가 아닌 문자열이어야 한다.
  • requestPop(id): ①②가 부른다. 항목이 최상단이 아니거나 이미 closing이면 무시. closing = true → 슬라이드 아웃 → finishPop(id, { consumeHistory: true }) = 배열 제거 + history.back() 1회.
  • popstate(③④): 히스토리는 이미 움직였다. finishPop(top.id, { consumeHistory: false }) = 최상단 상태만 닫는다(슬라이드 아웃 재생). 스택이 비었으면 아무것도 하지 않는다. "열려 있어 보이는 것"을 추측해 닫는 폴백과 커밋 뒤 갱신되는 상태 미러는 두지 않는다. 같은 화면이 두 경로로 두 번 닫히지 않게 id 기준으로 멱등하다.
  • unwindAll(): 탭바 버튼·드로워 행. 히스토리는 누른 즉시 history.go(-N) 1회(N = 스택 길이, 0이면 부르지 않음) + ignoreNextPop 1. 화면 제거는 판이 숨은 뒤(탭 전환 이펙트)에 한다. 같은 탭을 다시 누르면 그 자리에서 비우고 스크롤을 맨 위로 올린다. 초기화에는 슬라이드 아웃이 없다. 숨은 탭의 깊이는 남기지 않는다(브라우저 히스토리가 한 줄이고, 하단 크롬 신고가 판 가시성을 모르기 때문).
  • ignoreNextPop 계약: back()·go()를 우리가 부를 때마다 +1, popstate마다 −1. go(-N)은 실제 이동 시 popstate가 1회만 온다. N ≤ 0이면 부르지 않는다(이동이 없으면 popstate도 없어 카운터가 새고 다음 사용자의 뒤로가기가 삼켜진다).
  • 이동 중 호출은 미룬다: 브라우저의 back()·go()는 비동기이고 목표는 부르는 시점 위치에서 계산된다. 이동이 끝나기 전(popstate 전)에 들어온 push/replace/back/go는 드라이버가 순서대로 미뤄 두었다가 popstate 뒤에 실행한다. "닫고 바로 열기"(드로워 행 → 도구 화면, 운동 이탈 → 화면 push)가 이 경우다 — 미루지 않으면 새 항목이 이동 전 자리 위에 쌓이고 이동이 끝난 브라우저 위치는 그 아래에 서서, 다음 초기화의 go(-1)이 앱 밖으로 나간다(ci:local e2e dock-geometry가 잡음, 2026-09-08).
  • 뒤로가기 스크롤 복원: 복원 대상 여부는 등록표 속성, 실행은 호스트의 layout effect(항목 수가 줄 때). 오버레이 자신의 스크롤은 복원하지 않는다.
  • 데스크톱: 같은 저장소·히스토리 관리자를 쓰지만 스택 동작은 모바일 셸에서만 켠다. 데스크톱은 현행(진짜 탭 이동, 세션 상세는 일지 탭) 그대로.

4. 화면 셸과 스와이프

  • 공용 슬라이드 화면 셸(StackScreen)이 pg-slidein/out·스와이프·뒤로 버튼·하단 크롬 신고·재마운트 시 애니 생략(pg-restore)·항목별 Suspense 경계를 맡는다. 화면 컴포넌트는 본문과 onBack만 갖는다.
  • 오버레이는 판 안(첫 화면 루트의 형제)에 .pg-overlay 클래스로 둔다. 호스트는 DOM 래퍼를 만들지 않는다(컨테이닝 블록 .app·바닥 여백 유도식·숨은 판 가드·e2e 셀렉터 무변경). 새 슬롯은 만들지 않는다.
  • 스와이프 판정 한 벌(useSlideBack): 이동 중 첫 10px에서 가로/세로 축 고정, 세로면 그 터치 포기. 손 뗄 때 dx ≥ 60px, 또는 dx ≥ 30px이고 속도 > 0.4px/ms이면 requestPop. 왼쪽 가장자리 20px 안에서 시작한 터치와 touchcancel은 제외(iOS OS 제스처 몫). 스택 최상단 화면 루트에만 붙이고 touch-action: pan-y. 손가락 추종은 하지 않는다.
  • 예외: 종목 탭 가로 드래그가 있는 운동 기록 화면·그룹 작성창에는 루트 스와이프를 붙이지 않는다. 아래에서 올라온 시트가 열려 있는 동안 그 아래 슬라이드 화면은 스와이프 뒤로를 받지 않는다(시트가 최상단, 시트 안 좌우 넘김은 시트 소관).

5. 교차 탭 (규칙 5)

  • push는 항상 "지금 활성 탭"의 스택에 쌓는다. 원점 인자(returnTab·origin·sessionDetailOrigin·daySummary.origin)와 닫기 경로의 setTab(원점)은 두지 않는다.
  • 모바일에는 "기록(pr)" 탭이 없다. 종목 상세는 연 탭 스택 위 화면으로만 존재한다. 종목 상세 열기 함수는 "상태 세팅 + 적재"만 하고, 표시 위치(데스크톱 = 탭 이동, 모바일 = push)는 셸 컨테이너가 주입한 presentPrExerciseDetail이 정한다.
  • 열기 = 적재: push되는 화면의 데이터는 열기 함수가 직접 부른다. 탭 진입 적재 정책은 탭 값 변화에만 반응하므로 스택 push는 그 사각지대다. 종목 상세는 PR 개요를 함께 적재하고(래치가 있어 이미 있으면 즉시 반환), 일 요약의 하루 데이터는 "활성 탭이 일지일 때만" 게이트를 allowInactive로 우회한다. 홈 리포트 오버레이는 push 함수 안에서 리포트 개요를 부른다.
  • 세션 상세가 내 일지의 선택 날짜·표시 달을 바꾸는 것은 세션이 일지 판에서 그려질 때만이다. 일지 달력 조작이 세션 상세를 닫는 것도 일지 스택의 세션일 때만이다.

6. 친구 페이지·드로워 진입 화면

  • 친구 페이지 = 연 탭(피드 또는 그룹) 스택의 항목 하나. 오른쪽 진입. 안의 4탭(프로필·일지·리포트·피드)은 교체이고, 내부 탭 전환은 친구 항목 위의 모든 항목을 pop한다. 그 위 하루·월간·세션 상세·PR 전체 보기는 같은 스택의 상위 항목. 친구 피드의 세션 상세는 친구 페이지 소유 슬롯을 쓴다(전역 세션 경로 아님).
  • 탭바에 없는 탭(리포트·검색·내 정보)은 각자 첫 화면. 드로워 행 = 탭바 누름과 같은 의미(스택 비움 + 탭 이동).
  • 도구 화면 3종(D1)·일 요약·법률 문서는 스택 항목이다. 판 묶음 전체를 가리는 레이어 슬롯(.tab-keep off)은 은퇴한다.
  • 그룹 작성창과 보드 안 종목 기록 뷰는 보드 위 항목이다(뒤로 한 겹).

7. 범위 밖

  • 운동 플로우 안의 단계(시작 → 기록 → 완료)는 플로우 항목 1개 안의 단계다. 완료 화면에서 OS 뒤로가기가 플로우째 나가는 현행을 유지한다.
  • 로그인 전 온보딩 5단계.
  • 웹 브라우저(네이티브 셸 밖)에서 스택이 빈 상태의 뒤로가기는 지금처럼 앱 문서를 떠난다.
  • 아래에서 올라오는 시트·가운데 모달은 스택 항목이 아니다. 시트가 열린 동안의 스와이프 규칙은 §4.

8. 적용 상태 (이슈 #1393)

Phase내용상태
Phase 1이 문서 + 실패하는 명세 테스트 + "열기 = 적재" 분리(presentPrExerciseDetail 주입, 일 요약 allowInactive, 세션 상세 일지 상태 동기화를 일지 판 호스팅 시로 한정)완료 (2026-09-08)
Phase 2navigationStore.screens 화면 스택(탭 태그 + 전역 항목) + 히스토리 드라이버(barbelicNav.createHistoryDriver: 폴백·상태 미러 판정 폐지, go(-N)) + 등록표 controllers/screenKinds.ts + pushScreen/requestPop/finishPop/unwindAll/unwindTo/screensForTab + 탭바 = unwindAll. 종전 lgPushLayer/lgConsumeLayer는 스택 어댑터(consume이 최상단이 아니면 그 위까지 unwind — 배열에서만 지우는 분기 폐지). 명세 테스트 tests/react/navigationStack.test.mjs 게이트 편입완료 (2026-09-08)
Phase 3공용 슬라이드 화면 셸 ui/mobile/lib/StackScreen.tsx(UiStackScreen: 진입/퇴장·pg-restore·스와이프·뒤로·크롬 신고·finishPop) + 탭 스택 컨텍스트 ui/mobile/shell/tabStack.tsx·판별 호스트 features/navigation/TabStackProvider.tsx(홈·일지·기록 판, 친구 판은 가상 탭 "friend") + 스와이프 훅 한 벌 useSwipeBack(§4 값) + 이관: 일지 월간·하루(journalMonth/journalDay, 세션 아래 복원은 beneathTop), 홈 리포트(homeReport), PR 전체 보기(prTape·prList). 공유 리포트 요소의 복귀 탭 = 활성 탭완료 (2026-09-08)
Phase 4그룹(groupLounge/groupBoard/groupSession/groupComposer/groupBoardExercise, 닫기 = 컨테이너 등록 closer setScreenCloser), 세션 상세(session/friendSession — 공용 ui/mobile/lib/StackSessionOverlay.tsx, 판마다 TabStackEntries가 연 탭 스택 위에 그림, 모바일은 열기 시 탭 전환·원점 복귀 없음), 종목 상세(prDetail — 모바일 pr 판 제거, UiRecordsDetail이 스택 셸), 일 요약(daySummary 스택 셸, .tab-keep off 레이어 슬롯에서 은퇴), 친구 페이지(friendScope — 연 탭 스택 항목, 오른쪽 진입, 내부 4탭 교체 시 위 층 정리, 친구 피드 세션 = friendSession 슬롯, 칩 바 채널 fsb 신고). StackScreen: 최상단이 아닌 화면의 뒤로 = 위 하위 뷰 한 겹완료 (2026-09-08)
Phase 5도구 화면 3종(recordsTool, params.tool — D1=a: 워드마크 행 좌측 뒤로 버튼 + 스와이프 + OS 뒤로가기, 드로워에서 열면 현재 탭 스택을 비운 뒤 push, .prt-stack 셸 숨 12px) · MobileTabFrame의 레이어 슬롯(.tab-keep off)·판 밖 친구 판(scopePane) 은퇴 · 법률 문서는 종전대로 전역 항목(legal, app.tsx 층 호스트 — 앱 밖 고정 층이라 슬라이드·스와이프는 두지 않음) · 계약 문서 개정(shell-frames·bottom-chrome-clearance·feed-props·session-screen-props·group-props·records-props·profile-screen-props)완료 (2026-09-08)

게이트(전 PR): tests/react/navigationStack.test.mjs(스택 명세 14건) · tests/react/swipeBack.test.mjs(스와이프 판정 7건) · tests/react/navigationStackRegistry.test.mjs(push되는 모든 kind가 등록표에 있고, .pg-slidein은 공용 셸 한 곳만 그리며, 전역 항목은 menu·workout·legal뿐).

2026-09-10 편집 복귀 화면 보존 (#1554)

  • 운동·세션 편집을 여는 동안 기존 MobileTabsRegion과 방문 탭·상세 화면은 .tab-keep.off 안에 마운트를 유지한다. 편집 복귀는 같은 DOM과 로컬 상태를 다시 표시한다. 스택 항목과 브라우저 히스토리의 push/pop 의미는 유지한다.
  • UiStackScreen은 진입 애니메이션 완료 또는 숨김으로 인한 취소 시 진입을 소비한다. 이미 열린 상세를 편집 후 복원할 때 진입 슬라이드를 다시 재생하지 않는다. 닫기 슬라이드·한 겹 뒤로가기는 기존 규칙대로다.
  • 그룹 작성기는 전역 운동 flow와 별도 분기다. GroupStackPane은 작성기 진입 시 그룹 목록·스택·미전송 입력을 제거하지 않고 숨겨 유지한다. 작성기만 형제로 표시하고 닫을 때 동일 그룹 화면을 복원한다. 상세는 그룹 계약을 따른다.