2025. 4. 24. 17:53ㆍ공부/frontend
친구가 만들고 싶은 게 있다 해서 도와주는 겸 개인 프로젝트를 조금씩 해보고 있던 중 있던 일.
앱을 만들려하는데, 웹뷰라는 걸 알게 되어 전체적으로 웹뷰로 만들고자 함.
React Native로만 앱을 만들었던 적이 있었는데, 라이브러리 생태계가 그리 활발하진 않아서 여러모로 고생함.
이걸 웹 기반으로 구성할 수 있었다니… 왜 이제야 알았을까 싶었음.
웹뷰의 기반은 공부도 할 겸 Next.js로 결정.
먼저 Navbar 컴포넌트를 만들고, 기본적인 UI 구조는 완성함.
이후 CSS나 인터렉션 관련해 더 참조하고자 여러 앱들을 봤다.
그중 최고를 뽑자면 토스인 듯. 인터렉티브 하면서 깔끔하다. text와 SVG에 transition을 주는 방법을 구현하면 더 좋을 것 같긴 하다. 근데 써보면서 알게 된 사실
터치 시 진동이 온다. 진동이 온다는 건 네이티브로 이루어졌다는 것을 의미한다.
MDN에서 API를 제공하기에 웹에서도 진동을 구현은 가능하지만 IOS에서 지원이 안되기 때문에 이건 네이티브로 만들어진 것이라는 걸 확신함.
1. 하단바 어떻게 구현할까?
사실 개인적으로 프론트엔드를 좋아하는 이유 중 하나가 CSS인데, 앱은 CSS가 먹히는 곳이 아니라..
아래 같이 고민했는데 결국 네이티브로 구현하기로 결정
- 웹뷰 안에서 하단 바로 조작하는 경우
1. 단일 스크린(앱)에다가 웹뷰를 띄워야 함. 웹과 앱은 화면 전환 구조가 다르다.
그리고 네이티브 기능이 필요한 경우 웹과 앱 사이 통신이 필요할 텐데, 그런 경우가 웹 하나의 페이지가 아닌 여러 페이지에서 필요한 경우 단일 스크린에다가 관련 부분을 다 쑤셔 넣어야 할 것이 예상된다.. no...
2. 화면전환 이펙트의 밋밋함.. MDN에서 view-transtion API를 제공하지만 아직 지원이 안되는 브라우저도 있음. 사용자의 기종이 어떻게 될지 모르는데 이건 보수적으로 봐야 해서 단점이라고 생각한다.
3. 초기 렌더링시 네트워크 상황에 따라 하단바가 안보인다!? 음.. 해결방법은 하단바에 priority 주기, 로딩화면, suspense로 컨트롤등 다양하게 생각나긴 하긴 하지만 근본적인 네트워크 의존성에 대한 한계는 어쩔 수 없다.
- 네이티브로 하단 바를 만드는 경우
1. 웹과 앱의 전환 구조를 하나로 통일하는 법 공부해야함. 다행히 선구자들이 몇 분 있어서 공부하면 됨.
2. 화면 전환 이펙트 주기 쉬움. 이건 이전 RN으로 프로젝트 때 해봐서 쉽다.
3. 하단 바에 들어간 스크린 중 네이티브 부분으로만 이루어진 부분이 있을 경우, 네트워크 환경의 영향에 덜 의존적이다. 스크린들을 웹뷰로만 하고 싶긴 하지만 확장성까지 고려해야 된다고 생각.
이렇게 봤을때, 공부 더 하는 거 외에는 후자가 나은 것 같아서 결정함.
2. 웹과 앱의 전환 구조를 하나로 통일하기
웹뷰에서 html <a> 태그나 next.js <Link> 태그 같은 것들로 아무리 눌러봐야 앱은 그 사실을 모른다. 그렇기 위해 그걸 알려주기 위한 부분을 웹과 앱에 다 달아야 한다.
대충 이런 식으로 통신한다고 보면 된다. 통신 방식은
https://github.com/react-native-webview/react-native-webview/blob/master/docs/Guide.md
react-native-webview/docs/Guide.md at master · react-native-webview/react-native-webview
React Native Cross-Platform WebView. Contribute to react-native-webview/react-native-webview development by creating an account on GitHub.
github.com
react-native-webview 문서상 확인 가능하다. 이번에 적는 기본 편 외에 심화 편에 속할(쓰게 된다면..) 로그인과 쿠키, 다크모드 여부 체크 등과 관련된 정보 이외에도, ios 스와이프 기능 허용 여부와 같은 내용도 있어 꼭 보길 추천.
공부하면서 맨 처음 헷갈렸던 점이 react native 쪽에서 웹뷰예요!라고 알 수 있게 message를 내가 직접 보내줘야 한다고 생각을 했는데, React Native <WebView /> 컴포넌트가 자동으로 window 객체에 ReactNativeWebView를 주입해 줌. 그래서 해당 부분 유무로 웹뷰로서 호출된 건지 알 수 있게 된다.
3. 코드 짜보기
일단 기본 편이라 화면전환 관련된 부분만 짜봤다.
- Next.js 코드 (useAppRouter.ts)
1. 우선 위에 말한 window에는 원래 ReactNativeWebView라는 녀석은 없었기 때문에 TypeScript에서 타입을 알려달라고 아우성이다. 뭐 eslint 설정에 따라 굳이 필요 없을 수도 있음.
declare global {
interface Window {
ReactNativeWebView?: {
postMessage: (message: string) => void;
};
}
}
2. 웹뷰인지 여부 체크, SSR로 이루어진 페이지의 경우 window가 생성되지 않는다고 한다. 혹시 모르니 undefined 여부도 체크
const isWebView = typeof window !== 'undefined' && !!window.ReactNativeWebView;
나머지 부분은 위 boolean을 통해 웹 router를 쓸지, RN으로 통신을 보내면 되는지 체크하면 된다.
최종 코드는 다음과 같다.
// useAppRouter.ts
import { useRouter } from 'next/navigation';
const useAppRouter = () => {
const router = useRouter();
const isWebView = typeof window !== 'undefined' && !!window.ReactNativeWebView;
const navigate = (
method: 'push' | 'replace' | 'back' | 'forward',
path?: string,
screenName?: string,
data?: Record<string, unknown>
) => {
const nativeMethodMap = {
push: 'PUSH',
replace: 'REPLACE',
back: 'GO_BACK',
forward: 'GO_FORWARD',
};
if (isWebView) {
return window.ReactNativeWebView?.postMessage(
JSON.stringify({
type: 'ROUTER_EVENT',
method: nativeMethodMap[method],
path,
screenName,
data,
})
);
} else {
switch (method) {
case 'push':
if (!path) throw new Error('path 설정 오류');
return router.push(path);
case 'replace':
if (!path) throw new Error('path 설정 오류');
return router.replace(path);
case 'back':
return router.back();
case 'forward':
return router.forward?.();
}
}
};
return { navigate };
};
export default useAppRouter;
사용방식은 이런 식으로
// MapPage.tsx 또는 MapClient.tsx로 나눠서
'use client';
import useAppRouter from '@/hooks/useAppRouter';
const MapPage = () => {
const { navigate } = useAppRouter();
return (
<>
<div>지도입니다.</div>
<div onClick={() => navigate('push', '/')}>홈페이지로 이동</div>
<button onClick={() => navigate('push', '/community')}>커뮤니티페이지로 이동</button>
<div onClick={() => navigate('back')}>v</div>
</>
);
};
export default MapPage;
- ReactNative 코드 (WebViewConatiner.tsx)
일단 기본만 하는 거라 이쪽은 코드가 간단하다. 받은 메시지의 타입이 'ROUTER_EVENT' 면 그에 맞추어 분기처리하면 된다.
// WebViewConatiner.tsx
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { StackActions, useNavigation } from '@react-navigation/native';
export default function WebViewContainer({ baseURL }: { baseURL: string }) {
const navigation = useNavigation();
const requestOnMessage = (event: WebViewMessageEvent) => {
try {
const message = JSON.parse(event.nativeEvent.data);
if (message.type === 'ROUTER_EVENT') {
const { method, path, screenName, data } = message;
switch (method) {
case 'PUSH':
navigation.dispatch(
StackActions.push(screenName ?? 'WebView', {
url: path,
...data,
})
);
break;
case 'REPLACE':
navigation.dispatch(
StackActions.replace(screenName ?? 'WebView', {
url: path,
...data,
})
);
break;
case 'GO_BACK':
navigation.goBack();
break;
case 'GO_FORWARD':
// forward는 history 관리 직접 해야해서 (생략)
break;
}
}
} catch (err) {
console.warn('Invalid message format', err);
}
};
return (
<WebView
allowsBackForwardNavigationGestures={true}
bounces={false}
source={{ uri: baseURL }}
onMessage={requestOnMessage}
/>
);
}
<WebView /> 컴포넌트 안의 allowsBackForwardNavigationGestures는 ios에서 쓰는 스와이프 허용 유무, bounces는 화면 움직임 관련된 거라 본 주제랑은 연관은 없는 부분이다.
기본적으로 쓰는 방식은 아래와 같음, 하단 바로 들어갈 스크린들에서 이렇게 처리하면 된다.
// MapScreen.tsx
import { SafeAreaView } from 'react-native';
import WebViewContainer from '../WebViewConatiner';
export default function MapScreen() {
return (
<SafeAreaView style={{ flex: 1 }}>
<WebViewContainer baseURL="원하는 주소" />
</SafeAreaView>
);
}
그리고 이게 맞는 방식인지 아직 확신은 없지만, 토스나 당근처럼 하단 바에 들어갈 메인 스크린들을 제외하고서는 하단바를 이용해서 스크린 제어하는 것을 막기 위해 아래와 같이 처리함.
// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import AppTabs from './AppTabs';
import WebViewScreen from './src/screens/WebViewScreen';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<SafeAreaProvider>
<NavigationContainer>
<Stack.Navigator
screenOptions={{ gestureEnabled: true, headerShown: false }}
>
<Stack.Screen name="Tabs" component={AppTabs} />
<Stack.Screen name="WebView" component={WebViewScreen} />
</Stack.Navigator>
</NavigationContainer>
</SafeAreaProvider>
);
}
이런 식으로 메인 탭이 아닌 부분들은 WebView라는 스크린을 사용함
// WebViewScreen.tsx
import { SafeAreaView } from 'react-native';
import WebViewContainer from '../WebViewConatiner';
export default function WebViewScreen({ route }) {
const { url } = route.params;
return (
<SafeAreaView style={{ flex: 1 }}>
<WebViewContainer
baseURL={`https://메인주소${url}`}
/>
</SafeAreaView>
);
}
이러면 웹에서 준 url에 맞게 baseURL를 변경되어 스크린이 전환됨.
이거 만들 때 의문점인데 해보니까 문제없던 부분이 동일한 이름(WebView)이 여러 번 중첩되면 문제가 발생하지 않을까였는데, CSS 모듈처럼 key 값이 자동으로 유니크하게 생성된다. 역시 고민되면 해보고 확인하는 게 답이다.
4. 구현 결과
일단 이렇게 되어있다는 점을 참고해서 보면 편할 듯.
- Before
웹과 앱 화면 전환 방식이 달라 UX 적으로 최악이고, 스크린마다 별도의 웹 히스토리가 저장되어 있다.
- After
화면 전환 구조를 앱으로 통일.
일단 기초적인 거라 login 관련된 부분, 다크모드 부분들은 더 공부해봐야 할 것 같고, 더 테스트해서 문제점 있는 부분이 있는지 테스트해봐야 할 듯.
Reference
https://velog.io/@hyeon9782/인프콘-2023-웹뷰를-이용해-웹-서비스를-앱으로-빠르게-구현하기
[인프콘 2023] 웹뷰를 이용해 웹 서비스를 앱으로 빠르게 구현하기
첫 번째: 앱을 고려하기 모바일 장치 인터넷 사용량 통계 웹 사이트 트래픽의 55%는 모바일 장치에서 발생해요. 인터넷에 접속하는 92.3%가 모바일 장치를 사용해요. 약 43억 2천만 명의 활성 모바
velog.io
https://velog.io/@joch2712/LIKET-RN에서-웹뷰를-네이티브스럽게
'공부 > frontend' 카테고리의 다른 글
[Next.js] 공부 시작 및 프로젝트 생성 (0) | 2025.01.30 |
---|---|
[UI/UX] 최근 로그인 표시 기능 - 분석 (feat. 유플러스) (2) | 2025.01.23 |
[React Native] 좌충우돌 리액트 네이티브에서 이미지 스프라이트 기법 사용하기 (4) | 2024.12.25 |
[HTML] 이상적인 숫자 Input, <input type="number">를 쓰지 않는 이유 (1) | 2024.12.21 |