네이티브와 Next.js를 하나처럼 만들기

2024-12-01 00:00:00

RN + WebView 하이브리드 앱의 어려운 점은 "두 세계를 이어붙이는 코드"가 여기저기 튀어나오는 것이다. 분기 처리, 메시지 파싱, 플랫폼 감지... 비즈니스 로직보다 인프라 코드가 많아지면 본말이 전도된다.

이 프로젝트에서는 추상화 레이어를 쌓아서 웹 개발자가 WebView 안에서 돌아가고 있다는 사실을 거의 의식하지 않아도 되게 만들었다. 그 구조를 설명한다.

레이어 1: 플랫폼 감지 (PlatformProvider)

가장 아래 레이어. UserAgent를 파싱해서 현재 실행 환경이 WebView인지, iOS인지 Android인지를 판별한다.

// lib/PlatformResolver.ts
export const platformResolver = (userAgent: string): Platform => {
  let os = 'web';
  userAgent = userAgent.toLowerCase();
  const isWebView = userAgent.indexOf('inunity_webview') > -1;
  if (userAgent.indexOf("android") > -1) os = 'android';
  else if (userAgent.indexOf("ios") > -1) os = 'ios';
  return { os, isWebView } as Platform;
};
1
2
3
4
5
6
7
8
9

INUnity_WebView 식별자는 RN의 CustomWebView가 UserAgent에 심어둔다.

// inunity-native/components/CustomWebView.tsx
userAgent={`Mozilla/5.0 (${Platform.OS}) AppleWebKit/537.36 ... INUnity_WebView`}
1
2

그 결과, 서버 컴포넌트(layout.tsx)에서도 플랫폼을 알 수 있다.

// app/(pages)/layout.tsx (Server Component)
export default function RootLayout({ children }) {
  const ua = userAgent({ headers: headers() }).ua;
  const platform = platformResolver(ua);  // 서버에서 판별

  return (
    <html>
      <body>
        <Providers platform={platform}>{children}</Providers>
      </body>
    </html>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13

PlatformProvider가 이 값을 Context로 전달하면, 트리 어디서든 usePlatform()으로 현재 환경을 알 수 있다.

// lib/PlatformProvider.tsx
export const usePlatform = () => useContext(PlatformContext);
1
2

레이어 2: 메시지 통신 (MessageContext)

WebView와 RN이 메시지를 주고받는 진입점을 하나로 통일한다.

// shared/ui/MessageContext.tsx
export const MessageProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
  const { os, isWebView } = usePlatform();
  const [messageManager, setMessageManager] = useState<MessageManager | null>(null);
  const router = useRouter();

  useEffect(() => {
    if (!isWebView) return;

    const manager = new MessageManager(window.ReactNativeWebView);
    setMessageManager(manager);

    const onMessageReceived = (event: MessageEvent) => {
      manager.onMessageReceived(event, {
        [MessageEventType.Navigation]: (data: NavigationEvent) => {
          if (data === -1) router.back();
          else router.replace(data.path, { scroll: false });
        },
        [MessageEventType.Page]: (data) => setPageEvent(data),
      });
    };

    if (os === 'ios') window.addEventListener('message', onMessageReceived);
    else document.addEventListener('message', onMessageReceived as EventListener);

    return () => {
      (os === 'ios' ? window : document).removeEventListener(
        'message', onMessageReceived as EventListener
      );
    };
  }, [isWebView]);

  // 페이지 이동 시마다 테마 컬러를 네이티브에 동기화
  useEffect(() => {
    messageManager?.sendMessage(
      MessageEventType.ThemeColor,
      document.querySelector('meta[name="theme-color"]')?.getAttribute('content')
    );
  }, [messageManager, pathname]);

  return (
    <MessageContext.Provider value={{ messageManager, pageEvent }}>
      {children}
    </MessageContext.Provider>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

isWebViewfalse면 이 이펙트는 아무것도 하지 않는다. 일반 브라우저에서 접근해도 완전히 정상 동작한다.

이 레이어가 핵심이다. 개발자가 라우팅 코드를 쓸 때 "지금 WebView 안인가?"를 의식하지 않아도 된다.

// hooks/useNativeRouter.ts
export function useNativeRouter() {
  const router = useRouter();
  const { messageManager } = useMessageManager();
  const { isWebView } = usePlatform();

  const customPush = useCallback(
    (url: string) => {
      if (isWebView)
        messageManager?.sendMessage(MessageEventType.Navigation, { path: url });
      else router.push(url);
    },
    [isWebView, messageManager, router]
  );

  const customBack = useCallback(() => {
    if (isWebView) messageManager?.sendMessage(MessageEventType.Navigation, -1);
    else router.back();
  }, [isWebView, messageManager, router]);

  return { ...router, push: customPush, back: customBack };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

WebView 안이라면 Navigation 메시지를 RN에 보내고, RN의 Expo Router가 실제 네이티브 스택 네비게이션을 처리한다. 브라우저라면 그냥 Next.js router를 쓴다.

NativeLink도 마찬가지다.

// shared/ui/NativeLink.tsx
export function NativeLink({ href, ...props }: LinkProps) {
  const { messageManager } = useMessageManager();

  const handleClick = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      messageManager?.sendMessage(MessageEventType.Navigation, { path: href });
    },
    [href, messageManager]
  );

  return <Link href={href} onClick={handleClick} {...props} />;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

messageManager가 없다면(=WebView가 아닌 일반 브라우저라면) onClick을 등록해도 e.preventDefault() 이후 sendMessage가 실행되지 않아 기본 링크처럼 동작한다.

레이어 4: 인증 (쿠키 공유)

WebView와 네이티브 앱이 같은 인증 상태를 공유한다. sharedCookiesEnabled를 켜면 WebView의 쿠키를 @react-native-cookies/cookies로 읽을 수 있다.

// app/(tabs)/index.tsx
<WebView
  sharedCookiesEnabled  // 쿠키 공유 활성화
  ...
  onMessage={(event) => {
    handleMessage(parseMessage(event.nativeEvent.data), {
      [MessageEventType.Login]: async () => {
        // 로그인 성공 → WebView 쿠키를 SecureStore에 저장
        const cookies = await AuthManager.getAllCookiesFromManager();
        await AuthManager.saveBulkCookiesToStorage(cookies);
      },
    });
  }}
/>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

앱 재시작 시 useCookies가 SecureStore에서 쿠키를 복원하고, 유효성을 검증한 뒤 WebView에 다시 주입한다.

// _layout.tsx - authorizeApp()
const cookies = await AuthManager.getAllCookiesFromStorage();  // SecureStore
await checkCookieValidity(`${API_BASE_URL}/auth/test`, cookies);
await AuthManager.setBulkCookiesToManager(cookies);  // WebView에 주입
1
2
3
4

웹 코드는 이 과정을 전혀 모른다. 평소처럼 쿠키를 쓰면 된다.

전체 구조

Next.js (inunity-web)
  └── PlatformProvider         ← 플랫폼 감지
       └── MessageProvider     ← 통신 채널 확보
            └── 페이지 코드
                 ├── useNativeRouter()  ← 투명한 라우팅
                 └── <NativeLink>       ← 투명한 링크

React Native (inunity-native)
  └── WebViewProvider          ← URL 추적
       └── WebView
            ├── onMessage         ← Navigation, Login, ThemeColor 처리
            └── CustomTabBar     ← WebView URL로 활성 탭 판별
1
2
3
4
5
6
7
8
9
10
11
12

웹 코드는 useNativeRouter().push('/board')를 호출한다. 브라우저에서는 Next.js 라우터가, 앱에서는 RN 라우터가 처리한다. 웹 개발자는 어디서 실행되는지 알 필요가 없다.

이 구조를 만드는 데 가장 중요한 원칙은 하나였다. 플랫폼 분기는 항상 가장 낮은 레이어에서만. usePlatform, MessageContext, useNativeRouter 이 세 레이어가 분기를 담당하면, 그 위의 코드는 깨끗하다.