Expo Router 파일 기반 라우팅: 레이아웃, 동적 라우트, Stack·Tabs·Drawer, 딥링크

이 글의 핵심

Expo Router는 React Native(Expo)에서 Next.js App Router와 유사한 파일 기반 라우팅을 제공합니다. app 디렉터리의 파일 트리가 URL 경로가 되고, _layout으로 중첩 내비게이션(Stack·Tabs·Drawer)을 구성합니다. 동적 세그먼트·그룹 라우트·딥링크·전역/로컬 검색 파라미터와 상태 보존 패턴까지 실무 설계 관점으로 설명합니다.

이 글의 핵심

Expo Router는 파일 시스템을 단일 진실 공급원(single source of truth)으로 삼아 화면 전환 경로를 정의하는 라이브러리입니다. React Navigation에서 스크린을 수동으로 등록하는 대신 app 디렉터리의 파일 이름과 폴더 구조가 곧 URL이 됩니다. 그래서 폴더만 보고 앱의 정보 구조를 읽을 수 있고, 레이아웃 파일(_layout)로 탭·스택·드로어 같은 중첩 내비게이션을 선언적으로 묶을 수 있습니다.

이 글은 React Native와 TypeScript에 익숙하고 React Navigation의 스크린·네비게이터 개념을 알고 있다고 가정합니다.


왜 파일 기반 라우팅인가

화면이 수십 개로 늘면 스크린 등록 코드가 길어지고, 이름 충돌이나 딥링크 설정과 어긋난 경로 문자열 같은 문제가 생기기 쉽습니다. 이런 불일치는 실제로 링크를 열어 보기 전까지 드러나지 않습니다. 파일 기반 라우팅은 “경로 = 파일 경로”라는 규칙 하나로 경로 정의와 딥링크 매핑을 한곳에 모읍니다.

Expo Router의 설계는 Next.js App Router와 비슷해서, 웹(Next.js)과 앱(Expo)을 함께 다루는 팀이라면 경로를 정의하는 방식을 통일하기 쉽습니다. 필요하면 API 라우트(+api.ts)도 같은 app 트리에 둘 수 있습니다.

다만 파일 기반이라고 해서 좋은 구조가 저절로 만들어지지는 않습니다. 폴더를 잘못 나누면 스택이 불필요하게 깊어지거나, 레이아웃이 바뀌면서 화면이 다시 마운트됩니다. 뒤에서 다루는 그룹 (name), 레이아웃 분리, 인증 가드 패턴은 이런 비용을 줄이기 위한 규약입니다.


라우트·세그먼트·레이아웃 개념

app 디렉터리와 엔트리

Expo Router 프로젝트는 프로젝트 루트 또는 src 아래에 app 폴더를 둡니다. app/_layout.tsx는 모든 화면을 감싸는 루트 레이아웃이고, app/index.tsx는 기본 경로(/)에 대응하는 화면입니다.

// app/_layout.tsx: 루트 레이아웃에서 스택을 정의
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack
      screenOptions={{
        headerShown: true,
        animation: 'default',
      }}
    />
  );
}
// app/index.tsx: "/" 화면
import { View, Text } from 'react-native';

export default function HomeScreen() {
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>Home</Text>
    </View>
  );
}

Stack은 같은 디렉터리의 자식 라우트를 자동으로 스크린으로 등록합니다. 파일을 하나 추가하면 createStackNavigator에 따로 등록하지 않아도 그 경로로 이동할 수 있습니다.

파일 이름 규칙과 URL에 나타나지 않는 세그먼트

패턴의미
index.tsx디렉터리의 기본 경로(예: users/index → /users)
[id].tsx동적 세그먼트 한 개(/users/42)
[...slug].tsx여러 세그먼트를 받는 캐치올
(group) 폴더URL에 포함되지 않는 그룹
_layout.tsx해당 디렉터리 이하의 공통 레이아웃·네비게이터
+not-found.tsx매칭되는 라우트가 없을 때 보여 줄 화면

(tabs)처럼 괄호로 감싼 폴더는 경로에 나타나지 않고 구성용으로만 쓰입니다. 탭·온보딩·인증 플로를 같은 URL 깊이에서 서로 다른 레이아웃으로 분리할 때 유용합니다.

레이아웃 중첩과 화면 수명

_layout.tsx는 해당 트리의 자식 라우트를 감싸는 부모입니다. 자식 화면 사이를 오가도 부모 레이아웃은 마운트된 상태로 유지되므로, 탭 바처럼 고정된 UI는 상위 _layout에 두고 화면 전환 스택은 하위 _layout에 두는 식으로 수명을 분리합니다. 반대로 루트 레이아웃에 자주 바뀌는 상태를 두면 그 상태가 바뀔 때마다 앱 전체가 리렌더링될 수 있으므로, Context를 어느 레이아웃에 둘지 함께 설계해야 합니다.


단일 스택·그룹·중첩 경로

단일 스택 앱

가장 단순한 형태는 app 아래에 화면 파일을 나열하고 루트에서 Stack만 쓰는 것입니다.

app/
  _layout.tsx
  index.tsx
  settings.tsx
  profile.tsx

/settings, /profile 경로가 자동으로 생깁니다. 화면 간 이동은 router.push, router.replace, Link 컴포넌트로 처리합니다.

import { useRouter } from 'expo-router';
import { Pressable, Text } from 'react-native';

export default function HomeScreen() {
  const router = useRouter();

  return (
    <Pressable onPress={() => router.push('/settings')}>
      <Text>설정으로</Text>
    </Pressable>
  );
}

push는 스택에 새 화면을 쌓고, replace는 현재 화면을 대체합니다. 로그인 완료 후 메인으로 이동할 때처럼 뒤로 가기로 돌아오면 안 되는 흐름에는 replace를 씁니다.

그룹으로 플로 분리: (auth)와 (app)

미인증 사용자용 화면과 인증 후 메인 앱을 그룹으로 나누는 구성이 흔합니다.

app/
  _layout.tsx
  (auth)/
    _layout.tsx
    login.tsx
    register.tsx
  (app)/
    _layout.tsx
    index.tsx
    explore.tsx

그룹 이름은 URL에 나타나지 않으므로 실제 URL은 /login, /register, /, /explore입니다. 코드에서는 router.replace('/(auth)/login')처럼 그룹을 포함한 경로로도 이동할 수 있는데, 같은 URL이 여러 그룹에 존재할 때 어느 쪽인지 명시하는 용도입니다. 같은 URL로 매칭되는 파일이 두 그룹에 동시에 있으면(예: (auth)/index.tsx와 (app)/index.tsx) 어느 쪽이 열릴지 모호해지므로 피해야 합니다.

인덱스와 중첩 경로

users/index.tsx는 /users, users/me.tsx는 /users/me에 대응합니다. 폴더 깊이가 곧 경로 깊이라서 REST 스타일 리소스 트리를 옮기기 쉽습니다. 다만 모바일에서는 스택이 너무 깊어지면 사용자가 길을 잃기 쉬우므로 users/[id].tsx 한 단계로 줄이는 편이 나은 경우가 많습니다.


Stack, Tabs, Drawer 네비게이션

Stack

expo-router의 Stack은 화면 전환 애니메이션과 헤더를 담당하며, 내부적으로 React Navigation의 네이티브 스택을 씁니다. 화면별 옵션은 _layout에서 Stack.Screen의 name(파일 이름)으로 지정합니다.

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: '홈' }} />
      <Stack.Screen
        name="modal"
        options={{ presentation: 'modal', title: '모달' }}
      />
    </Stack>
  );
}

presentation: 'modal'은 iOS에서 아래에서 올라오는 시트 형태로 표시되어 폼 입력 같은 일시적 작업에 잘 맞습니다. 여러 화면에 공통인 옵션은 screenOptions에, 개별 화면 옵션은 Stack.Screen에 두면 읽기 쉽습니다. 화면 파일 안에서 <Stack.Screen options={{ title }} />를 렌더링해 동적으로 제목을 바꿀 수도 있습니다.

Tabs

하단 탭은 app/(tabs)/_layout.tsx에서 Tabs를 정의하는 패턴이 대표적입니다.

// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabsLayout() {
  return (
    <Tabs
      screenOptions={{
        headerShown: true,
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: '피드',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" color={color} size={size} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: '검색',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search" color={color} size={size} />
          ),
        }}
      />
    </Tabs>
  );
}

탭 안에서 다시 스택을 쌓으려면 app/(tabs)/feed/_layout.tsx처럼 탭 폴더 아래에 Stack 레이아웃을 둡니다. 탭마다 별도 스택이 생기므로 다른 탭에 갔다 와도 각 탭의 스택 위치가 보존됩니다.

Drawer

Drawer는 사이드 메뉴가 필요할 때 씁니다. expo-router/drawer의 Drawer를 레이아웃에서 사용하며, @react-navigation/drawer와 react-native-gesture-handler, react-native-reanimated를 함께 설치해야 합니다.

// app/(drawer)/_layout.tsx
import { Drawer } from 'expo-router/drawer';

export default function DrawerLayout() {
  return (
    <Drawer
      screenOptions={{
        drawerPosition: 'left',
        headerShown: true,
      }}
    />
  );
}

Drawer는 제스처 영역과 뒤로 가기 동작이 플랫폼마다 다르고, 메뉴가 숨겨져 있어 탭보다 발견성이 낮습니다. 설정 화면 몇 개 정도라면 스택이나 모달이 더 단순하고, 메뉴 항목이 많아 탭에 다 담기 어려운 앱에서 Drawer가 의미가 있습니다.

네비게이터 혼합

실제 앱은 루트 Stack 안에 탭이 있고, 각 탭 안에 다시 Stack이 있으며, 그 위에 모달이 뜨는 식으로 여러 네비게이터가 중첩됩니다. 사용자가 항상 접근해야 하는 최상위 섹션은 Tabs에 두고, 글쓰기나 결제처럼 시작과 끝이 있는 흐름은 스택 push나 모달로 격리합니다. 토스트·전역 로딩 같은 오버레이는 루트 레이아웃에서 네비게이터와 형제로 렌더링합니다. 네비게이터 경계를 상태 경계로 생각하면 Context나 React Query 캐시 범위를 정하기도 쉬워집니다.


동적 라우트와 레이아웃

동적 세그먼트 [id]

app/users/[id].tsx는 /users/:id에 대응합니다. 화면에서는 useLocalSearchParams로 파라미터를 읽습니다.

// app/users/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text } from 'react-native';

export default function UserScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Text>사용자 ID: {id}</Text>
    </View>
  );
}

제네릭 타입은 단언일 뿐 런타임 검증이 아닙니다. 쿼리로 같은 키가 여러 번 들어오면 값이 배열이 될 수 있고, 딥링크로 잘못된 형식이 들어올 수도 있습니다. Zod 같은 도구로 검증한 뒤 실패하면 에러 화면이나 +not-found로 보내는 규약을 두는 것이 안전합니다.

캐치올 [...slug]

문서처럼 깊이가 가변적인 경로가 필요하면 [...slug].tsx를 씁니다. 이때 slug는 문자열 배열로 들어옵니다. 캐치올이 넓으면 어떤 URL이 어느 화면으로 가는지 추적하기 어려워지므로, 가능하면 명시적인 세그먼트로 나누는 편이 유지보수에 유리합니다.

중첩 _layout과 공유 UI

app/users/_layout.tsx를 두면 /users 이하 화면에 공통 헤더 설정이나 Provider를 둘 수 있습니다. 목록 화면(index)과 상세 화면([id])이 같은 데이터 컨텍스트를 공유할 때 유용합니다.

// app/users/_layout.tsx
import { Stack } from 'expo-router';

export default function UsersLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: '사용자' }} />
      <Stack.Screen name="[id]" options={{ title: '프로필' }} />
    </Stack>
  );
}

React Query를 쓴다면 QueryClientProvider는 루트 레이아웃에 두고, 사용자 데이터는 ['user', id] 같은 키로 캐시하는 방식이 흔합니다. 이렇게 하면 목록에서 받은 데이터를 상세 화면 초기값으로 재사용하기도 쉽습니다.

Typed Routes

app config에서 experiments.typedRoutes를 true로 켜면, 개발 서버가 app 트리를 기준으로 경로 타입을 생성합니다. 그러면 router.push('/setings') 같은 오타나 동적 세그먼트 누락이 타입 오류로 잡힙니다. 동적 세그먼트가 많은 앱일수록 효과가 큽니다.


딥링크

스킴과 도메인

딥링크는 외부 URL로 앱의 특정 화면을 여는 기능입니다. Expo에서는 app config의 scheme으로 myapp:// 같은 커스텀 스킴을 정하고, https 링크로 앱을 열려면 iOS의 associatedDomains(Universal Links)와 Android의 intentFilters(App Links)를 설정한 뒤 웹 도메인에 검증 파일(apple-app-site-association, assetlinks.json)을 올려야 합니다. 개발 빌드와 스토어 빌드가 다른 번들 ID·스킴을 쓴다면 환경별 app config로 분리해 두어야 같은 링크가 각 빌드에서 올바르게 열립니다.

Expo Router는 들어온 URL의 경로를 파일 경로와 같은 규칙으로 해석하므로, 별도의 링킹 설정 객체 없이 myapp://users/42가 app/users/[id].tsx로 연결됩니다.

useLocalSearchParams와 useGlobalSearchParams

useLocalSearchParams는 그 화면이 속한 라우트의 파라미터를 돌려주고, 화면이 포커스를 잃은 상태(스택 아래에 깔린 상태)에서는 다른 화면의 URL 변화에 반응하지 않습니다. useGlobalSearchParams는 현재 전역 URL의 파라미터를 돌려주므로 어떤 화면에서 URL이 바뀌든 다시 렌더링됩니다. 그래서 대부분의 화면은 useLocalSearchParams를 쓰고, 분석 로깅처럼 전역 URL을 관찰해야 하는 곳에서만 useGlobalSearchParams를 씁니다.

딥링크로 들어온 캠페인 코드나 추천인 코드는 쿼리로 전달되는 경우가 많습니다. 값을 저장한 뒤 router.setParams로 쿼리에서 제거하면, 이후 공유되는 URL에 코드가 남지 않습니다.

웹과의 정합성

같은 코드베이스로 웹을 배포하면 브라우저 주소창이 곧 딥링크입니다. 고객 지원이나 마케팅 팀이 URL을 공유할 수 있도록 의미 있는 경로명을 쓰고, 한 번 공개한 경로는 쉽게 바꾸지 않는 것이 좋습니다.


내비게이션 상태 관리

useRouter, usePathname, useSegments

useRouter는 push, replace, back, canGoBack, setParams 같은 명령형 내비게이션 함수를 제공합니다. usePathname은 그룹이 제거된 현재 URL 경로를, useSegments는 그룹을 포함한 세그먼트 배열을 돌려줍니다. 인증 가드처럼 “지금 어느 그룹에 있는가”를 알아야 할 때는 useSegments를 씁니다.

import { useSegments, useRouter } from 'expo-router';
import { useEffect } from 'react';

export function useAuthRedirect(isSignedIn: boolean, isReady: boolean) {
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    if (!isReady) return; // 세션 복원이 끝나기 전에는 리다이렉트하지 않음

    const inAuthGroup = segments[0] === '(auth)';

    if (!isSignedIn && !inAuthGroup) {
      router.replace('/(auth)/login');
    } else if (isSignedIn && inAuthGroup) {
      router.replace('/(app)');
    }
  }, [isSignedIn, isReady, segments, router]);
}

이 훅은 루트 레이아웃이 네비게이터(<Stack> 또는 <Slot>)를 렌더링한 뒤에 동작해야 합니다. 세션을 불러오는 동안 루트 레이아웃이 네비게이터 대신 로딩 화면만 반환하고 그 상태에서 router.replace를 호출하면, “Attempted to navigate before mounting the Root Layout component” 오류가 납니다. 로딩 중에는 스플래시 화면을 유지하고 네비게이터는 항상 렌더링하는 구조가 안전합니다.

Expo Router 5(Expo SDK 53)부터는 <Stack.Protected guard={isSignedIn}>처럼 레이아웃에서 선언적으로 화면 접근을 막는 보호 라우트를 쓸 수 있습니다. 새 프로젝트라면 수동 리다이렉트 훅보다 이 방식이 무한 리다이렉트 같은 실수를 줄여 줍니다.

파라미터와 setParams

필터 상태를 URL에 남기고 싶다면 router.setParams로 현재 화면의 쿼리를 갱신합니다. 탭 사이에 공유하는 상태는 URL보다 Zustand 같은 전역 스토어나 React Context가 나을 때가 많습니다. URL에서 상태를 복원할 수 있다는 점이 실제로 가치가 있을 때만 쿼리를 늘리는 편이 유지보수에 유리합니다.

화면 포커스와 데이터 갱신

다른 화면에서 돌아왔을 때 목록을 새로고침하려면 useFocusEffect를 씁니다. Expo Router가 같은 이름의 훅을 직접 export하므로 @react-navigation/native를 따로 import할 필요가 없습니다.

import { useCallback } from 'react';
import { useFocusEffect } from 'expo-router';

export function useRefetchOnFocus(refetch: () => void) {
  useFocusEffect(
    useCallback(() => {
      refetch();
    }, [refetch]),
  );
}

콜백은 화면이 포커스를 얻을 때마다 실행되므로, useCallback으로 감싸지 않으면 렌더링마다 새 함수로 인식되어 불필요하게 다시 실행됩니다.

중복 전환 방지

버튼을 빠르게 두 번 누르면 같은 화면이 두 번 push될 수 있습니다. 결제처럼 중요한 동작은 서버에서 멱등하게 처리하고, 클라이언트에서는 요청 중 버튼을 비활성화해 중복 전환과 중복 요청을 함께 막습니다.


실전 앱 구조 설계

디렉터리 예시

app/
  _layout.tsx                 # QueryClient, Theme, 세션 Provider
  (auth)/
    _layout.tsx
    login.tsx
    register.tsx
  (app)/
    _layout.tsx               # 인증 후 영역, Stack
    (tabs)/
      _layout.tsx             # Tabs
      index.tsx               # 홈 탭
      inbox.tsx
      profile.tsx
    users/
      [id].tsx                # 프로필 상세 (탭 밖에서도 진입 가능)
  +not-found.tsx

(app)/(tabs) 아래에는 하단 탭으로 전환되는 주요 기능을 두고, users/[id]처럼 푸시 알림이나 딥링크로 바로 열릴 수 있는 화면은 탭 밖의 스택에 둡니다. 이렇게 하면 상세 화면이 어느 탭에서 열렸든 같은 스택 위에 쌓입니다.

기능 모듈과 app의 경계

비즈니스 로직과 API 클라이언트는 app 밖의 features/, services/ 같은 폴더에 두고, app에는 라우팅과 화면 조립만 남기는 편이 테스트와 재사용에 유리합니다. 화면 컴포넌트는 features/user/screens/UserProfileScreen.tsx에 두고 app/users/[id].tsx는 그 컴포넌트를 export만 하는 얇은 파일로 두면, 라우트 구조를 바꿔도 화면 코드는 그대로 둘 수 있습니다.

에러 처리와 404

존재하지 않는 경로는 +not-found.tsx가 처리합니다. 라우트 파일에서 ErrorBoundary 컴포넌트를 named export하면 그 라우트에서 발생한 렌더링 오류를 해당 화면 범위에서 잡을 수 있으므로, 여기서 Sentry 같은 크래시 리포터로 오류를 보내고 재시도 버튼을 보여 주면 됩니다.

React Navigation 코드베이스에서 옮기기

기존에 스크린을 수동으로 등록하던 앱이라면 한 번에 옮기기보다 새 화면부터 Expo Router 경로로 만들고 점진적으로 옮기는 편이 안전합니다. 기존 스크린 컴포넌트를 그대로 export하는 라우트 파일을 만들면 화면 코드를 수정하지 않고 경로만 옮길 수 있습니다. 이때 navigation.navigate('ScreenName') 같은 이름 기반 호출은 경로 기반 router.push('/path')로 바꿔야 합니다.


참고


같이 보면 좋은 글