- Published on
WebView 서비스를 PC 웹으로 확장하며 멀티플랫폼 구조 설계하기
- Authors

- Name
- GW
개요
기존 앱의 WebView를 중심으로 제공하던 서비스를 PC 웹까지 확장하는 작업을 진행한 적이 있습니다.
처음에는 기존 화면을 반응형으로 만들고 PC 레이아웃을 추가하면 될 것처럼 보였습니다.
하지만 실제로 확장해야 했던 것은 화면의 크기만이 아니었습니다.
WebView에서 사용하던 인증 정보와 위치 정보, 라우팅, 공유 등과 같은 기능 일부는 Native App에서 제공하는 기능에 의존하고 있었습니다.
반면 일반적인 웹 브라우저에서는 이러한 Native 기능을 사용할 수 없고, 브라우저가 제공하는 Web API나 Next.js의 기능으로 대체해야 합니다.
여기에 Next.js의 서버 렌더링까지 고려하면 하나의 서비스가 서로 다른 환경에서 실행될 수 있습니다.
하나의 Web Service
├─ iOS App WebView
├─ Android App WebView
├─ Mobile Web
├─ PC Web
└─ Next.js Server
같은 사용자 기능을 제공하면서도 실행 환경마다 다른 구현을, 어떻게 하나의 서비스 안에서 관리할 것인가가 더 중요한 문제였습니다.
WebView 중심의 서비스를 Web까지 확장하면서 플랫폼 의존적인 기능을 어떻게 분리했고, 인증·위치 정보와 같은 Native Bridge를 어떤 기준으로 다루었는지 정리해보려고 합니다.
주요 내용은 다음과 같습니다.
- WebView와 Web의 차이를 실행 환경과 기능 관점에서 구분하기
- Native Bridge의 구현 세부사항을 화면에서 분리하기
- 인증·위치 정보·라우팅 등의 기능을 공통 인터페이스로 다루기
- 플랫폼 판별과 기능 지원 여부를 분리하기
- 구버전 App에서도 주요 사용 흐름을 유지할 수 있도록 Fallback 구성하기
WebView와 Web은 UI만 다른 것이 아니다
PC 웹 확장을 처음 생각하면 가장 먼저 떠오르는 것은 반응형 UI입니다.
Mobile UI
↓
Breakpoint 추가
↓
PC Layout 추가
↓
PC Web 대응 완료?
하지만 WebView 서비스에서는 UI 외에도 플랫폼에 종속된 기능이 존재할 수 있습니다.
예를 들어 현재 위치를 가져오는 기능을 생각해볼 수 있습니다.
App에서는 Native가 위치 권한을 관리하고 WebView가 Native Bridge를 통해 현재 위치를 요청할 수 있습니다.
반면 일반적인 Web에서는 브라우저가 제공하는 Geolocation API를 사용해야 합니다.
페이지 이동에서도 차이가 발생할 수 있습니다.
Web에서는 Next.js Router와 Browser History를 이용할 수 있지만, App에서는 WebView의 History뿐만 아니라 Native가 관리하는 화면 Stack까지 고려해야 할 수 있습니다.
현재 위치 정보 가져오기
├─ App → Native 기능 사용
└─ Web → Browser Geolocation API 사용
페이지 이동
├─ App → WebView / Native Stack 고려
└─ Web → Next.js Router / Browser History 고려
인증 역시 마찬가지입니다.
Server에서는 HTTP Request에 포함된 Cookie를 사용할 수 있지만, App WebView에서는 Native에서 전달받은 인증 상태가 필요할 수 있습니다.
즉 사용자가 원하는 기능은 같지만 그 기능을 구현하는 방법은 실행 환경마다 달랐습니다.
이 차이를 고려하지 않고 기존 WebView 코드를 그대로 Web에서 실행하면 Native에서만 존재하는 API를 호출하거나, 서버 렌더링 중 window나 navigator에 접근하는 문제가 발생할 수 있습니다.
따라서 먼저 플랫폼의 차이가 어느 영역에 존재하는지 구분할 필요가 있습니다.
멀티플랫폼의 실행 환경 구분하기
멀티플랫폼 대응에서 먼저 필요한 것은 현재 코드가 어떤 환경에서 실행되고 있는지 판단하는 것이었습니다.
여기서 Mobile, Desktop처럼 화면 크기만으로 구분하는 것은 충분하지 않습니다.
같은 모바일 화면에서도 일반 브라우저와 App WebView는 사용할 수 있는 기능이 다르기 때문입니다.
iPhone Safari
→ Mobile UI
→ Browser API 사용
iPhone App WebView
→ Mobile UI
→ Browser API + Native Bridge 사용 가능
따라서 UI를 결정하는 화면 크기와 기능을 결정하는 실행 환경은 서로 다른 문제로 바라볼 필요가 있었습니다.
개념적으로는 다음과 같이 실행 환경을 구분할 수 있습니다.
type Runtime = 'app-ios' | 'app-android' | 'web' | 'server';
App WebView를 일반적인 Browser와 구분하기 위해 App에서 WebView의 User Agent에 식별값을 추가하고 Web에서 이를 확인하는 방식도 사용할 수 있습니다.
Android의 WebView 공식 문서에서도 WebView의 User Agent를 변경할 수 있는 API를 제공합니다.
다만 User Agent를 이용해 브라우저 종류와 기능 지원 여부까지 모두 추론하는 것은 주의해야 합니다.
MDN의 User Agent를 이용한 브라우저 판별 문서에서도 UA Sniffing은 신뢰하기 어렵고 버그의 원인이 될 수 있기 때문에, 가능한 경우 실제 기능이 존재하는지를 확인하는 Feature Detection을 권장합니다.
여기서 UA Sniffing이란 User Agent 문자열을 보고, 사용자의 브라우저나 실행 환경을 추측하는 방식을 의미합니다.
예를 들어 위치 정보가 필요한 경우 다음처럼 확인할 수 있습니다.
const supportsGeolocation = () => {
return typeof navigator !== 'undefined' && 'geolocation' in navigator;
};
Native 기능도 실제 인터페이스가 존재하는지 확인할 수 있습니다.
const supportsNativeShare = () => {
return typeof window !== 'undefined' && typeof window.nativeBridge?.share === 'function';
};
즉 User Agent는 App WebView처럼 호스트 실행 환경을 식별해야 하는 경우 사용할 수 있지만, 특정 기능의 사용 가능 여부까지 User Agent만으로 결정할 필요는 없습니다.
Server와 Browser의 실행 환경 구분하기
Next.js를 사용한다면 App과 Web뿐만 아니라 Server와 Browser의 차이도 고려해야 합니다.
Next.js는 Server와 Client라는 서로 다른 실행 환경에서 코드를 실행할 수 있으며 각 환경에서 사용할 수 있는 기능에도 차이가 있습니다.
예를 들어 window, navigator, sessionStorage와 같은 객체는 Browser에서 제공되는 API이기 때문에 Server에서도 동일하게 사용할 수 있다고 가정해서는 안 됩니다.
Next.js Server
├─ HTTP Request
├─ Cookie
└─ Browser API는 사용 불가
Browser
├─ window
├─ navigator
├─ sessionStorage
└─ Browser API 사용 가능
따라서 실행 환경을 판별하는 코드 역시 Server에서 호출될 가능성을 고려해야 합니다.
const isBrowser = () => {
return typeof window !== 'undefined';
};
예를 들어 다음과 같은 코드는 Server 환경까지 고려하면 안전하지 않습니다.
const getUserAgent = (userAgent = navigator?.userAgent ?? '') => {
return userAgent;
};
navigator라는 식별자 자체가 존재하지 않는 환경에서는 Optional Chaining만으로 안전해지는 것이 아니기 때문입니다.
Browser에서 직접 읽어야 한다면 먼저 존재 여부를 확인할 수 있습니다.
const getUserAgent = () => {
if (typeof navigator === 'undefined') {
return '';
}
return navigator.userAgent;
};
또는 Server에서는 Request Header의 User Agent를 전달하고 Browser에서는 navigator.userAgent를 사용하는 것처럼 실행 환경에 따라 값을 명시적으로 전달할 수도 있습니다.
Native Bridge를 공통 인터페이스로 추상화하기
WebView에서는 웹의 JavaScript와 Native App이 서로 통신해야 하는 경우가 있습니다.
iOS의 WKWebView에서는 WKScriptMessageHandler를 통해 JavaScript가 Native에 메시지를 전달할 수 있습니다.
Apple의 WKScriptMessageHandler에서 설명하는 구조를 이용하면 JavaScript에서 등록된 Message Handler를 통해 Native로 메시지를 전달할 수 있습니다.
예를 들어 Native에서 등록한 Handler가 있다면 JavaScript에서는 다음과 같은 형태로 메시지를 전달할 수 있습니다.
window.webkit.messageHandlers.appBridge.postMessage({
action: 'share',
payload: { url: window.location.href },
});
Android WebView 역시 JavaScript와 Native 사이에 통신 계층을 구성할 수 있습니다.
Android의 JavaScript Bridge에서는 현재 addWebMessageListener를 현대적인 권장 방식으로 설명하고 있으며, 기존 addJavascriptInterface 방식은 출처 기반 접근 제어가 없다는 등의 보안상 주의점이 있기 때문에 신뢰할 수 없는 Web Content를 다룰 때 특히 주의해야 합니다.
이처럼 플랫폼마다 Native Bridge를 사용하는 방식에는 차이가 있지만, 이러한 차이가 화면의 비즈니스 로직까지 그대로 드러나게 할 필요는 없습니다.
예를 들어 화면에서 직접 다음과 같이 구현하면,
const handleShare = () => {
if (isIOSApp()) {
window.webkit.messageHandlers.appBridge.postMessage({
action: 'share',
payload: { url: window.location.href },
});
return;
}
if (isAndroidApp()) {
window.androidBridge.share(window.location.href);
}
};
화면이 공유 기능뿐만 아니라 iOS와 Android의 통신 방식까지 알아야 합니다.
Native와 통신하는 세부 구현을 별도의 Adapter 뒤에 숨기면 화면의 책임을 줄일 수 있습니다.
interface NativeBridge {
share: (url: string) => Promise<void>;
openExternalLink: (url: string) => Promise<void>;
getCurrentPosition: () => Promise<PositionResult>;
navigate: (url: string) => Promise<void>;
}
그러면 화면에서는 의미 단위의 기능만 사용할 수 있습니다.
const handleShare = async () => {
await platformActions.share(window.location.href);
};
이렇게 하면 UI가 알아야 하는 것은 share()라는 기능의 의미뿐입니다.
iOS가 어떤 Message Handler를 사용하는지, Android가 어떤 Native Interface를 사용하는지는 Bridge 계층의 책임으로 남길 수 있습니다.
App과 Web의 기능을 공통 인터페이스로 추상화하기
Native Bridge를 한 번 감싸는 것만으로는 모두 해결되지 않습니다.
Web에서는 Native Bridge 자체가 없기 때문입니다.
그래서 한 단계 더 나아가 App과 Web에서 동일한 의미를 가지는 기능을 공통 인터페이스로 바라볼 수 있습니다.
예를 들어 외부 링크를 여는 기능이 있다고 가정해보겠습니다.
interface PlatformActions {
openExternalLink: (url: string) => Promise<void>;
}
App에서는 Native Bridge를 사용할 수 있습니다.
const appActions: PlatformActions = {
openExternalLink: async (url) => {
await nativeBridge.openExternalLink(url);
},
};
Web에서는 Browser API를 사용할 수 있습니다.
const webActions: PlatformActions = {
openExternalLink: async (url) => {
window.open(url, '_blank', 'noopener,noreferrer');
},
};
화면에서는 어느 구현이 선택되었는지 알 필요가 없습니다.
const handleOpenLink = async () => {
await platformActions.openExternalLink(url);
};
openExternalLink() 호출
↓
├─ App → Native Bridge
└─ Web → Browser API
공통화의 목적은 App과 Web이 내부적으로 똑같이 동작하도록 만드는 것이 아닙니다.
서로 다른 구현을 같은 의미의 인터페이스 뒤에 배치하는 것에 가깝습니다.
플랫폼별 인증 정보를 하나의 인터페이스로 통합하기
인증은 플랫폼 차이가 명확하게 나타나는 기능 중 하나입니다.
같은 로그인 사용자라도 실행 환경에 따라 인증 정보를 얻을 수 있는 위치가 다르기 때문입니다.
Next.js Server
→ Request의 Cookie
App WebView
→ Native에서 전달된 인증 정보
Web
→ Browser에서 사용할 수 있는 인증 정보
이 차이를 인증이 필요한 화면마다 처리한다면 플랫폼 분기가 다시 애플리케이션 전체로 퍼집니다.
const token = isApp() ? getTokenFromNative() : getTokenFromWeb();
const data = await fetchData(token);
하지만 인증이 필요한 화면의 관심사는 "토큰이 어디에서 왔는가?" 가 아니라 "현재 사용자가 인증되어 있는가?" 에 더 가깝습니다.
따라서 플랫폼별 인증 정보의 출처를 공통 Auth Layer 안에서 처리하고, 나머지 애플리케이션에서는 동일한 인증 상태를 사용할 수 있도록 구성할 수 있습니다.
여기서 Auth Layer는 인증과 관련된 공통 처리를 담당하는 영역입니다.
예를 들어 실행 환경에 따라 인증 정보를 가져오는 로직을 다음과 같이 분리할 수 있습니다.
const getAuth = () => {
if (isServer()) {
return getAuthFromRequestCookie();
}
if (isApp()) {
return getAuthFromNative();
}
return getAuthFromWeb();
};
그리고 useAuth()에서는 이렇게 가져온 인증 정보를 화면에서 사용하기 쉬운 형태로 제공할 수 있습니다.
const useAuth = () => {
const auth = getAuth();
return {
isAuthenticated: Boolean(auth.token),
user: auth.user,
};
};
전체 흐름을 정리하면 다음과 같습니다.
인증이 필요한 화면
useAuth()로 인증 상태 확인
↓
공통 Auth Layer
→ 실행 환경에 맞게 인증 정보 확인
├─ Server → Request Cookie
├─ App → Native에서 전달받은 인증 정보
└─ Web → Browser의 인증 정보
↓
인증 정보를 바탕으로 로그인 여부 판단 (isAuthenticated 제공)
↓
화면에서 사용
그러면 화면에서는 다음처럼 인증 상태만 사용할 수 있습니다.
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Login />;
}
이렇게 하면 화면은 인증 정보가 Native에서 왔는지 Server Request에서 왔는지 알 필요가 없습니다.
플랫폼별 위치 정보를 하나의 인터페이스로 통합하기
위치 기반 검색이나 지도 기능에서는 사용자의 현재 위치가 필요하지만 App과 Web에서 위치 정보를 얻는 방법은 다릅니다.
App
Native Bridge
↓
Native 위치 권한 확인
↓
현재 위치 요청
Web
navigator.geolocation
↓
Browser 위치 권한 확인
↓
현재 위치 요청
Web에서는 브라우저가 제공하는 Geolocation API를 사용할 수 있습니다.
Geolocation API는 Secure Context(HTTPS처럼 안전한 연결이 보장된 환경)에서 제공되며 사용자의 위치 정보 권한이 필요합니다.
App과 Web은 위치 정보를 얻는 방법뿐만 아니라 결과의 형태도 다를 수 있기 때문에 이를 그대로 화면까지 전달하면 위치 정보를 사용하는 모든 곳에서 플랫폼 차이를 처리해야 합니다.
그래서 플랫폼별 결과를 애플리케이션에서 사용할 공통 형태로 변환했습니다.
type LocationResult =
| { state: 'granted'; coords: { latitude: number; longitude: number } }
| { state: 'denied' | 'unavailable'; coords: null };
App에서는 Native Bridge의 응답을 공통 형태로 변환할 수 있습니다.
const getAppLocation = async (): Promise<LocationResult> => {
const result = await nativeBridge.getCurrentPosition();
if (!result.permission) {
return { state: 'denied', coords: null };
}
return {
state: 'granted',
coords: { latitude: result.latitude, longitude: result.longitude },
};
};
Web에서도 Browser API의 결과를 같은 형태로 변환할 수 있습니다.
const getWebLocation = (): Promise<LocationResult> => {
if (typeof navigator === 'undefined' || !('geolocation' in navigator)) {
return Promise.resolve({ state: 'unavailable', coords: null });
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
({ coords }) => {
resolve({
state: 'granted',
coords: { latitude: coords.latitude, longitude: coords.longitude },
});
},
() => {
resolve({ state: 'denied', coords: null });
}
);
});
};
그러면 위치 정보를 사용하는 화면에서는 실제 구현을 알 필요가 없습니다.
현재 위치 요청
↓
실행 환경에 맞는 구현 선택
├─ App → Native Bridge
└─ Web → Geolocation API
↓
공통 LocationResult를 위치 기반 기능에서 사용
화면에서는 결과에 따라 사용자 기능만 처리할 수 있습니다.
const handleCurrentLocation = async () => {
const location = await locationService.getCurrentPosition();
if (location.state === 'granted') {
updateSearchLocation(location.coords);
return;
}
showLocationGuide();
};
App과 Web의 페이지 이동 방식 분리하기
일반적인 Web에서는 Next.js Router와 Browser History를 이용해 페이지를 이동할 수 있습니다.
App WebView에서도 WebView 내부의 웹 페이지끼리 이동하는 경우에는 동일하게 Next.js Router를 사용할 수 있습니다.
Web 페이지끼리 이동
Web
→ Next.js Router를 사용해 Web 페이지 이동
App WebView
→ Next.js Router를 사용해 WebView 내부의 Web 페이지 이동
App WebView에서 화면 이동
Web으로 구현된 화면
→ Next.js Router를 사용해 WebView 내부의 Web 페이지 이동
Native로 구현된 화면
→ Native Bridge를 사용해 Native 화면으로 이동
즉, WebView에서 실행된다는 이유만으로 모든 페이지 이동을 Native Navigation으로 처리할 필요는 없습니다.
하지만 App에서는 WebView 내부의 페이지 이동뿐 아니라 Native로 구현된 화면으로 이동해야 하는 경우도 존재합니다.
예를 들어 로그인이나 홈이 Native 화면으로 구현되어 있다면 WebView에서 해당 화면으로 이동할 때는 Next.js Router가 아니라 Native Bridge를 통해 App에 화면 이동을 요청해야 합니다.
WebView
↓
이동하려는 화면 확인
├─ Web 페이지
│ └─ Next.js Router를 사용해 WebView 내부에서 페이지 이동
│
└─ Native 화면
└─ Native Bridge를 사용해 Native Navigation으로 화면 이동
따라서 Navigation에서 중요한 것은 단순히 "App인가 Web인가?" 를 구분하는 것이 아니라 "어떤 화면으로 이동하려는가?" 를 구분하는 것입니다.
예를 들어 상품 상세 페이지처럼 Web으로 구현된 페이지는 Web과 App WebView 모두 Next.js Router를 사용할 수 있습니다.
const handleDetailClick = () => {
router.push(`/items/${itemId}`);
};
상품 상세 페이지로 이동 (Web으로 구현된 화면)
↓
Next.js Router
├─ Web → Web 페이지 이동
└─ App → WebView 내부의 Web 페이지 이동
반면 App에서 Native로 구현된 로그인 화면으로 이동해야 한다면 Native Bridge를 사용할 수 있습니다.
const goToLogin = () => {
nativeBridge.openLogin();
};
Web에서는 Native 화면이 존재하지 않으므로 동일한 사용자 행동이라도 Web의 로그인 페이지로 이동해야 합니다.
const goToLogin = () => {
router.push('/login');
};
이처럼 "로그인 화면으로 이동한다"는 목적은 같지만 실제 이동 방식은 플랫폼에 따라 달라질 수 있습니다.
이러한 차이를 화면마다 직접 처리하면 플랫폼 분기가 여러 곳으로 퍼질 수 있습니다.
const handleLoginClick = () => {
if (isApp()) {
nativeBridge.openLogin();
return;
}
router.push('/login');
};
따라서 플랫폼에 따라 이동 방식이 달라지는 경우에는 화면이 구체적인 이동 방법을 직접 결정하지 않도록 공통 Navigation 인터페이스로 분리할 수 있습니다.
interface Navigation {
goToLogin: () => void;
goHome: () => void;
}
각 환경에서는 같은 이동 목적을 플랫폼에 맞는 방법으로 구현합니다.
const webNavigation: Navigation = {
goToLogin: () => router.push('/login'),
goHome: () => router.push('/'),
};
const appNavigation: Navigation = {
goToLogin: () => nativeBridge.openLogin(),
goHome: () => nativeBridge.goHome(),
};
그러면 화면에서는 로그인 화면이 Web 페이지인지 Native 화면인지 알 필요 없이 "로그인 화면으로 이동한다"는 목적만 표현할 수 있습니다.
const handleLoginClick = () => {
navigation.goToLogin();
};
정리하면 일반적인 Web 페이지 이동은 Next.js Router를 그대로 사용하고 플랫폼에 따라 실제 이동 방식이 달라지는 경우에만 공통 인터페이스로 분리했습니다.
화면 이동 요청
↓
이동 대상 확인
├─ 일반 Web 페이지
│ └─ Next.js Router
│ ├─ Web → Web 페이지 이동
│ └─ App → WebView 내부 페이지 이동
│
└─ 플랫폼에 따라 구현이 다른 화면
↓
공통 Navigation 인터페이스
├─ Web → Next.js Router
└─ App → Native Bridge
└─ Native Navigation
App이라고 모든 Native 기능을 사용할 수 있는 것은 아니다
실행 환경을 App과 Web으로 구분한 뒤에도 하나의 문제가 남았습니다.
App 환경이라는 사실을 확인했다고 해서 모든 Native 기능을 사용할 수 있는 것은 아닙니다.
Web과 Native App의 배포 방식이 서로 다르기 때문입니다.
새로운 Native 기능 개발
↓
새로운 App에 Bridge 추가
↓
Web에서 새로운 Bridge를 사용하는 코드 배포
↓
사용자의 App Version
├─ 지원 버전 → 새로운 Bridge 사용 가능
└─ 구버전 → 새로운 Bridge가 없음
Web 코드는 배포를 통해 비교적 빠르게 변경할 수 있지만 새로운 Native Bridge는 사용자가 해당 기능이 포함된 App으로 업데이트해야 사용할 수 있습니다.
따라서 다음 두 질문을 구분할 필요가 있습니다.
현재 App에서 실행 중인가?
→ 실행 환경 확인 (Runtime)
현재 App에서 newFeature를 사용할 수 있는가?
→ 기능 지원 여부 확인 (Capability)
App인지 확인하는 것은 실행 환경에 대한 판단이고, 특정 Bridge를 사용할 수 있는지 확인하는 것은 기능 지원 여부에 대한 판단입니다.
이를 구분하지 않으면 다음과 같은 코드가 문제가 될 수 있습니다.
if (isApp()) {
nativeBridge.newFeature();
}
현재 App에서 실행되고 있더라도 사용자의 App Version이 오래되었다면 newFeature가 존재하지 않을 수 있기 때문입니다.
Native 기능별 지원 여부 관리하기
Web 코드는 배포를 통해 비교적 빠르게 변경할 수 있지만, 새로운 Native Bridge는 해당 기능이 포함된 App Version으로 사용자가 업데이트해야 사용할 수 있습니다.
따라서 App에서 실행되고 있다는 사실만으로 모든 Native 기능을 사용할 수 있다고 판단해서는 안 됩니다.
App에서 실행 중인가?
→ 실행 환경 확인 (Runtime)
현재 App에서 Share 기능을 사용할 수 있는가?
→ 기능 지원 여부 확인 (Capability)
예를 들어 share라는 새로운 Native Bridge가 추가되었다고 해보겠습니다.
사용자가 App에서 서비스를 이용하고 있더라도 오래된 App Version에는 해당 Bridge가 존재하지 않을 수 있습니다.
App 5.1.0
├─ App에서 실행 중 → YES
└─ Share Bridge 지원 → NO
App 5.2.0
├─ App에서 실행 중 → YES
└─ Share Bridge 지원 → YES
따라서 다음과 같이 App 여부만 확인하고 Native 기능을 호출하는 것은 안전하지 않습니다.
if (isApp()) {
await nativeBridge.share(url);
}
Native 기능의 존재 여부 확인하기
Native 기능을 사용할 수 있는지 판단할 때는 App Version만으로 추측하기보다 실제 Bridge가 존재하는지 확인하는 방법을 우선적으로 고려할 수 있습니다.
예를 들어 Share Bridge를 사용해야 한다면 다음과 같이 실제 함수의 존재 여부를 확인할 수 있습니다.
const canUseNativeShare = () => {
return typeof window !== 'undefined' && typeof window.nativeBridge?.share === 'function';
};
사용하는 쪽에서는 App Version을 직접 알 필요 없이 현재 기능을 사용할 수 있는지만 확인합니다.
if (canUseNativeShare()) {
await nativeBridge.share(url);
}
이렇게 하면 코드의 관심사가 "현재 App Version이 몇인가?" 가 아니라 "현재 환경에서 Share 기능을 사용할 수 있는가?" 로 바뀝니다.
Share 기능이 필요한 시점
Share Bridge가 실제로 존재하는가?
├─ YES → Native Share 사용
└─ NO → 사용하지 않거나 대체 동작 제공
Version Check가 필요한 경우
그렇다고 App Version 확인이 필요 없는 것은 아닙니다.
같은 Native 기능이 존재하더라도 특정 App Version부터 인자나 반환값, 동작 방식과 같은 기능이 달라지는 경우에는 Version Check가 필요할 수 있습니다.
예를 들어 Share Bridge의 사용 방법이 App Version에 따라 달라졌다고 해보겠습니다.
App 5.0.0
→ share(url)
App 6.0.0
→ share({ url, title })
이 경우에는 Bridge가 존재하는지만 확인해서는 어떤 방식으로 호출해야 하는지 판단할 수 없습니다.
따라서 App Version을 확인하여 버전에 맞는 방식을 사용할 수 있습니다.
if (isVersionAtLeast(appVersion, '6.0.0')) {
await nativeBridge.share({ url, title });
} else {
await nativeBridge.share(url);
}
또는 특정 기능이 어느 App Version부터 제공되는지를 명시적으로 관리해야 한다면 기능별 최소 지원 버전을 관리할 수도 있습니다.
type NativeFeature = 'clipboard' | 'share' | 'map';
const minimumVersions: Record<NativeFeature, string> = {
clipboard: '5.1.0',
share: '5.2.0',
map: '5.3.0',
};
이때 사용하는 쪽에서 버전 숫자를 직접 비교하기보다 기능 지원 여부를 묻는 함수로 감싸는 것이 좋습니다.
const supportsNativeFeature = (feature: NativeFeature, appVersion: string) => {
const minimumVersion = minimumVersions[feature];
return isVersionAtLeast(appVersion, minimumVersion);
};
그러면 사용하는 쪽에서는 다음과 같이 표현할 수 있습니다.
if (supportsNativeFeature('share', appVersion)) {
await nativeBridge.share(url);
}
App Version을 비교할 때 주의할 점
App Version은 단순 문자열이나 점을 제거한 숫자로 비교해서는 안 됩니다.
예를 들어 다음과 같은 버전이 있다고 해보겠습니다.
5.9.0
5.10.0
버전은 각 구성 요소를 기준으로 비교해야 하므로 서비스에서 사용하는 App Version 규칙에 맞는 비교 방법을 사용해야 합니다.
App Version이 Semantic Versioning을 따른다면 검증된 버전 비교 도구인 semver를 사용할 수도 있습니다.
semver는 npm에서도 사용하는 Semantic Versioning 파싱/비교 라이브러리로, gte, gt, lte, lt 등의 API를 제공합니다.
예를 들어 gte는 greater than or equal, 즉 "크거나 같다" 를 의미합니다.
import semver from 'semver';
semver.gte('5.2.0', '5.1.0'); // true
semver.gte('5.1.0', '5.2.0'); // false
따라서 앞의 기능별 최소 지원 버전도 다음과 같이 구현할 수 있습니다.
import semver from 'semver';
const supportsNativeFeature = (feature: NativeFeature, appVersion: string) => {
const minimumVersion = minimumVersions[feature];
return semver.gte(appVersion, minimumVersion);
};
단, 서비스의 App Version이 Semantic Versioning을 따르지 않는다면 semver를 그대로 사용하는 것이 아니라 해당 서비스의 버전 규칙에 맞는 비교 방법을 사용해야 합니다.
위 내용들을 정리하자면, Native 기능의 지원 여부를 판단할 때는 실제 Bridge의 존재 여부를 우선 확인합니다.
그리고 앱 버전에 따라 Bridge의 호출 방식이나 동작이 달라지는 등 버전 정보가 필요한 경우에는 Version Check를 함께 사용할 수 있습니다.
Native 기능 사용 필요
↓
실제 Bridge가 존재하는가?
├─ NO
│ → 기능 사용 불가
│ → 대체 동작 또는 사용 불가 안내
│
└─ YES
↓
버전에 따라 동작 방식이 다른가?
├─ NO
│ → Native 기능 사용
│
└─ YES
→ App Version 확인 후, 버전에 맞는 방식으로 Bridge 호출
공통화는 모든 플랫폼을 똑같이 만드는 것이 아니다
처음에는 App과 Web에서 가능한 한 같은 코드를 사용하는 것이 좋은 구조처럼 느껴질 수 있습니다.
하지만 위치 정보나 Navigation처럼 플랫폼마다 기능을 제공하는 방식이 다른 경우까지 하나의 구현으로 억지로 맞추면, 오히려 플랫폼을 구분하는 조건문이 곳곳에 늘어날 수 있습니다.
공통화 ≠ 모든 플랫폼에서 같은 코드 실행
App에서는 Native Bridge를 사용하는 것이 자연스럽고 Web에서는 Browser API를 사용하는 것이 자연스럽습니다.
중요한 것은 내부 구현까지 동일하게 만드는 것이 아니었습니다.
공통화 = 화면에서 사용하는 기능의 인터페이스는 동일하게 유지 + 플랫폼별 구현 차이는 정해진 경계 안에서 관리
예를 들어 현재 위치를 가져오는 내부 구현은 플랫폼에 따라 완전히 다를 수 있습니다.
getCurrentPosition()
├─ App → Native Bridge → Native Location
└─ Web → navigator.geolocation → Browser Location
내부적으로 사용하는 방식은 다르지만, 이를 사용하는 화면에서는 모두 getCurrentPosition()이라는 동일한 인터페이스를 통해 현재 위치를 가져올 수 있습니다.
결국 공통화의 목적은 코드의 중복을 무조건 제거하는 것이 아니라, 플랫폼별 구현의 차이가 애플리케이션 전체로 퍼지지 않도록 명확한 경계를 만드는 것이었습니다.
마치며
WebView 중심으로 만들어진 서비스를 PC 웹까지 확장하면서 플랫폼마다 다른 실행 환경과 기능을 어떻게 다뤄야 하는지에 대해 깊이 고민해볼 수 있었습니다.
처음에는 App과 Web을 구분하고 Web에서 사용할 수 없는 Native 기능만 대체하면 될 것이라고 생각했습니다.
하지만 실제로 확장을 진행하면서 WebView의 동작 방식부터 Native Bridge를 통한 기능 호출, 플랫폼별 API 차이, App 버전에 따른 기능 지원 여부와 Fallback까지 생각보다 다양한 부분을 함께 고려해야 했습니다.
결과적으로 WebView 서비스를 Web으로 확장하는 작업을 통해 Web과 Native 사이의 경계를 이해하고, 서로 다른 실행 환경을 하나의 서비스 안에서 어떻게 관리할지를 고민해볼 수 있었던 경험이었습니다.