본문 바로가기
프로그래밍/모바일 앱

[React Native] 사용자 경험의 완성: 인앱 브라우저 한계를 넘는 하이브리드 앱 구축 여정

by me_in_sk 2026. 1. 28.
반응형

기존 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 일관성  브라우저 전환으로 단절  네이티브 앱 경험

배운 점

  1. 하이브리드의 힘: 모든 것을 네이티브로 재작성할 필요 없이, 핵심 경험만 네이티브로 구현하면 개발 비용 대비 높은 가치를 얻을 수 있습니다.
  2. FOUC 방지의 핵심: injectedJavaScriptBeforeContentLoaded와 localStorage 직접 조작으로 페이지 로드 전 상태를 주입하는 것이 해결책입니다.
  3. 브릿지 설계 원칙: 메시지 타입을 명확히 정의하고, 양방향 통신의 책임을 분리하면 복잡도를 관리할 수 있습니다.
  4. 네이티브 모듈 연동: 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


◆ 함께 보면 좋은 글

반응형

댓글