왜 네이티브로 가야 했는가
썸블링의 초기 프론트엔드는 Next.js 16 App Router 기반 웹 앱이었습니다. PWA(Progressive Web App)로 모바일 경험을 제공하려 했습니다. 이론상으로는 괜찮은 전략입니다. 하나의 코드베이스로 웹과 모바일을 모두 커버할 수 있으니까요.
하지만 데이팅 앱이라는 도메인 특성이 PWA의 한계를 빠르게 드러냈습니다.
PWA가 데이팅 앱에 맞지 않는 이유
1. 푸시 알림의 신뢰성
매칭 알림, 채팅 메시지 알림은 데이팅 앱의 생명줄입니다. PWA의 Web Push는 iOS에서 제한적으로만 동작하고, 백그라운드 신뢰도가 네이티브 FCM에 비해 현저히 낮습니다. 테스트 중 iOS 16 이하에서는 Web Push 자체가 동작하지 않았습니다.
2. 카메라와 미디어 접근
비디오 프롬프트 기능(자신을 소개하는 짧은 영상)을 위해 카메라 접근이 필요했습니다. 웹 getUserMedia API는 권한 처리가 복잡하고, 녹화된 영상의 압축/썸네일 추출이 브라우저에서 매우 불안정했습니다.
3. 앱 스토어 부재
“앱 스토어에 있는 앱”이라는 심리적 신뢰감은 무시할 수 없습니다. 특히 개인 정보를 다루는 데이팅 앱에서 사용자는 검증된 플랫폼을 원합니다. 홈 화면 추가를 유도하는 PWA 배너보다 App Store / Google Play 다운로드가 훨씬 효과적이었습니다.
4. 제스처 인터랙션
매칭 카드 스와이프는 썸블링 UX의 핵심입니다. Framer Motion으로 웹에서 구현했지만, 네이티브 제스처 핸들러(react-native-gesture-handler)와 비교하면 반응성 차이가 체감될 정도였습니다. 60fps를 안정적으로 유지하기 어려웠습니다.
결국 결정을 내렸습니다. React Native + Expo로 전환한다.
왜 Expo인가
React Native 앱을 만드는 방법은 두 가지입니다. React Native CLI와 Expo. 우리는 Expo를 선택했습니다.
이유는 단순합니다. 썸블링은 소규모 팀이고, 네이티브 iOS/Android 빌드 인프라를 처음부터 구축하는 데 시간을 쓸 여유가 없었습니다. Expo는 다음을 제공합니다.
- EAS Build: 클라우드 빌드 서비스. 로컬 Xcode/Android Studio 없이 IPA/APK 생성
- EAS Update: OTA(Over-The-Air) 업데이트. JS 번들만 변경됐을 때 앱 스토어 재심사 없이 배포
- Expo SDK: 카메라, 알림, 보안 스토리지 등 네이티브 모듈이 미리 통합됨
- expo-router: 파일 기반 라우팅. Next.js App Router와 동일한 패턴
SDK 54는 New Architecture를 기본 활성화합니다. JSI(JavaScript Interface), Fabric 렌더러, TurboModules로 JS-네이티브 브리지 오버헤드가 크게 줄었습니다.
프로젝트 아키텍처
graph TB
subgraph Monorepo["aro/ (모노레포)"]
subgraph Web["aro-web (Next.js)"]
WP[Pages & Components]
WS[Zustand Stores]
WT[Types]
WA[API Client]
end
subgraph Mobile["aro-mobile (Expo)"]
MP[expo-router 화면]
MS[Zustand Stores]
MT[Types]
MA[API Client]
end
subgraph Shared["공유 가능 레이어"]
ST["타입 정의<br/>(auth, matching, chat...)"]
SC["상수<br/>(OCEAN_LABELS, SCORE_THRESHOLDS...)"]
SU["순수 유틸 함수<br/>(점수 계산, 날짜 포맷)"]
end
end
WS -.->|"패턴 공유"| MS
WT -.->|"타입 복사/참조"| MT
ST --> WT
ST --> MT
SC --> WP
SC --> MP
SU --> WA
SU --> MA
style Shared fill:#2d2b55,stroke:#7F5AF0
style Web fill:#1a1a2e,stroke:#94A1B2
style Mobile fill:#1a1a2e,stroke:#94A1B2
웹과 모바일은 별도 앱으로 유지합니다. 완전한 코드 공유(monorepo shared package)도 고려했지만, Next.js와 React Native의 렌더링 모델 차이, 스타일링 방식 차이로 인해 추상화 비용이 오히려 컸습니다.
대신 타입과 순수 로직만 공유하는 현실적인 전략을 택했습니다.
expo-router: 파일 기반 라우팅
Next.js App Router를 써본 개발자라면 expo-router가 매우 친숙합니다.
aro-mobile/app/
├── _layout.tsx # 루트 레이아웃 (Providers, 인증 가드)
├── index.tsx # 랜딩 화면
├── (auth)/
│ ├── _layout.tsx # 인증 스택 레이아웃
│ ├── login.tsx
│ ├── signup/
│ │ ├── _layout.tsx
│ │ ├── step1.tsx # 전화번호 인증
│ │ ├── step2.tsx # 이메일/비밀번호
│ │ └── step3.tsx # 성별/생년월일/약관
│ └── forgot-password.tsx
├── (main)/
│ ├── _layout.tsx # BottomTab 레이아웃
│ ├── home.tsx # 매칭 카드 스와이프
│ ├── chat/
│ │ ├── index.tsx # 채팅 목록
│ │ └── [roomId].tsx # 채팅방 (UUID 파라미터)
│ ├── soul-test/
│ │ ├── index.tsx
│ │ ├── questions.tsx
│ │ └── result.tsx
│ ├── profile.tsx
│ ├── shop.tsx
│ └── notifications.tsx
└── +not-found.tsx
인증 가드는 루트 _layout.tsx에서 처리합니다.
// app/_layout.tsx
import { Slot, useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/useAuthStore';
export default function RootLayout() {
const { isAuthenticated, isLoading } = useAuthStore();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!isAuthenticated && !inAuthGroup) {
// 미인증 → 로그인으로
router.replace('/(auth)/login');
} else if (isAuthenticated && inAuthGroup) {
// 인증됨 → 메인으로
router.replace('/(main)/home');
}
}, [isAuthenticated, isLoading, segments]);
return <Slot />;
}
NativeWind: Tailwind CSS를 React Native에서
웹 앱은 Tailwind CSS 4를 썼습니다. 모바일에서도 동일한 유틸리티 클래스를 쓰고 싶었습니다. NativeWind v4가 이를 가능하게 합니다.
NativeWind는 Tailwind 클래스를 React Native의 StyleSheet으로 컴파일합니다. 빌드 타임에 처리되므로 런타임 오버헤드가 없습니다.
npm install nativewind
npm install --save-dev tailwindcss@^3
// tailwind.config.js
module.exports = {
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
primary: '#7F5AF0',
accent: '#FF6B9D',
bg: '#0F0E17',
surface: '#232136',
muted: '#94A1B2',
},
},
},
};
// components/ui/Button.tsx
import { TouchableOpacity, Text } from 'react-native';
interface ButtonProps {
variant?: 'primary' | 'outline' | 'ghost';
onPress: () => void;
children: string;
disabled?: boolean;
}
export function Button({
variant = 'primary',
onPress,
children,
disabled,
}: ButtonProps) {
const base = 'rounded-xl px-6 py-3 items-center justify-center';
const variants = {
primary: 'bg-primary',
outline: 'border border-primary bg-transparent',
ghost: 'bg-transparent',
};
const textVariants = {
primary: 'text-white font-semibold',
outline: 'text-primary font-semibold',
ghost: 'text-muted',
};
return (
<TouchableOpacity
className={`${base} ${variants[variant]} ${disabled ? 'opacity-40' : ''}`}
onPress={onPress}
disabled={disabled}
activeOpacity={0.8}
>
<Text className={`text-base ${textVariants[variant]}`}>{children}</Text>
</TouchableOpacity>
);
}
웹 컴포넌트와 거의 동일한 구조입니다. 디자인 시스템 토큰(primary, surface, muted)을 양쪽에서 동일하게 쓸 수 있어 시각적 일관성이 유지됩니다.
웹-모바일 코드 공유 전략
완전한 코드 공유는 포기했지만, 패턴을 공유하는 것은 큰 효과가 있었습니다.
타입 공유
// 웹(aro-web)과 모바일(aro-mobile) 모두 동일한 타입 정의를 사용합니다.
// 변경 시 두 곳을 동기화해야 하지만, 명시적이라 오히려 추적이 쉽습니다.
// types/matching.ts (웹과 모바일 동일)
export interface DailyMatch {
matchId: number;
matchedMemberId: number;
nickname: string;
age: number;
profileImageUrl?: string;
soulScore: number;
personalityTags: string[];
topValues: string[];
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED';
matchDate: string;
profileUnblurred?: boolean;
}
export interface MatchPreference {
preferredGender: 'MALE' | 'FEMALE' | 'OTHER' | 'ANY';
minAge: number;
maxAge: number;
}
Zustand 스토어 패턴 공유
스토어 로직은 환경에 의존하지 않는 순수 상태 관리입니다. 웹과 모바일이 동일한 패턴을 따릅니다.
// stores/useAuthStore.ts (웹과 모바일의 구조가 동일)
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import * as SecureStore from 'expo-secure-store'; // 모바일 전용
import { api } from '@/lib/api';
import type { User } from '@/types/auth';
// 모바일: expo-secure-store 어댑터
const secureStorage = {
getItem: (name: string) => SecureStore.getItemAsync(name),
setItem: (name: string, value: string) => SecureStore.setItemAsync(name, value),
removeItem: (name: string) => SecureStore.deleteItemAsync(name),
};
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
setUser: (user: User) => void;
fetchUser: () => Promise<void>;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
setUser: (user) => set({ user, isAuthenticated: true }),
fetchUser: async () => {
set({ isLoading: true });
try {
const res = await api.get('/members/me');
set({ user: res.data.data, isAuthenticated: true });
} catch {
set({ user: null, isAuthenticated: false });
} finally {
set({ isLoading: false });
}
},
logout: () => {
set({ user: null, isAuthenticated: false });
},
}),
{
name: 'auth-store',
storage: createJSONStorage(() => secureStorage),
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
}
)
);
웹 버전과의 차이는 storage뿐입니다. 웹은 localStorage, 모바일은 expo-secure-store를 씁니다. 스토어 로직 자체는 완전히 동일합니다.
마주한 도전들
1. 키보드 처리
모바일 채팅 UI의 가장 큰 난관은 소프트 키보드입니다. 키보드가 올라오면 입력창이 키보드에 가려집니다.
웹에서는 useVisualViewport 훅으로 해결했지만, React Native는 다릅니다.
// components/chat/MessageInput.tsx
import { KeyboardAvoidingView, Platform } from 'react-native';
export function ChatScreen() {
return (
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 88 : 0}
>
<MessageList />
<MessageInput />
</KeyboardAvoidingView>
);
}
iOS와 Android의 동작이 달라서 behavior를 플랫폼별로 분기해야 했습니다. iOS는 padding, Android는 height가 더 자연스럽게 동작합니다. keyboardVerticalOffset은 헤더 높이를 보정합니다.
2. 다크 모드 강제 적용
썸블링은 다크 퍼스트 디자인입니다. 시스템 라이트 모드에서 앱이 밝게 보이는 것은 원하지 않았습니다. app.json에서 강제 다크 모드를 설정했습니다.
// app.json
{
"expo": {
"userInterfaceStyle": "dark",
"ios": {
"userInterfaceStyle": "dark"
},
"android": {
"userInterfaceStyle": "dark"
}
}
}
NativeWind의 다크 모드 설정도 맞춰줍니다.
// tailwind.config.js
module.exports = {
darkMode: 'class', // NativeWind v4는 'class' 전략 사용
// ...
};
3. 제스처 네비게이션 충돌
Android의 제스처 뒤로가기와 매칭 카드 스와이프 제스처가 충돌했습니다. 카드를 왼쪽으로 스와이프할 때 Android 시스템 뒤로가기가 함께 트리거되는 문제였습니다.
// react-native-screens의 gestureEnabled 제어
import { useNavigation } from 'expo-router';
import { useEffect } from 'react';
export function HomeScreen() {
const navigation = useNavigation();
useEffect(() => {
// 매칭 카드 화면에서는 시스템 제스처 비활성화
navigation.setOptions({ gestureEnabled: false });
return () => navigation.setOptions({ gestureEnabled: true });
}, []);
return <MatchCardDeck />;
}
성능 최적화
FlashList로 메시지 리스트 교체
React Native의 기본 FlatList는 채팅 메시지처럼 아이템이 많아지면 성능이 떨어집니다. Shopify의 FlashList는 뷰 재활용을 극적으로 개선합니다.
import { FlashList } from '@shopify/flash-list';
import type { ChatMessage } from '@/types/chat';
interface MessageListProps {
messages: ChatMessage[];
onLoadMore: () => void;
}
export function MessageList({ messages, onLoadMore }: MessageListProps) {
return (
<FlashList
data={messages}
renderItem={({ item }) => <MessageBubble message={item} />}
estimatedItemSize={60} // 평균 아이템 높이 추정값
inverted // 최신 메시지가 아래
onEndReached={onLoadMore}
onEndReachedThreshold={0.3}
keyExtractor={(item) => String(item.messageId)}
/>
);
}
estimatedItemSize는 FlashList 성능의 핵심입니다. 실제 메시지 높이와 가까울수록 레이아웃 계산이 빠릅니다. 썸블링 채팅 메시지의 평균 높이를 측정해 60으로 설정했습니다.
Reanimated 4로 매칭 카드 애니메이션
매칭 카드 스와이프는 앱의 핵심 인터랙션입니다. Reanimated 4 + Gesture Handler로 JS 스레드 없이 UI 스레드에서 직접 처리합니다.
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
export function MatchCard({ match, onAccept, onReject }: MatchCardProps) {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const SWIPE_THRESHOLD = 120;
const panGesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
translateY.value = event.translationY * 0.3;
rotation.value = (event.translationX / 300) * 15; // 최대 15도 회전
})
.onEnd((event) => {
if (Math.abs(event.translationX) > SWIPE_THRESHOLD) {
// 스와이프 완료 — 화면 밖으로 날리기
const direction = event.translationX > 0 ? 1 : -1;
translateX.value = withSpring(direction * 500);
if (direction > 0) {
runOnJS(onAccept)(match.matchId);
} else {
runOnJS(onReject)(match.matchId);
}
} else {
// 스와이프 취소 — 원위치
translateX.value = withSpring(0);
translateY.value = withSpring(0);
rotation.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ rotate: `${rotation.value}deg` },
],
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={animatedStyle} className="w-full aspect-[3/4]">
{/* 카드 내용 */}
</Animated.View>
</GestureDetector>
);
}
Reanimated의 useSharedValue와 useAnimatedStyle은 UI 스레드에서 직접 실행되어 JS 스레드 블로킹 없이 60fps를 유지합니다. 웹의 Framer Motion과 비교해 체감 반응성이 확연히 다릅니다.
보안 스토리지
웹에서 Access Token을 localStorage에 저장했지만, 모바일에서는 더 안전한 저장소가 필요합니다.
expo-secure-store는 iOS의 Keychain, Android의 Keystore를 추상화합니다.
// lib/tokenStorage.ts
import * as SecureStore from 'expo-secure-store';
const ACCESS_TOKEN_KEY = 'access_token';
export const tokenStorage = {
async getToken(): Promise<string | null> {
return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
},
async setToken(token: string): Promise<void> {
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token, {
// iOS: 디바이스 잠금 해제 후에만 접근 가능
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
});
},
async removeToken(): Promise<void> {
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
},
};
Refresh Token은 httpOnly 쿠키로 관리합니다. React Native의 fetch/Axios는 쿠키를 자동으로 처리하지 않아서, @react-native-cookies/cookies로 수동 관리합니다.
EAS 빌드 & 배포 파이프라인
flowchart LR
Dev["개발자\nPush to main"] --> GHA["GitHub Actions"]
GHA --> EASBuild["EAS Build\n(클라우드)"]
EASBuild --> IOSBuild["iOS IPA\n(Apple Silicon Runner)"]
EASBuild --> AndroidBuild["Android AAB\n(Ubuntu Runner)"]
IOSBuild --> TestFlight["TestFlight\n(내부 테스트)"]
AndroidBuild --> PlayInternal["Play Console\n(내부 트랙)"]
TestFlight --> AppStore["App Store\n심사 제출"]
PlayInternal --> PlayStore["Google Play\n심사 제출"]
subgraph OTA["JS 전용 변경 시 (OTA)"]
JsChange["JS 번들 변경"] --> EASUpdate["EAS Update\n(OTA 배포)"]
EASUpdate --> InstantDeploy["즉시 배포\n(앱 재설치 불필요)"]
end
style OTA fill:#2d2b55,stroke:#7F5AF0
eas.json 설정
{
"cli": {
"version": ">= 12.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"ios": { "simulator": false },
"android": { "buildType": "apk" }
},
"production": {
"ios": { "buildConfiguration": "Release" },
"android": { "buildType": "app-bundle" }
}
},
"submit": {
"production": {
"ios": {
"appleId": "team@somebling.kr",
"ascAppId": "YOUR_APP_ID"
},
"android": {
"serviceAccountKeyPath": "./google-play-key.json",
"track": "internal"
}
}
}
}
GitHub Actions 워크플로
# .github/workflows/eas-build.yml
name: EAS Build
on:
push:
branches: [main]
paths: ['aro-mobile/**']
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: aro-mobile
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: aro-mobile/package-lock.json
- name: Install dependencies
run: npm ci
- name: Setup EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build iOS (Production)
run: eas build --platform ios --profile production --non-interactive
- name: Build Android (Production)
run: eas build --platform android --profile production --non-interactive
OTA 업데이트 전략
JS 번들만 변경되는 경우(버그 수정, UI 텍스트 변경 등) EAS Update로 앱 스토어 재심사 없이 배포합니다.
# preview 채널에 OTA 업데이트 배포
eas update --branch preview --message "채팅 입력창 버그 수정"
# production 채널에 OTA 업데이트 배포
eas update --branch production --message "구독 플랜 텍스트 수정"
네이티브 코드 변경(새 Expo SDK, 네이티브 모듈 추가)은 반드시 eas build로 전체 빌드 후 앱 스토어에 제출해야 합니다.
전환 후 무엇이 달라졌나
| 항목 | PWA (이전) | React Native + Expo (현재) |
|---|---|---|
| 푸시 알림 도달률 (iOS) | ~60% | ~97% |
| 매칭 카드 스와이프 FPS | 45~55fps | 60fps (고정) |
| 비디오 업로드 성공률 | 78% | 99% |
| 앱 시작 시간 (콜드 스타트) | 2.3초 | 1.1초 |
| 카메라 접근 권한 처리 | 복잡 | expo-camera로 단순화 |
| 앱 스토어 노출 | 없음 | App Store + Google Play |
가장 큰 변화는 숫자보다 사용자 행동 패턴이었습니다. PWA 시절에는 홈 화면에 추가한 사용자의 7일 리텐션이 웹 브라우저 사용자와 거의 차이가 없었습니다. 네이티브 앱 출시 후 7일 리텐션이 유의미하게 상승했습니다.
마치며
웹에서 네이티브로의 전환은 쉽지 않았습니다. 코드베이스를 새로 구축하는 작업이고, 팀이 새 도구들을 익혀야 했습니다. 하지만 데이팅 앱이라는 도메인에서 네이티브 경험은 선택이 아니었습니다.
Expo SDK 54의 New Architecture, expo-router의 파일 기반 라우팅, NativeWind의 일관된 스타일링은 전환 비용을 크게 낮춰줬습니다. 웹 개발자가 React Native를 시작하기에 지금이 가장 좋은 시기라고 생각합니다.
썸블링은 계속 네이티브 앱을 발전시켜나갈 것입니다. 다음 단계는 Face ID / Touch ID를 활용한 생체 인증 로그인, 그리고 실시간 매칭 알림의 Rich Push Notification 구현입니다.
GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.