Expo Router로 배우는 React Native 파일 기반 내비게이션 완벽 가이드

Expo Router를 활용한 React Native 파일 기반 라우팅을 다룹니다. 레이아웃, 동적 라우트, 타입 안전 내비게이션, 탭, 모달, 미들웨어 등 최신 패턴을 종합적으로 설명합니다.

React Native 모바일 앱을 위한 Expo Router 파일 기반 내비게이션 시스템

Expo Router는 React Native에 파일 기반 라우팅을 도입하여, Next.js에서 영감을 받은 규약 기반 접근 방식으로 수동 내비게이션 설정을 대체합니다. Expo SDK 57과 Expo Router를 사용하면, Android, iOS, 웹을 위한 크로스 플랫폼 내비게이션을 구축하는 데 적절한 디렉토리에 파일을 생성하는 것 외에 다른 작업이 필요하지 않습니다.

빠른 설정

새로운 Expo 프로젝트는 Expo Router가 사전 구성되어 제공됩니다. npx create-expo-app@latest --template default를 실행하면 파일 기반 라우팅이 즉시 적용된 프로젝트가 생성됩니다. 기존 프로젝트는 expo-router를 설치하고 진입점을 업데이트하여 추가할 수 있습니다.

Expo Router의 파일 기반 라우팅 원리

app 디렉토리 내의 모든 파일은 자동으로 라우트가 됩니다. 파일 경로가 URL 경로에 직접 매핑되어 중앙 집중식 내비게이션 설정이 필요하지 않습니다. app/settings.tsx 파일은 /settings 라우트를 생성하고, app/profile/edit.tsx는 /profile/edit에 매핑됩니다.

이 접근 방식은 기존 React Navigation 설정에 비해 세 가지 핵심 이점을 제공합니다.

  • 설정 불필요: 파일을 생성하는 순간 라우트가 존재함
  • 자동 딥 링킹: 모든 화면에 URL이 부여되어 공유 및 테스트가 가능함
  • 타입 안전 내비게이션: TypeScript가 컴파일 시점에 어떤 라우트가 존재하는지 인식함
app/index.tsxtypescript
import { View, Text, StyleSheet } from 'react-native'
import { Link } from 'expo-router'

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome</Text>
      {/* Link maps directly to file path */}
      <Link href="/settings" style={styles.link}>
        Open Settings
      </Link>
      <Link href="/profile/edit" style={styles.link}>
        Edit Profile
      </Link>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 16 },
  link: { fontSize: 16, color: '#61DAFB', marginTop: 12 },
})

Link 컴포넌트는 모든 플랫폼에서 내비게이션을 처리합니다. 웹에서는 SEO를 위한 적절한 href 속성을 가진 앵커 태그를 렌더링합니다. 네이티브 플랫폼에서는 스택 기반 내비게이션을 트리거합니다.

프로젝트 구조와 레이아웃 파일

Expo Router는 _layout.tsx 파일을 사용하여 내비게이션 컨테이너를 정의합니다. 각 디렉토리에 자체 레이아웃을 가질 수 있어 중첩된 내비게이션 계층 구조를 생성합니다. 루트 레이아웃은 전체 앱을 감싸고, 중첩된 레이아웃은 특정 섹션을 제어합니다.

일반적인 프로젝트 구조는 다음과 같습니다.

text
app/
  _layout.tsx          # Root layout (Stack or custom)
  index.tsx            # Home screen (/)
  (tabs)/              # Tab group (parentheses = route group)
    _layout.tsx        # Tab navigator
    home.tsx           # /home tab
    search.tsx         # /search tab
    profile.tsx        # /profile tab
  settings/
    _layout.tsx        # Settings stack layout
    index.tsx          # /settings
    notifications.tsx  # /settings/notifications
    privacy.tsx        # /settings/privacy

라우트 그룹(괄호로 감싼 디렉토리)은 URL에 영향을 주지 않으면서 파일을 구성합니다. 위의 (tabs) 디렉토리는 탭 네비게이터를 생성하지만, URL은 /tabs/home이 아닌 /home, /search, /profile로 유지됩니다.

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

export default function RootLayout() {
  return (
    <Stack
      screenOptions={{
        headerStyle: { backgroundColor: '#1a1a2e' },
        headerTintColor: '#ffffff',
        headerTitleStyle: { fontWeight: '600' },
      }}
    >
      <Stack.Screen name="index" options={{ title: 'Home' }} />
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="settings" options={{ title: 'Settings' }} />
    </Stack>
  )
}

루트 레이아웃은 폰트 로딩, 프로바이더 초기화, 전역 설정을 구성하는 곳이기도 합니다.

Expo Router로 탭 내비게이션 구현하기

탭 내비게이션은 라우트 그룹 내에 _layout.tsx 파일이 필요합니다. Expo Router는 플랫폼별 탭 경험을 위한 NativeTabs와 대부분의 사용 사례에 적합한 표준 Tabs 컴포넌트를 모두 제공합니다. SDK 56부터 Native Tabs는 탭을 표시하면서 선택을 방지하는 disabled 속성을 지원합니다.

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

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#61DAFB',
        tabBarInactiveTintColor: '#888',
        tabBarStyle: {
          backgroundColor: '#1a1a2e',
          borderTopColor: '#2d2d44',
        },
      }}
    >
      <Tabs.Screen
        name="home"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Search',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  )
}

각 탭 화면 파일은 표준 React 컴포넌트를 export합니다. 탭 바 아이콘, 라벨, 배지는 레이아웃의 options prop을 통해 설정됩니다.

동적 라우트와 라우트 파라미터

동적 세그먼트는 파일 이름에 대괄호를 사용합니다. [id].tsx라는 이름의 파일은 단일 세그먼트에 매칭되고, [...slug].tsx는 나머지 모든 세그먼트를 캡처합니다.

app/product/[id].tsxtypescript
import { View, Text, StyleSheet } from 'react-native'
import { useLocalSearchParams, Stack } from 'expo-router'

export default function ProductScreen() {
  // Extract the dynamic parameter from the URL
  const { id } = useLocalSearchParams<{ id: string }>()

  return (
    <View style={styles.container}>
      <Stack.Screen options={{ title: `Product ${id}` }} />
      <Text style={styles.heading}>Product Details</Text>
      <Text style={styles.id}>ID: {id}</Text>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  heading: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
  id: { fontSize: 16, color: '#888' },
})

/product/42로 이동하면 이 화면이 id가 "42"로 설정된 상태로 렌더링됩니다. useLocalSearchParams 훅은 모든 라우트 파라미터에 대한 타입화된 접근을 제공합니다.

캐치올 라우트의 경우, [...slug].tsx가 전체 경로 세그먼트를 캡처합니다.

app/docs/[...slug].tsxtypescript
import { useLocalSearchParams } from 'expo-router'

export default function DocsScreen() {
  // /docs/getting-started/installation → slug = ['getting-started', 'installation']
  const { slug } = useLocalSearchParams<{ slug: string[] }>()

  return <DocViewer path={slug.join('/')} />
}

React Native 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

컴파일 타임 안전성을 위한 타입 라우트

Expo Router는 typed routes가 활성화되면 라우트 타입을 자동으로 생성합니다. 이를 통해 런타임이 아닌 컴파일 타임에 잘못된 링크를 감지할 수 있습니다.

app.json에서 타입 라우트를 활성화합니다.

json
{
  "expo": {
    "experiments": {
      "typedRoutes": true
    }
  }
}

활성화되면 Link의 href prop과 router.push()의 인자는 유효한 라우트 문자열만 허용합니다.

app/checkout.tsxtypescript
import { router } from 'expo-router'

function handleCheckout(cartId: string) {
  // TypeScript validates this route exists
  router.push(`/product/${cartId}`)

  // This would cause a compile error if /nonexistent doesn't exist
  // router.push('/nonexistent')
}

타입 라우트는 useLocalSearchParams와 잘 어울립니다. 생성된 타입은 라우트 정의와 소비하는 컴포넌트 간에 파라미터 이름이 일치하도록 보장하여, 특정 화면으로 이동할 때만 발견되는 미묘한 버그를 방지합니다.

모달 화면과 프레젠테이션 옵션

Expo Router에서 모달은 레이아웃에서 presentation: 'modal'로 설정된 일반 화면입니다. 이 접근 방식은 파일 기반 규약을 유지합니다. 모달은 단지 또 다른 라우트일 뿐입니다.

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

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      {/* Modal screen slides up from the bottom */}
      <Stack.Screen
        name="create-post"
        options={{
          presentation: 'modal',
          headerTitle: 'New Post',
        }}
      />
    </Stack>
  )
}
app/create-post.tsxtypescript
import { View, TextInput, Button, StyleSheet } from 'react-native'
import { router } from 'expo-router'
import { useState } from 'react'

export default function CreatePostModal() {
  const [title, setTitle] = useState('')

  const handleSubmit = () => {
    // Submit logic here
    router.back() // Dismiss the modal
  }

  return (
    <View style={styles.container}>
      <TextInput
        style={styles.input}
        placeholder="Post title"
        value={title}
        onChangeText={setTitle}
      />
      <Button title="Publish" onPress={handleSubmit} />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  input: {
    borderWidth: 1,
    borderColor: '#333',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    marginBottom: 16,
  },
})

/create-post로 이동하면 모달 프레젠테이션이 트리거됩니다. router.back() 호출은 모달을 닫고 스택의 이전 화면으로 돌아갑니다.

프로그래밍 방식 내비게이션과 Router API

Link 컴포넌트 외에도 Expo Router는 router 객체를 통해 명령형 API를 제공합니다. 이는 사용자 탭이 아닌 비즈니스 로직에 의해 트리거되는 내비게이션을 처리합니다.

typescript
import { router } from 'expo-router'

// Push a new screen onto the stack
router.push('/profile/settings')

// Replace the current screen (no back button)
router.replace('/login')

// Go back to the previous screen
router.back()

// Navigate with parameters
router.push({
  pathname: '/product/[id]',
  params: { id: '42', source: 'recommendations' },
})

// Check if going back is possible
import { useRouter } from 'expo-router'

function BackButton() {
  const router = useRouter()
  return router.canGoBack() ? (
    <Button title="Back" onPress={() => router.back()} />
  ) : null
}

router.push와 router.replace의 구분은 React Native의 인증 플로우에서 중요합니다. 로그인 성공 후 router.replace('/dashboard')를 사용하면 사용자가 로그인 화면으로 다시 돌아가는 것을 방지합니다.

내비게이션 vs 리다이렉트

router.replace()는 내비게이션 히스토리의 현재 항목을 대체합니다. 미인증 사용자를 리다이렉트하는 인증 가드의 경우, 컴포넌트 렌더링 내에서 <Redirect href="/login" />를 사용합니다. 이는 렌더링 단계에서 트리거되고 웹에서 서버 사이드 렌더링과 올바르게 작동합니다.

미들웨어와 라우트 보호

Expo Router는 라우트 수준 로직을 위한 서버 미들웨어를 제공합니다. +middleware.ts 파일은 요청이 라우트 컴포넌트에 도달하기 전에 가로채서 인증 검사, 리다이렉트, 헤더 조작을 가능하게 합니다.

app/+middleware.tstypescript
import { type MiddlewareRequest } from 'expo-router/server'

export function middleware(request: MiddlewareRequest) {
  const { pathname } = request.nextUrl

  // Protect dashboard routes
  const protectedPaths = ['/dashboard', '/settings', '/profile']
  const isProtected = protectedPaths.some(p => pathname.startsWith(p))

  if (isProtected) {
    const token = request.cookies.get('session')
    if (!token) {
      return Response.redirect(new URL('/login', request.url))
    }
  }

  return undefined // Continue to route
}

서버 미들웨어가 실행되지 않는 네이티브 플랫폼에서는 라우트 보호가 클라이언트 측 가드에 의존합니다. 일반적인 패턴은 보호된 레이아웃을 인증 검사로 감싸는 것입니다.

app/(authenticated)/_layout.tsxtypescript
import { Redirect, Stack } from 'expo-router'
import { useAuth } from '@/hooks/useAuth'

export default function AuthenticatedLayout() {
  const { isLoggedIn, isLoading } = useAuth()

  if (isLoading) return null
  if (!isLoggedIn) return <Redirect href="/login" />

  return <Stack />
}
플랫폼 인식

서버 미들웨어는 서버 사이드 렌더링이 있는 웹에서만 실행됩니다. 네이티브 앱의 경우, 항상 레이아웃 컴포넌트에서 클라이언트 측 라우트 가드를 구현해야 합니다. 두 접근 방식을 결합하면 모든 플랫폼에서 일관된 보호가 보장됩니다.

SDK 56 마이그레이션: React Navigation 포크

Expo SDK 56부터 Expo Router는 더 이상 @react-navigation/* 패키지에 의존하지 않습니다. 프로젝트에서 React Navigation을 직접 import하는 경우, import를 업데이트해야 합니다.

기존 패키지새 위치
@react-navigation/nativeexpo-router/react-navigation
@react-navigation/stackexpo-router/js-stack
@react-navigation/bottom-tabsexpo-router/js-tabs

codemod를 실행하여 import를 자동으로 업데이트할 수 있습니다.

bash
npx expo-codemod sdk-56-expo-router-react-navigation-replace src

런타임 API는 변경되지 않았습니다. 모듈 지정자만 이동했습니다. @react-navigation/core에서 여전히 import하는 서드파티 라이브러리는 Expo CLI가 번들링 중에 해당 import를 재작성하므로 자동으로 작동합니다.

출처

면접관이 Expo Router에 대해 기대하는 내용

  • Expo Router는 app 디렉토리의 각 파일을 자동으로 라우트에 매핑하여 수동 내비게이션 설정을 제거함
  • _layout.tsx 파일을 통해 정의된 레이아웃은 스택, 탭, 드로어의 내비게이션 계층을 생성함
  • [param].tsx의 동적 라우트와 [...slug].tsx의 캐치올 라우트는 완전한 TypeScript 지원으로 파라미터화된 내비게이션을 처리함
  • app.json에서 활성화된 타입 라우트는 컴파일 타임에 잘못된 내비게이션 링크를 감지함
  • 모달 화면, router.push/replace/back을 통한 프로그래밍 방식 내비게이션, 서버 미들웨어가 라우팅 툴킷을 완성함
  • 괄호로 감싼 라우트 그룹은 URL에 영향을 주지 않으면서 코드를 구성함
  • 레이아웃의 클라이언트 측 라우트 가드가 네이티브에서 인증을 처리하고, 서버 미들웨어가 웹을 커버함
  • SDK 56부터 @react-navigation/*의 import는 expo-router/* 동등물로 업데이트해야 함

React Native 아키텍처 및 면접 준비에 대해서는 네이티브 모듈 모듈을 참조하세요.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

오늘의 챌린지

React Native 코드의 버그를 찾을 수 있나요

실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

Anthony Fillion-Maillet

작성자

Anthony Fillion-Maillet

SharpSkill 창업자

10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.

2026년 10월 6일 업데이트

태그

#react-native
#expo
#expo-router
#navigation
#mobile-development
#tutorial

공유

관련 기사