웹 서비스가 먼저 있었고, 그 웹을 웹뷰로 감싼 React Native 앱이 나중에 붙었다. 즉 앱 사용자가 보는 화면도 우리 웹이다.
앱을 낸 뒤 스토어 리뷰를 유도하고 싶었다. 그런데 리뷰 요청 팝업을 그냥 띄우면, 불만 있는 사용자가 바로 낮은 별점을 줄 거라 생각했다. 그래서 한 단계를 앞에 뒀다.

넵! -> 스토어 리뷰 작성 화면으로 보낸다 아니요 -> 웹 내 피드백 작성 페이지로 보낸다 불만 리뷰는 스토어가 아니라 우리가 직접 받도록 했다. 충분히 써보지 않는 사용자에게 물으면 답도 부정확하고 이탈만 늘기 때문에, 노출 조건은 3일 방문 또는 동아리 조회 3회로 뒀다. 그리고 스토어 리뷰는 앱에서만 의미가 있기에 앱 웹뷰에서만 띄우도록 했다.
const handleSatisfied = () => {
trackEvent(USER_EVENT.SATISFACTION_ANSWERED, { satisfied: true });
closeForever();
handleLink(isIOS() ? APP_STORE_REVIEW_URL : PLAY_STORE_REVIEW_URL);
};
여기서 분기가 하나 필요한데, 넵!을 눌렀을 때 App Store로 보낼지 Play Store로 보낼지에 대한 분기다.
처음엔 UA로 구분했다.
const isIOS = () => /(iPhone|iPad|iPod)/.test(navigator.userAgent);
PR에 붙은 코드래빗 리뷰가 지적한 문제는 이랬다.
iPadOS의 데스크톱급 User-Agent는 iPad를 포함하지 않고 Macintosh로 보고될 수 있습니다. 현재 구현은 해당 사용자를 Play Store로 보내므로 iOS 리뷰 화면을 열지 못합니다. navigator.maxTouchPoints를 함께 검사하고 해당 User-Agent 테스트를 추가하세요.
iPadOS 13+는 기본적으로 데스크톱 Mac UA를 보내서 UA문자열만으로는 맥과 구분되지 않는다. 맞는 말이었기에 바로 반영했다.
/**
* iPadOS 13+는 기본으로 데스크톱 Mac UA를 보냄
* 그것만 보면 iPad 사용자가 Play 스토어로 가므로 터치 지원 여부를 함께 봄
*/
const isIOS = () => {
const ua = navigator.userAgent;
if (/(iPhone|iPad|iPod)/.test(ua)) return true;
return /Macintosh/.test(ua) && navigator.maxTouchPoints > 1;
};
push를 하고 나서 다시 반대의 지적이 들어왔다.
이 모달은 웹뷰 전용이라 Macintosh 분기가 사실상 죽은 코드다.
useSatisfactionSurvey.ts:30에서 if (!isInAppWebView()) return false로 막혀 있어서, 이 모달은 앱 WKWebView / Android WebView 안에서만 뜬다. iPadOS 13+의 “데스크톱 Mac UA”는 Safari의 desktop-class browsing 동작이고 앱 안의 WKWebView는 기본적으로 iPad를 그대로 보낸다. 즉 주석이 걱정한 상황은 이 경로에선 발생하지 않고, navigator.maxTouchPoints까지 볼 이유가 없다.
사실 이것도 맞는 말이었다. 지우면 되는 일이었고, 실제로 지웠다.
다만 지우기 전에 한 가지를 확인하고 싶었다. "그럼 앱 웹뷰에서 대체 뭐가 올까?"
앱 레포에서 코드를 직접 확인했다.
export const getWebViewUserAgent = (): string => {
const appVersion = Constants.expoConfig?.version || '1.0.0';
const platform = Platform.OS === 'ios' ? 'iOS' : 'Android';
return `MoadongApp/${appVersion} (${platform})`;
};
<WebView userAgent={userAgent} ... />
react-native-webview의 타입 정의를 보면 prop이 두 개다.
userAgent?: string;
/** Append to the existing user-agent. Overridden if `userAgent` is set. */
applicationNameForUserAgent?: string;
applicationNameForUserAgent — 기존 UA 뒤에 붙인다.userAgent — 기존 UA를 통째로 교체한다. 그리고 이게 있으면 위쪽은 무시된다.우리는 교체하는 쪽을 쓰고 있었다. 그래서 앱 웹뷰 안에서 navigator.userAgent의 값은 아래가 전부다.
MoadongApp/1.6.0 (iOS)
iPhone, iPad, Macintosh, Mozilla, AppleWebKit 모두 없다.
브라우저가 원래 넣어주던 토큰이 하나도 남아 있지 않았다.
/(iPhone|iPad|iPod)/ → false
/Macintosh/ → false
앱의 모든 iOS 사용자에게 isIOS()가 false를 반환하고 있었다.
그렇다면 넵!을 누른 iOS 사용자는 모두 Play 스토어로 갔을 것이다.
지적받은 iPad 엣지 케이스뿐만 아니라 iOS 전체가 문제였고, 죽어 있던 건 Macintosh 분기 하나가 아니라 함수 전체였다.
// utils/isInAppWebView.ts
const isInAppWebView = () => /MoadongApp/.test(navigator.userAgent);
웹뷰 판별은 처음부터 잘 되고 있었다. 모달이 앱에서만 뜬 것도, 두 번째 지적의 전제가 성립한 것도 사실 이 유틸이 잘 동작했기 때문이다. 같은 navigator.userAgent를 두 군데서 읽는데, 한쪽은 MoadongApp 토큰을 보고 있어서 맞았고 다른 한쪽은 iPhone 토큰을 보았기에 틀렸었다.
UA에 MoadongApp이 들어 있다는 걸 알고 코드를 짜놓고도, iOS 판별에서는
”UA에 iPhone이야 당연히 들어 있겠지”라고 관성적으로 가정했던 것이다.
it('iPhone UA면 true를 반환한다', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MoadongApp/1.5.1';
expect(isIOS(ua)).toBe(true);
});
const WEBVIEW_UA = 'Mozilla/5.0 (iPhone) MoadongApp/1.6.0';
테스트는 있었고 전부 통과했다.
웹뷰 UA는 브라우저 UA에 앱 이름이 덧붙는 형태라는 가정으로 fixture로 냅뒀고, 테스트는 그 가정을 충실히 검증하고 있었다. 테스트 통과가 말한 건 "코드가 맞다"가 아니라 "코드가 내 가정과 일치한다"였다. 가정이 틀렸기에 테스트도 통과했던 것이다.
더 나쁜 건 앱 레포에 getWebViewUserAgent()라는 단일 소스가 이미 있었는데 확인하지 못했다.
/**
* iOS 기기인지 판별한다.
*
* 앱 웹뷰는 `userAgent` prop으로 UA를 통째로 교체해서 `MoadongApp/1.6.0 (iOS)` 형태로만 온다.
* iPhone·iPad 토큰이 없으므로 앱이 넣어준 플랫폼 표기를 함께 본다.
*
* iPadOS 13+가 데스크톱 Mac UA를 보내는 경우는 다루지 않는다. 사파리의 desktop-class
* browsing 동작이라 앱 웹뷰에서는 나타나지 않고, 지금 이 함수를 쓰는 곳은 웹뷰 전용이다.
*/
const isIOS = (userAgent: string = navigator.userAgent): boolean =>
/(iPhone|iPad|iPod)/.test(userAgent) ||
/MoadongApp\/\S+ \(iOS\)/.test(userAgent);
maxTouchPoints분기는 지웠다. 대신 다루지 않기로 한 이유를 주석으로 남겼다.describe로 나눴다. describe('앱 웹뷰', () => {
it('iOS 앱 웹뷰면 true를 반환한다', () => {
expect(isIOS('MoadongApp/1.6.0 (iOS)')).toBe(true);
});
it('Android 앱 웹뷰면 false를 반환한다', () => {
expect(isIOS('MoadongApp/1.6.0 (Android)')).toBe(false);
});
});
실제 앱 UA를 주입해 iOS는 App Store, Android는 Play 스토어 링크가 나가는 것까지 확인했다.
“여기 죽은 코드예요” 같은 사소한 지적일수록, 지우기 전에 왜 죽었는지 물어보자.