React Native 하이브리드 앱 시작하기

React Native 하이브리드 앱 시작하기

1. 들어가며

a. 왜 하이브리드 구조인가

React 웹 개발자가 처음 React Native를 접할 때 가장 먼저 마주하는 고민은 “기존 웹을 어디까지 재사용할 수 있는가”입니다. 모바일 앱을 만든다고 해서 모든 화면과 비즈니스 로직을 React Native로 다시 작성해야 하는 것은 아닙니다. 이미 React 웹 애플리케이션에 라우팅, 권한 처리, 상태 관리, API 연동 흐름이 갖춰져 있다면, 이를 React Native 앱 내부의 WebView에서 실행하는 하이브리드 구조도 현실적인 선택지가 됩니다.

이 구조는 웹과 앱의 역할을 나누는 방식입니다. 웹 애플리케이션은 기존 화면과 도메인 로직을 유지하고, React Native 앱은 WebView를 감싸는 네이티브 셸 역할을 맡습니다. 네이티브 셸은 인증 정보 저장, 스플래시 화면, 네트워크 상태 감지, Android 하드웨어 뒤로 가기, 안전 영역 처리처럼 모바일 환경에 가까운 기능을 담당합니다.

다만 WebView를 사용한다고 해서 웹 주소만 앱 안에 띄우면 되는 것은 아닙니다. 웹과 네이티브는 서로 다른 실행 환경에서 동작합니다. 웹은 브라우저 런타임 위에서 실행되고, React Native는 모바일 OS와 가까운 네이티브 런타임 위에서 실행됩니다. 따라서 두 영역 사이의 상태 동기화, 메시지 전달 방식, 초기화 순서, 오류 폴백을 별도로 설계해야 합니다.

이 글은 React Native 앱의 세부 기능 구현기가 아니라, React 웹 개발자가 처음 WebView 기반 하이브리드 앱 구조를 잡을 때 고려해야 할 설계 포인트를 정리한 글입니다.

b. 이 글의 범위

이 글에서 다루는 범위는 하이브리드 앱의 초기 뼈대 설계입니다. 구체적으로는 다음과 같은 항목을 중심으로 봅니다.

  • React 웹 애플리케이션과 React Native 앱의 역할 분리

  • WebView 셸 구조

  • 웹과 네이티브 사이의 브리지 통신 계약

  • 초기 인증 정보 동기화

  • 스플래시 화면과 웹 준비 상태의 연결

  • Android 뒤로 가기 대응

  • 네트워크 상태 전달

  • 운영 단계에서 보완해야 할 보안 및 검증 지점

반대로 네이티브 앱의 모든 세부 화면 구현, 스토어 배포, 푸시 알림, 앨범 접근, 카메라 권한 처리 같은 기능은 이 글의 중심 범위가 아닙니다. 이러한 기능은 브리지 구조가 안정적으로 잡힌 뒤 단계적으로 확장할 수 있는 영역으로 봅니다.

2. 기본 구조

a. WebView 셸

WebView 기반 하이브리드 앱의 출발점은 단일 WebView 셸입니다. React Native 앱은 사용자가 설치하는 앱의 껍데기 역할을 하고, 실제 서비스 화면은 WebView 안에서 실행되는 React 웹 애플리케이션이 담당합니다.

이 구조에서 React Native 앱은 다음과 같은 책임을 가집니다.

  • WebView 로드

  • 앱 상단/하단 안전 영역 처리

  • 네이티브 스플래시 제어

  • SecureStore 같은 네이티브 저장소 접근

  • Android 하드웨어 뒤로 가기 처리

  • NetInfo 기반 네트워크 상태 확인

  • 웹과 네이티브 사이의 메시지 중계

웹 애플리케이션은 기존과 동일하게 React, React Router, React Query, 상태 관리 도구 등을 사용해 화면과 비즈니스 로직을 처리합니다. 사용자는 하나의 앱을 사용하는 것처럼 느끼지만, 내부적으로는 네이티브 셸과 웹 애플리케이션이 역할을 나눠 동작합니다.

처음 구조를 잡을 때 중요한 점은 React Native를 “웹을 대체하는 새 프론트엔드”로 보기보다, “웹을 모바일 앱 환경에서 실행시키기 위한 셸”로 보는 것입니다. 이렇게 접근하면 기존 웹 로직을 보존하면서 모바일 앱에 필요한 최소한의 네이티브 기능을 연결할 수 있습니다.

b. 역할 분리

하이브리드 구조에서 가장 피해야 할 것은 웹과 네이티브의 책임이 뒤섞이는 것입니다. 웹 프로젝트에 React Native 의존성이 직접 들어가면 웹 단독 실행과 테스트가 어려워집니다. 반대로 네이티브 앱에 웹 도메인 로직이 과하게 들어가면 기존 웹 아키텍처의 장점을 잃게 됩니다.

따라서 기본 방향은 다음과 같이 잡는 것이 좋습니다.

  • 웹은 웹 기술 스택과 도메인 로직을 소유합니다.

  • 네이티브 앱은 Expo, React Native, WebView, SecureStore, 네이티브 설정을 소유합니다.

  • 양쪽이 함께 알아야 하는 통신 규격은 공용 브리지 패키지로 분리합니다.

이 분리는 초기 설계 단계에서 특히 중요합니다. 앱의 세부 기능이 모두 완성되지 않았더라도, 책임 경계를 먼저 정해두면 이후 기능을 추가할 때 영향 범위를 예측하기 쉬워집니다.

예를 들어 웹이 네이티브 헤더 제목을 변경해야 한다면, 웹이 React Native 컴포넌트를 직접 알 필요는 없습니다. 웹은 UPDATE_HEADER_TITLE 같은 메시지만 브리지로 보냅니다. 네이티브는 해당 메시지를 받아 자신의 header 상태를 변경합니다. 이렇게 하면 웹은 “네이티브에 제목 변경을 요청한다”는 계약만 알고, 실제 네이티브 UI 구현에는 의존하지 않습니다.

c. 모노레포와 공용 패키지

웹과 네이티브 앱이 같은 저장소 안에 있다면, 공용 패키지를 두기 좋습니다. 특히 브리지 패키지는 웹과 네이티브가 함께 참조해야 하는 액션 이름, 페이로드 타입, 저장소 키 등을 관리하는 데 적합합니다.

예시 구조는 다음과 같습니다.

packages/
브리지/

apps(episodes)/
web-app/
mobile-shell/

브리지 패키지는 특정 화면이나 도메인 로직을 알지 않는 순수 통신 계층으로 두는 것이 좋습니다. 이 패키지가 비즈니스 로직까지 알게 되면 공용 패키지가 점점 무거워지고, 웹과 네이티브의 결합도도 높아집니다.

공용 패키지에 들어가면 좋은 항목은 다음과 같습니다.

  • 브리지 액션 이름

  • 액션별 페이로드 타입

  • 메시지 생성 함수

  • 메시지 파싱 함수

  • 웹에서 네이티브로 보내는 함수

  • 네이티브에서 웹으로 보내는 함수 생성기

  • 웹과 네이티브가 공유하는 저장소 키

이 정도만 공통화해도 웹과 네이티브 사이의 문자열 불일치, 페이로드 누락, 저장 키 오타를 줄일 수 있습니다.

3. 브리지 설계

a. 웹과 네이티브의 통신 방식

WebView 안의 웹과 React Native 앱은 서로의 상태를 직접 참조할 수 없습니다. 둘 사이에는 명시적인 메시지 통로가 필요합니다. 이 통로가 브리지입니다.

웹에서 네이티브로 메시지를 보낼 때는 WebView 환경에서 주입되는 window.ReactNativeWebView.postMessage()를 사용합니다. 네이티브는 WebView의 onMessage를 통해 이 메시지를 수신합니다.

반대로 네이티브에서 웹으로 메시지를 보낼 때는 WebView.injectJavaScript()를 사용합니다. 네이티브가 WebView 내부에 JavaScript 코드를 주입하고, 웹은 message 이벤트 리스너를 통해 이 값을 받습니다.

흐름을 단순화하면 다음과 같습니다.

Web → Native
window.ReactNativeWebView.postMessage(JSON.stringify(message))

Native → Web
webView.injectJavaScript(...)
window.dispatchEvent(new MessageEvent('message', { data }))

이 통신은 기본적으로 JSON 문자열 기반입니다. 따라서 메시지를 보내는 쪽과 받는 쪽이 같은 액션 이름과 페이로드 구조를 공유해야 합니다.

b. 타입 기반 메시지 계약

브리지 메시지는 문자열 기반이므로 실수 가능성이 있습니다. 예를 들어 웹에서는 REQUEST_USER_CONTEXT를 보냈는데 네이티브에서는 REQUEST_CONTEXT로 처리하고 있다면 메시지는 전달되더라도 기능은 동작하지 않습니다. 페이로드 구조가 바뀌었는데 한쪽만 수정되는 문제도 생길 수 있습니다.

이를 줄이려면 브리지를 단순 유틸 함수가 아니라 통신 계약으로 관리해야 합니다. 방향별 이벤트 맵을 정의하고, 액션 이름을 선택하면 페이로드 타입이 자동으로 결정되도록 구성할 수 있습니다.

예시는 다음과 같습니다.

interface NativeToWebEventMap {
  SYNC_USER_CONTEXT: SyncUserContext페이로드;
  NETWORK_STATUS_CHANGED: {
    isConnected: boolean;
    isInternetReachable?: boolean | null;
    type?: string;
  };
}

interface WebToNativeEventMap {
  REQUEST_USER_CONTEXT: void;
  SYNC_USER_CONTEXT: SyncUserContext페이로드;
  CLEAR_USER_CONTEXT: void;
  WEB_APP_READY: void;
  UPDATE_HEADER_TITLE: { title: string };
  REQUEST_NETWORK_STATUS: void;
}

이 구조에서는 페이로드가 필요한 액션과 필요 없는 액션을 타입으로 구분할 수 있습니다. 페이로드가 없는 액션은 void로 정의하고, 조건부 타입을 사용하면 잘못된 인자 전달을 컴파일 단계에서 막을 수 있습니다.

또한 이벤트 맵을 기반으로 discriminated union 형태의 메시지 타입을 만들면, 수신부에서도 액션별 페이로드 타입을 안전하게 다룰 수 있습니다.

다만 TypeScript 타입은 컴파일 단계의 안전성입니다. 실제 런타임에서는 외부에서 들어온 JSON 문자열을 파싱하는 구조이므로, 운영 단계에서는 Zod 같은 스키마 검증 도구를 추가하는 것도 고려할 수 있습니다.

4. 초기화 흐름

a. 인증 핸드셰이크

WebView 앱에서 가장 주의해야 할 부분 중 하나는 초기 인증 정보 전달입니다. 네이티브 앱에는 SecureStore에 저장된 token과 role이 있고, WebView 안의 React 웹 애플리케이션은 이 정보를 받아 초기 화면을 결정해야 합니다.

가장 단순한 방식은 WebView의 onLoadEnd 시점에 네이티브가 웹으로 토큰을 보내는 것입니다. 하지만 이 방식은 안정적이지 않습니다. onLoadEnd는 WebView가 HTML 로드를 마쳤다는 의미에 가깝습니다. React 애플리케이션이 마운트되고, 브리지 메시지를 받을 listener가 등록되었다는 뜻은 아닙니다.

즉, 네이티브는 “웹이 로드되었다”고 판단해 메시지를 보내지만, 웹은 아직 메시지를 받을 준비가 되지 않았을 수 있습니다. 이 경우 토큰 메시지가 유실되고, 사용자는 토큰이 있음에도 비로그인 상태처럼 보이는 화면을 볼 수 있습니다.

이 문제는 push 방식보다 핸드셰이크 방식으로 설계하는 편이 안전합니다. 핵심은 네이티브가 먼저 인증 정보를 밀어 넣지 않고, 웹이 listener를 등록한 뒤 직접 요청하도록 만드는 것입니다.

흐름은 다음과 같습니다.

  1. 웹의 최상위 컴포넌트가 SYNC_USER_CONTEXT listener를 등록합니다.

  2. listener 등록 후 웹이 REQUEST_USER_CONTEXT를 보냅니다.

  3. 네이티브는 SecureStore에서 token과 role을 조회합니다.

  4. 네이티브는 SYNC_USER_CONTEXT로 응답합니다.

  5. 웹은 token과 role을 저장소와 상태에 반영합니다.

  6. 동기화가 끝난 뒤 실제 화면을 렌더링합니다.

이 방식의 핵심은 WebView 로드 완료 시점과 React listener 준비 완료 시점을 같은 것으로 보지 않는 데 있습니다. 웹이 받을 준비가 된 뒤 요청하고, 네이티브가 응답하는 구조가 초기 메시지 유실 가능성을 줄입니다.

b. 초기 화면 방어

핸드셰이크 방식으로 인증 정보를 받아오더라도 요청과 응답 사이에는 짧은 비동기 구간이 존재합니다. 이때 웹 애플리케이션이 먼저 렌더링되면 아직 token이 반영되지 않은 상태에서 비로그인 화면을 보여줄 수 있습니다. 이후 token이 도착하면 다시 메인 화면으로 전환되면서 화면이 깜빡입니다.

모바일 앱에서는 이런 깜빡임이 특히 어색하게 느껴집니다. 사용자는 WebView 내부 구조를 알지 못하고 하나의 앱으로 인식하기 때문입니다. 따라서 초기 인증 동기화가 끝나기 전까지는 실제 라우팅 화면을 렌더링하지 않는 방어가 필요합니다.

웹의 최상위 영역에 isSynced 같은 상태를 두고, 동기화가 끝나기 전에는 흰색 배경이나 로딩 영역만 보여주는 방식이 적합합니다. 이 상태에서는 로그인 페이지, 역할 선택 페이지, 메인 페이지 중 어떤 화면도 먼저 그리지 않습니다. 네이티브에서 인증 정보를 받거나, 인증 정보가 없다는 응답을 받은 뒤에 화면을 결정합니다.

다만 브리지 응답이 오지 않는 상황도 대비해야 합니다. 네이티브 오류, 메시지 파싱 오류, WebView 환경 차이 등으로 응답이 유실되면 앱이 무한히 빈 화면에 머물 수 있습니다. 이를 방지하기 위해 일정 시간 후 강제로 준비 상태로 전환하는 폴백 타이머를 둘 수 있습니다.

정상 경로는 브리지 응답입니다. 비정상 경로는 timeout입니다. 초기화 설계에서는 이 두 경로를 함께 두는 것이 안전합니다.

c. 스플래시 연동

React Native/Expo 앱에서는 스플래시 화면을 설정할 수 있습니다. 하지만 WebView 앱에서 더 중요한 것은 스플래시를 언제 닫을지입니다.

WebView의 onLoadEnd에서 바로 스플래시를 닫으면 화면이 빨리 보이는 것처럼 느껴질 수 있습니다. 하지만 HTML 로드가 끝났다고 해서 React 앱의 초기화, 인증 정보 동기화, 라우팅 판단이 끝난 것은 아닙니다. 이 시점에 스플래시를 닫으면 사용자는 아직 준비되지 않은 흰 화면이나 로그인 화면 깜빡임을 볼 수 있습니다.

따라서 스플래시 종료 시점은 웹의 실제 준비 상태와 연결하는 것이 좋습니다. 웹 애플리케이션이 인증 동기화와 초기 화면 판단을 마친 뒤 WEB_APP_READY 메시지를 네이티브로 보내고, 네이티브는 이 메시지를 받은 시점에 스플래시를 닫습니다.

흐름은 다음과 같습니다.

  1. 앱 실행

  2. Expo 정적 스플래시 표시

  3. React Native 셸이 WebView 로드

  4. 웹이 브리지 listener 등록

  5. 웹이 인증 정보 요청

  6. 네이티브가 인증 정보 응답

  7. 웹이 초기 화면 결정

  8. 웹이 WEB_APP_READY 전송

  9. 네이티브가 스플래시 종료

여기에도 폴백은 필요합니다. 웹에서 오류가 발생하거나 WEB_APP_READY 메시지가 오지 않으면 스플래시가 계속 유지될 수 있습니다. 따라서 WebView 로드 후 일정 시간이 지나면 스플래시를 강제로 닫는 타이머를 두고, 정상 신호를 받으면 타이머를 해제하는 방식이 안전합니다.

d. 저장소 동기화

WebView 앱에서는 저장소가 여러 계층으로 나뉩니다. 웹은 sessionStorage, localStorage, cookie, IndexedDB, Cache Storage 등을 사용할 수 있고, 네이티브는 SecureStore 같은 별도 저장소를 사용할 수 있습니다. 인증 정보를 다룰 때는 이 저장소들이 서로 어긋나지 않도록 설계해야 합니다.

초기 진입 시에는 네이티브의 SecureStore에서 token과 role을 읽어 웹으로 전달합니다. 웹은 이를 기존 인증 흐름에서 사용할 수 있는 저장소와 상태에 반영합니다. 반대로 웹에서 role을 변경하면 네이티브에도 선택된 role을 알려야 합니다. 그래야 앱을 재실행했을 때 마지막 선택 상태를 복원할 수 있습니다.

로그아웃은 특히 주의가 필요합니다. 웹 저장소에서 token만 지우고 끝내면 네이티브 SecureStore에는 여전히 인증 정보가 남아 있을 수 있습니다. 그러면 앱 재실행 시 네이티브가 다시 token을 웹에 전달하면서 로그아웃 상태가 복구되지 않는 문제가 생길 수 있습니다.

따라서 로그아웃 시에는 웹과 네이티브 저장소를 함께 정리하는 흐름을 설계해야 합니다. 웹에서는 sessionStorage, localStorage, cookie, Cache Storage, IndexedDB 등을 정리하고, 네이티브에는 CLEAR_USER_CONTEXT 메시지를 보내 SecureStore를 삭제하도록 구성할 수 있습니다.

또한 저장 키는 공통 상수로 관리하는 편이 좋습니다. 웹과 네이티브가 각자 문자열 키를 직접 작성하면 오타나 변경 누락이 발생하기 쉽습니다. 브리지 패키지에서 token key, role key를 함께 관리하면 양쪽의 저장소 계약을 일관되게 유지할 수 있습니다.

5. 모바일 환경 고려사항

a. Android 뒤로 가기

WebView 앱에서 Android 하드웨어 뒤로 가기는 반드시 고려해야 하는 항목입니다. 웹 브라우저에서는 뒤로 가기가 자연스럽게 history와 연결됩니다. 하지만 React Native 앱에서는 Android의 뒤로 가기 이벤트가 먼저 네이티브 레이어로 전달됩니다.

네이티브 앱은 WebView 내부의 React Router 상태를 자동으로 알지 못합니다. 사용자가 상세 화면에서 목록으로 돌아가려고 뒤로 가기를 눌렀는데 앱이 바로 종료된다면 매우 어색한 경험이 됩니다.

이를 방지하려면 WebView의 navigation state에서 canGoBack을 추적하는 구조가 필요합니다. Android 하드웨어 뒤로 가기 이벤트가 발생했을 때 canGoBack이 true이면 webView.goBack()을 호출하고 앱 종료를 막습니다. 반대로 더 이상 WebView 내부 히스토리가 없으면 Android 기본 동작을 허용합니다.

이 처리는 단순하지만 앱 완성도에 큰 영향을 줍니다. 사용자는 이 앱이 WebView 기반인지 여부와 무관하게 “뒤로 가기 버튼을 누르면 이전 화면으로 돌아간다”는 기대를 가집니다. 하이브리드 구조에서도 이 기대를 맞추는 것이 중요합니다.

b. 네트워크 상태

모바일 환경에서는 네트워크 상태가 자주 바뀝니다. 지하철, 엘리베이터, 이동 중 Wi-Fi 전환, 일시적인 통신 음영 구간에서는 요청이 실패하거나 지연될 수 있습니다. 웹에서는 보통 navigator.onLine이나 브라우저의 online, offline 이벤트를 사용하지만, WebView 앱에서는 이것만으로 충분하지 않을 수 있습니다.

React Native에서는 NetInfo를 통해 기기의 네트워크 연결 상태를 더 직접적으로 확인할 수 있습니다. 이를 브리지로 웹에 전달하면 웹 애플리케이션도 네이티브 기준의 네트워크 상태를 활용할 수 있습니다.

예를 들어 네이티브가 NETWORK_STATUS_CHANGED 메시지를 보내면, 웹은 이 값을 React Query의 onlineManager와 연결할 수 있습니다. 연결이 끊겼을 때는 query나 mutation이 무리하게 실패하지 않도록 하고, 연결이 복구되면 중단된 mutation을 재개하거나 query를 invalidate해 최신 데이터를 다시 가져오도록 구성할 수 있습니다.

이 방식은 단순히 오프라인 안내 화면을 띄우는 것보다 한 단계 더 안정적인 구조입니다. 사용자가 네트워크 복구 후 직접 새로고침하지 않아도 앱이 정상 흐름으로 돌아올 수 있기 때문입니다.

다만 브라우저의 online/offline 이벤트도 완전히 배제할 필요는 없습니다. 네이티브 브리지가 동작하지 않는 환경이나 웹 단독 실행 환경을 고려해 폴백으로 함께 사용하는 것이 좋습니다.

c. 운영 보완

WebView 브리지는 편리하지만, 운영 환경에서는 보안 측면도 함께 고려해야 합니다. 개발 중에는 빠른 확인을 위해 origin을 넓게 허용하거나, iframe 테스트에서 postMessage('*')를 사용하는 경우가 있습니다. 하지만 운영 환경에서는 메시지를 주고받는 origin을 명확히 제한하는 것이 좋습니다.

또한 브리지 메시지는 JSON 문자열이므로 파싱에 실패할 수 있습니다. TypeScript 타입은 개발 단계의 안전성을 높여주지만, 런타임에서 들어오는 값이 실제로 그 타입을 만족한다는 보장은 없습니다. 따라서 중요한 액션에는 런타임 스키마 검증을 추가하는 편이 안전합니다.

특히 인증 정보, 사용자 컨텍스트, 네트워크 상태, 네이티브 기능 요청처럼 앱 동작에 직접 영향을 주는 메시지는 다음 항목을 고려할 수 있습니다.

  • 허용된 origin에서 온 메시지인지 확인합니다.

  • 액션이 정의된 목록에 있는지 확인합니다.

  • 페이로드 구조가 예상과 일치하는지 검증합니다.

  • 파싱 오류를 단순히 무시하지 않고 로깅하거나 추적합니다.

  • 민감한 정보는 필요한 범위에서만 전달합니다.

처음 WebView 앱 구조를 잡을 때는 기능 연결에 집중하기 쉽지만, 브리지는 웹과 앱 사이의 통로입니다. 따라서 초기 설계 단계부터 검증과 제한을 고려해두는 것이 이후 운영 안정성에 도움이 됩니다.

마무리

React 개발자가 처음 React Native WebView 기반 하이브리드 앱을 시도할 때 중요한 것은 네이티브 화면을 얼마나 많이 구현하는지가 아닙니다. 더 중요한 것은 웹과 네이티브가 어떤 책임을 나누고, 어떤 규격으로 통신하며, 어떤 시점에 초기 상태를 맞출 것인지 정하는 일입니다.

WebView 셸 구조는 기존 React 웹 애플리케이션의 장점을 유지하면서 모바일 앱 형태로 서비스를 제공할 수 있는 실용적인 방식입니다. 하지만 이 구조의 핵심은 WebView에 웹 주소를 넣는 것이 아니라, 웹과 네이티브 사이의 경계를 설계하는 데 있습니다.

초기 구조 설계 단계에서는 다음 항목을 먼저 잡아두는 것이 좋습니다.

  • 웹과 네이티브의 책임 분리

  • 브리지 액션과 페이로드 타입 계약

  • 인증 정보 핸드셰이크 흐름

  • 초기 화면 렌더링 보류와 폴백

  • 스플래시 종료 시점

  • SecureStore와 웹 저장소 동기화

  • Android 뒤로 가기 처리

  • 네트워크 상태 전달

  • 운영 환경에서의 origin 제한과 런타임 검증

이러한 뼈대가 먼저 잡혀 있으면 이후 세부 기능을 확장할 때도 구조가 크게 흔들리지 않습니다. React Native를 처음 접하는 React 개발자에게 WebView 하이브리드 앱은 낯선 영역일 수 있지만, 웹과 앱의 역할을 분리하고 브리지를 명확한 계약으로 관리하면 기존 웹 경험을 바탕으로 충분히 접근할 수 있는 구조입니다.

Hazel

Site footer