프로젝트를 리액트로 웹사이트 구현을 해야겠다고 생각하고 시작했지만, 제가 원하는 서비스를 제공하려면 결국 앱으로 구현이 되어야 겠다는 생각을 했습니다. 특히나 고객에게 알람 메시지를 보내야 하는데, 이는 웹 환경만으로는 한계가 있어서, 기존의 리액트를 리액트 네이티브로 옮겨보자는 생각으로 시작하게 되었습니다.
프로젝트 개요
기존 프로젝트 (React Web) 🌐
- 기술 스택: React 18 + TypeScript + Vite + Wouter 라우팅
- 상태 관리: TanStack Query + Zustand
- UI 라이브러리: Tailwind CSS + Shadcn/ui
- 인증: Passport.js (username/password)
- 백엔드: Express.js + PostgreSQL
마이그레이션 목표 (Expo React Native) 📱
- 플랫폼: iOS/Android 네이티브 앱
- 인증 방식: Google OAuth 2.0로 변경
- 최신 기술: 모던 React Native 개발 방법론 적용
1단계: 프로젝트 분석 및 계획 수립 📝
마이그레이션 전략 설정
- Phase 1: 기본 설정 및 의존성 설치
- Phase 2: 핵심 앱 구조 마이그레이션
- Phase 3: 인증 시스템 마이그레이션 (Google Login 우선)
- Phase 4: 기타 컴포넌트 및 서비스 마이그레이션
- Phase 5: 테스트 및 최적화
기술 스택 선정
{
"핵심 라이브러리": {
"expo": "~53.0.20",
"expo-router": "~5.1.4",
"zustand": "^5.0.7",
"@tanstack/react-query": "^5.84.2"
},
"인증": {
"expo-auth-session": "~6.2.1",
"expo-secure-store": "~14.2.3"
},
"저장소": {
"@react-native-async-storage/async-storage": "2.1.2"
},
"개발 도구": {
"typescript": "~5.8.3",
"class-variance-authority": "^0.7.1"
}
}
2단계: Expo 프로젝트 초기 설정 🛠️
프로젝트 생성 및 기본 구조
# Expo 프로젝트 생성
npx create-expo-app shook_app --template tabs@53
# 필수 의존성 설치
cd shook_app
npm install zustand@^5.0.7 immer@^10.1.1
npm install @tanstack/react-query@^5.84.2
npm install expo-auth-session@~6.2.1 expo-secure-store@~14.2.3
npm install @react-native-async-storage/async-storage@2.1.2
npm install react-hook-form@^7.62.0 zod@^4.0.16
npm install class-variance-authority@^0.7.1 clsx@^2.1.1
TypeScript 설정 최적화
tsconfig.json 파일에 baseUrl과 paths를 설정하여 절대 경로 임포트를 사용합니다.
// tsconfig.json
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["./shared/*"]
}
}
}
디렉토리 구조 설계
src/
├── components/ # 재사용 가능한 컴포넌트
├── hooks/ # 커스텀 훅
├── services/ # API 및 비즈니스 로직
├── stores/ # Zustand 상태 관리
├── lib/ # 유틸리티 및 설정
└── types/ # TypeScript 타입 정의
3단계: 모던 상태 관리 시스템 구축 🧠
Zustand 스토어 with Immer 미들웨어
zustand와 immer를 사용해 불변성을 유지하면서도 직관적인 상태 관리를 구현합니다. persist 미들웨어와 expo-secure-store를 연동하여 인증 상태를 안전하게 저장합니다.
// src/stores/auth-store.ts
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { secureStorage } from '@/lib/storage';
interface User {
id: string;
username: string;
email?: string;
picture?: string;
givenName?: string;
familyName?: string;
verified?: boolean;
}
interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
}
interface AuthActions {
login: (user: User) => void;
logout: () => void;
updateUser: (updates: Partial<User>) => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState & AuthActions>()(
persist(
immer((set) => ({
// 초기 상태
user: null,
isLoading: false,
isAuthenticated: false,
// 액션
login: (user: User) =>
set((state) => {
state.user = user;
state.isAuthenticated = true;
state.isLoading = false;
}),
logout: () =>
set((state) => {
state.user = null;
state.isAuthenticated = false;
state.isLoading = false;
}),
updateUser: (updates: Partial<User>) =>
set((state) => {
if (state.user) {
Object.assign(state.user, updates);
}
}),
setLoading: (loading: boolean) =>
set((state) => {
state.isLoading = loading;
}),
})),
{
name: 'auth-storage',
storage: createJSONStorage(() => ({
getItem: async (name: string) => {
const value = await secureStorage.getItem(name);
return value ? JSON.parse(value) : null;
},
setItem: async (name: string, value: string) => {
await secureStorage.setItem(name, value);
},
removeItem: async (name: string) => {
await secureStorage.removeItem(name);
},
})),
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated
}),
}
)
);
보안 저장소 구현
AsyncStorage는 일반 데이터를, expo-secure-store는 토큰과 같은 민감한 데이터를 저장하는 데 사용합니다.
// src/lib/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
// 민감하지 않은 데이터용 저장소
export const storage = {
getString: async (key: string): Promise<string | undefined> => {
try {
const value = await AsyncStorage.getItem(key);
return value ?? undefined;
} catch {
return undefined;
}
},
set: async (key: string, value: string): Promise<void> => {
try {
await AsyncStorage.setItem(key, value);
} catch {
// 에러 무시
}
},
delete: async (key: string): Promise<void> => {
try {
await AsyncStorage.removeItem(key);
} catch {
// 에러 무시
}
}
};
// 민감한 데이터용 보안 저장소
export const secureStorage = {
async setItem(key: string, value: string) {
await SecureStore.setItemAsync(key, value);
},
async getItem(key: string): Promise<string | null> {
return await SecureStore.getItemAsync(key);
},
async removeItem(key: string) {
await SecureStore.deleteItemAsync(key);
}
};
4단계: Google OAuth 2.0 인증 구현 🔐
환경 변수 설정
프로젝트 루트에 .env 파일을 생성하고 Google Cloud Console에서 발급받은 클라이언트 ID를 추가합니다.
# .env
EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS=your-ios-client-id.apps.googleusercontent.com
EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID=your-android-client-id.apps.googleusercontent.com
EXPO_PUBLIC_GOOGLE_CLIENT_ID_WEB=your-web-client-id.apps.googleusercontent.com
EXPO_PUBLIC_APP_SCHEME=com.shook.app
Google 인증 훅 구현
expo-auth-session의 Google.useAuthRequest 훅을 사용하여 Google 로그인 로직을 캡슐화합니다.
// src/hooks/useGoogleAuth.ts
import { useState, useCallback, useEffect } from 'react';
import * as Google from 'expo-auth-session/providers/google';
import { useAuthStore } from '@/stores/auth-store';
import { secureStorage } from '@/lib/storage';
export function useGoogleAuth() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { login, logout, setLoading } = useAuthStore();
const [request, response, promptAsync] = Google.useAuthRequest({
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS,
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID,
webClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_WEB,
scopes: ['openid', 'profile', 'email'],
});
useEffect(() => {
if (response?.type === 'success') {
handleGoogleResponse();
} else if (response?.type === 'error') {
setError('Google 로그인에 실패했습니다.');
setIsLoading(false);
}
}, [response]);
const handleGoogleResponse = async () => {
try {
if (!response?.authentication?.accessToken) {
throw new Error('액세스 토큰을 받지 못했습니다');
}
const userResponse = await fetch(
`https://www.googleapis.com/oauth2/v2/userinfo?access_token=${response.authentication.accessToken}`
);
const googleUser = await userResponse.json();
await secureStorage.setItem('google_access_token', response.authentication.accessToken);
const user = {
id: googleUser.id,
username: googleUser.name,
email: googleUser.email,
picture: googleUser.picture,
givenName: googleUser.given_name,
familyName: googleUser.family_name,
verified: googleUser.verified_email,
};
login(user);
setError(null);
} catch (err) {
setError('Google 로그인에 실패했습니다.');
console.error('Google Sign-In Error:', err);
} finally {
setIsLoading(false);
setLoading(false);
}
};
const signIn = useCallback(async () => {
try {
setIsLoading(true);
setLoading(true);
setError(null);
if (!request) {
throw new Error('Google Auth 요청이 초기화되지 않았습니다');
}
await promptAsync();
} catch (err) {
setError('Google 로그인에 실패했습니다.');
setIsLoading(false);
setLoading(false);
}
}, [request, promptAsync, setLoading]);
const signOut = useCallback(async () => {
try {
setIsLoading(true);
await Promise.all([
secureStorage.removeItem('google_access_token'),
secureStorage.removeItem('google_refresh_token'),
]);
logout();
} catch (err) {
setError('로그아웃에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [logout]);
return { signIn, signOut, isLoading, error };
}
Google 로그인 버튼 컴포넌트
// src/components/GoogleSignInButton.tsx
import React from 'react';
import { View, Text, Image, StyleSheet, Pressable } from 'react-native';
import { useGoogleAuth } from '@/hooks/useGoogleAuth';
interface GoogleSignInButtonProps {
onSuccess?: () => void;
onError?: (error: string) => void;
}
export function GoogleSignInButton({ onSuccess, onError }: GoogleSignInButtonProps) {
const { signIn, isLoading, error } = useGoogleAuth();
const handleSignIn = async () => {
try {
await signIn();
onSuccess?.();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Google 로그인에 실패했습니다.';
onError?.(errorMessage);
}
};
return (
{!isLoading && (
https://developers.google.com/identity/images/g-logo.png',
}}
style={styles.googleIcon}
resizeMode="contain"
/>
)}
{isLoading ? '로그인 중...' : 'Google로 계속하기'}
{error && {error}}
);
}
const styles = StyleSheet.create({
container: { width: '100%' },
button: {
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
buttonDisabled: { opacity: 0.6 },
buttonContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
googleIcon: { width: 20, height: 20, marginRight: 12 },
buttonText: { color: '#374151', fontSize: 16, fontWeight: '500' },
errorText: { color: '#ef4444', fontSize: 14, marginTop: 8, textAlign: 'center' },
});
5단계: 라우팅 및 네비게이션 구현 🧭
보호된 라우트 컴포넌트
인증 상태에 따라 접근을 제어하는 ProtectedRoute 컴포넌트를 생성합니다. 인증되지 않은 사용자는 로그인 페이지로 리디렉션됩니다.
// src/components/ProtectedRoute.tsx
import React, { useEffect } from 'react';
import { router } from 'expo-router';
import { useAuthStore } from '@/stores/auth-store';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuthStore();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace('/auth-complex');
}
}, [isAuthenticated, isLoading]);
if (isLoading) {
return null; // 로딩 스피너 표시 가능
}
if (!isAuthenticated) {
return null; // useEffect에서 리다이렉트됨
}
return <>{children}</>;
}
탭 레이아웃 구성
expo-router의 Tabs를 사용하여 메인 네비게이션을 구성하고, ProtectedRoute로 전체 탭을 감싸 보호합니다.
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import React from 'react';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { ProtectedRoute } from '@/components/ProtectedRoute';
export default function TabLayout() {
return (
<ProtectedRoute>
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen
name="index"
options={{
title: '홈',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="channels"
options={{
title: '채널',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="play.rectangle.fill" color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: '설정',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="gear" color={color} />,
}}
/>
</Tabs>
</ProtectedRoute>
);
}
6단계: 실제 개발에서 발생한 문제와 해결 과정 🧐
문제 1: MMKV TurboModules 오류
- 오류 내용: ERROR: react-native-mmkv 3.x.x requires TurboModules, but the new architecture is not enabled!
- 해결 방법: react-native-mmkv는 새 아키텍처(TurboModules)를 필요로 하지만, Expo Managed Workflow에서는 기본적으로 활성화되지 않아 발생한 문제였습니다. AsyncStorage로 대체하여 해결했습니다.
-
Bash
npm uninstall react-native-mmkv npm install @react-native-async-storage/async-storage
문제 2: Expo Router nanoid 의존성 오류
- 오류 내용: ERROR: Unable to resolve module nanoid/non-secure from expo-router
- 해결 방법: expo-router가 특정 버전의 nanoid에 의존하고 있어 발생한 문제입니다. 호환되는 버전을 직접 설치하여 해결했습니다.
-
Bash
npm install nanoid@^3.3.11
문제 3: Google OAuth 2.0 정책 준수 오류
- 오류 내용: You can't sign in to this app because it doesn't comply with Google's OAuth 2.0 policy
- 해결 방법: Google Cloud Console에서 iOS와 Android 타입의 OAuth 2.0 클라이언트 ID를 각각 생성하고, expo-auth-session/providers/google에서 제공하는 플랫폼별 clientId 옵션에 정확히 할당하여 해결했습니다.
문제 4: TailwindCSS 비동기 플러그인 처리 오류
- 오류 내용: Use process(css).then(cb) to work with async plugins
- 해결 방법: nativewind와 같은 라이브러리의 특정 버전에서 비동기 처리 관련 문제가 있었습니다. 마이그레이션의 복잡성을 줄이기 위해, className 대신 React Native의 기본 StyleSheet.create를 사용하여 스타일을 직접 작성하는 방식으로 전환했습니다.
7단계: 성능 최적화 및 베스트 프랙티스 ✨
메모리 관리
컴포넌트가 언마운트될 때 useEffect의 반환 함수(cleanup function)를 사용하여 타이머, 이벤트 리스너, 구독 등을 정리하여 메모리 누수를 방지합니다.
useEffect(() => {
// 리소스 할당 (e.g., 타이머 설정, 구독)
const timer = setInterval(() => { ... }, 1000);
// cleanup 함수
return () => {
clearInterval(timer); // 타이머 정리
};
}, []);
이미지 최적화
expo-image 라이브러리는 캐싱, 플레이스홀더, 전환 효과 등 고성능 이미지 처리를 지원합니다. 기본 Image 컴포넌트 대신 사용하여 사용자 경험을 개선할 수 있습니다.
import { Image } from 'expo-image';
<Image
source={{ uri: user.picture }}
style={styles.avatar}
contentFit="cover"
transition={1000}
/>
마무리 및 학습 포인트 🎓
핵심 학습 내용
- 현대적인 React Native 개발: Expo를 활용한 효율적인 개발 환경 구축
- 상태 관리: Zustand + Immer를 통한 불변성 유지와 직관적인 상태 업데이트
- 보안: Expo SecureStore를 활용한 민감한 데이터 보호
- 인증: Google OAuth 2.0 표준 구현
- 에러 처리: 실제 개발에서 발생하는 문제들의 체계적 해결
추가 개선 사항
- 테스트 코드 작성: Jest, React Native Testing Library
- CI/CD 파이프라인: GitHub Actions, EAS Build
- 성능 모니터링: Flipper, React Native Performance
- 접근성: React Native Accessibility
참고 자료
'개발' 카테고리의 다른 글
| React Native/Expo 개발, 로컬 서버 연결 실패? 원인부터 해결까지 (0) | 2025.08.22 |
|---|---|
| Expo 완전 마스터 가이드 - 개발부터 배포까지 (0) | 2025.08.20 |
| React와 Node.js에서 GIS를 통한 구글 로그인 연동기 (2) | 2025.08.02 |
| Node.js 풀스택 개발시 로컬 개발 환경과 배포 환경의 괴리에서 발생하는 문제점에 대한 논의 (0) | 2025.08.02 |
| Vite와 Node.js 풀스택 개발에서 npm run dev를 통해 프론트와 백 동시에 실행하기(디버깅) (3) | 2025.08.02 |