React 웹 프로젝트를 Expo React Native로 완전 마이그레이션하기: 실전 가이드

2025. 8. 9. 18:43·개발

프로젝트를 리액트로 웹사이트 구현을 해야겠다고 생각하고 시작했지만, 제가 원하는 서비스를 제공하려면 결국 앱으로 구현이 되어야 겠다는 생각을 했습니다. 특히나 고객에게 알람 메시지를 보내야 하는데, 이는 웹 환경만으로는 한계가 있어서, 기존의 리액트를 리액트 네이티브로 옮겨보자는 생각으로 시작하게 되었습니다.


프로젝트 개요

기존 프로젝트 (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: 테스트 및 최적화

기술 스택 선정

JSON
 
{
  "핵심 라이브러리": {
    "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 프로젝트 초기 설정 🛠️

프로젝트 생성 및 기본 구조

Bash
 
# 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를 설정하여 절대 경로 임포트를 사용합니다.

JSON
 
// 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를 연동하여 인증 상태를 안전하게 저장합니다.

TypeScript
 
// 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는 토큰과 같은 민감한 데이터를 저장하는 데 사용합니다.

TypeScript
 
// 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 로그인 로직을 캡슐화합니다.

TypeScript
 
// 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 로그인 버튼 컴포넌트

TypeScript
 
// 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 컴포넌트를 생성합니다. 인증되지 않은 사용자는 로그인 페이지로 리디렉션됩니다.

TypeScript
 
// 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로 전체 탭을 감싸 보호합니다.

TypeScript
 
// 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)를 사용하여 타이머, 이벤트 리스너, 구독 등을 정리하여 메모리 누수를 방지합니다.

JavaScript
 
useEffect(() => {
  // 리소스 할당 (e.g., 타이머 설정, 구독)
  const timer = setInterval(() => { ... }, 1000);

  // cleanup 함수
  return () => {
    clearInterval(timer); // 타이머 정리
  };
}, []);

이미지 최적화

expo-image 라이브러리는 캐싱, 플레이스홀더, 전환 효과 등 고성능 이미지 처리를 지원합니다. 기본 Image 컴포넌트 대신 사용하여 사용자 경험을 개선할 수 있습니다.

JavaScript
 
import { Image } from 'expo-image';

<Image
  source={{ uri: user.picture }}
  style={styles.avatar}
  contentFit="cover"
  transition={1000}
/>

마무리 및 학습 포인트 🎓

핵심 학습 내용

  1. 현대적인 React Native 개발: Expo를 활용한 효율적인 개발 환경 구축
  2. 상태 관리: Zustand + Immer를 통한 불변성 유지와 직관적인 상태 업데이트
  3. 보안: Expo SecureStore를 활용한 민감한 데이터 보호
  4. 인증: Google OAuth 2.0 표준 구현
  5. 에러 처리: 실제 개발에서 발생하는 문제들의 체계적 해결

추가 개선 사항

  • 테스트 코드 작성: Jest, React Native Testing Library
  • CI/CD 파이프라인: GitHub Actions, EAS Build
  • 성능 모니터링: Flipper, React Native Performance
  • 접근성: React Native Accessibility

참고 자료

  • Expo Documentation
  • React Native Documentation
  • Zustand GitHub Repository
  • Google Identity OAuth 2.0

'개발' 카테고리의 다른 글

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
'개발' 카테고리의 다른 글
  • React Native/Expo 개발, 로컬 서버 연결 실패? 원인부터 해결까지
  • Expo 완전 마스터 가이드 - 개발부터 배포까지
  • React와 Node.js에서 GIS를 통한 구글 로그인 연동기
  • Node.js 풀스택 개발시 로컬 개발 환경과 배포 환경의 괴리에서 발생하는 문제점에 대한 논의
5jyan5
5jyan5
  • 5jyan5
    jyan
    5jyan5
  • 전체
    오늘
    어제
    • 분류 전체보기 (242)
      • 김영한의 스프링 핵심 원리(기본편) (8)
      • 김영한의 스프링 핵심 원리 - 고급편 (11)
      • 김영한의 스프링 MVC 1편 (1)
      • 김영한의 스프링 DB 1편 (3)
      • 김영한의 스프링 MVC 2편 (3)
      • 김영한의 ORM 표준 JPA 프로그래밍(기본편) (9)
      • 김영한의 스프링 부트와 JPA 활용2 (2)
      • 김영한의 실전 자바 - 중급 1편 (1)
      • 김영한의 실전 자바 - 고급 1편 (9)
      • 김영한의 실전 자바 - 고급 2편 (9)
      • Readable Code: 읽기 좋은 코드를 작성.. (2)
      • 김영한의 실전 자바 - 고급 3편 (9)
      • CKA (118)
      • 개발 (37)
      • 경제 (4)
      • 리뷰 (1)
      • 정보 (2)
  • 블로그 메뉴

    • 링크

    • 공지사항

    • 인기 글

    • 태그

      프록시 팩토리
      hibernate5module
      조회 성능 최적화
      JPQL
      스레드
      @discriminatorvalue
      gesingleresult
      김영한
      cglib
      양방향 맵핑
      Thread
      프록시
      @discriminatorcolumn
      reentarantlock
      @args
      @within
      고급
      jdk 동적 프록시
      자바
      typequery
      jpq
      requset scope
      락
      Target
      log trace
      WAS
      페치 조인
      빈 후처리기
      단방향 맵핑
      버퍼
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.2
    5jyan5
    React 웹 프로젝트를 Expo React Native로 완전 마이그레이션하기: 실전 가이드
    상단으로

    티스토리툴바