Moving a Web App to Expo
React와 Vite로 만든 모바일 웹 서비스를 React Native와 Expo로 다시 만든다면 무엇을 가져가고 무엇을 새로 써야 하는지 검토했습니다. 이 글은 수주 전 기술 검토로 진행한 PoC의 기록이며 실제 서비스 이관과 스토어 출시는 하지 않았습니다. 의존성 대조표, Expo Router 라우팅, 공용 UI의 스타일 병합 순서, Unistyles 스타일 규약을 정리하고 끝내 확인하지 못한 항목을 따로 적었습니다.
검토한 범위와 버전
검토는 두 갈래로 진행했습니다. 기존 웹 서비스의 package.json을 한 줄씩 읽어 앱에서 그대로 쓸 의존성과 바꿀 의존성을 나눴습니다. 그리고 별도 PoC 저장소에서 탭, 일반 스택, 모달, 인증 그룹으로 나눈 탐색 구조와 공통 UI, 스타일 규약을 구현했습니다. 대조표는 문서로만 남았고 실제 화면을 옮기지는 않았습니다.
| 항목 | PoC에서 한 일 | 하지 않은 일 |
|---|---|---|
| 의존성 | 런타임과 개발 의존성을 유지, 대체, 제거로 분류 | 대체 라이브러리로 실제 화면 구현 |
| 라우팅 | Expo Router로 탭, 스택, 모달, 인증 그룹 구성 | 인증 가드, 딥링크 도메인 검증 |
| 스타일 | Unistyles 3 테마와 공통 UI 스타일, 키 이름 규약 | 기존 SCSS 화면의 변환 |
| 배포 | 개발 빌드 설정 | EAS Build와 Update, 스토어 제출 |
PoC는 package.json 기준으로 Expo SDK 54(expo 54.0.8), React Native 0.81.4, React 19.1.0, Expo Router 6.0, Reanimated 4.1, Unistyles 3.0을 썼습니다. 2026년 10월 25일에 확인한 Expo 문서의 최신 SDK는 58이고 React Native 0.88, React 19.3.0과 짝을 이룹니다. npm의 expo-router도 SDK 번호를 따라 58.x로 올라왔습니다. 아래 내용은 SDK 54에서 구현한 결과이며 SDK 58에서 다시 실행해 보지는 않았습니다.
의존성을 유지, 대체, 제거로 나눴습니다
판단 기준은 하나였습니다. DOM, CSS, 브라우저 API, Node API에 기대는 라이브러리는 앱에서 그대로 쓸 수 없습니다. 순수 JavaScript로 동작하는 라이브러리는 대부분 그대로 가져갑니다.
flowchart TD
D{"DOM, CSS, 브라우저 API에 기대는가?"} -- 아니요 --> N{"Node API나 웹 번들러용인가?"}
D -- 예 --> R["React Native 대체 후보를 찾는다"]
N -- 예 --> X["제거하고 Metro와 Expo 설정으로 옮긴다"]
N -- 아니요 --> K["유지하고 앱 이벤트만 연결한다"]| 웹에서 쓰던 것 | 앱에서 | 이유 |
|---|---|---|
| React Query, Zustand, axios, dayjs, React Hook Form | 유지 | 순수 JavaScript |
| Stackflow 같은 웹 스택 라우팅 | Expo Router | 화면 스택을 네이티브 내비게이터가 관리 |
| Sass, stylelint | Unistyles 3 | 앱에는 CSS 엔진과 선택자가 없음 |
Swiper, @tanstack/react-virtual | Reanimated 기반 캐러셀, FlatList 계열 목록 | DOM 측정과 포인터 이벤트에 의존 |
| DOM 포털 기반 모달 | 모달 라우트 | React DOM이 없음 |
GSAP, @use-gesture/react | Reanimated, Gesture Handler | DOM 스타일과 포인터 이벤트에 의존 |
lottie-web, oidc-client-ts | lottie-react-native, expo-auth-session | Canvas, 브라우저 리다이렉트와 저장소에 의존 |
Vite, env-cmd, react-dom | 제거 | Metro와 EXPO_PUBLIC_ 환경 변수로 대체 |
유지로 분류해도 할 일은 남습니다. React Query는 브라우저의 window 포커스 이벤트로 다시 조회합니다. 앱에는 그 이벤트가 없으므로 공식 문서대로 AppState를 focusManager에 연결해야 합니다. 네트워크 재연결도 onlineManager에 따로 연결합니다.
import { useEffect } from 'react'
import { AppState, Platform, type AppStateStatus } from 'react-native'
import { focusManager } from '@tanstack/react-query'
const onAppStateChange = (status: AppStateStatus) => {
// 웹 빌드에서는 브라우저 포커스 이벤트를 그대로 씁니다
if (Platform.OS !== 'web') focusManager.setFocused(status === 'active')
}
export const useAppFocus = () => {
useEffect(() => {
const subscription = AppState.addEventListener('change', onAppStateChange)
return () => subscription.remove()
}, [])
}환경 변수도 방식이 바뀝니다. Expo CLI는 EXPO_PUBLIC_ 접두사가 붙은 변수를 번들에 값으로 바꿔 넣습니다. process.env.EXPO_PUBLIC_API_URL처럼 점 표기로 직접 참조해야 하고 구조 분해나 대괄호 접근은 치환되지 않습니다. 번들에 평문으로 남으므로 비밀값은 넣지 않습니다.
라우팅은 Expo Router의 그룹으로 나눴습니다
웹에서는 스택 라우팅 라이브러리가 브라우저 위에 화면 스택을 흉내 냈습니다. 이전 화면을 언마운트하지 않고 쌓아 두면서 브라우저 히스토리와 스택을 맞추는 방식입니다. 앱에서는 이 일을 네이티브 내비게이터가 맡으므로 스택을 흉내 낼 필요가 없습니다.
처음 대조표에는 React Navigation을 직접 쓰는 안을 적었습니다. PoC에서는 Expo Router를 골랐습니다. Expo 문서는 Expo Router를 모든 Expo 프로젝트의 기본이자 권장 라우팅으로 소개합니다. Expo Router는 파일 기반 라우팅과 자동 딥링크를 제공합니다. 웹의 경로 문자열 규약을 파일 구조로 옮기면 딥링크 경로도 함께 생깁니다.
괄호로 감싼 폴더는 route group이며 URL에 나타나지 않습니다. 루트 Stack 아래에 탭이 있는 화면, 탭 없는 일반 스택, 모달, 인증 흐름을 그룹으로 나눴습니다.
flowchart TB
Root["app/_layout: 루트 Stack"] --> Shell["(shell): Tabs"]
Root --> Stack["(stack): 탭 없는 상세와 설정"]
Root --> Modal["(modal): presentation modal"]
Root --> Auth["(auth): 로그인과 가입"]
Shell --> Home["index"]
Shell --> My["my"]import { Stack } from 'expo-router'
import { SafeAreaProvider } from 'react-native-safe-area-context'
const RootLayout = () => (
<SafeAreaProvider>
<Stack initialRouteName="(shell)" screenOptions={{ headerShown: false }}>
<Stack.Screen name="(shell)" />
<Stack.Screen name="(stack)" />
<Stack.Screen name="(modal)" options={{ presentation: 'modal' }} />
<Stack.Screen name="(auth)" />
</Stack>
</SafeAreaProvider>
)
export default RootLayout화면 파일에는 내용만 두고 제목과 표시 방식은 각 그룹의 _layout.tsx에서 정했습니다. 화면마다 옵션이 흩어지지 않고 그룹 단위로 전환 방식을 읽을 수 있습니다. 웹의 push, pop, replace는 router.push, router.back, router.replace로 옮기고 경로 인자는 useLocalSearchParams로 받습니다.
인증 그룹은 화면을 나눠 둔 수준에서 멈췄습니다. Expo Router에는 Stack.Protected에 guard 값을 주는 보호 라우트가 있고 PoC에 설치한 6.0.12에도 들어 있었습니다. PoC에서는 이 가드를 연결하지 않았습니다. 문서가 밝히듯 보호 라우트는 클라이언트 탐색만 막으므로 서버 인증을 대신하지 않습니다.
라우트 파일에는 화면 컴포넌트를 불러와 렌더하는 코드만 남겼습니다. 화면 구현은 별도 폴더에 두었기 때문에 app/ 아래에서는 경로 구조만 읽힙니다. 레이아웃을 포함한 라우트 파일이 65개라 트리만 보고는 전체 흐름을 따라가기 어려웠습니다. 그래서 파일 경로, URL, 그룹, 표시 방식, 파라미터, 이어지는 화면을 한 줄씩 적은 경로 표를 따로 관리했습니다.
웹 라우터의 표기를 그대로 가져와 생긴 실수도 있었습니다. 목록 탭을 유지한 채 게시글을 미리 보는 화면을 Next.js의 인터셉트 라우트처럼 post/(.)[postId].tsx라는 이름으로 만들었습니다. 이번에 글을 쓰면서 PoC에 설치된 Expo Router 6.0.12의 경로 해석 함수를 직접 호출해 봤습니다.
// 공개 API가 아닌 빌드 모듈이라 버전에 따라 바뀔 수 있습니다
import { matchDynamicName, matchGroupName } from 'expo-router/build/matchers'
matchGroupName('(.)[postId]') // '.' → route group으로 처리되어 URL에서 빠진다
matchDynamicName('(.)[postId]') // undefined → postId 파라미터가 생기지 않는다
matchDynamicName('[postId]') // { name: 'postId', deep: false }괄호로 시작하는 세그먼트는 route group으로 처리되고 URL에서 빠집니다. 대괄호가 세그먼트 전체를 감싸지 않으므로 동적 파라미터도 생기지 않습니다. Expo Router 문서도 괄호는 route group을 나타낸다고만 설명할 뿐 인터셉트 표기는 다루지 않습니다. 앱에서 다시 재현해 보지는 않았습니다. 다만 이 파일이 의도한 미리보기로 동작하지 않았을 가능성이 큽니다. 같은 요구라면 모달 그룹 아래에 post-preview/[postId].tsx 같은 동적 라우트를 두고 그룹 레이아웃에서 presentation: 'modal'로 여는 쪽이 문서가 안내하는 방법입니다.
공용 UI는 스타일 병합 순서부터 정했습니다
처음 대조표에는 Material 계열 UI 킷인 react-native-paper를 쓰는 안이 있었습니다. 비교 메모에는 이 킷이 화면을 빨리 만들 수 있는 대신 Material 디자인에 묶이고 킷의 테마를 Unistyles 토큰과 따로 맞춰야 한다는 점이 남아 있습니다. 스타일을 직접 제어해야 한다는 전제를 두었으므로 PoC에는 UI 킷을 넣지 않았습니다. 상자, 텍스트, 버튼, 입력, 목록, 캐러셀 같은 공용 UI 13종을 Unistyles 위에 직접 만들었습니다.
첫 판에서 곧바로 문제가 생겼습니다. 기본 상자는 기본 props와 호출부 props를 합친 객체를 만들어 마지막에 스프레드했습니다. 그런데 합친 객체에 style이 남아 있었습니다. style={[base, merged.style]} {...merged}에서 뒤의 스프레드가 앞의 배열을 통째로 덮었습니다. JSX에서는 같은 이름의 prop이 뒤에 온 값으로 정해지기 때문입니다. 호출부가 style을 넘기는 순간 공용 기본 스타일이 사라졌습니다.
이틀 뒤 스타일 가이드를 쓰면서 규칙을 고정했습니다. style은 처음부터 따로 꺼내고 나머지 props만 합칩니다. 스타일 배열은 앵커 스타일, 동적 스타일, 호출부 style 순서로 둡니다. 앵커는 variants가 붙는 정적 스타일입니다. 동적 스타일은 같은 부위 이름 뒤에 _dyn을 붙인 함수입니다. 호출부 값이 마지막에 오므로 화면에서 덮어쓸 수 있고 덮어쓰지 않으면 공용 기본값이 그대로 남습니다.
import type { ComponentProps } from 'react'
import { View } from 'react-native'
import { styles, type BoxDynamic, type BoxVariants } from './ui-box.style'
type UiBoxProps = ComponentProps<typeof View> & {
variants?: BoxVariants
dynamic?: BoxDynamic
}
export const UiBox = ({ variants, dynamic, style, ...rest }: UiBoxProps) => {
styles.useVariants({ ...variants })
// 첫 판: style={[styles.ui_box_root, merged.style]} {...merged}
// merged에 style이 남아 있으면 뒤의 스프레드가 배열을 덮는다
return (
<View
{...rest}
style={[styles.ui_box_root, styles.ui_box_root_dyn(dynamic), style]}
/>
)
}Pressable은 style로 함수를 받을 수 있습니다. React Native 0.87 문서 기준으로 이 함수는 { pressed }를 받아 스타일을 돌려줍니다. 공용 버튼은 이 함수 안에서도 같은 순서로 배열을 만들었습니다. 호출부 style이 함수이면 같은 상태로 호출한 결과를 마지막에 넣었습니다.
<Pressable
{...rest}
style={(state) => [
styles.ui_pressable_root,
styles.ui_pressable_state({ pressed: state.pressed, disabled: !!rest.disabled }),
typeof style === 'function' ? style(state) : style,
]}
/>키보드도 웹에서는 없던 기본값 문제였습니다. 화면 아래쪽에 입력이 여러 개 있으면 키보드가 올라오면서 입력을 가립니다. 공용 키보드 상자는 KeyboardAvoidingView를 감싸 iOS에는 padding, 다른 플랫폼에는 height를 기본값으로 두었고 호출부에서 바꿀 수 있게 했습니다. React Native 0.87 문서는 두 플랫폼이 behavior를 다르게 다룬다고만 적고 양쪽 모두 값을 지정하라고 권합니다. 어떤 값이 맞는지는 입력이 많은 실제 화면을 단말에서 열어 봐야 알 수 있는데 PoC는 거기까지 가지 못했습니다.
스타일은 클래스가 아니라 객체로 옮겨야 했습니다
React Native에는 선택자와 캐스케이드가 없습니다. 스타일은 style prop에 넘기는 객체이고 배열로 넘기면 뒤의 값이 앞을 덮습니다. Flexbox 기본값도 다릅니다. flexDirection은 column, flexShrink는 0이 기본이고 position에는 sticky가 없습니다. 그래서 SCSS 클래스 이름을 그대로 옮기는 대신 토큰은 테마 객체로, 클래스는 스타일 키로 다시 설계했습니다.
PoC는 Unistyles 3을 썼습니다. StyleSheet.configure에 light, dark 테마와 브레이크포인트를 등록하고 StyleSheet.create에 테마를 받는 함수를 넘깁니다. 테마 파일 하나에 팔레트와 쓰임별 이름을 붙인 색을 나눠 두었습니다. Unistyles 3은 테마가 바뀌면 앱 전체를 다시 렌더링하지 않고 해당 스타일을 쓰는 노드만 갱신합니다.
설치 조건은 미리 확인해야 합니다. Unistyles 3은 React Native 0.81 이상, New Architecture, Expo SDK 54 이상을 요구하고 react-native-nitro-modules에 의존합니다. 네이티브 코드가 들어가므로 Expo Go에서는 실행할 수 없고 개발 빌드가 필요합니다. PoC에도 expo-dev-client를 넣었습니다.
선택지가 닫혀 있는 값은 variants로 두고 실행 중에 정해지는 연속값은 함수로 받았습니다. 동적 함수의 인자는 네이티브 쪽에 저장되므로 문자열, 숫자, 불리언 같은 직렬화할 수 있는 값만 넘깁니다.
import { StyleSheet } from 'react-native-unistyles'
export const styles = StyleSheet.create((theme) => ({
ui_button_root: {
borderRadius: theme.radii.md,
variants: {
size: {
sm: { paddingVertical: theme.spacing.xs, paddingHorizontal: theme.spacing.md },
md: { paddingVertical: theme.spacing.sm, paddingHorizontal: theme.spacing.lg },
},
tone: {
primary: { backgroundColor: theme.colors.primary },
outline: { borderWidth: 1, borderColor: theme.colors.primary },
},
},
},
// 실행 중에 정해지는 값. 인자는 직렬화할 수 있는 값만 넘깁니다
ui_button_dim: (opacity: number) => ({ opacity }),
}))
// 사용하는 쪽: styles.useVariants({ size, tone })
// style={[styles.ui_button_root, styles.ui_button_dim(disabled ? 0.5 : 1)]}스타일을 합칠 때는 배열을 씁니다. Unistyles 문서는 스프레드로 합치면 스타일 순서를 잃고 개발 모드에서 경고한다고 설명합니다. 앞 절에서 배열 순서를 고정한 규칙도 이 권고와 맞물립니다.
variants와 함수의 경계에는 피해야 할 모양도 적어 두었습니다. isTitle, isMuted처럼 불리언 prop이 여러 개 생기면 tone 하나의 variant 축으로 모읍니다. 반대로 투명도처럼 연속된 값을 opacity_10, opacity_20 같은 이름으로 늘어놓지 않고 함수로 받습니다. 함수에서는 받은 값을 허용 범위로 자르고 색이나 글자 크기처럼 토큰으로 정한 값은 덮어쓰지 않게 했습니다.
스타일 키 이름은 <범위>_<블록>_<요소>__<수정자> 형식으로 정했습니다. 범위는 화면 scr, 위젯 w, 공용 UI ui 셋입니다. 하이픈을 쓰면 객체 키에 대괄호로 접근해야 하므로 밑줄만 씁니다. 수정자는 하나를 기본으로 하고 플랫폼처럼 다른 축이 겹칠 때만 둘까지 허용했습니다. container나 wrapper처럼 이유가 없는 이름은 금지하고 actions, nav, list처럼 묶은 이유를 쓰게 했습니다. 색과 톤, 글자 크기는 공용 UI의 variants로 처리하고 화면 키에는 간격과 정렬만 남겼습니다. BEM의 블록, 요소, 수정자를 객체 키에 맞게 줄인 형식입니다.
옮기지 못한 것과 확인하지 못한 것
PoC가 남긴 것은 탐색 그룹, 공통 UI, 테마와 이름 규약입니다. 다수 화면에는 표본 데이터와 자리만 잡은 UI가 있었고 인증 화면은 안내 문구만 있는 표본이었습니다. 화면 파일이 등록돼 있다고 기능이 완성된 것은 아닙니다.
웹 구조를 그대로 옮길 수 없는 지점은 대조표에 적어 두고 구현하지 않았습니다.
- 이미지 경로 상수: 웹에서는 경로 문자열을 조합했습니다. React Native의
require는 이미지 이름을 정적으로 알아야 하므로 빌드 시점에require지도를 만드는 스크립트가 필요합니다. - 당겨서 새로고침: 웹 라이브러리 대신
ScrollView와FlatList의RefreshControl로 다시 만들어야 합니다. - 고정 헤더:
position: sticky가 없으므로 내비게이션 헤더나stickyHeaderIndices로 옮겨야 합니다. - 모달 오류 안내: DOM 모달 대신 모달 라우트나 네이티브
Modal로 옮기고 닫기 동작을router.back과 맞춰야 합니다.
측정도 하지 않았습니다. 실제 단말의 프레임과 메모리, 키보드와 safe area 조합, 캐러셀 제스처와 뒤로가기 제스처의 충돌, 긴 목록의 스크롤 성능은 재현하지 않았습니다. 다음에 이어 간다면 SDK 58로 올린 개발 빌드를 중저가 Android 단말에 설치하고 목록과 캐러셀 화면의 프레임부터 재겠습니다. 버전 차이도 남은 과제입니다. PoC 이후 npm 최신 버전은 Unistyles 3.6.0, Reanimated 4.7.1로 올라왔으므로 설치 조건을 다시 확인해야 합니다.
대조표는 무엇을 버려야 하는지 빨리 보여 줬습니다. 하지만 어려운 일은 대부분 표의 한 줄 뒤에 있습니다. 스타일을 옮기는 일은 패키지 교체가 아니라 SCSS 캐스케이드에 기대던 화면을 객체 스타일로 다시 설계하는 일입니다. 실제 이관을 맡는다면 공용 UI와 테마를 먼저 세우고 화면은 그 위에서 하나씩 옮기는 순서를 택하겠습니다.
PoC의 구현 범위는 Expo App에 따로 정리했습니다.
참고 자료
- Expo SDK reference: versions
- Expo Router: Router notation
- Expo Router: Modals
- Expo Router: Protected routes
- Expo: Environment variables
- Expo: Development builds
- Unistyles 3: Getting started
- Unistyles 3: Variants
- Unistyles 3: Dynamic functions
- Unistyles 3: Merging styles
- React Native: Pressable
- React Native: KeyboardAvoidingView
- TanStack Query: React Native
- React Native: Layout with Flexbox
- React Native: Images
- React Native: RefreshControl