Skip to Content
Native Appnative 앱 네비게이션 · 라우팅 · 진입 게이팅 · 딥링크 · webview 브릿지

native 앱 네비게이션 · 라우팅 · 진입 게이팅 · 딥링크 · webview 브릿지

apps/native-app(Expo/expo-router)의 화면 전환 전반을 다룬다. 크게 다섯 축이다.

  1. expo-router 파일 트리src/app/ 아래 파일이 곧 라우트. 루트 Stack + (tabs) 탭 네비게이터 + 커뮤니티 중첩 Stack.
  2. routes 레지스트리 — native 코드 안의 모든 화면 이동은 타입 안전한 함수(routes.xxx())로 한다. 하드코딩 router.push('/native/...') 금지.
  3. 진입 게이팅 — 앱 진입점(src/app/index.tsx)이 인증/온보딩 상태로 목적지를 직접 결정한다. 강제 플로우는 홈 위 모달이 아니라 게이팅 라우트로.
  4. 딥링크 · intent — Airbridge/푸시 URL을 하나의 파이프라인(resolveDeepLinknavigateIntent)으로 해석하고, intent는 게이팅을 가로질러 종착지에서 1회 소비된다.
  5. 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.tsexpo-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 (editProfilenative/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 → Stack

Stack에는 모든 화면이 <Stack.Screen name=... options=.../>로 등록된다. 옵션 패턴:

  • 일반 push 화면: CARD_PUSH_OPTIONS(lib/navigation/transitions.ts) — 우측 슬라이드 카드 전환.
  • 게이팅 진입 화면(index Redirect로 뜨는 것: 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.tsroutes 를 통한다. 하드코딩된 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.tspushWebview(path, title?) 를 쓴다(내부에서 routes.webview 사용, 상대 경로면 WEB2_URL로 절대화). 나중에 그 화면이 native로 이식되면 pushWebview(...)router.push(routes.xxx())로 바꾸기만 하면 된다.

homeNav.tsgoBackOrHome() 도 유용하다 — 홈 배너/바텀탭에서 바로 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.tsresolveEntryDestination이 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-create

getMyOnboardingStatus@SkipAuth라 비로그인에서도 호출 가능하다.

cache-first (stale-while-revalidate)

게이팅 입력(getMe, getMyOnboardingStatus, getPrivacyConsentList, getHomeAccess, getMyListingList)은 각각 ensureQueryData({revalidateIfStale: true})로 읽는다. 앱 재실행 시 persist된(MMKV) 캐시를 네트워크 없이 즉시 반환해 첫 게이팅이 빠르고, 백그라운드로 최신화한다. 화면(useAPIQuery)과 동일한 queryKey를 공유하므로, getMe가 null로 재검증되면 같은 키를 관찰하는 SessionExpired가 로그아웃을 처리한다.

게이팅 결과 자체(useEntryGatinguseSuspenseQuery)는 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.tsredirectSystemPath는 모든 시스템 딥링크를 /로 redirect한다. Airbridge/푸시 URL은 expo-router 파일 라우트가 아니라 SDK 콜백(lib/airbridge.ts)이 처리하므로, expo-router가 “Unmatched Route” 화면을 띄우는 걸 막는 안전망이다.
  • 실제 라우팅은 src/lib/webview/mainWebviewRouter.tshandleDeepLinkUrl(url)resolveDeepLinknavigateIntent.

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(뒤로가기로 홈 복귀).

현재 세 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.tsxsetNavigationRef(navigationRef)로 주입한다.

pendingIntent + replay: 비인증 → 인증 → 소비

intent는 pendingDeepLinkAtom에 저장돼 진입 게이팅(본인/집주인 인증)을 가로질러 살아남고, 종착지에서 1회 소비된다. web의 initialUrl + redirectAfterCertificationPath를 일반화한 것이다.

  • 본인인증 단계는 intent를 소비하지 않고 통과시킨다.
  • 게이팅은 token intent를 peek만 한다(peekPendingDeepLink) — 실제 소비(searchEstate({token}))는 집주인 인증 화면이 한다. webview/route intent는 건드리지 않고 홈까지 넘긴다.
  • 홈 도달 시 src/features/home/handlers/HomeRedirectHandler.tsx가 남은 intent를 소비한다: webviewpushWebview(url), routepushrouter.push(홈 위) / 아니면 router.navigate(탭 전환). token은 게이팅 몫이라 여기선 남겨둔다.

HomeRedirectHandler는 mount-once가 아니라 useAtomValue로 atom을 구독해 소비한다. cold start 알림(특히 notifee로 표시되는 Sendbird 채팅)은 홈이 마운트된 뒤에야 intent가 세팅되는 경우가 있어, mount-once effect로는 놓쳐 빈 화면이 됐다.

이 흐름 덕분에 비인증 유저가 딥링크로 들어와 → 게이팅(인증)을 마친 뒤 → 원래 딥링크 지점으로 복귀한다. 게이팅을 우회해 미인증 상태로 콘텐츠에 도달하는 일도 막는다.

webview 브릿지

남아 있는 web 화면은 src/components/webview/HowmuchhomeWebView.tsxreact-native-webview로 띄우고, 양방향 메시지로 native와 통신한다. 메시지 타입은 @howmuchhome-web/bridge-protocol이 정의한다.

메시지 프로토콜

  • web → app: SentToAppMessage (discriminated union, type 필드로 분기). HowmuchhomeWebViewonMessage가 파싱해 src/lib/webview/messageHandler.tshandleWebviewMessage(msg, ctx)로 디스패치한다.
  • app → web: ReceivedFromAppMessage. HowmuchhomeWebViewpostMessage(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.tsREGISTRY 를 조회한다. 키는 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에 둔다).
  • 미등록 키getNativeScreennullpushNativeScreenfalse 반환 → 핸들러가 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.tssignIn/restoreSessionsetAuthCookie(token)(src/lib/cookies.ts)로 WebView 쿠키 jar에 accesstoken을 심는다. 이게 없으면 인증 후 메인 웹뷰가 로드 시점에 토큰을 못 읽어 다시 온보딩을 띄운다 — AUTH_TOKEN postMessage는 web load 이후라 초기 게이팅/SSR에는 늦기 때문. signOut/leaveclearAuthCookie로 제거한다.

브릿지 인증 케이스는 native 로직을 직접 호출한다: SIGN_INsignIn(token) + invalidateMe()(→ UserLoader 재조회), SIGN_OUTsignOut() + returnToLoggedOutHome()(재게이팅), LEAVEleave()(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/nativeBackGuardProvider/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.tsroutes 레지스트리 — 타입 안전한 native 라우트 함수.
apps/native-app/src/lib/navigation/homeNav.tspushWebview(미이식→웹뷰) · goBackOrHome 헬퍼.
apps/native-app/src/lib/navigation/navigationReady.tssetNavigationRef · runWhenNavigationReady(cold start 가드).
apps/native-app/src/features/auth/useEntryGating.ts진입 게이팅 결정 트리 · cache-first · 재게이팅.
apps/native-app/src/store/deepLink.tspendingDeepLinkAtom · DeepLinkIntent · peek/consume.
apps/native-app/src/lib/webview/mainWebviewRouter.tsresolveDeepLinknavigateIntenthandleDeepLinkUrl.
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.tshandleWebviewMessageSentToAppMessage 디스패치.
apps/native-app/src/lib/webview/nativeScreens.tsOPEN_NATIVE_SCREEN name→화면 레지스트리(native/webview) + fallback.
apps/native-app/src/lib/cookies.tssetAuthCookie/clearAuthCookie — WebView accesstoken 쿠키.
packages/bridge-protocol/index.tsSentToAppMessage/ReceivedFromAppMessage 메시지 타입 정의.
Last updated on