Expo Router 완벽 가이드 — React Native 파일 기반 라우팅
이 글의 핵심
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)로 탭·스택·드로어 등 중첩 내비게이션을 선언적으로 묶을 수 있습니다.
본문에서는 핵심 개념(라우트·세그먼트·레이아웃)부터 Stack·Tabs·Drawer 구성, [id] 동적 라우트와 공유 레이아웃, 딥링크, useRouter·세그먼트·파라미터를 통한 상태 관리, 마지막으로 인증·온보딩·탭이 공존하는 실전 디렉터리 설계까지 단계적으로 정리합니다. 이미 React Native와 TypeScript에 익숙하고, react-navigation의 기본 개념(스크린, 네비게이터)을 알고 있다고 가정합니다.
1. 왜 파일 기반 라우팅인가
모바일 앱에서 화면이 수십 개로 늘면 스크린 등록 코드만 수백 줄이 되기 쉽습니다. 이름 충돌, 중복 경로, 딥링크와 불일치하는 문자열 상수가 누적되면 릴리스 직전에만 발견되는 내비게이션 버그로 이어집니다. 파일 기반 라우팅은 경로 = 파일 경로라는 규칙으로 이 부담을 줄입니다.
Expo Router의 설계는 웹 프레임워크의 App Router 경험과 맞닿아 있습니다. 한 팀이 웹(Next.js)과 앱(Expo)을 동시에 다룰 때, “경로를 어디에 적었는가”를 통일하기 쉽습니다. 또 서버 라우트(필요 시)와 클라이언트 화면을 같은 app 트리에서 확장할 수 있어, 풀스택 Expo 프로젝트에서도 경로 모델이 한곳에 머무릅니다.
주의할 점은 파일 기반이 아키텍처를 강제하지는 않는다는 것입니다. 폴더를 잘못 나누면 깊은 중첩 스택이나 불필요한 리마운트가 생깁니다. 뒤에서 다루는 그룹 (name), 레이아웃 분리, 인증 가드 패턴은 이런 비용을 줄이기 위한 실무 규약입니다.
2. 핵심 개념: 라우트, 세그먼트, 레이아웃
2.1 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 등록 없이 스택에 스크린이 나타납니다. 이것이 파일 기반 라우팅의 첫 체감입니다.
2.2 파일 이름 규칙과 “보이지 않는” 세그먼트
자주 쓰는 규칙은 다음과 같습니다.
| 패턴 | 의미 |
|---|---|
index.tsx | 디렉터리의 기본 경로(예: users/index → /users) |
[id].tsx | 동적 세그먼트 한 개(/users/42) |
[...slug].tsx | 캐치올(여러 세그먼트) |
(group) 폴더 | URL에 포함되지 않는 그룹(폴더 구조만) |
_layout.tsx | 해당 디렉터리 이하의 공통 레이아웃·네비게이터 |
그룹 (tabs)처럼 괄호 폴더를 쓰면, 폴더 이름은 경로에 노출되지 않고 내부 구성용으로만 쓰입니다. 탭·온보딩·인증 플로를 같은 깊이에서 논리적으로 분리할 때 유용합니다.
2.3 레이아웃 중첩과 화면 수명
_layout.tsx는 해당 트리의 자식 라우트를 감싸는 부모입니다. 부모 레이아웃은 자식 전환 시 파괴되지 않는 한 유지되므로, 탭 바처럼 “고정된 UI”는 상위 _layout에 두고, 스택 전환은 하위 _layout에 두는 식으로 수명 주기를 분리합니다. 반대로 불필요하게 큰 레이아웃을 루트에 두면 리렌더 범위가 넓어질 수 있으므로, 상태를 Context로 둘 위치와 함께 설계합니다.
3. 파일 기반 라우팅 패턴
3.1 단일 스택 앱
가장 단순한 형태는 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가 자주 쓰입니다.
3.2 그룹으로 플로 분리: (auth)와 (app)
실무에서는 미인증 사용자용 화면과 인증 후 메인 앱을 URL 그룹으로 나눕니다.
app/
_layout.tsx
(auth)/
_layout.tsx
login.tsx
register.tsx
(app)/
_layout.tsx
index.tsx
explore.tsx
루트 _layout.tsx에서 세션에 따라 (auth) 또는 (app)으로 리다이렉트하는 패턴이 널리 쓰입니다. 이때 그룹 이름은 URL에 안 나오므로, 실제 경로는 /(auth)/login, /(app)/ 형태로 디버깅하면 됩니다(프로젝트 설정에 따라 표기가 약간 다를 수 있음).
3.3 인덱스와 중첩 경로
users/index.tsx는 /users에 대응합니다. users/me.tsx는 /users/me입니다. 폴더 깊이가 곧 경로 깊이이므로, REST 스타일 리소스 트리를 그대로 옮기기 쉽습니다. 대신 모바일 UX상 스택이 너무 깊어지지 않게 users/[id].tsx 한 단계로 줄이는 편이 나은 경우도 많습니다.
4. Stack, Tabs, Drawer 네비게이션
4.1 Stack
expo-router의 Stack은 화면 전환 애니메이션과 헤더를 담당합니다. 화면별 옵션은 _layout의 Stack.Screen으로 지정하거나, 파일 기반 이름으로 타겟팅합니다.
// 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>
);
}
모달 프레젠테이션은 iOS에서 자연스러운 바텀시트·폼 입력에 자주 쓰입니다. 공유할 옵션은 screenOptions로, 개별 스크린은 Stack.Screen으로 분리하는 편이 읽기 쉽습니다.
4.2 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 레이아웃을 두는 방식이 일반적입니다. 이렇게 하면 탭 전환 시 각 탭의 스택이 보존되는 경험을 만들 수 있습니다(설정에 따라 동작을 조정).
4.3 Drawer
Drawer는 햄버거 메뉴·사이드 패널이 필요할 때 사용합니다. Expo Router에서는 expo-router/drawer의 Drawer를 레이아웃에서 사용합니다(프로젝트에 @react-navigation/drawer 및 의존성 설치 필요).
// app/(drawer)/_layout.tsx
import { Drawer } from 'expo-router/drawer';
export default function DrawerLayout() {
return (
<Drawer
screenOptions={{
drawerPosition: 'left',
headerShown: true,
}}
/>
);
}
Drawer는 제스처 영역과 뒤로가기 동작이 플랫폼마다 다릅니다. 단순 설정 화면 몇 개라면 Drawer 대신 스택 위 모달이나 설정 스택이 더 단순할 수 있습니다. 정보 구조가 많은 엔터프라이즈 앱에서 Drawer 가치가 큽니다.
4.4 한 프로젝트에서 혼합하기
실제 앱은 루트 Stack → (tabs) 내부 Stack → 모달처럼 여러 네비게이터가 중첩됩니다. 원칙은 다음과 같습니다.
- 사용자가 동시에 보아야 하는 내비(탭)는
Tabs레이아웃에 둔다. - 일시적 흐름(글쓰기, 결제)은 스택
push또는modal프레젠테이션으로 격리한다. - 전역 오버레이(토스트, 로딩)는 루트 레이아웃의 형제 슬롯이나 포털로 처리한다.
이렇게 “내비게이터 경계 = 상태 경계”로 생각하면 Context·React Query 캐시 범위 설계가 쉬워집니다.
5. Dynamic Routes와 Layouts
5.1 동적 세그먼트 [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>
);
}
런타임 검증을 생략하면 id가 undefined이거나 잘못된 형식일 때 조용히 깨지는 화면이 됩니다. 팀에서는 Zod로 스키마 검증한 뒤, 실패 시 notFound 처리나 에러 화면으로 보내는 규약을 두는 경우가 많습니다.
5.2 캐치올 [...slug]
문서·블로그처럼 가변 깊이의 경로가 필요하면 [...slug].tsx를 사용합니다. 반환된 slug는 배열로 다루는 경우가 많습니다. 과도한 캐치올은 디버깅을 어렵게 하므로, 가능하면 명시적 세그먼트로 나누는 편이 유지보수에 유리합니다.
5.3 중첩 _layout과 공유 UI
app/users/_layout.tsx를 두면 /users 이하에 공통 헤더·서브탭·Suspense 경계를 둘 수 있습니다. 목록 화면 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>
);
}
레이아웃에 Provider를 두는지, 페이지별로 fetch하는지는 데이터 전략과 연결됩니다. React Query를 쓴다면 QueryClientProvider는 루트, 사용자 단위 캐시 키는 ['user', id]처럼 잡는 식이 흔합니다.
5.4 Typed Routes
Expo Router는 경로 문자열의 타입 생성을 지원합니다. router.push에 오타 난 경로를 넣는 실수를 줄이려면 app 트리를 기준으로 한 typed routes를 켜는 것이 좋습니다(공식 문서의 Typed routes 항목 참고). 특히 동적 세그먼트가 많은 앱에서 비용 대비 효과가 큽니다.
6. Deep Linking
6.1 스킴과 호스트
딥링크는 앱이 외부 URL로 특정 화면을 열게 하는 메커니즘입니다. Expo에서는 app.json / app.config.js의 scheme, 연관 도메인(Universal Links / App Links) 설정이 함께 갑니다. 개발 빌드와 스토어 빌드에서 동일한 경로가 열리도록 환경별 설정을 분리해 두어야 합니다.
일반적인 흐름은 다음과 같습니다.
- 스킴 정의:
myapp://같은 커스텀 스킴 또는https+ 도메인. - 경로 매핑: Expo Router는 파일 경로와 동일한 규칙으로 URL을 해석합니다.
- 검증: 쿼리 파라미터·동적 세그먼트를 서버 또는 클라이언트에서 검증합니다.
6.2 쿼리스트링과 useGlobalSearchParams
화면은 useLocalSearchParams(해당 라우트)와 useGlobalSearchParams(전역)로 검색 파라미터를 읽을 수 있습니다. 딥링크로 들어온 캠페인 코드·추천인 코드는 쿼리로 전달하는 경우가 많고, 이후 SecureStore에 저장하고 쿼리는 replace로 정리해 URL을 깨끗이 유지하기도 합니다.
6.3 웹과의 정합성
같은 코드베이스로 웹을 배포하면 브라우저 주소창이 곧 딥링크입니다. SEO가 필요 없는 앱 내부 화면이라도, 고객 지원·마케팅이 URL을 공유할 수 있게 하려면 의미 있는 경로명과 안정적인 슬라이스를 유지하는 것이 좋습니다.
7. Navigation State 관리
7.1 useRouter, usePathname, useSegments
useRouter:push,replace,back,canGoBack,setParams등 명령형 내비게이션.usePathname: 현재 경로 문자열.useSegments: 세그먼트 배열 — 인증 가드, 분석 이벤트에 자주 사용합니다.
인증 가드 예시 개념은 다음과 같습니다. 세션이 없을 때 (app) 경로 세그먼트가 보이면 로그인으로 보냅니다(구현은 프로젝트의 세션 저장소에 맞춤).
import { useSegments, useRouter } from 'expo-router';
import { useEffect } from 'react';
export function useAuthRedirect(isSignedIn: boolean) {
const segments = useSegments();
const router = useRouter();
useEffect(() => {
const inAuthGroup = segments[0] === '(auth)';
if (!isSignedIn && !inAuthGroup) {
router.replace('/(auth)/login');
}
if (isSignedIn && inAuthGroup) {
router.replace('/(app)');
}
}, [isSignedIn, segments, router]);
}
실제 코드에서는 초기 로딩 스플래시, 세션 복원 지연, 라우터 준비 여부를 함께 고려해야 합니다. 무한 리다이렉트를 막으려면 의존 배열과 조건을 엄격히 두는 것이 좋습니다.
7.2 파라미터와 setParams
스택 위에서 필터 상태를 URL에 남기고 싶다면 router.setParams로 쿼리를 갱신합니다. 탭 간 공유 상태는 URL보다 전역 스토어(Zustand 등)나 React Context가 나을 때도 있습니다. “URL이 곧 복원 가능한 상태”가 가치 있을 때만 쿼리를 늘리는 편이 유지보수에 유리합니다.
7.3 뒤로가기와 데이터 갱신
스크린에 포커스가 돌아올 때 목록을 새로고침하려면 @react-navigation/native의 useFocusEffect를 함께 쓰는 패턴이 흔합니다. Expo Router 화면도 React Navigation 트리 안에 있으므로, 포커스 기반 invalidation이 그대로 적용됩니다.
import { useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native';
export function useRefetchOnFocus(refetch: () => void) {
useFocusEffect(
useCallback(() => {
refetch();
}, [refetch]),
);
}
7.4 전역 이벤트와 타입 안전성
내비게이션은 비동기적입니다. push 직후 즉시 상태가 바뀌지 않거나, 빠른 연타로 중복 전환이 일어날 수 있습니다. 중요한 액션(결제 완료)은 idempotent한 서버 처리와 함께 두고, 클라이언트는 로딩·중복 클릭 방지를 기본값으로 두는 것이 안전합니다.
8. 실전 앱 구조 설계
8.1 권장 디렉터리 스케치
중규모 앱에서 자주 쓰는 구조 예시입니다.
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]는 푸시 알림·딥링크로 바로 열릴 수 있는 전역 경로로 두는 식입니다. 팀마다 /modal을 루트에 두어 전역 모달만 분리하기도 합니다.
8.2 기능 단위 모듈과 app의 경계
비즈니스 로직·API 클라이언트는 app 밖의 features/, services/ 등으로 두고, app에는 라우팅과 화면 조립만 남기는 편이 테스트와 재사용에 유리합니다. 화면 컴포넌트는 features/user/screens/UserProfileScreen.tsx에 두고, app/users/[id].tsx는 얇게 연결만 하는 패턴이 깔끔합니다.
8.3 에러·404·오프라인
+not-found.tsx: 존재하지 않는 경로 처리.- 에러 바운더리: 루트 또는 주요 레이아웃에 두고 크래시 리포트(Sentry 등)와 연동.
- 오프라인: React Query의 네트워크 모드, 캐시 우선 표시와 함께 내비게이션은 가능하되 데이터는 스켈레톤으로 보여주는 UX를 구성합니다.
8.4 마이그레이션: React Navigation만 쓰던 코드베이스
기존 수동 스크린 등록 코드가 있다면, 한 번에 옮기기보다 새 플로만 Expo Router로 추가하고 점진적으로 이전하는 전략이 안전합니다. 동일한 스크린 컴포넌트를 export하여 라우트 파일에서 재사용하면 리스크를 줄일 수 있습니다.
9. 베스트 프랙티스와 흔한 실수
- 경로 문자열 하드코딩: typed routes 또는 경로 상수 모듈로 중앙화합니다.
- 동적 파라미터 미검증: 딥링크·옛 클라이언트에서 깨진
id가 들어올 수 있습니다. - 과도한 루트 Provider: 필요한 서브트리에만 Context를 제한합니다.
- 탭·스택 혼동: “탭에서 스택을 또 쌓을지, 모달로 뺄지” 기준을 팀 규약으로 정합니다.
- 딥링크만 믿은 보안: 민감 작업은 항상 서버 재검증과 세션으로 보호합니다.
10. 정리
Expo Router는 파일 트리로 경로를 고정하고, _layout으로 내비게이터를 선언하여 React Native 앱의 확장성과 온보딩 비용을 동시에 다루게 해 줍니다. Stack·Tabs·Drawer는 각각 일시적 흐름·병렬 섹션·대량 메뉴에 맞춰 선택하고, 동적 라우트와 레이아웃으로 리소스 중심 화면을 표현합니다. 딥링크는 스킴·도메인·경로 검증을 함께 설계하고, useRouter·세그먼트·포커스 효과로 내비게이션 상태와 데이터 패칭을 일관되게 맞추면 운영 단계에서의 내비게이션 결함을 크게 줄일 수 있습니다.
공식 문서의 Routing, Layout, Linking, Typed routes 항목을 버전에 맞춰 함께 보면, 본 가이드의 패턴을 프로젝트 템플릿으로 옮기는 데 도움이 됩니다.