기존 React 웹앱의 인앱 브라우저 제한을 React Native 하이브리드 앱으로 근본적으로 해결하고, 네이티브 기능(홈 위젯, 푸시 알림)을 추가하여 사용자 경험을 크게 향상시킨 개발 여정을 공유합니다.
📑 바로가기
🎯 문제 정의: 웹앱의 한계를 넘어
이전 글에서 카카오톡, 에브리타임 등 인앱 브라우저에서 Google OAuth가 차단되는 문제를 해결하기 위해 URL 스킴 기반 외부 브라우저 리다이렉트를 구현했습니다. 하지만 이는 완전한 해결책이 아니었습니다.
⚠️ 기존 웹 우회 방식의 한계
- iOS에서 일부 앱(인스타그램, 에브리타임 등)은 외부 브라우저 열기가 기술적으로 불가능하여 수동 안내에 의존
- 외부 브라우저로 이동 시 사용자 이탈률 증가
- 네이티브 기능(홈 위젯, 푸시 알림) 제공 불가

이 문제를 근본적으로 해결하기 위해 React Native 앱 개발을 결정했습니다. 목표는 단순한 WebView 래핑이 아닌, 기존 웹앱 자산을 100% 재사용하면서 네이티브 수준의 사용자 경험을 제공하는 것이었습니다.
🔍 아키텍처 선택: 왜 하이브리드인가
접근 방식 비교 분석
React Native 앱 개발에는 크게 두 가지 접근 방식이 있었습니다.
| 구분 | 순수 React Native | WebView 하이브리드 |
|---|---|---|
| 개발 비용 | 높음 (전체 재작성) | 낮음 (기존 코드 재사용) |
| 유지보수 | 이중 관리 필요 | 웹앱 수정 시 앱도 자동 반영 |
| 성능 | 최적 (네이티브 렌더링) | 우수 (WebView 오버헤드 존재) |
| 네이티브 기능 | 완전 지원 | 브릿지 통해 선택적 지원 |
| 인증 연동 | 네이티브 OAuth | 네이티브 OAuth + WebView 동기화 |
최종적으로 WebView 하이브리드 + 선택적 네이티브 접근을 선택했습니다. 핵심 UX(인증, 위젯, 푸시 알림)만 네이티브로 구현하고, 나머지는 검증된 웹앱을 그대로 활용하는 전략입니다.
✅ 하이브리드 선택 이유
- 개발 속도: 기존 React 웹앱 100% 재사용으로 빠른 출시
- 일관성: 웹/앱 동일한 UI/UX, 동시 업데이트
- 네이티브 강점 확보: 인증, 위젯, 알림은 네이티브로 최적화

🔥 핵심 기술 도전과제
1. FOUC(Flash Of Unstyled Content) 방지
가장 먼저 마주친 문제는 WebView 로드 시 로그인 화면이 순간적으로 깜빡이는 현상이었습니다. 네이티브에서 이미 로그인했는데, WebView가 로드되면서 잠시 로그인 페이지가 보이는 문제입니다.
원인: WebView가 로드 완료 후에야 토큰을 주입할 수 있어, 그 사이 FE의 RouteGuard가 "미인증 상태"로 판단
2. Web ↔ Native 양방향 상태 동기화
하이브리드 앱의 핵심 난제는 두 세계의 상태를 동기화하는 것입니다.
| 방향 | 동기화 대상 | 트리거 |
|---|---|---|
| Native → Web | Access Token, 유저 정보, 테마, 언어 | 앱 시작, 로그인 성공 |
| Web → Native | 로그아웃, 세션만료, 시간표 저장 | 사용자 액션, API 응답 |
3. Android 하드웨어 백버튼 처리
Android의 물리 백버튼은 WebView 내부의 라우팅과 연동되어야 합니다. 단순히 canGoBack에 의존하면 사이드바나 모달 같은 웹 내부 상태와 충돌합니다.
4. Kotlin 네이티브 모듈 연동
Android 홈 위젯은 React Native에서 직접 지원하지 않습니다. Kotlin으로 위젯을 구현하고, JavaScript와 데이터를 주고받는 브릿지를 설계해야 했습니다.
💡 해결 과정
해결책 1: 네이티브 Google OAuth 구현
인앱 브라우저 문제의 근본 해결책은 네이티브 Google Sign-In SDK를 사용하는 것입니다. 이 방식은 시스템 브라우저(Chrome Custom Tabs)를 활용하므로 Google의 보안 정책을 완벽히 충족합니다.
📄 src/services/authService.ts → GitHub에서 보기
// authService.ts - 네이티브 Google 로그인
import { GoogleSignin, statusCodes } from '@react-native-google-signin/google-signin';
export const configureGoogleSignIn = (): void => {
GoogleSignin.configure({
webClientId: Config.GOOGLE_WEB_CLIENT_ID,
offlineAccess: true, // refresh token 발급
});
};
export const signInWithGoogle = async () => {
await GoogleSignin.hasPlayServices();
const response = await GoogleSignin.signIn();
if (!response.data?.idToken) {
throw new AuthError('ID Token을 받지 못했습니다.', 'MISSING_ID_TOKEN');
}
return {
userInfo: {
id: response.data.user.id,
email: response.data.user.email,
name: response.data.user.name,
photo: response.data.user.photo,
},
idToken: response.data.idToken, // 백엔드 인증에 사용
};
};
해결책 2: WebView 브릿지 아키텍처
FOUC를 방지하기 위해 injectedJavaScriptBeforeContentLoaded를 활용했습니다. 이 스크립트는 페이지가 로드되기 전에 실행되어 localStorage에 토큰을 미리 주입합니다.
💻 토큰 사전 주입 (FOUC 방지)
📄 src/components/WebViewContainer.tsx → GitHub에서 보기
// WebViewContainer.tsx - 페이지 로드 전 토큰 주입
const injectedJavaScriptBeforeContentLoaded = React.useMemo(() => {
return `
(function() {
// 앱 환경 표시 (FE에서 감지)
window.IS_NATIVE_APP = true;
window.PLATFORM = '${Platform.OS}';
window.IS_GUEST = ${isGuest};
// 시스템 테마 및 언어 동기화
window.NATIVE_THEME = '${colorScheme || 'light'}';
window.NATIVE_LOCALE = '${getDeviceLocale()}';
// 토큰 사전 주입 (RouteGuard가 인증 상태로 인식)
${accessToken ? `
localStorage.setItem('access_token', '${escapeJsString(accessToken)}');
// Zustand persist 스토리지 직접 조작
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const data = JSON.parse(authStorage);
data.state = data.state || {};
data.state.isAuthenticated = true;
data.state.isLoading = true; // 핑퐁 방지
data.state.user = ${JSON.stringify(userForFE)};
localStorage.setItem('auth-storage', JSON.stringify(data));
}
` : ''}
// 네이티브 → 웹 메시지 전송 헬퍼
window.sendToNative = function(type, payload) {
window.ReactNativeWebView?.postMessage(JSON.stringify({ type, payload }));
};
true;
})();
`;
}, [accessToken, isGuest, colorScheme]);
💻 양방향 브릿지 메시지 처리
📄 src/components/WebViewContainer.tsx → GitHub에서 보기
// WebViewContainer.tsx - 웹에서 오는 메시지 처리
const handleMessage = useCallback((event: WebViewMessageEvent) => {
const message = JSON.parse(event.nativeEvent.data);
switch (message.type) {
case 'SCHEDULE_SAVED':
// 시간표 저장 → 위젯 업데이트
widgetService.updateWidget(message.payload);
break;
case 'LOGOUT':
// 웹에서 로그아웃 → 위젯 초기화 + 네이티브 상태 초기화
widgetService.clearWidget();
onLogout?.();
break;
case 'SESSION_EXPIRED':
// 401 응답 감지 → 토큰 갱신 시도 또는 로그아웃
onSessionExpired?.();
break;
case 'REQUEST_LOGIN':
// 게스트 모드에서 로그인 요청 → 네이티브 로그인 화면
onRequestLogin?.();
break;
case 'THEME_CHANGED':
// 웹에서 테마 변경 → 네이티브 상태 동기화
useSettingsStore.getState().setTheme(message.payload.theme);
break;
}
}, [onLogout, onSessionExpired, onRequestLogin]);
해결책 3: Android 홈 위젯 연동
사용자가 대표 시간표를 저장하면, WebView에서 SCHEDULE_SAVED 메시지가 전송되고, React Native가 이를 Kotlin 네이티브 모듈로 전달합니다.
💻 위젯 데이터 변환 및 전송
📄 src/services/widgetService.ts → GitHub에서 보기
// widgetService.ts - 시간표 데이터를 위젯 포맷으로 변환
export const widgetService = {
updateWidget: async (scheduleData: ScheduleData) => {
if (Platform.OS !== 'android') return;
const now = dayjs();
const rawList = scheduleData.courses || scheduleData.classes || [];
// 1. 수업 데이터 검증 및 변환
const allClasses: WidgetClassItem[] = [];
rawList.forEach((course) => {
course.slots?.forEach((slot) => {
// 시간 포맷 검증, 요일 매핑, 색상 검증 등
allClasses.push({
id: course.id,
title: course.name || '수업',
location: slot.location || '강의실 미정',
timeDisplay: `${slot.startTime} - ${slot.endTime}`,
color: course.color || '#6366f1',
// 겹치는 수업 처리를 위한 열 정보
colIndex: 0,
maxCols: 1,
});
});
});
// 2. 겹치는 수업 열 계산 (FE 알고리즘 포팅)
// ... 생략
// 3. Kotlin 네이티브 모듈로 전송
const widgetData = {
updatedAtDisplay: `업데이트: ${now.format('A h:mm')}`,
formattedDate: now.format('M월 D일 (ddd)'),
classes: allClasses,
isEmpty: allClasses.length === 0,
theme: Appearance.getColorScheme() || 'light',
};
WidgetModule.updateScheduleData(JSON.stringify(widgetData));
},
};
💻 Kotlin 위젯 구현
📄 android/.../widget/ScheduleWidgetProvider.kt → GitHub에서 보기
// ScheduleWidgetProvider.kt - Android 홈 위젯
class ScheduleWidgetProvider : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray) {
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
companion object {
fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager,
appWidgetId: Int) {
// WidgetRepository 패턴으로 데이터 로드 (SharedPreferences 추상화)
val repository = WidgetRepository(context)
val widgetData = repository.getScheduleData() ?: return
// 현재 날짜 기준 재포맷 (RN에서 받은 데이터가 오래됐을 수 있음)
val now = Calendar.getInstance()
val dateStr = "${now.get(Calendar.MONTH) + 1}월 ${now.get(Calendar.DAY_OF_MONTH)}일"
// RemoteViews로 위젯 UI 구성
val views = RemoteViews(context.packageName, R.layout.widget_schedule_layout)
views.setTextViewText(R.id.widget_date, dateStr)
views.setTextViewText(R.id.widget_updated_at, widgetData.updatedAtDisplay)
// 오늘 수업만 필터링하여 빈 상태 처리
val todayItems = widgetData.classes?.filter { it.day == (now.get(Calendar.DAY_OF_WEEK) - 1) }
if (todayItems.isNullOrEmpty()) {
views.setViewVisibility(R.id.widget_list_view, View.GONE)
views.setViewVisibility(R.id.widget_empty_view, View.VISIBLE)
} else {
views.setViewVisibility(R.id.widget_list_view, View.VISIBLE)
views.setViewVisibility(R.id.widget_empty_view, View.GONE)
}
// ListView 어댑터 연결 (RemoteViewsService)
val intent = Intent(context, ScheduleRemoteViewsService::class.java)
views.setRemoteAdapter(R.id.widget_list_view, intent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
}

해결책 4: 하드웨어 백버튼 처리
Android 백버튼을 WebView 내부 라우팅과 연동하되, 앱 상태(백그라운드 전환)에 따른 리스너 재등록을 처리했습니다.
📄 src/components/WebViewContainer.tsx → GitHub에서 보기
// WebViewContainer.tsx - 백버튼 처리
React.useEffect(() => {
if (Platform.OS !== 'android') return;
const onBackPress = () => {
// WebView에 백버튼 이벤트 전달
webViewRef.current?.postMessage(JSON.stringify({
type: 'HARDWARE_BACK_PRESS',
}));
return true; // 기본 동작(앱 종료) 방지
};
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
// 앱이 포그라운드로 돌아올 때 리스너 재등록
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
// 리스너 새로 등록
}
});
return () => {
subscription.remove();
appStateSubscription.remove();
};
}, []);
웹(FE) 측에서는 이 이벤트를 받아 사이드바 닫기, 모달 닫기, 뒤로가기, 또는 앱 종료를 순차적으로 처리합니다.
해결책 5: 수업 시작 전 푸시 알림
네이티브 앱의 가장 큰 강점 중 하나는 푸시 알림입니다. 웹앱에서는 브라우저가 닫혀 있으면 알림을 받을 수 없지만, 네이티브 앱은 백그라운드에서도 정확한 시간에 알림을 전달할 수 있습니다.
🔔 푸시 알림 구현 핵심
- AlarmManager: Android 시스템 알람으로 정확한 시간에 트리거
- 위젯 데이터 연동: 시간표 저장 시 알림도 자동 스케줄링
- 시스템 이벤트 대응: 부팅, 시간대 변경 시 알람 재등록
사용자가 시간표를 저장하면 위젯 업데이트와 함께 NotificationScheduler가 오늘의 수업 알림을 스케줄링합니다. 자정에는 자동으로 다음 날 알림이 갱신됩니다.
📄 android/.../notification/NotificationScheduler.kt → GitHub에서 보기
// NotificationScheduler.kt - 수업 알림 스케줄링 (핵심 로직)
object NotificationScheduler {
fun scheduleTodayAlarms(context: Context) {
val repository = WidgetRepository(context)
val widgetData = repository.getScheduleData() ?: return
// 오늘 요일의 수업만 필터링
val today = Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 1
val todayClasses = widgetData.classes?.filter { it.day == today } ?: return
todayClasses.forEach { classItem ->
// 수업 시작 10분 전 알림 예약
val triggerTime = calculateTriggerTime(classItem.startTime)
scheduleAlarm(context, classItem, triggerTime)
}
}
private fun scheduleAlarm(context: Context, classItem: WidgetClassItem, triggerTime: Long) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, NotificationReceiver::class.java).apply {
putExtra("title", classItem.title)
putExtra("location", classItem.location)
}
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
PendingIntent.getBroadcast(context, classItem.id.hashCode(), intent, flags)
)
}
}

위젯과 푸시 알림 구현의 자세한 내용(권한 처리, 알림 채널 설정, RemoteViewsService 등)은 별도 포스트에서 다룰 예정입니다.
📈 개선 결과
| 시나리오 | 기존 (웹 우회) | 개선 후 (네이티브 앱) |
|---|---|---|
| 카카오톡 공유 → 로그인 | 외부 브라우저 리다이렉트 필요 | 앱 설치 시 원클릭 로그인 |
| iOS 인앱 브라우저 | 수동 Safari 열기 안내 | 완전 해결 (앱 사용) |
| 시간표 접근성 | 앱 실행 필요 | 홈 위젯으로 즉시 확인 |
| 수업 알림 | 불가능 | 수업 전 푸시 알림 |
| UX 일관성 | 브라우저 전환으로 단절 | 네이티브 앱 경험 |
배운 점
- 하이브리드의 힘: 모든 것을 네이티브로 재작성할 필요 없이, 핵심 경험만 네이티브로 구현하면 개발 비용 대비 높은 가치를 얻을 수 있습니다.
- FOUC 방지의 핵심:
injectedJavaScriptBeforeContentLoaded와 localStorage 직접 조작으로 페이지 로드 전 상태를 주입하는 것이 해결책입니다. - 브릿지 설계 원칙: 메시지 타입을 명확히 정의하고, 양방향 통신의 책임을 분리하면 복잡도를 관리할 수 있습니다.
- 네이티브 모듈 연동: React Native Bridge와 SharedPreferences를 통해 JavaScript와 Kotlin 간 데이터를 효과적으로 공유할 수 있습니다.
🛠 기술 스택 요약
| 영역 | 기술 | 활용 내용 |
|---|---|---|
| 프레임워크 | React Native | 하이브리드 앱 프레임워크 |
| WebView | react-native-webview | 기존 웹앱 렌더링 + 브릿지 |
| 인증 | @react-native-google-signin | 네이티브 Google OAuth |
| 상태 관리 | Zustand | 인증/설정 상태 + persist |
| 보안 저장소 | EncryptedStorage | 토큰 암호화 저장 |
| Android 위젯 | Kotlin + AppWidgetProvider | 홈 화면 시간표 위젯 |
| UI | react-native-linear-gradient | FE와 동일한 그라데이션 디자인 |
| 날짜 처리 | dayjs | 위젯 날짜 포맷 |
웹앱의 한계를 넘어, 네이티브 앱의 강점을 더한 하이브리드 개발 여정.
📂 프로젝트 저장소: KangNaengBot-App
◆ 함께 보면 좋은 글
- [React] 사용자 이탈을 0%로: 인앱 브라우저 감지 및 리다이렉트 구현 여정
- [React] 자연어 기반 시간표 자동 생성을 위한 프론트엔드 최적화 여정
- [React] AI 챗봇의 체감 속도를 높인 프론트엔드 최적화 여정
댓글