native 앱 네비게이션 · 라우팅 · 진입 게이팅 · 딥링크 · webview 브릿지
apps/native-app(Expo/expo-router)의 화면 전환 전반을 다룬다. 크게 다섯 축이다.
- expo-router 파일 트리 —
src/app/아래 파일이 곧 라우트. 루트 Stack +(tabs)탭 네비게이터 + 커뮤니티 중첩 Stack. routes레지스트리 — native 코드 안의 모든 화면 이동은 타입 안전한 함수(routes.xxx())로 한다. 하드코딩router.push('/native/...')금지.- 진입 게이팅 — 앱 진입점(
src/app/index.tsx)이 인증/온보딩 상태로 목적지를 직접 결정한다. 강제 플로우는 홈 위 모달이 아니라 게이팅 라우트로. - 딥링크 · intent — Airbridge/푸시 URL을 하나의 파이프라인(
resolveDeepLink→navigateIntent)으로 해석하고, intent는 게이팅을 가로질러 종착지에서 1회 소비된다. - webview 브릿지 — 남아 있는 web 화면을
HowmuchhomeWebView로 띄우고,SentToAppMessage/ReceivedFromAppMessage(@howmuchhome-web/bridge-protocol) 양방향 메시지로 통신한다.
관련: 데이터/세션/provider 스택은 데이터·상태, 화면 작성 컨벤션은 컨벤션 체크리스트, 푸시 수신·표시·탭 라우팅은 푸시 알림, 공유 UI/funnel 패키지는 native-app-shared 참고.
expo-router 구조
파일 위치 = 라우트다. 검증된 실제 트리(apps/native-app/src/app/):
| 경로 | 역할 |
|---|---|
_layout.tsx | 루트 Stack. 모든 provider의 최상위 컨테이너이자 화면 등록처. |
index.tsx | 앱 진입점 = 진입 게이팅 라우터. 어떤 화면도 렌더하지 않고 <Redirect>만 한다. |
+native-intent.ts | expo-router가 매칭 못 한 시스템 딥링크를 /로 redirect (아래 딥링크 참고). |
(tabs)/_layout.tsx | 바텀 탭 네비게이터 + 커스텀 tabBar. 그룹 폴더 (tabs)는 URL에 세그먼트를 안 만든다. |
(tabs)/{home,knowledge-space,more}.tsx | 실제 탭 콘텐츠(native 화면). |
webview/[id].tsx | 남은 web 콘텐츠를 띄우는 범용 웹뷰 스크린. |
native/*.tsx | 모든 native 화면. native/ 아래 평면(flat) — 파일명은 라우트 키의 kebab-case (editProfile → native/edit-profile). |
native/community/[chatUrl]/_layout.tsx | 커뮤니티 공통 셸(카페/채팅 탭)을 위한 중첩 Stack. |
루트 _layout.tsx = provider 스택 + Stack.Screen 등록
_layout.tsx는 (1) 부트스트랩(쿠키·세션·MMKV 캐시 복원)이 끝날 때까지 null을 반환해 스플래시를 붙잡고, (2) 완료되면 provider들을 겹겹이 두른 Stack을 렌더한다. provider 스택 개요(상세는 데이터·상태):
GestureHandlerRootView → KeyboardProvider → (DatadogProvider) →
QueryClientProvider → QueryOptionsOverrider(clientOnly) →
[authToken 있으면] UserLoader + SessionExpired →
ThemeProvider → HowmuchhomeErrorBoundary → NotificationProvider →
AppVersionGate → MaintenanceGate → StackStack에는 모든 화면이 <Stack.Screen name=... options=.../>로 등록된다. 옵션 패턴:
- 일반 push 화면:
CARD_PUSH_OPTIONS(lib/navigation/transitions.ts) — 우측 슬라이드 카드 전환. - 게이팅 진입 화면(
indexRedirect로 뜨는 것:identity-verification,listing-create,mandatory-onboarding,certification-progress,(tabs)):animation: 'none'— 첫 진입/인증 전환이 슬라이드 없이 즉시 표시. - memory 라우터 퍼널 화면(
principal-consent-funnel/[voteId],vote-voter-landing/...):gestureEnabled: false. 스와이프 백이 step이 아니라 스크린 전체를 닫아 진행 내용이 날아가므로 제스처 백을 끄고 앱바 ‘나가기’/CTA로만 이동한다. - 전체화면 모달(
id-card-capture):presentation: 'fullScreenModal'. tax-chat:animation: 'slide_from_bottom'+gestureEnabled: false(스와이프-다운 닫기가 채팅 세로 스크롤과 충돌).
routes 레지스트리 (하드코딩 금지)
native 코드 안의 화면 이동은 반드시 src/lib/navigation/routes.ts의 routes 를 통한다. 하드코딩된 router.push('/native/...') 문자열이나 {pathname, params} 객체를 호출부에 직접 쓰지 않는다.
이유는 타입 안전이다. 각 함수의 반환 타입을 명시적으로 : Href로 선언하면, expo-router의 typedRoutes가 pathname/params를 컴파일타임에 검증하면서도 호출부에서 바로 router.push/router.replace/<Redirect href=...>에 넘길 수 있다. web의 getRoutePath(중첩 객체 params + 무거운 조건부 타입)를 단순화한 함수형 버전이다.
세 가지 형태
import {routes} from '@/lib/navigation/routes';
// 정적 경로 — 인자 없는 함수가 Href 문자열을 반환
router.push(routes.settings());
router.replace(routes.identityVerification());
<Redirect href={routes.home()} />;
// 동적 세그먼트 — 필수 위치 인자, {pathname, params} Href 반환
router.push(routes.onlineAssembly(assemblyId));
router.push(routes.certificationProgress(String(listingId)));
router.push(routes.privacyShareConsent(unionId, consentId));
// 쿼리 파라미터 — 필수는 위치 인자, 옵션은 마지막 객체 인자
router.push(routes.surveyResult(surveyId));
router.push(routes.webview('news-1', {uri, title, presentation: 'card'}));정의부(발췌):
export const routes = {
home: (): Href => '/home',
settings: (): Href => '/native/settings',
onlineAssembly: (assemblyId: string): Href => ({
pathname: '/native/online-assembly/[assemblyId]',
params: {assemblyId},
}),
webview: (
id: string,
q: {uri: string; title?: string; presentation?: WebviewPresentation},
): Href => ({pathname: '/webview/[id]', params: {id, ...q}}),
} as const;레지스트리 추가 규칙
- 각 함수의 반환 타입을 명시적으로
: Href로 선언한다.satisfies Href는 반환 타입을string으로 넓혀(widening) 호출부에서 typedRoutes 오류를 내므로 쓰지 않는다. - dynamicSegment는 필수 위치 인자, queryString은 필수면 위치 인자 / 옵션이면 마지막 객체 인자로 받는다.
- 동적 라우트는
{pathname:'/native/.../[seg]', params:{...}}형태를 반환한다. - 캐스팅이 꼭 필요한 예외(정적 Href 리터럴 타입에 안 잡히는 런타임 경로 등)는 레지스트리 한 곳에서만 처리해 호출부를 깨끗하게 유지한다.
정적 타입화가 불가능한 예외만 직접 push
두 곳뿐이다.
- 브릿지가 런타임에 레지스트리에서 경로를 정하는
pushNativeScreen(spec.route as never,HowmuchhomeWebView.tsx). - 루트 index 재게이팅(
router.replace('/'))과 런타임 유효하지만 정적 타입에 없는 딥링크 경로(... as Href,mainWebviewRouter.ts).
아직 native로 안 옮긴 화면은 webview로
대상이 아직 web 페이지면 routes.webview(...)로 메인 web 호스트(env.WEB2_URL) 기준 절대 URL을 push한다. 홈/일반 섹션에서는 이를 감싼 헬퍼 lib/navigation/homeNav.ts의 pushWebview(path, title?) 를 쓴다(내부에서 routes.webview 사용, 상대 경로면 WEB2_URL로 절대화). 나중에 그 화면이 native로 이식되면 pushWebview(...)를 router.push(routes.xxx())로 바꾸기만 하면 된다.
homeNav.ts의 goBackOrHome() 도 유용하다 — 홈 배너/바텀탭에서 바로 push되어 pop할 이전 화면이 없는 경우(iOS에서 router.back()이 조용히 무반응) 홈으로 replace해 “뒤로가기 버튼 무반응”을 막는다.
진입 게이팅 (native-driven)
본인인증/집주인 인증/필수 온보딩은 앱 진입 직후 라우팅되는 핵심 플로우라, web이 OPEN_NATIVE_SCREEN으로 여는 다른 화면과 달리 native 진입점(src/app/index.tsx)이 게이팅을 직접 담당한다.
진입점 = Redirect만 하는 라우터
index.tsx는 화면을 렌더하지 않는다. useEntryGating()(Suspense) 결과로 <Redirect>만 한다:
function EntryGate() {
const destination = useEntryGating();
switch (destination.type) {
case 'identity': return <Redirect href={routes.identityVerification()} />;
case 'listing-create': return <Redirect href={routes.listingCreate()} />;
case 'progress': return <Redirect href={routes.certificationProgress(String(destination.listingId))} />;
case 'mandatory-onboarding': return <Redirect href={routes.mandatoryOnboarding()} />;
case 'home':
default: return <Redirect href={routes.home()} />; // native 탭
}
}결정 트리
src/features/auth/useEntryGating.ts의 resolveEntryDestination이 web apps/app/src/app/page.tsx의 트리를 재현한다:
getMe() === null → identity (native 본인인증)
보류 딥링크 intent 가 {type:'token'} → listing-create (집주인 추가 인증, peek만)
onboardingStatus === COMPLETED 또는 getHomeAccess()
├─ 미동의 동의 존재 || !me.location → mandatory-onboarding (필수 온보딩)
└─ 그 외 → home
미인증(status !== CERTIFIED) listing 있음 → progress (진행상태 화면)
그 외(신규) → listing-creategetMyOnboardingStatus는 @SkipAuth라 비로그인에서도 호출 가능하다.
cache-first (stale-while-revalidate)
게이팅 입력(getMe, getMyOnboardingStatus, getPrivacyConsentList, getHomeAccess, getMyListingList)은 각각 ensureQueryData({revalidateIfStale: true})로 읽는다. 앱 재실행 시 persist된(MMKV) 캐시를 네트워크 없이 즉시 반환해 첫 게이팅이 빠르고, 백그라운드로 최신화한다. 화면(useAPIQuery)과 동일한 queryKey를 공유하므로, getMe가 null로 재검증되면 같은 키를 관찰하는 SessionExpired가 로그아웃을 처리한다.
게이팅 결과 자체(useEntryGating의 useSuspenseQuery)는 staleTime: 0, gcTime: 0으로 persist하지 않고 매 진입마다 입력 캐시로부터 새로 derive한다.
인증 전환 후 재게이팅
인증 완료(로그인/본인인증/집주인 인증/로그아웃) 직후엔 같은 게이팅 트리를 재사용한다.
useResetEntryGating()→ 게이팅 결과 + 입력 캐시(getMe등)를 모두 비운다.cancelQueries로 in-flight 요청을 먼저 취소(commit 방지)한 뒤removeQueries로 지운다 — 안 그러면 갓 로그인했는데 캐시된getMe=null로 오라우팅될 수 있다.- 이후
router.replace('/')로 index 재진입 → 재게이팅 → 다음 목적지.
강제 플로우 = 게이팅 라우트, 홈 위 모달 ❌
필수 동의/실거주지 같은 강제 전체화면 플로우는 홈 위에 RN Modal로 덮지 않고 게이팅 라우트(/native/mandatory-onboarding)로 둔다. 홈 위 모달로 두면 ① 탭바를 덮으려 RN Modal 필요 ② 모달 뒤 홈 레이아웃 오측정(콘텐츠 뭉침) ③ 다른 바텀시트와 z-order 경쟁(RN Modal끼리는 표시 순서로만 쌓이고 컴포넌트 순서로 위계 지정 불가) ④ children suspend 격리 — 의 복잡도가 줄줄이 붙는다. resolveEntryDestination에 분기 하나(홈 자격이어도 미동의 동의 존재 || !me.location이면 mandatory-onboarding)를 두는 편이 단순하다.
딥링크 · intent 파이프라인
Airbridge 트래킹 링크와 푸시 payload를 단일 파이프라인으로 처리한다. resolve(해석)와 navigate(실행)를 분리해, 화면이 native로 이식될수록 케이스만 추가하면 되도록 설계됐다.
진입점
+native-intent.ts의redirectSystemPath는 모든 시스템 딥링크를/로 redirect한다. Airbridge/푸시 URL은 expo-router 파일 라우트가 아니라 SDK 콜백(lib/airbridge.ts)이 처리하므로, expo-router가 “Unmatched Route” 화면을 띄우는 걸 막는 안전망이다.- 실제 라우팅은
src/lib/webview/mainWebviewRouter.ts의handleDeepLinkUrl(url)—resolveDeepLink→navigateIntent.
resolveDeepLink(url) → DeepLinkIntent (단일 소스)
“이 URL이 native인지 웹뷰인지”를 한 곳에서 결정한다. intent 종류(src/store/deepLink.ts):
| intent | 조건 | 소비처 |
|---|---|---|
{type:'token', token} | 루트 /?token=... (집주인 추가 인증) | 게이팅(집주인 인증 화면 → token, 홈 → /?token) |
{type:'route', path, push?} | native로 이식된 경로 | 홈 도달 시 HomeRedirectHandler |
{type:'webview', url} | 그 외 모든 경로 (절대 URL로 정규화) | 홈 도달 시 HomeRedirectHandler(웹뷰로 로드) |
route 매칭은 두 소스로 한다:
NATIVE_ROUTE_BY_PATH(정적 경로 맵, 예:/knowledge-space,/more) → 탭 전환/navigate.- 동적 경로
/online-assembly/{assemblyId}는 별도 정규식으로 매칭해/native{pathname}으로 보낸다(하위 세그먼트는 제외 → 웹뷰). NATIVE_ROUTE_BY_PATTERN(정규식, 예:/community/{chatUrl}/cafe/posts/{postId},/community/{chatUrl}/chat) →routes.*빌더로 만든 Href를push: true로 홈 위에 push(뒤로가기로 홈 복귀).
navigateIntent(intent) (실행)
현재 세 intent 모두 보류(setPendingDeepLink) + 재게이팅(regate()) 이다. regate()는 게이팅 결과 캐시를 비우고 runWhenNavigationReady로 Root Layout이 ready된 뒤 router.replace('/')를 한다.
⚠️ cold start 푸시/딥링크는 Root Layout(Stack)이 ready되기 전에 들어올 수 있다. 이 버전 expo-router의
router는 ready 전 호출 시 “Attempted to navigate before mounting the Root Layout”로 throw하므로, 직접router.replace를 부르지 말고runWhenNavigationReady(lib/navigation/navigationReady.ts)로 감싼다. 같은 ref는_layout.tsx가setNavigationRef(navigationRef)로 주입한다.
pendingIntent + replay: 비인증 → 인증 → 소비
intent는 pendingDeepLinkAtom에 저장돼 진입 게이팅(본인/집주인 인증)을 가로질러 살아남고, 종착지에서 1회 소비된다. web의 initialUrl + redirectAfterCertificationPath를 일반화한 것이다.
- 본인인증 단계는 intent를 소비하지 않고 통과시킨다.
- 게이팅은
tokenintent를 peek만 한다(peekPendingDeepLink) — 실제 소비(searchEstate({token}))는 집주인 인증 화면이 한다.webview/routeintent는 건드리지 않고 홈까지 넘긴다. - 홈 도달 시
src/features/home/handlers/HomeRedirectHandler.tsx가 남은 intent를 소비한다:webview→pushWebview(url),route→push면router.push(홈 위) / 아니면router.navigate(탭 전환).token은 게이팅 몫이라 여기선 남겨둔다.
HomeRedirectHandler는 mount-once가 아니라useAtomValue로 atom을 구독해 소비한다. cold start 알림(특히 notifee로 표시되는 Sendbird 채팅)은 홈이 마운트된 뒤에야 intent가 세팅되는 경우가 있어, mount-once effect로는 놓쳐 빈 화면이 됐다.
이 흐름 덕분에 비인증 유저가 딥링크로 들어와 → 게이팅(인증)을 마친 뒤 → 원래 딥링크 지점으로 복귀한다. 게이팅을 우회해 미인증 상태로 콘텐츠에 도달하는 일도 막는다.
webview 브릿지
남아 있는 web 화면은 src/components/webview/HowmuchhomeWebView.tsx가 react-native-webview로 띄우고, 양방향 메시지로 native와 통신한다. 메시지 타입은 @howmuchhome-web/bridge-protocol이 정의한다.
메시지 프로토콜
- web → app:
SentToAppMessage(discriminated union,type필드로 분기).HowmuchhomeWebView의onMessage가 파싱해src/lib/webview/messageHandler.ts의handleWebviewMessage(msg, ctx)로 디스패치한다. - app → web:
ReceivedFromAppMessage.HowmuchhomeWebView의postMessage(type, args?)로 보낸다. 화면이 focus + load되기 전 메시지는 큐잉했다가 활성화되면 flush한다(양방향). - 자동 동기화: load 후
AUTH_TOKEN,USER,SET_FOCUS,SET_LAYOUT(safe-area inset + 키보드 높이),APP_INFO,POWER_STATE_UPDATE를 web에 push한다.
주요 SentToAppMessage 케이스(발췌): 인증(SIGN_IN/SIGN_OUT/LEAVE), 네비게이션(NAVIGATE/NAVIGATE_FULLSCREEN/NAVIGATE_VERTICAL/NAVIGATE_GO_BACK/CLOSE_SCREEN/CLOSE_ALL_SCREEN), OPEN_NATIVE_SCREEN, 미디어/업로드(SELECT_MEDIA/OPEN_CAMERA/SELECT_FILE), ID_CARD_CAPTURE, FILE_DOWNLOAD, PERMISSION_REQUEST 등.
nativeScreens 레지스트리와 fallback
web이 OPEN_NATIVE_SCREEN {name, params, reqId}를 보내면, pushNativeScreen(name, params)가 src/lib/webview/nativeScreens.ts의 REGISTRY 를 조회한다. 키는 web의 라우트 키와 동일. 두 종류(discriminated union):
const REGISTRY: Record<string, NativeScreenSpec> = {
customerSupport: {route: '/native/customer-support'}, // kind:'native'(기본) — expo-router 경로
serviceIntro: {kind: 'webview'}, // 외부 URL → native 웹뷰(/webview/[id])
};kind:'native'(기본):route로 expo-router 화면을 push.kind:'webview': web이 넘긴{url, title}params로/webview/[id]를 push(URL 단일 소스를 web에 둔다).- 미등록 키 →
getNativeScreen이null→pushNativeScreen이false반환 → 핸들러가NATIVE_SCREEN_NOT_SUPPORTED {v, reqId, name}를 web에 회신한다. web은 이를 받아 앱 업데이트 안내(스토어 이동)로 fallback한다. 즉 web은 항상 native를 시도해도 안전하다.
레지스트리 없이 명령형으로 push하는 화면
일부 화면은 REGISTRY를 거치지 않고 브릿지 핸들러가 직접 push한다. 예: 본인인증 웹뷰가 ID_CARD_CAPTURE를 보내면 openIdCardCapture(screenId)가 router.push(routes.idCardCapture(requesterId))로 신분증 촬영 화면을 띄우고, 결과를 CAMERA_CAPTURE_RESULT로 요청 웹뷰에 회신한다. 이런 화면도 경로 규칙은 동일하게 native/ 아래에 둔다.
인증 세션 전달 (accesstoken 쿠키)
native가 본인인증을 직접 처리하므로(web의 document.cookie = accesstoken=... 대응), store/auth.ts의 signIn/restoreSession이 setAuthCookie(token)(src/lib/cookies.ts)로 WebView 쿠키 jar에 accesstoken을 심는다. 이게 없으면 인증 후 메인 웹뷰가 로드 시점에 토큰을 못 읽어 다시 온보딩을 띄운다 — AUTH_TOKEN postMessage는 web load 이후라 초기 게이팅/SSR에는 늦기 때문. signOut/leave는 clearAuthCookie로 제거한다.
브릿지 인증 케이스는 native 로직을 직접 호출한다: SIGN_IN → signIn(token) + invalidateMe()(→ UserLoader 재조회), SIGN_OUT → signOut() + returnToLoggedOutHome()(재게이팅), LEAVE → leave()(UserAPI.leave + signOut).
푸시 payload → 딥링크 → 이 파이프라인으로 이어지는 흐름은 푸시 알림 참고.
뒤로가기 주의점
- 웹뷰 화면(Android):
HowmuchhomeWebView가 focus 상태일 때hardwareBackPress를 인터셉트한다. 웹뷰 내부에 뒤로 갈 히스토리가 있으면(navigationState.canGoBack)webview.goBack()후true(소비), 없으면false(expo-router가 스크린 pop 처리)를 반환한다. - web 헤더의 닫기/뒤로 버튼:
router.back()(Next)이 아니라useNavigation().back()(@howmuchhome-web/app)을 쓴다 → 웹뷰면NAVIGATE_GO_BACK브릿지 → native가navigation.goBack()(스크린 pop), 웹이면router.back(). goBack(브릿지): 루트(메인 웹뷰)에서는 pop할 native 화면이 없다.router.canGoBack()가드 없이dismissAll을 부르면 expo-router가 “POP_TO_TOP was not handled” 경고를 내므로, 루트면 noop 처리한다.- memory 라우터 퍼널 화면: 스와이프 백이 step이 아니라 스크린 전체를 닫으므로
gestureEnabled: false로 두고 앱바/CTA로만 이동한다(위_layout.tsx옵션 참고). 퍼널 내부 뒤로가기(하드웨어 백 + back 버튼) 통합은@howmuchhome-web/funnel/native의BackGuardProvider/PreventLeaveConfirm가 담당한다 → native-app-shared. goBackOrHome(): pop 대상이 없어router.back()이 iOS에서 무반응이 되는 화면은 이 헬퍼로 홈 replace한다(homeNav.ts).
관련 파일
| 경로 | 내용 |
|---|---|
apps/native-app/src/app/index.tsx | 진입점 — useEntryGating 결과로 <Redirect>만. |
apps/native-app/src/app/_layout.tsx | 루트 Stack + provider 스택 + Stack.Screen 등록. |
apps/native-app/src/app/+native-intent.ts | 시스템 딥링크 → / redirect(안전망). |
apps/native-app/src/app/(tabs)/_layout.tsx | 바텀 탭 네비게이터 + 커스텀 tabBar(pushHref 탭 push). |
apps/native-app/src/lib/navigation/routes.ts | routes 레지스트리 — 타입 안전한 native 라우트 함수. |
apps/native-app/src/lib/navigation/homeNav.ts | pushWebview(미이식→웹뷰) · goBackOrHome 헬퍼. |
apps/native-app/src/lib/navigation/navigationReady.ts | setNavigationRef · runWhenNavigationReady(cold start 가드). |
apps/native-app/src/features/auth/useEntryGating.ts | 진입 게이팅 결정 트리 · cache-first · 재게이팅. |
apps/native-app/src/store/deepLink.ts | pendingDeepLinkAtom · DeepLinkIntent · peek/consume. |
apps/native-app/src/lib/webview/mainWebviewRouter.ts | resolveDeepLink → navigateIntent → handleDeepLinkUrl. |
apps/native-app/src/features/home/handlers/HomeRedirectHandler.tsx | 홈 도달 시 보류 intent 소비(반응형). |
apps/native-app/src/components/webview/HowmuchhomeWebView.tsx | 웹뷰 컴포넌트 — 브릿지 메시지 큐잉·자동 동기화·NavigationApi. |
apps/native-app/src/lib/webview/messageHandler.ts | handleWebviewMessage — SentToAppMessage 디스패치. |
apps/native-app/src/lib/webview/nativeScreens.ts | OPEN_NATIVE_SCREEN name→화면 레지스트리(native/webview) + fallback. |
apps/native-app/src/lib/cookies.ts | setAuthCookie/clearAuthCookie — WebView accesstoken 쿠키. |
packages/bridge-protocol/index.ts | SentToAppMessage/ReceivedFromAppMessage 메시지 타입 정의. |