본문 바로가기
풀스택 개발이야기

📱 React Native 크로스플랫폼 개발 후기 하나의 코드로 iOS와 Android 앱 동시 개발

by junetapa 2025. 7. 6.
반응형

 

"정말 하나의 코드로 두 플랫폼 앱을 만들 수 있을까?" React Native로 실제 상용 앱을 개발하며 겪은 생생한 경험담입니다. 시행착오부터 해결 방법까지, 크로스플랫폼 개발의 현실을 솔직하게 공유합니다.

🤔 왜 React Native를 선택했나?

❌ 네이티브 개발의 문제점 ✅ React Native의 장점
  • iOS(Swift)와 Android(Kotlin) 각각 개발
  • 두 배의 개발 시간과 비용
  • 플랫폼별 전문 개발자 필요
  • 기능 동기화와 유지보수 부담
  • 작은 스타트업에는 현실적 부담
  • 하나의 코드베이스로 두 플랫폼
  • 개발 시간 50% 단축
  • 웹 개발자도 모바일 앱 개발 가능
  • 실시간 새로고침과 빠른 개발
  • React 생태계 활용 가능

프로젝트 배경과 요구사항

📊 프로젝트 개요:
  • 앱 유형: 소셜 커머스 플랫폼
  • 팀 구성: 프론트엔드 개발자 2명, 백엔드 1명
  • 개발 기간: 4개월 (MVP)
  • 주요 기능: 상품 검색, 결제, 채팅, 푸시 알림
  • 목표: iOS/Android 동시 출시

기술 스택 선택 과정

기술 장점 단점 선택 여부
네이티브 최고 성능, 완전한 제어 두 배 개발 시간, 높은 비용
Flutter 빠른 성능, 일관된 UI Dart 학습 필요, 생태계 부족
React Native React 경험 활용, 큰 생태계 브릿지 성능, 플랫폼별 차이
💡 최종 선택 이유:
  • 팀 역량: React 개발 경험 보유
  • 개발 속도: 빠른 MVP 출시 필요
  • 생태계: 풍부한 라이브러리와 커뮤니티
  • 유지보수: 하나의 코드베이스 관리

⚙️ 개발 환경 구축과 초기 설정

개발 환경 세팅 체크리스트

# React Native 개발 환경 구축

# 1. Node.js 및 패키지 매니저 설치
node --version  # v18.17.0 이상 권장
npm --version   # 또는 yarn 사용

# 2. React Native CLI 설치
npm install -g @react-native-community/cli

# 3. iOS 개발 환경 (macOS 전용)
# Xcode 설치 (App Store)
# iOS Simulator 설정
# CocoaPods 설치
sudo gem install cocoapods

# 4. Android 개발 환경
# Android Studio 설치
# Android SDK 및 빌드 도구 설정
# Android Virtual Device (AVD) 생성

# 5. 프로젝트 생성
npx react-native init MyAwesomeApp --version 0.72.6

# 6. 의존성 설치 및 실행
cd MyAwesomeApp
npm install

# iOS 실행
npx react-native run-ios

# Android 실행
npx react-native run-android

필수 라이브러리 설정

# package.json 주요 의존성
{
  "dependencies": {
    "react": "18.2.0",
    "react-native": "0.72.6",
    
    // 네비게이션
    "@react-navigation/native": "^6.1.9",
    "@react-navigation/stack": "^6.3.20",
    "@react-navigation/bottom-tabs": "^6.5.11",
    "react-native-screens": "^3.27.0",
    "react-native-safe-area-context": "^4.7.4",
    
    // UI 라이브러리
    "react-native-vector-icons": "^10.0.2",
    "react-native-paper": "^5.11.1",
    
    // 상태 관리
    "@reduxjs/toolkit": "^1.9.7",
    "react-redux": "^8.1.3",
    
    // 네트워킹
    "axios": "^1.6.0",
    "@react-native-async-storage/async-storage": "^1.19.5",
    
    // 이미지 처리
    "react-native-image-picker": "^7.0.3",
    "react-native-fast-image": "^8.6.3",
    
    // 푸시 알림
    "@react-native-firebase/app": "^18.6.1",
    "@react-native-firebase/messaging": "^18.6.1",
    
    // 결제
    "react-native-iap": "^12.10.7"
  },
  "devDependencies": {
    "@types/react": "^18.2.37",
    "@types/react-native": "^0.72.6",
    "typescript": "^5.2.2",
    "flipper-plugin-react-native-performance": "^1.2.0"
  }
}

프로젝트 구조 설계

src/
├── components/          # 재사용 가능한 컴포넌트
│   ├── common/         # 공통 UI 컴포넌트
│   ├── forms/          # 폼 관련 컴포넌트
│   └── business/       # 비즈니스 로직 컴포넌트
├── screens/            # 화면 컴포넌트
│   ├── auth/          # 인증 관련 화면
│   ├── home/          # 홈 화면
│   ├── product/       # 상품 관련 화면
│   └── profile/       # 프로필 화면
├── navigation/         # 네비게이션 설정
├── store/             # Redux 상태 관리
│   ├── slices/        # Redux Toolkit 슬라이스
│   └── api/           # RTK Query API
├── services/          # 외부 서비스 연동
│   ├── api.ts         # API 클라이언트
│   ├── auth.ts        # 인증 서비스
│   └── storage.ts     # 로컬 스토리지
├── utils/             # 유틸리티 함수
├── hooks/             # 커스텀 훅
├── types/             # TypeScript 타입 정의
├── assets/            # 이미지, 폰트 등
└── config/            # 설정 파일
    ├── constants.ts   # 상수 정의
    └── theme.ts       # 테마 설정
🚀 초기 설정 팁:
  • TypeScript 필수: 타입 안정성으로 런타임 오류 방지
  • 절대 경로 설정: babel-plugin-module-resolver 사용
  • ESLint/Prettier: 코드 품질과 일관성 유지
  • Flipper 디버깅: 개발 생산성 향상
  • 환경별 설정: react-native-config로 환경 변수 관리

⚖️ 플랫폼별 차이점과 대응법

"Write once, run anywhere"라는 이상과 달리, 실제로는 플랫폼별 차이점이 존재합니다.

디자인 가이드라인 차이

요소 iOS Android 해결책
네비게이션 Tab Bar 하단 Bottom Navigation 플랫폼별 네비게이션 컴포넌트
버튼 둥근 모서리, 그림자 Material Design Platform.select() 활용
아이콘 SF Symbols Material Icons react-native-vector-icons
알림 배너 스타일 Snackbar 조건부 렌더링

플랫폼별 스타일링 대응

// Platform.select()를 활용한 플랫폼별 스타일링
import { Platform, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  button: {
    padding: 12,
    borderRadius: Platform.select({
      ios: 8,      // iOS: 둥근 모서리
      android: 4,  // Android: 적당한 모서리
    }),
    ...Platform.select({
      ios: {
        shadowColor: '#000',
        shadowOffset: { width: 0, height: 2 },
        shadowOpacity: 0.25,
        shadowRadius: 3.84,
      },
      android: {
        elevation: 5,  // Android Material Design 그림자
      },
    }),
  },
  
  header: {
    height: Platform.select({
      ios: 44,     // iOS 네비게이션 바 높이
      android: 56, // Android 액션 바 높이
    }),
    paddingTop: Platform.select({
      ios: 0,
      android: 24, // Android 상태바 높이 고려
    }),
  },
  
  text: {
    fontFamily: Platform.select({
      ios: 'San Francisco',      // iOS 시스템 폰트
      android: 'Roboto',         // Android 시스템 폰트
    }),
    fontSize: Platform.select({
      ios: 16,
      android: 14,
    }),
  },
});

// 조건부 컴포넌트 렌더링
const PlatformSpecificComponent = () => {
  if (Platform.OS === 'ios') {
    return <IOSSpecificComponent />;
  }
  return <AndroidSpecificComponent />;
};

// 플랫폼별 파일 분리
// Button.ios.tsx
// Button.android.tsx
// Button.tsx (공통)

실제 겪은 플랫폼별 이슈들

⚠️ iOS 관련 이슈:
  • SafeArea 처리: 노치/Dynamic Island 대응
  • 키보드 회피: KeyboardAvoidingView 필수
  • 상태바 스타일: 콘텐츠에 따른 동적 변경
  • 앱스토어 심사: 엄격한 가이드라인 준수
⚠️ Android 관련 이슈:
  • 백 버튼 처리: 하드웨어 백 버튼 이벤트
  • 권한 관리: 런타임 권한 요청
  • 다양한 화면 크기: 다양한 디바이스 대응
  • APK 사이즈: ProGuard 및 번들 최적화
// SafeArea 처리 (iOS)
import { useSafeAreaInsets } from 'react-native-safe-area-context';

const Screen = () => {
  const insets = useSafeAreaInsets();
  
  return (
    <View style={{ 
      flex: 1, 
      paddingTop: insets.top,
      paddingBottom: insets.bottom 
    }}>
      {/* 컨텐츠 */}
    </View>
  );
};

// Android 백 버튼 처리
import { BackHandler } from 'react-native';

useEffect(() => {
  const backAction = () => {
    // 커스텀 백 버튼 동작
    navigation.goBack();
    return true; // 기본 동작 방지
  };

  const backHandler = BackHandler.addEventListener(
    'hardwareBackPress',
    backAction
  );

  return () => backHandler.remove();
}, [navigation]);

// 권한 요청 (Android)
import { PermissionsAndroid } from 'react-native';

const requestCameraPermission = async () => {
  try {
    const granted = await PermissionsAndroid.request(
      PermissionsAndroid.PERMISSIONS.CAMERA,
      {
        title: '카메라 권한 요청',
        message: '사진을 촬영하기 위해 카메라 권한이 필요합니다.',
        buttonNeutral: '나중에',
        buttonNegative: '거부',
        buttonPositive: '허용',
      }
    );
    
    return granted === PermissionsAndroid.RESULTS.GRANTED;
  } catch (err) {
    console.warn(err);
    return false;
  }
};

⚡ 성능 최적화 실전 경험

성능 문제 진단과 해결

🚨 실제 겪은 성능 문제들:
  • JavaScript 브릿지 병목: 네이티브-JS 간 데이터 전송 지연
  • 메모리 누수: 이벤트 리스너 정리 미흡
  • 리스트 렌더링: 대용량 데이터 렌더링 성능
  • 이미지 로딩: 고해상도 이미지 처리
  • 애니메이션 끊김: 복잡한 애니메이션 성능

FlatList 최적화

// ❌ 성능 문제가 있는 리스트
const BadList = ({ data }) => {
  return (
    <FlatList
      data={data}
      renderItem={({ item }) => (
        <View style={{ padding: 20 }}>
          <Text>{item.title}</Text>
          <Image source={{ uri: item.imageUrl }} style={{ width: 100, height: 100 }} />
        </View>
      )}
    />
  );
};

// ✅ 최적화된 리스트
const OptimizedList = ({ data }) => {
  const renderItem = useCallback(({ item }) => (
    <MemoizedListItem item={item} />
  ), []);

  const keyExtractor = useCallback((item) => item.id.toString(), []);

  const getItemLayout = useCallback((data, index) => ({
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  }), []);

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      getItemLayout={getItemLayout}
      
      // 성능 최적화 옵션
      removeClippedSubviews={true}
      maxToRenderPerBatch={10}
      updateCellsBatchingPeriod={50}
      initialNumToRender={10}
      windowSize={10}
      
      // 메모리 최적화
      onEndReachedThreshold={0.5}
      onEndReached={loadMoreData}
    />
  );
};

// 메모이제이션된 리스트 아이템
const MemoizedListItem = React.memo(({ item }) => {
  return (
    <View style={styles.itemContainer}>
      <Text style={styles.title}>{item.title}</Text>
      <FastImage
        source={{ uri: item.imageUrl }}
        style={styles.image}
        resizeMode="cover"
      />
    </View>
  );
}, (prevProps, nextProps) => {
  // 커스텀 비교 함수
  return prevProps.item.id === nextProps.item.id &&
         prevProps.item.updatedAt === nextProps.item.updatedAt;
});

이미지 최적화

// react-native-fast-image 사용
import FastImage from 'react-native-fast-image';

const OptimizedImage = ({ uri, style }) => {
  return (
    <FastImage
      source={{
        uri,
        priority: FastImage.priority.normal,
        cache: FastImage.cacheControl.immutable,
      }}
      style={style}
      resizeMode={FastImage.resizeMode.cover}
      onLoad={() => console.log('Image loaded')}
      onError={() => console.log('Image load error')}
    />
  );
};

// 이미지 사이즈 최적화
const getOptimizedImageUri = (originalUri, width, height) => {
  // 서버에서 크기별 이미지 제공
  return `${originalUri}?w=${width}&h=${height}&fit=crop`;
};

// 레이지 로딩 구현
const LazyImage = ({ uri, style }) => {
  const [isVisible, setIsVisible] = useState(false);
  const ref = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { threshold: 0.1 }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <View ref={ref} style={style}>
      {isVisible ? (
        <FastImage source={{ uri }} style={style} />
      ) : (
        <View style={[style, { backgroundColor: '#f0f0f0' }]} />
      )}
    </View>
  );
};

메모리 관리

// 메모리 누수 방지
const useCleanupEffect = (effect, deps) => {
  useEffect(() => {
    const cleanup = effect();
    return cleanup;
  }, deps);
};

// 타이머 정리
const TimerComponent = () => {
  const timerRef = useRef();

  useEffect(() => {
    timerRef.current = setInterval(() => {
      // 타이머 로직
    }, 1000);

    return () => {
      if (timerRef.current) {
        clearInterval(timerRef.current);
      }
    };
  }, []);

  return null;
};

// 이벤트 리스너 정리
const EventListenerComponent = () => {
  useEffect(() => {
    const handleAppStateChange = (nextAppState) => {
      // 앱 상태 변경 처리
    };

    const subscription = AppState.addEventListener(
      'change',
      handleAppStateChange
    );

    return () => {
      subscription?.remove();
    };
  }, []);

  return null;
};

// 네트워크 요청 취소
const useApiCall = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const abortController = new AbortController();
    setLoading(true);

    fetch(url, { signal: abortController.signal })
      .then(response => response.json())
      .then(setData)
      .catch(error => {
        if (error.name !== 'AbortError') {
          console.error('API Error:', error);
        }
      })
      .finally(() => setLoading(false));

    return () => {
      abortController.abort();
    };
  }, [url]);

  return { data, loading };
};
📊 성능 최적화 결과:
  • 앱 시작 시간: 3.2초 → 1.8초 (44% 개선)
  • 리스트 스크롤: 끊김 현상 95% 감소
  • 메모리 사용량: 평균 30% 감소
  • 크래시율: 2.1% → 0.3% (85% 감소)

🔧 네이티브 모듈 연동하기

React Native만으로 구현하기 어려운 기능들을 네이티브 모듈로 해결한 경험을 공유합니다.

필요했던 네이티브 모듈들

기능 사용 라이브러리 도입 이유 난이도
푸시 알림 @react-native-firebase/messaging 사용자 재참여율 향상 보통
생체 인증 react-native-biometrics 보안 강화 쉬움
앱 내 결제 react-native-iap 수익 모델 구현 어려움
카메라 react-native-camera 상품 사진 촬영 보통
지도 react-native-maps 위치 기반 서비스 쉬움

Firebase 푸시 알림 구현

// Firebase 설정
// firebase.json
{
  "react-native": {
    "messaging_android_notification_channel_id": "default"
  }
}

// 푸시 알림 서비스
import messaging from '@react-native-firebase/messaging';
import { PermissionsAndroid, Platform } from 'react-native';

class PushNotificationService {
  constructor() {
    this.configure();
  }

  async configure() {
    // 권한 요청
    await this.requestPermission();
    
    // 토큰 가져오기
    const token = await this.getToken();
    console.log('FCM Token:', token);
    
    // 메시지 리스너 설정
    this.setupMessageHandlers();
  }

  async requestPermission() {
    if (Platform.OS === 'ios') {
      const authStatus = await messaging().requestPermission();
      const enabled =
        authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
        authStatus === messaging.AuthorizationStatus.PROVISIONAL;
      
      if (!enabled) {
        console.log('Push notification permission denied');
      }
    } else {
      await PermissionsAndroid.request(
        PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
      );
    }
  }

  async getToken() {
    try {
      const token = await messaging().getToken();
      // 서버로 토큰 전송
      await this.sendTokenToServer(token);
      return token;
    } catch (error) {
      console.error('Failed to get FCM token:', error);
    }
  }

  setupMessageHandlers() {
    // 포그라운드 메시지 처리
    messaging().onMessage(async remoteMessage => {
      console.log('Foreground message:', remoteMessage);
      this.showInAppNotification(remoteMessage);
    });

    // 백그라운드에서 앱 열기
    messaging().onNotificationOpenedApp(remoteMessage => {
      console.log('Background message opened:', remoteMessage);
      this.handleNotificationNavigation(remoteMessage);
    });

    // 앱이 종료된 상태에서 열기
    messaging()
      .getInitialNotification()
      .then(remoteMessage => {
        if (remoteMessage) {
          console.log('Initial notification:', remoteMessage);
          this.handleNotificationNavigation(remoteMessage);
        }
      });
  }

  async sendTokenToServer(token) {
    try {
      await fetch('/api/device-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token, platform: Platform.OS }),
      });
    } catch (error) {
      console.error('Failed to send token to server:', error);
    }
  }

  showInAppNotification(message) {
    // 인앱 알림 표시 (Toast, Modal 등)
    ToastService.show({
      title: message.notification?.title,
      body: message.notification?.body,
    });
  }

  handleNotificationNavigation(message) {
    // 알림 데이터에 따른 네비게이션
    const { screen, params } = message.data || {};
    
    if (screen) {
      NavigationService.navigate(screen, JSON.parse(params || '{}'));
    }
  }
}

앱 내 결제 구현 (어려웠던 부분)

// react-native-iap 사용
import {
  initConnection,
  purchaseUpdatedListener,
  purchaseErrorListener,
  flushFailedPurchasesCachedAsPendingAndroid,
  getProducts,
  requestPurchase,
  finishTransaction,
} from 'react-native-iap';

class InAppPurchaseService {
  constructor() {
    this.productIds = ['premium_monthly', 'premium_yearly'];
    this.purchaseUpdateSubscription = null;
    this.purchaseErrorSubscription = null;
  }

  async initialize() {
    try {
      await initConnection();
      
      // Android 전용: 실패한 구매 처리
      if (Platform.OS === 'android') {
        await flushFailedPurchasesCachedAsPendingAndroid();
      }

      // 구매 이벤트 리스너 설정
      this.setupPurchaseListeners();
      
      // 상품 정보 가져오기
      await this.loadProducts();
    } catch (error) {
      console.error('IAP initialization failed:', error);
    }
  }

  setupPurchaseListeners() {
    this.purchaseUpdateSubscription = purchaseUpdatedListener(
      async (purchase) => {
        console.log('Purchase successful:', purchase);
        
        try {
          // 서버에서 영수증 검증
          await this.verifyPurchase(purchase);
          
          // 구매 완료 처리
          await finishTransaction(purchase);
          
          // UI 업데이트
          this.onPurchaseSuccess(purchase);
        } catch (error) {
          console.error('Purchase verification failed:', error);
          this.onPurchaseError(error);
        }
      }
    );

    this.purchaseErrorSubscription = purchaseErrorListener(
      (error) => {
        console.error('Purchase error:', error);
        this.onPurchaseError(error);
      }
    );
  }

  async loadProducts() {
    try {
      const products = await getProducts(this.productIds);
      console.log('Available products:', products);
      return products;
    } catch (error) {
      console.error('Failed to load products:', error);
      return [];
    }
  }

  async purchaseProduct(productId) {
    try {
      await requestPurchase(productId);
    } catch (error) {
      console.error('Purchase request failed:', error);
      throw error;
    }
  }

  async verifyPurchase(purchase) {
    const response = await fetch('/api/verify-purchase', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        transactionReceipt: purchase.transactionReceipt,
        productId: purchase.productId,
        platform: Platform.OS,
      }),
    });

    if (!response.ok) {
      throw new Error('Purchase verification failed');
    }

    return response.json();
  }

  onPurchaseSuccess(purchase) {
    // 프리미엄 기능 활성화
    UserService.updatePremiumStatus(true);
    
    // 성공 메시지 표시
    Alert.alert('구매 완료', '프리미엄 기능이 활성화되었습니다.');
  }

  onPurchaseError(error) {
    let message = '구매 중 오류가 발생했습니다.';
    
    if (error.code === 'E_USER_CANCELLED') {
      message = '구매가 취소되었습니다.';
    } else if (error.code === 'E_ITEM_UNAVAILABLE') {
      message = '상품을 찾을 수 없습니다.';
    }
    
    Alert.alert('구매 실패', message);
  }

  cleanup() {
    if (this.purchaseUpdateSubscription) {
      this.purchaseUpdateSubscription.remove();
    }
    if (this.purchaseErrorSubscription) {
      this.purchaseErrorSubscription.remove();
    }
  }
}
⚠️ 네이티브 모듈 도입 시 주의사항:
  • 버전 호환성: React Native 버전과 모듈 호환성 확인
  • 플랫폼별 설정: iOS/Android 각각 네이티브 설정 필요
  • 빌드 시간 증가: 네이티브 코드 컴파일로 빌드 시간 늘어남
  • 업데이트 주의: React Native 업그레이드 시 호환성 검토
  • 테스트 복잡성: 실제 디바이스에서만 테스트 가능한 기능들

🚀 앱스토어 배포 과정

iOS App Store 배포

# iOS 배포 준비 과정

# 1. 프로덕션 빌드 생성
npx react-native bundle --platform ios --dev false --entry-file index.js --bundle-output ios/main.jsbundle

# 2. Xcode에서 Archive 생성
# - Xcode 열기 → Product → Archive
# - Release 모드로 빌드
# - 코드 사이닝 인증서 확인

# 3. App Store Connect 업로드
# - Organizer에서 Distribute App
# - App Store Connect 선택
# - 자동 사이닝 또는 수동 사이닝

# 4. 메타데이터 설정
# - 앱 이름, 설명, 키워드
# - 스크린샷 (6.7", 6.5", 5.5" 등)
# - 앱 아이콘 (1024x1024)
# - 개인정보처리방침 URL

# Fastlane으로 자동화
# Fastfile 예제
lane :release_ios do
  increment_build_number
  build_app(scheme: "YourApp")
  upload_to_app_store(
    force: true,
    skip_metadata: false,
    skip_screenshots: false
  )
end

Android Play Store 배포

# Android 배포 준비

# 1. 서명 키 생성
keytool -genkeypair -v -keystore my-upload-key.keystore -alias my-key-alias -keyalg RSA -keysize 2048 -validity 10000

# 2. gradle.properties 설정
MYAPP_UPLOAD_STORE_FILE=my-upload-key.keystore
MYAPP_UPLOAD_KEY_ALIAS=my-key-alias
MYAPP_UPLOAD_STORE_PASSWORD=your_password
MYAPP_UPLOAD_KEY_PASSWORD=your_password

# 3. build.gradle 설정 (android/app/build.gradle)
android {
    signingConfigs {
        release {
            if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) {
                storeFile file(MYAPP_UPLOAD_STORE_FILE)
                storePassword MYAPP_UPLOAD_STORE_PASSWORD
                keyAlias MYAPP_UPLOAD_KEY_ALIAS
                keyPassword MYAPP_UPLOAD_KEY_PASSWORD
            }
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled enableProguardInReleaseBuilds
            proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
        }
    }
}

# 4. AAB 빌드 생성
cd android
./gradlew bundleRelease

# 5. Google Play Console 업로드
# - Production/Internal testing/Closed testing 선택
# - AAB 파일 업로드
# - 스토어 등록정보 작성

배포 자동화 (GitHub Actions)

# .github/workflows/deploy.yml
name: Deploy to App Stores

on:
  push:
    tags:
      - 'v*'

jobs:
  deploy-ios:
    runs-on: macos-latest
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Install CocoaPods
      run: cd ios && pod install
    
    - name: Build iOS
      run: |
        npx react-native bundle --platform ios --dev false --entry-file index.js --bundle-output ios/main.jsbundle
        cd ios
        xcodebuild -workspace YourApp.xcworkspace -scheme YourApp archive -archivePath YourApp.xcarchive
    
    - name: Upload to App Store
      env:
        APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
      run: |
        xcrun altool --upload-app --type ios --file YourApp.ipa --apiKey $APP_STORE_CONNECT_API_KEY

  deploy-android:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Setup JDK
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Build Android AAB
      env:
        MYAPP_UPLOAD_STORE_FILE: ${{ secrets.MYAPP_UPLOAD_STORE_FILE }}
        MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }}
        MYAPP_UPLOAD_KEY_ALIAS: ${{ secrets.MYAPP_UPLOAD_KEY_ALIAS }}
        MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }}
      run: |
        cd android
        ./gradlew bundleRelease
    
    - name: Upload to Google Play
      uses: r0adkll/upload-google-play@v1
      with:
        serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
        packageName: com.yourapp.package
        releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
        track: production
📈 배포 과정에서 겪은 어려움과 해결책:
  • iOS 심사 거절: 개인정보처리방침 누락 → 필수 정책 페이지 추가
  • Android 번들 크기: 150MB 초과 → ProGuard 적용으로 60MB 감소
  • 권한 설정: 불필요한 권한으로 심사 지연 → 최소 권한만 요청
  • 충돌 보고: 릴리스 후 크래시 발생 → Crashlytics 도입으로 모니터링

🔥 실제 겪은 문제들과 해결법

메모리 누수로 인한 앱 크래시

🚨 문제 상황:
  • 증상: 앱 사용 중 갑작스러운 크래시 발생
  • 원인: 이벤트 리스너 정리 미흡으로 메모리 누수
  • 발생 빈도: 5분 이상 사용 시 70% 확률로 크래시
  • 영향: 사용자 이탈률 급증
// ❌ 문제가 있던 코드
const ProblematicComponent = () => {
  const [location, setLocation] = useState(null);

  useEffect(() => {
    // 위치 추적 시작 (정리 안됨)
    const watchId = Geolocation.watchPosition(
      (position) => setLocation(position),
      (error) => console.error(error)
    );

    // WebSocket 연결 (정리 안됨)
    const ws = new WebSocket('ws://localhost:8080');
    ws.onmessage = (event) => {
      console.log('Received:', event.data);
    };

    // 타이머 설정 (정리 안됨)
    const interval = setInterval(() => {
      console.log('Timer tick');
    }, 1000);

    // ❌ cleanup 함수 누락
  }, []);

  return <View>{/* UI */}</View>;
};

// ✅ 수정된 코드
const FixedComponent = () => {
  const [location, setLocation] = useState(null);

  useEffect(() => {
    let watchId;
    let ws;
    let interval;

    // 위치 추적 시작
    watchId = Geolocation.watchPosition(
      (position) => setLocation(position),
      (error) => console.error(error)
    );

    // WebSocket 연결
    ws = new WebSocket('ws://localhost:8080');
    ws.onmessage = (event) => {
      console.log('Received:', event.data);
    };

    // 타이머 설정
    interval = setInterval(() => {
      console.log('Timer tick');
    }, 1000);

    // ✅ 정리 함수
    return () => {
      if (watchId !== undefined) {
        Geolocation.clearWatch(watchId);
      }
      if (ws) {
        ws.close();
      }
      if (interval) {
        clearInterval(interval);
      }
    };
  }, []);

  return <View>{/* UI */}</View>;
};

Android 키보드 이슈

⚠️ 문제 상황:
  • 증상: Android에서 키보드가 화면을 가림
  • 원인: 다양한 Android 기기의 키보드 동작 차이
  • 영향: 폼 입력 시 사용성 저하
// Android Manifest 설정
<activity
  android:name=".MainActivity"
  android:windowSoftInputMode="adjustResize"
  android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode">
</activity>

// React Native 컴포넌트에서 해결
import { KeyboardAvoidingView, Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

const FormScreen = () => {
  const insets = useSafeAreaInsets();

  return (
    <KeyboardAvoidingView
      style={{ flex: 1 }}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      keyboardVerticalOffset={Platform.select({
        ios: insets.top + 44, // 네비게이션 바 높이 고려
        android: 0,
      })}
    >
      <ScrollView
        contentContainerStyle={{ flexGrow: 1 }}
        keyboardShouldPersistTaps="handled"
        showsVerticalScrollIndicator={false}
      >
        {/* 폼 컨텐츠 */}
      </ScrollView>
    </KeyboardAvoidingView>
  );
};

// 더 정교한 키보드 처리
const useKeyboardHeight = () => {
  const [keyboardHeight, setKeyboardHeight] = useState(0);

  useEffect(() => {
    const keyboardDidShow = (event) => {
      setKeyboardHeight(event.endCoordinates.height);
    };

    const keyboardDidHide = () => {
      setKeyboardHeight(0);
    };

    const showSubscription = Keyboard.addListener('keyboardDidShow', keyboardDidShow);
    const hideSubscription = Keyboard.addListener('keyboardDidHide', keyboardDidHide);

    return () => {
      showSubscription?.remove();
      hideSubscription?.remove();
    };
  }, []);

  return keyboardHeight;
};

이미지 캐싱 문제

💡 문제 해결 과정:
  • 문제: 동일한 이미지가 반복 다운로드됨
  • 해결: react-native-fast-image로 교체
  • 결과: 이미지 로딩 속도 70% 향상

문제 해결 통계

문제 유형 발생 빈도 해결 시간 난이도
메모리 누수 30% 2-3일 높음
UI 레이아웃 25% 4-8시간 보통
네이티브 모듈 20% 1-2일 높음
성능 최적화 15% 1일 보통
배포 이슈 10% 2-4시간 낮음

🎯 총평과 추천 여부

개발 경험 총정리

✅ React Native의 장점 ❌ React Native의 단점
  • 개발 속도: 네이티브 대비 50% 빠른 개발
  • 코드 재사용: 80% 이상 코드 공유
  • 핫 리로드: 빠른 개발 피드백
  • 생태계: 풍부한 써드파티 라이브러리
  • 학습 곡선: React 경험자에게 진입 장벽 낮음
  • 팀 효율성: 한 팀으로 두 플랫폼 개발
  • 성능 한계: 네이티브 대비 10-20% 성능 저하
  • 플랫폼별 차이: 완전한 "Write once"는 불가능
  • 디버깅 복잡성: 브릿지 관련 이슈 추적 어려움
  • 업데이트 주기: React Native 버전 업그레이드 부담
  • 네이티브 지식: 결국 iOS/Android 지식 필요
  • 앱 크기: 네이티브 대비 10-30MB 증가

프로젝트 성과 지표

📊 최종 개발 성과:
  • 개발 기간: 네이티브 예상 8개월 → React Native 4.5개월
  • 팀 구성: 네이티브 4명 필요 → React Native 2명으로 가능
  • 코드 공유율: 플랫폼 간 82% 코드 공유
  • 유지보수 시간: 50% 단축 (하나의 코드베이스)
  • 버그 수정 속도: 동시 양쪽 플랫폼 수정으로 2배 빠름

프로젝트 유형별 추천도

프로젝트 유형 추천도 이유
MVP/스타트업 ⭐⭐⭐⭐⭐ 빠른 출시, 적은 리소스로 검증 가능
소셜/커머스 앱 ⭐⭐⭐⭐⭐ UI 중심, 복잡한 네이티브 기능 불필요
게임 앱 ⭐⭐ 성능 크리티컬, 네이티브 권장
엔터프라이즈 앱 ⭐⭐⭐⭐ 개발 효율성 높음, 보안 요구사항 검토 필요
카메라/AR 앱 ⭐⭐⭐ 네이티브 모듈 활용 필요, 성능 고려

팀 상황별 고려사항

🎯 React Native를 선택해야 하는 경우:
  • 웹 개발 경험: React/JavaScript 팀이 있는 경우
  • 빠른 출시: MVP를 빨리 검증해야 하는 경우
  • 제한된 리소스: 작은 팀으로 두 플랫폼 개발 필요
  • 일반적인 앱: 특수한 네이티브 기능이 불필요한 경우
  • 지속적 업데이트: 빈번한 기능 업데이트가 필요한 경우
⚠️ 네이티브를 고려해야 하는 경우:
  • 높은 성능 요구: 게임, 실시간 처리 앱
  • 복잡한 네이티브 기능: AR, 복잡한 카메라 처리
  • 플랫폼 특화: 각 플랫폼의 고유 기능 적극 활용
  • 충분한 리소스: 각 플랫폼 전문 개발자 확보 가능
  • 장기적 관점: 수년간 지속될 대규모 프로젝트

🎯 마무리

React Native로 실제 앱을 개발해본 결과, "완벽하지는 않지만 현실적으로 최선의 선택"이라는 결론을 내렸습니다. 특히 스타트업이나 MVP 개발에는 매우 효과적이며, 웹 개발 경험이 있는 팀이라면 더욱 추천합니다.

🏆 개발 후기 핵심 메시지:
  • 현실적 기대: 100% 코드 공유는 불가능하지만 80% 이상은 가능
  • 성능 고려: 네이티브 대비 성능 차이는 있지만 대부분 앱에서는 무시 가능
  • 학습 투자: 초기 학습 비용은 있지만 장기적으로 효율적
  • 지속적 발전: React Native는 계속 발전하고 있어 미래 가치 높음
  • 팀 역량: 기존 웹 개발 역량을 모바일로 확장 가능

4개월간의 개발 과정에서 많은 시행착오가 있었지만, 결국 성공적으로 두 플랫폼에 동시 출시할 수 있었습니다. React Native는 완벽한 해답은 아니지만, 현재 크로스플랫폼 개발의 현실적인 대안 중 하나임은 분명합니다.

🏷️ 태그

React Native 크로스플랫폼 모바일 앱 개발 iOS Android 개발 후기 네이티브 모듈 앱스토어 배포 성능 최적화 실무 경험 

반응형