React Native / Expo 패턴
Expo로 프로덕션 React Native 앱을 구축하기 위한 실용적인 패턴입니다. 네비게이션, 상태, 데이터 패칭, 리스트, 스타일링 및 네이티브 API를 다룹니다. rules/react-native/ 규칙 세트와 함께 사용됩니다. 규칙은 무엇을 강제해야 하는지를 말하고, 이 스킬은 어떻게 하는지를 보여줍니다.
아래에 언급된 라이브러리들(NativeWind, Zustand/Jotai, TanStack Query)은 예시로 보여주는 일반적이고 잘 정립된 옵션입니다. 중요한 것은 특정 패키지보다 패턴 자체이며, 동등한 기능을 하는 어떤 라이브러리든 사용 가능합니다. Zod는 ECC의 기존 typescript/ 규칙과 일관성을 유지하기 위해 검증에 사용됩니다.
이 패턴들은 관리되는 Expo 워크플로우(Expo Router, EAS, expo-* 모듈)와 New Architecture(최근 Expo SDK의 기본값, SDK 55+부터 필수)를 가정합니다. 브라우저 DOM은 가정하지 않습니다. React Native에는 <div>, URL 표시줄, 웹 데이터 패칭 기본값이 없습니다.
활성화 시기
다음과 같은 경우 이 스킬을 사용하십시오:
- React Native / Expo 화면, 컴포넌트, 네비게이션 구축 또는 편집
- Expo Router(파일 기반
app/디렉토리)로 라우팅 설정 - 상태가 어디에 속하는지 결정(서버 캐시 vs 클라이언트 저장소 vs 경로 파라미터 vs 폼)
- TanStack Query로 데이터 패칭을 연결하고 Zod로 응답 검증
- 길거나 무거운 리스트 렌더링
- 스타일링 접근 방식 선택 또는 적용(NativeWind 또는 StyleSheet)
- 네이티브 장치 API(카메라, 위치, 알림) 또는 보안 저장소 접근
- 모바일 특정 이슈에 대한 RN 코드 검토
여기서 웹/React-DOM 패턴(URL-as-state, <div>, SWR-for-browser)은 React Native에 적용되지 않으므로 사용하지 마십시오.
핵심 개념
프로젝트 구조 (Expo Router)
app/ 디렉토리 아래 파일 기반 라우팅. 라우트 파일은 얇게 유지하십시오. 라우트 파일은 파라미터를 읽고 검증한 다음, components/ 또는 features/에 있는 스크린 컴포넌트에 위임합니다.
app/
_layout.tsx # 루트 스택
(tabs)/
_layout.tsx # 탭 네비게이터
index.tsx # 홈
user/[id].tsx # 동적 라우트
components/
features/
user/UserProfile.tsx
네비게이션: 라우트 파라미터 검증
딥 링크와 동적 라우트는 신뢰할 수 없는 문자열을 전달합니다. 사용 전에 Zod로 검증하십시오.
// app/user/[id].tsx
import { useLocalSearchParams, router } from 'expo-router'
import { z } from 'zod'
import { UserProfile } from '@/features/user/UserProfile'
const Params = z.object({ id: z.string().uuid() })
export default function UserRoute() {
const parsed = Params.safeParse(useLocalSearchParams())
if (!parsed.success) {
router.replace('/not-found')
return null
}
return <UserProfile userId={parsed.data.id} />
}
상태: 관심사 분리 유지
서버 데이터를 클라이언트 저장소에 복제하지 마십시오. 각 관심사는 자신의 집이 있습니다.
| 관심사 | 일반적인 선택 |
|---|---|
| 서버 상태 (원격 데이터) | 서버 캐시 라이브러리 (TanStack Query, SWR) |
| 클라이언트/UI 상태 | 가벼운 저장소 (Zustand, Jotai) 또는 Context |
| 라우트/네비게이션 상태 | Expo Router 파라미터 |
| 폼 상태 | 폼 라이브러리 (예: React Hook Form) + 스키마 검증 |
| 비밀 / 토큰 | expo-secure-store |
| 비-비밀 지속성 | AsyncStorage / MMKV |
상태가 진정으로 공유될 필요가 있을 때까지는 지역 useState를 선호하십시오.
데이터 패칭: 캐시 라이브러리 + Zod
useEffect에서 fetch를 사용하는 대신 서버 캐시 라이브러리(TanStack Query, SWR)를 사용하십시오. 경계에서 검증하고 스키마에서 타입을 추론하십시오. 로딩, 오류, 빈 상태를 명시적으로 처리하십시오. (예제는 TanStack Query를 사용합니다.)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
const User = z.object({ id: z.string(), email: z.string().email() })
type User = z.infer<typeof User>
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async (): Promise<User> => User.parse(await api.getUser(id)),
})
}
export function useUpdateEmail(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (email: string) => api.updateEmail(id, email),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }),
})
}
리스트: 가상화, ScrollView에서 큰 배열을 map하지 마십시오
import { FlatList } from 'react-native'
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem} // 메모이제이션
initialNumToRender={10}
windowSize={5}
/>
크거나 이질적인 리스트에는 FlashList(Shopify)를 사용하십시오.
스타일링: 하나의 시스템 선택
StyleSheet.create()는 프레임워크 네이티브 옵션입니다; 유틸리티 클래스 라이브러리(예: NativeWind)는 일반적인 대안입니다. 하나를 선택하고 일관성을 유지하십시오. 핫 경로의 JSX에서 스타일 객체를 인라인으로 빌드하지 마십시오.
// NativeWind
<View className="p-4 rounded-2xl bg-white">
<Text className="text-base font-semibold">안녕하세요</Text>
</View>
// StyleSheet
const styles = StyleSheet.create({ card: { padding: 16, borderRadius: 16, backgroundColor: '#fff' } })
<View style={styles.card}>...</View>
네이티브 API: 훅으로 감싸고, 효과 정리
Expo SDK 호출 및 구독을 JSX가 아닌 use* 훅 내부에 유지하십시오. 항상 정리하십시오.
import { useEffect, useState } from 'react'
import * as Location from 'expo-location'
type LocationState =
| { status: 'loading' }
| { status: 'denied' }
| { status: 'granted'; coords: Location.LocationObjectCoords }
export function useCurrentLocation() {
// 상태를 추적하고, 좌표만 추적하지 마십시오. UI가 "여전히 로딩 중"과
// "권한 거부됨"을 구분하여 실행 가능한 메시지를 표시할 수 있도록 합니다.
const [state, setState] = useState<LocationState>({ status: 'loading' })
useEffect(() => {
let active = true
;(async () => {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') {
if (active) setState({ status: 'denied' })
return
}
const pos = await Location.getCurrentPositionAsync({})
if (active) setState({ status: 'granted', coords: pos.coords })
})()
return () => { active = false } // 언마운트 후 오래된 결과 무시
}, [])
return state
}
토큰을 위한 보안 저장소
import * as SecureStore from 'expo-secure-store'
await SecureStore.setItemAsync('auth_token', token) // Keychain / Keystore
const token = await SecureStore.getItemAsync('auth_token')
코드 예제
전체 화면: 라우트 → 쿼리 → 리스트 → 상태
// app/(tabs)/orders.tsx
import { memo, useCallback } from 'react'
import { FlatList, Text, View } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
const OrderSchema = z.object({ id: z.string(), total: z.number(), status: z.string() })
const OrdersSchema = z.array(OrderSchema)
type Order = z.infer<typeof OrderSchema>
function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: async () => OrdersSchema.parse(await api.listOrders()),
})
}
// 리렌더링 간에 참조가 안정적이도록 메모이제이션 (리스트 지침 참조).
const OrderRow = memo(function OrderRow({ item }: { item: Order }) {
return (
<View className="px-4 py-3 border-b border-neutral-200">
<Text className="font-medium">#{item.id}</Text>
<Text className="text-neutral-500">{item.status} · ${item.total}</Text>
</View>
)
})
export default function OrdersScreen() {
const { data, isLoading, isError, refetch, isRefetching } = useOrders()
const renderItem = useCallback(({ item }: { item: Order }) => <OrderRow item={item} />, [])
if (isLoading) return <Centered><Text>로딩 중…</Text></Centered>
if (isError) return <Centered><Text accessibilityRole="alert">주문을 불러올 수 없습니다.</Text></Centered>
if (!data?.length) return <Centered><Text>아직 주문이 없습니다.</Text></Centered>
return (
<FlatList
data={data}
keyExtractor={(o) => o.id}
onRefresh={refetch}
refreshing={isRefetching}
renderItem={renderItem}
/>
)
}
폼: React Hook Form + Zod 리졸버
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextInput, Button, Text } from 'react-native'
const Schema = z.object({ email: z.string().email('유효하지 않은 이메일') })
type FormValues = z.infer<typeof Schema>
export function EmailForm({ onSubmit }: { onSubmit: (v: FormValues) => void }) {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(Schema),
defaultValues: { email: '' },
})
return (
<>
<Controller
control={control}
name="email"
render={({ field: { value, onChange, onBlur } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="이메일 주소"
/>
)}
/>
{errors.email && <Text accessibilityRole="alert">{errors.email.message}</Text>}
<Button title="저장" onPress={handleSubmit(onSubmit)} />
</>
)
}
안티 패턴
// 잘못됨: ScrollView 내부에서 큰 배열을 map (가상화 없음, 버벅거림, 높은 메모리)
<ScrollView>{items.map((i) => <Row key={i.id} item={i} />)}</ScrollView>
// 올바름: FlatList / FlashList
// 잘못됨: 서버 데이터를 클라이언트 저장소에 복사 (두 출처, 오래된 데이터)
const useStore = create((set) => ({ users: [], setUsers: (u) => set({ users: u }) }))
useEffect(() => { getUsers().then(setUsers) }, [])
// 올바름: useQuery가 서버 상태를 소유; 필요한 것을 파생
// 잘못됨: AsyncStorage에 토큰 저장 (암호화되지 않음)
await AsyncStorage.setItem('auth_token', token)
// 올바름: expo-secure-store
// 잘못됨: 딥 링크 파라미터 신뢰
const { id } = useLocalSearchParams(); fetchUser(id)
// 올바름: 사용 전에 Zod로 검증
// 잘못됨: 핫 경로에서 매 렌더링마다 재생성되는 인라인 스타일 객체
<View style={{ padding: 16, backgroundColor: '#fff' }} />
// 올바름: 모듈 범위에서 StyleSheet.create, 또는 NativeWind className
// 잘못됨: 번들에 실제 비밀 탑재
const STRIPE_SECRET = 'sk_live_...'
// 올바름: 권한 있는 호출은 서버 측에서 유지; 백엔드 규칙으로 보호된 공개 키만 탑재
모범 사례
- 라우트 파일은 얇게 유지; 로직은 스크린 컴포넌트와
use*훅에 넣습니다. - 모든 외부 입력(API 응답, 라우트 파라미터, 푸시 페이로드)을 Zod로 검증합니다.
- TanStack Query가 서버 상태를 소유하게 하고; 클라이언트 저장소는 작게 유지합니다.
- 항상 로딩, 오류, 빈 상태를 렌더링합니다. 폴백 없이 스피너만 표시하지 마십시오.
- 리스트를 가상화;
renderItem을 메모이제이션; 안정적인keyExtractor를 제공합니다. - 애니메이션에는
react-native-reanimated사용 (UI 스레드); JS 스레드에서 무거운 작업을 피합니다. - 토큰은
expo-secure-store에 저장; 인증을 위해 클라이언트를 신뢰하지 마십시오. - 처음부터 safe areas, Dynamic Type, 접근성 역할/레이블을 존중합니다.
- 릴리스 전에 모든 네이티브 의존성의 New Architecture 호환성을 확인합니다.
관련 스킬
frontend-patterns— React/Next.js (웹) 패턴; 공유 React 개념에 유용하지만, DOM 특화.coding-standards— RN 코드에 적용되는 TypeScript/JavaScript 관용구.tdd-workflow,e2e-testing— 테스트 프로세스 (RN용 Jest + React Native Testing Library, Maestro/Detox 사용).security-review— 위의 RN 번들/비밀 지침을 보완하는 일반 보안 체크리스트.