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

📋 목차
🤔 왜 React Native를 선택했나?
| ❌ 네이티브 개발의 문제점 | ✅ React Native의 장점 |
|---|---|
|
|
프로젝트 배경과 요구사항
📊 프로젝트 개요:
- 앱 유형: 소셜 커머스 플랫폼
- 팀 구성: 프론트엔드 개발자 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의 단점 |
|---|---|
|
|
프로젝트 성과 지표
📊 최종 개발 성과:
- 개발 기간: 네이티브 예상 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 개발 후기 네이티브 모듈 앱스토어 배포 성능 최적화 실무 경험
반응형
'풀스택 개발이야기' 카테고리의 다른 글
| 🚀 웹 성능 최적화 체크리스트: 느린 웹사이트는 그만! (6) | 2025.07.08 |
|---|---|
| ⚡ JavaScript 비동기 처리 완벽 가이드: 콜백 지옥에서 async/await까지 (0) | 2025.07.07 |
| ⚡ TypeScript 제네릭 마스터하기: 기초부터 고급까지 완벽 가이드 (0) | 2025.07.05 |
| 🌿 Git 브랜치 전략과 협업 워크플로우: 팀에 맞는 최적 전략 찾기 (2) | 2025.07.04 |
| 🐳 Docker로 개발환경 통일하기: 팀 협업 혁신과 CI/CD 구축 (2) | 2025.07.03 |