본문 바로가기
  • 🦄 창민이 개발일지
프론트엔드

[BUCL 프로젝트] React와 React Native 간 브릿지 통신을 통한 WebView에 Stack Navigation 구현

by 창민이 개발일지 2024. 5. 30.

👀 Intro

상황

  • React로 웹개발하고 Webview로 띄우는 방식인 하이브리드 앱 방식으로 진행했습니다.
  • 하이브리드 앱 장점
    • 장점: 웹으로 개발하기 때문에 유지보수가 쉽고 IOS 와 안드로이드를 동시에 개발할 수 있다.
    • 단점: Webview로 띄우는 방식이기 때문에, 네이티브 방식 보다 느리고 밑의 그림같은 앱 내 효과를 사용하기 어렵다.
  • 하이브리드 사용한 이유
    • 네이티브 보다 느리지만, 이커머스 서비스는 다른 앱 서비스에 비해 상대적으로 사용자 경험을 많이 요구하지 않음
    • 벤치마킹: 무신사, 온더룩, 캐치테이블 등 이미 많은 기업들이 하이브리드 앱 방식으로 개발을 진행

요구사항

  • 서비스 기획 상 앱처럼 보여야 됐기 때문에 WebView 안에서 Stack Navigation 구현이 필요
  • WebView 특성상 하나의 Sreen View에 웹페이지를 띄우는 방식이기에 페이지를 이동할 때마다 Stack Navigation를 할 수 없습니다.

해결 방안

많은 자료 조사 밑 고민 끝에 react-navigation 및 onMessage를 사용하고, 웹에서 Stack Navigation 과 관련 있는 페이지로 이동 시, postMessage를 사용하여 앱에 신호를 보내는 방식으로 구현했습니다.






 

문제 접근

  • React Native 와 React 간의 브릿지 통신을 통해 Stack Navigation를 구현했습니다.

React Native 브릿지 통신

  • 리액트 네이티브는 네이티브 프레임워크와 자바스크립트 엔진 간의 통신을 하게 되는 데, 이러한 방식을 브릿지 방식이라고 합니다.
  • 브릿지 방식은 웹 브라우저에서 사용되는 자바스크립트의 엔진의 핵심 부분을 이용하여, 자바스크립트로 작성된 컴포넌트가 네이티브 환경이 뷰 클래스를 호출할 수 있도록 연결합니다.
  • 리액트 네이티브는 이런한 방식을 채택하기 때문에, WebView의 리액트와 리액트 네이티브간의 브릿지 통신이 가능합니다.

 

 

React Native 웹뷰 통신

socket 단위로 통신을 하기 때문에, 각 메시지를 보낼때, JSON을 문자열 형식으로 변환해서 보내야 합니다. 

React-Native

  • postMeaage : React로 메세지 보낼 때 사용
  • onMessage : React로 부터 메세지 받을 때 사용


React (Webview)

  • window.ReactNativeWebview.postMeaage : React-Native로 메세지 보낼 때 사용
  • window.ReactNativeWebview.addEventListener : React-Native로 부터 메세지 받을 때 사용

 

코드 구현 및 동작 과정

  • AppLink 모듈은 React의 내장 모듈인 Link 모듈과 비슷한 기능 및 React-Native로 브릿지 통신을 보내는 기능을 포함한 모듈입니다.
  • MainViewWebView는 첫 앱 실행시, 보여주는 웹뷰 입니다. 해당 컴포넌트 안에는 브릿지 통신 기능 뿐만아니라, 웹뷰를 실행 시키기 위한 초기 설정 값들도 포함하고 있습니다.

동작 과정

  1. AppLink를 둘러 싼 컴포넌트를 누를 시(이떄 isApp 속성이 true 인 경우), 리액트 네이티브로 type값 과 url 정보를 보냅니다.
  2. React-Native의 MainViewWebView는 React가 보낸 통신 값을 확인하고 이떄 type의 값이 NAVIGATION_TO(=”navigationTo”)인 경우 Stack Navigation에 새로운 웹 뷰(SUB_HEAD_WEBVIEW_NAME: subHeadWebViewPage)를 넣어주고 동시에 보내 준 url로 이동시켜 새로운 웹뷰가 보여주도록 합니다.
  3. NAVIGATION_BACK(=”navigationTo”)인 경우 Stack-Navigation에서 해당 웹뷰를 제거해, 이전 웹뷰로 이동합니다.

전체 코드

AppLink.tsx

import React, { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import { NAVIGATION_BACK, NAVIGATION_TO } from '../const/AppVars';

declare global {
  interface Window {
    ReactNativeWebView: {
      postMessage: (message: string) => void;
    };
  }
}

interface AppLinkProps {
  children: ReactNode;
  to: string;
  type?: string;
  isApp?: boolean;
  style?: React.CSSProperties;
}

const AppLink: React.FC<
  AppLinkProps & React.RefAttributes<HTMLAnchorElement>
> = ({ to, children, type = NAVIGATION_TO, isApp = true, style }) => {
  const navigate = useNavigate();
  return (
    <AppContainer
      style={style}
      className="app-link"
      onClick={() => {
        if (window.ReactNativeWebView && isApp) {
          window.ReactNativeWebView.postMessage(
            JSON.stringify({ type: type, url: to }),
          );
        } else {
          if (type === NAVIGATION_BACK) {
            navigate(-1);
          } else {
            navigate(to);
          }
        }
      }}
    >
      {children}
    </AppContainer>
  );
};

const AppContainer = styled.div`
  cursor: pointer;
`;

export default AppLink;

MainViewWebView.tsx

import React, { useEffect, useRef, useState } from 'react';
import { StyleSheet, Dimensions, Linking, Alert } from 'react-native';
import WebView from 'react-native-webview';
import { APP_BASE_URL } from "@env";
import { StackNavigationProp } from '@react-navigation/stack';
import { MAIN_WEBVIEW_NAME, NAVIGATION_TO, SUB_HEAD_WEBVIEW_NAME } from '../constants/WebViewVar';
import CookieManager from '@react-native-cookies/cookies';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { COOKIES, CookieProps, REFRESH_TOKEN } from '../constants/CookieVar';
import { RouteProp } from '@react-navigation/native';

const windowWidth = Dimensions.get('window').width;
const windowHeight = Dimensions.get('window').height;

type MainWebViewPageProps = {
    navigation: StackNavigationProp<any, typeof MAIN_WEBVIEW_NAME>;
    route: RouteProp<any, typeof MAIN_WEBVIEW_NAME>;
};

interface messageProps {
    type: string;
    url: string;
}

const MainWebviewPage: React.FC<MainWebViewPageProps> = ({ navigation, route }) => {
    const webViewRef = useRef<WebView>(null);
    const params = route.params;;
    const to = params?.to || '';
    const [url, setUrl] = useState(APP_BASE_URL);
    const [initialUrl, setInitialUrl] = useState(APP_BASE_URL);
    const [error, setError] = useState<boolean>(false);

    const handleMessage = (event: any) => {
        const message: messageProps = JSON.parse(event.nativeEvent.data);
        if (message.type === NAVIGATION_TO) {
            navigation.navigate(SUB_HEAD_WEBVIEW_NAME, { to: message.url })
        }
        else {
            if (navigation?.canGoBack()) {
                navigation.goBack();
            }
        }
    };

    useEffect(() => {
        AsyncStorage.getItem(COOKIES).then(storageCookiesString => {
            if (storageCookiesString === null) return;
            const storageCookies: { [key: string]: CookieProps } = JSON.parse(storageCookiesString);

            const cookiesKey = Object.keys(storageCookies);
            if (cookiesKey.includes(REFRESH_TOKEN)) {
                const cookie: { [key: string]: CookieProps } = {}
                const refreshTokenCookie: CookieProps = storageCookies[REFRESH_TOKEN];
                cookie[REFRESH_TOKEN] = refreshTokenCookie
                const refreshToken = JSON.stringify(cookie);
                // AsyncStorage.setItem(COOKIES, refreshToken);
                CookieManager.set(APP_BASE_URL, refreshTokenCookie);
            }
        })

    }, [initialUrl]);

    useEffect(() => {
        CookieManager.getAll(true)
            .then(cookies => {
                AsyncStorage.setItem(COOKIES, JSON.stringify(cookies));
            });
    }, [url]);

    const errorAlert = (code: number) => {
        let msg = '';
        if (code === -1009) msg = "인터넷에 연결되어 있지 않습니다. 네트워크 연결을 확인해주세요.";
        else msg = "네트워크에 오류가 발생하여 앱에 연결할 수 없습니다.";

        Alert.alert(
            "BUCL",
            msg,                         // 두번째 text: 그 밑에 작은 제목
            [
                {
                    text: "재시도",                              // 버튼 제목
                    onPress: () => {
                        setError(false)
                        webViewRef.current?.reload();
                    },
                    style: "cancel"
                }
            ],
            { cancelable: false }
        );
    }

    return <>
        {!error && <WebView
            ref={webViewRef}
            source={{ uri: APP_BASE_URL + to }}
            scalesPageToFit={false}
            style={styles.webview}
            onMessage={handleMessage}
            onError={(event) => {
                setError(true);
                errorAlert(event.nativeEvent.code);
            }}
            onLoadStart={(syntheticEvent) => {
                const { nativeEvent } = syntheticEvent;
                setInitialUrl(nativeEvent.url);

            }}
        />}

    </>
};

export default MainWebviewPage;

const styles = StyleSheet.create({
    webview: {
        flex: 1,
        width: windowWidth,
        height: windowHeight,
    },
});

 

참고