모바일 앱 개발에서 웹뷰(WebView)는 현대 하이브리드/웹 앱에서 필수적인 기술 스택이다.
웹뷰를 사용하는 이유를 생각해보자면,
네이티브 앱
단점 ❌ : 업데이트를 할 때마다 앱스토어 검수를 새로 받아야함 (rn codepush 제외), 개발이 상대적으로느림
하이브리드 앱
장점 ✅ :개발 비용과 시간이 절약됨,
다양한 플랫폼에 쉽게 대응 가능 (플러터든 rn이든 스위프트든 웹뷰는 상관 x),
OTA(Over-The-Air) 업데이트가 가능하여 앱스토어 심사 없이 콘텐츠 업데이트 가능
단점 ❌: 네이티브 기능들에 접근이 복잡함. ux적으로 첫 로딩이 느림
실제로 토스는 거의 모든 앱 서비스를 리액트 네이티브 + 웹뷰로 구현하고 있다고..
참고) https://toss.tech/article/react-native-2024
이 포스트에서는 인턴을 하며 네이티브 앱에 웹뷰를 마이그레이션해본 경험을 토대로 인증에 대해 서술함
웹뷰에서 인증을 처리할 때 가장 큰 문제점은 네이티브 앱의 인증 상태와 웹뷰의 인증 상태를 동기화하는 건데, 일반적으로 다음과 같은 문제가 발생할 수 있다.
이러한 문제를 해결하기 위해, 네이티브 앱에서 안전하게 토큰을 저장하고 이를 웹뷰 쿠키에 적용하는 방법을 알아보자.
제가 리액트 네이티브 유저임으로 rn 기준으로 서술하겠습니다. 물론 플러터나 다른 프레임 워크들도 비슷한 기능이 있다.
Storage는 네이티브 앱의 localStorage라고 생각하면 좋을 것 같습니다. 문자열만 저장 가능하고, key-value 값으로 이루어져있다는 부분에서 더욱 그렇죠. 저는 인증이 필요 없다면 mmkv를 사용하지만 보안이 중요한 데이터는 encrypted-stroage에 저장함.
로그인 과정에서 받은 토큰을 저장소에 저장하는 방법은 다음과 같다:
import EncryptedStorage from 'react-native-encrypted-storage';
async function storeUserSession(token, refreshToken, expiryDate) {
try {
await EncryptedStorage.setItem(
"user_session",
JSON.stringify({
token,
refreshToken,
expiryDate
})
);
} catch (error) {
console.error("Failed to save auth tokens", error);
}
}
저장된 토큰을 사용할 때는 다음과 같이 불러올 수 있다:
async function retrieveUserSession() {
try {
const session = await EncryptedStorage.getItem("user_session");
if (session !== undefined) {
return JSON.parse(session);
}
return null;
} catch (error) {
console.error("Failed to load auth tokens", error);
return null;
}
}
그럼 여기에 로그인을 하며 받아온 리프레시 토큰을 저장해놓고, 웹뷰의 쿠키에 박아넣음으로서 우리가 웹에서 사용하는 refresh, accessToken의 기능을 그대로 구현할 수 있다.
인증 토큰을 웹뷰 쿠키에 적용하기 위해서는 react-native-webview와 react-native-cookies 라이브러리를 활용할 수 있다.
import CookieManager from 'react-native-cookies';
async function setAuthCookies(token, domain) {
try {
// JWT 토큰을 쿠키로 설정
await CookieManager.set(domain, {
name: 'auth_token',
value: token,
domain: domain,
path: '/',
secure: true,
httpOnly: true
});
console.log('Cookie set successfully');
return true;
} catch (error) {
console.error('Error setting cookie', error);
return false;
}
}
이제 앞서 살펴본 내용을 바탕으로 전체 인증 플로우를 구현해보겠슴다.
로그인부터 웹뷰 로드까지의 전체 과정을 담은 예제 코드:
import React, { useState, useEffect } from 'react';
import { View, ActivityIndicator, Alert } from 'react-native';
import { WebView } from 'react-native-webview';
import EncryptedStorage from 'react-native-encrypted-storage';
import CookieManager from 'react-native-cookies';
const AuthenticatedWebView = ({ webViewUrl, authApiUrl }) => {
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
setupAuthentication();
}, []);
const setupAuthentication = async () => {
try {
// 1. 저장된 세션 확인
const session = await retrieveUserSession();
// 2. 세션이 없거나 만료되었다면 로그인 수행
if (!session || new Date(session.expiryDate) < new Date()) {
// 원래는 로그아웃 시키고 로그인 창으로 다시 돌려야 함.
await performLogin();
} else {
// 3. 유효한 세션이 있다면 쿠키 설정
await setupWebViewCookies(session.token);
setIsAuthenticated(true);
}
} catch (error) {
console.error('Authentication setup failed', error);
Alert.alert('Error', '인증 과정에서 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
const performLogin = async () => {
try {
// 로그인 시켜야함 원래는 뭐 리다이렉트 시킨다거나 기타 등등
const response = await fetch(authApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
// id, pw 등 로그인 정보
}),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
// 토큰을 암호화된 저장소에 저장
await storeUserSession(
data.token,
data.refreshToken,
data.expiryDate
);
// 웹뷰 쿠키 설정
await setupWebViewCookies(data.token);
setIsAuthenticated(true);
} catch (error) {
console.error('Login failed', error);
Alert.alert('로그인 실패', '인증에 실패했습니다. 다시 시도해주세요.');
}
};
const setupWebViewCookies = async (token) => {
// URL에서 도메인 추출
const domain = new URL(webViewUrl).hostname;
// 기존 쿠키 삭제
await CookieManager.clearAll();
// 인증 쿠키 설정
await setAuthCookies(token, domain);
};
// 이전에 정의한 함수들 (retrieveUserSession, storeUserSession, setAuthCookies)
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
);
}
if (!isAuthenticated) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>인증에 실패했습니다.</Text>
</View>
);
}
return (
<WebView
source={{ uri: webViewUrl }}
sharedCookiesEnabled={true}
thirdPartyCookiesEnabled={true}
onError={(error) => console.error('WebView error:', error)}
/>
);
};
export default AuthenticatedWebView;
그리고 웹뷰에서는 다음과 같이 네이티브 앱과 통신이 가능함.
function useDeviceInfo() {
const setDevie = useDeviceStore((s) => s.setDevice)
useEffect(() => {
const getUserAgent = () => {
const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.indexOf('android') > -1) {
return 'android'
}
if (
userAgent.indexOf('iphone') > -1 ||
userAgent.indexOf('ipad') > -1 ||
userAgent.indexOf('ipod') > -1
) {
return 'ios'
}
return 'other'
}
setDevie(getUserAgent())
}, [])
}
..근데 이건 앱에서 넘겨주는게 더 좋을 수도?
import { deviceType } from '@/types';
import useDeviceStore from '@stores/useDevice';
type AppActions =
| 'godismiss'
| 'goreload'
| 'goqna'
| 'logout'
| 'addrecommend'
| string
// 기타 등등
interface AppMessage {
action: AppActions
message?: string
}
// iOS용 인터페이스
interface AppHandler {
postMessage(action: AppMessage): void
}
// iOS Webkit 인터페이스
interface WebkitHandler {
messageHandlers: {
AppInterface: AppHandler
}
}
// 안드로이드용
interface AndroidAppInterface {
postMessage(
action: string,
utterance?: string,
sessionId?: string,
chainId?: string
): void
}
declare global {
interface Window {
webkit?: WebkitHandler
AppInterface?: AndroidAppInterface
}
}
const handleMobileActions = (
action: AppActions,
message: string
) => {
const device = useDeviceStore((s) => s.device)
if (device === 'ios') {
if (message) {
window.webkit?.messageHandlers.AppInterface.
postMessage({
action,
message
})
} else { window.webkit?.messageHandlers.AppInterface.
postMessage({
action
})
}
} else if (device === 'android') {
if (message) {
window.AppInterface?.postMessage(action, message)
} else {
window.AppInterface?.postMessage(action)
}
}
}
저는 이렇게 구현을 했지만, 더 좋은 방법이 있을 수도 있다. 이걸 구현했을 때에는 이미 만들어진 네이티브 앱에 웹뷰를 마이그레이션하는 상황이였기에 로그인 로직을 건드릴 수 없었고
실제로 웹뷰에서 백엔드와 통신할 때에 도메인 값이 틀어져 cors에 걸리는 문제가 있었음. (이상하게 안드로이드 개발자 분이 쿠키의 도메인 값 수정이 안된다 하더라고요)..
암튼 끝