From 430b17745ed725d6b3911ba2e74899aa0b53d9bd Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Thu, 17 Sep 2026 14:50:45 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20=ED=98=B8=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EC=B2=98=EB=A6=AC=ED=95=98=EC=A7=80=20=EB=AA=BB?= =?UTF-8?q?=ED=95=9C=20=EC=9B=B9=EB=B7=B0=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EA=B4=80=EC=B8=A1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 웹 화면이 홈 웹셸, ClubDetailScreen, webview/[slug] 세 WebView 안에서 도는데 메시지 처리 구현은 두 벌이다. 공용 훅과 홈의 인라인 switch가 서로를 모른 채 각자 자라서, 호스트마다 아는 메시지 목록이 다르다. 그 자체보다 나쁜 건 어긋남이 조용하다는 점이다. 공용 훅의 default는 console.warn만 남기고 홈의 switch는 default조차 없이 바깥 catch가 삼킨다. 그래서 웹이 6월(#1665)부터 보내던 SUBSCRIBE_TOGGLE을 상세 컨테이너가 버리고 있다는 사실을 12월에 사용자 제보로 알았다(ffb53d0). - 각 호스트 switch의 default에서 reportUnknownBridgeMessage를 부른다. dev는 console.warn, prod는 Mixpanel Bridge UnknownMessage 이벤트 - 판별 기준을 "알려진 타입 집합과 대조"가 아니라 "이 호스트의 switch가 잡지 못함"으로 뒀다. 홈만 처리하는 REQUEST_APP_VERSION처럼 호스트별로 목록이 다른 지금 구조에서, 집합 대조는 정상 처리된 메시지를 오탐한다 - 서드파티가 쏜 비정형 메시지를 거르려고 문자열 type만 취급한다. JSON 파싱 실패는 대상이 아니다. 기존 로그로 이미 드러나고, 비JSON을 쏘는 스크립트가 있으면 노이즈가 된다 - useWebViewMessageHandler의 host를 필수 옵션으로 뒀다. 웹뷰 호스트가 늘어날 때 타입 에러로 막혀 이름을 붙이게 된다 리포터는 void를 반환하고 예외를 밖으로 내보내지 않는다. 관측이 실패해도 메시지 처리는 계속돼야 한다. 기존 9종의 처리 경로는 건드리지 않았다. 갈라진 구현을 한 벌로 합치는 일은 이 PR의 범위가 아니다. 홈에만 있는 REQUEST_APP_VERSION, 호스트마다 다른 OPEN_EXTERNAL_URL 구현, NAVIGATE_BACK의 서로 다른 처리를 훅으로 흡수해야 해서 규모가 다르다. 그 전까지 이 관측 장치가 어긋남을 드러낸다. Co-Authored-By: Claude Opus 5 (1M context) --- app/webview/[slug].tsx | 1 + constants/eventname.ts | 7 ++++++- hooks/use-webview-message-handler.ts | 7 ++++++- ui/club-detail/club-detail-screen.tsx | 1 + ui/home/home-webview-screen.tsx | 5 ++++- utils/webview.ts | 23 +++++++++++++++++++++++ 6 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index 6cf9104..9cad762 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -98,6 +98,7 @@ export default function WebViewScreen() { }; const { handleMessage } = useWebViewMessageHandler({ + host: 'webview_slug', onNavigateBack: handleBack, onNavigateWebview: handleNavigateWebview, }); diff --git a/constants/eventname.ts b/constants/eventname.ts index ae8a21b..668ec7a 100644 --- a/constants/eventname.ts +++ b/constants/eventname.ts @@ -18,4 +18,9 @@ export const PAGE_VIEW_EVENT = { MAIN_PAGE: 'MainPage', SUBSCRIBE_PAGE: 'SubscribePage', MORE_PAGE: 'MorePage', -} \ No newline at end of file +} + +// 앱 자체 진단용 — 사용자 행동이 아니라 웹↔앱 브리지 상태를 관측한다. +export const DIAGNOSTIC_EVENT = { + BRIDGE_UNKNOWN_MESSAGE: 'Bridge UnknownMessage', +} diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts index 4b7143f..19ff45b 100644 --- a/hooks/use-webview-message-handler.ts +++ b/hooks/use-webview-message-handler.ts @@ -1,8 +1,11 @@ import { WebViewMessage, WebViewMessageEvent, WebViewMessageTypes } from '@/types/webview-message.types'; +import { reportUnknownBridgeMessage } from '@/utils/webview'; import { useCallback } from 'react'; import { Linking } from 'react-native'; interface UseWebViewMessageHandlerOptions { + /** 처리되지 않은 메시지를 어느 화면이 받았는지 구분하기 위한 이름 */ + host: string; // 뒤로가기 요청 시 호출 onNavigateBack?: () => void; // 웹뷰 내 화면 이동 요청 시 호출 @@ -21,6 +24,7 @@ interface UseWebViewMessageHandlerOptions { // WebView 메시지를 처리하는 Hook export const useWebViewMessageHandler = ({ + host, onNavigateBack, onNavigateWebview, onSubscribe, @@ -83,12 +87,13 @@ export const useWebViewMessageHandler = ({ ); break; default: - console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); + reportUnknownBridgeMessage((message as { type?: unknown }).type, host); } } catch (error) { console.error('[WebViewHandler] 메시지 파싱 오류:', error); } }, [ + host, onNavigateBack, onNavigateWebview, onSubscribe, diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index 28d998b..689540f 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -114,6 +114,7 @@ export default function ClubWebViewScreen() { // WebView 메시지 핸들러 const { handleMessage } = useWebViewMessageHandler({ + host: 'club_detail', onNavigateBack: handleBack, onSubscribe: async (targetId: string, clubName?: string) => { trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index b0975d5..0024f0b 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -2,7 +2,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-co import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { ensureAccessToken } from '@/services/auth-token.service'; -import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; +import { appendSessionId, getWebViewUserAgent, reportUnknownBridgeMessage } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; @@ -158,6 +158,9 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { payload: { version: Constants.expoConfig?.version ?? 'unknown' }, }); break; + + default: + reportUnknownBridgeMessage(type, 'home'); } } catch { // 파싱 실패 무시 diff --git a/utils/webview.ts b/utils/webview.ts index 7d6f847..5801dec 100644 --- a/utils/webview.ts +++ b/utils/webview.ts @@ -1,3 +1,5 @@ +import { DIAGNOSTIC_EVENT } from '@/constants/eventname'; +import { getMixpanel } from '@/utils/mixpanel'; import Constants from 'expo-constants'; import { Platform } from 'react-native'; @@ -12,3 +14,24 @@ export const appendSessionId = (url: string, sessionId: string): string => { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}session_id=${encodeURIComponent(sessionId)}`; }; + +// 웹이 앱보다 앞서 나가 새 메시지를 추가했는데 호스트가 그걸 모르는 상황을 관측한다. +// 각 호스트 switch의 default에서만 부르므로, 그 호스트가 실제로 처리하지 못한 것만 잡힌다. +// 리포팅 실패가 메시지 처리를 막아선 안 되므로 예외를 밖으로 내보내지 않는다. +export const reportUnknownBridgeMessage = (type: unknown, host: string): void => { + // 서드파티가 쏜 비정형 메시지를 걸러내기 위해 문자열 type만 취급한다. + if (typeof type !== 'string') return; + + console.warn(`[WebViewBridge] 처리되지 않은 메시지: ${type} (host: ${host})`); + + getMixpanel() + .then(mixpanel => + mixpanel?.track(DIAGNOSTIC_EVENT.BRIDGE_UNKNOWN_MESSAGE, { + message_type: type, + host, + app_version: Constants.expoConfig?.version || '1.0.0', + platform: Platform.OS, + }), + ) + .catch(err => console.error('[WebViewBridge] 리포팅 실패:', err)); +}; From ddddf1ce66c9ed6f194880f88b2ae81af2ac9623 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 15:23:43 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=97=B4=EB=A6=AC=EB=8A=94=20=EC=9B=B9=EB=B7=B0=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=ED=95=99=EC=83=9D=20=ED=86=A0=ED=81=B0=EC=9D=84=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 우체통은 로그인 없이 쓰므로 익명 학생 토큰으로 사람을 구분한다. 웹은 앱이 주입한 토큰을 먼저 쓰고(studentFetch), 없으면 자기 토큰을 발급한다. 그러면 앱과 신원이 갈려서 백엔드가 Feedback.studentId로 찾는 편지함이 비어 보인다. 주입이 홈 웹뷰에만 있었다. 답장 푸시는 path=/feedback/letters/ 를 보내고 (FeedbackAdminService), use-fcm 은 /webview/clubDetail/ 로 시작하지 않는 path 를 /webview/[slug] 로 넘긴다. 그 화면에는 주입이 없어서, 답장 알림을 탭해 연 편지함이 방금 보낸 편지를 못 찾는다. 주입 스크립트 생성을 utils/webview.ts 로 올려 홈과 공유한다. 가드 기준을 진입 URL이 아니라 모아동 URL로 바꿨다. 기존 홈 코드는 new URL(BASE_URL).origin !== window.location.origin 으로 비교했는데, 홈은 BASE_URL 이 항상 모아동이라 맞지만 webview/[slug] 는 진입 URL 자체가 외부일 수 있어(slug=external) "외부 == 외부" 로 통과해 버린다. 그대로 옮기면 외부 사이트에 베어러 토큰이 주입된다. 그래서 가드가 두 겹이다. RN 쪽에서 진입 URL이 모아동 오리진일 때만 스크립트를 만들고, 스크립트 안에서 실행 시점 origin 을 다시 본다(웹뷰가 나중에 외부로 이동하는 경우). 두 번째 비교는 웹뷰 안에서 한다 - RN 의 URL 폴리필이 호스트 대소문자와 기본 포트를 정규화하지 않아 RN 에서 만든 origin 문자열이 window.location.origin 과 어긋날 수 있다. 주입은 content load 이전에 끝나야 하므로 토큰이 정해질 때까지 웹뷰를 렌더하지 않는다. 외부 URL 진입이면 주입할 일이 없으니 기다리지 않는다. club-detail-screen 에는 넣지 않았다. studentFetch 를 쓰는 페이지를 직접 열지 않는다. Co-Authored-By: Claude Opus 5 (1M context) --- app/webview/[slug].tsx | 90 ++++++++++++++++++++++++--------- ui/home/home-webview-screen.tsx | 17 +------ utils/webview.ts | 41 +++++++++++++++ 3 files changed, 108 insertions(+), 40 deletions(-) diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index 6cf9104..a5c4e70 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -6,10 +6,11 @@ import { MoaText } from "@/components/moa-text"; import { useMixpanelContext } from "@/contexts/mixpanel-context"; import { useWebViewMessageHandler } from "@/hooks/use-webview-message-handler"; -import { appendSessionId, getWebViewUserAgent } from "@/utils/webview"; +import { ensureAccessToken } from "@/services/auth-token.service"; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin } from "@/utils/webview"; import { Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams, useRouter } from "expo-router"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, TouchableOpacity } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { WebView } from "react-native-webview"; @@ -77,6 +78,42 @@ export default function WebViewScreen() { const url = useMemo(() => appendSessionId(baseUrl, sessionId), [baseUrl, sessionId]); + /** + * 우체통은 앱이 주입한 학생 토큰을 먼저 쓴다(웹 studentFetch). 주입이 없으면 웹이 + * 자체 토큰을 발급해 앱과 신원이 갈리고, 답장 푸시로 열린 편지함이 비어 보인다. + * 답장 푸시의 path가 /feedback/letters/... 라 이 화면으로 들어온다. + * + * 주입은 content load 이전에 끝나야 하므로 토큰이 정해질 때까지 웹뷰를 렌더하지 않는다. + * 외부 URL로 진입한 경우에는 주입할 일이 없으니 기다리지도 않는다. + */ + const [studentToken, setStudentToken] = useState(null); + const [tokenResolved, setTokenResolved] = useState(false); + + useEffect(() => { + if (!isWebViewOrigin(baseUrl)) { + setTokenResolved(true); + return; + } + + let cancelled = false; + ensureAccessToken() + .then((token) => { + if (!cancelled) setStudentToken(token); + }) + .catch(() => { + if (!cancelled) setStudentToken(null); + }) + .finally(() => { + if (!cancelled) setTokenResolved(true); + }); + + return () => { + cancelled = true; + }; + }, [baseUrl]); + + const injectedToken = buildStudentTokenInjection(baseUrl, studentToken); + const userAgent = getWebViewUserAgent(); const handleBack = () => { @@ -150,29 +187,32 @@ export default function WebViewScreen() { )} - { - if (!hasLoadedOnce) { - setLoading(true); - setError(false); - } - }} - onLoadEnd={() => { - setLoading(false); - if (!hasLoadedOnce) { - setHasLoadedOnce(true); - } - }} - onError={() => { - setError(true); - setLoading(false); - }} - javaScriptEnabled={true} - domStorageEnabled={true} - /> + {tokenResolved && ( + { + if (!hasLoadedOnce) { + setLoading(true); + setError(false); + } + }} + onLoadEnd={() => { + setLoading(false); + if (!hasLoadedOnce) { + setHasLoadedOnce(true); + } + }} + onError={() => { + setError(true); + setLoading(false); + }} + javaScriptEnabled={true} + domStorageEnabled={true} + /> + )} diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index be1bac7..5c79613 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -2,7 +2,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-co import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { ensureAccessToken } from '@/services/auth-token.service'; -import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; @@ -61,20 +61,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const url = sessionLoading || !tokenResolved ? null : appendSessionId(BASE_URL, sessionId); - // 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로, - // origin 가드 없이는 외부 사이트로 이동했을 때 베어러 토큰이 노출된다. - // origin 비교는 웹뷰 안에서 한다. RN 의 URL 폴리필은 호스트 대소문자와 기본 포트를 - // 정규화하지 않아 window.location.origin 과 어긋날 수 있다. 파싱에 실패하면 주입하지 않는다. - const injectedToken = studentToken - ? `(function(){ - try { - if (new URL(${JSON.stringify(BASE_URL)}).origin !== window.location.origin) return; - } catch (e) { - return; - } - window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(studentToken)}; - })(); true;` - : undefined; + const injectedToken = buildStudentTokenInjection(BASE_URL, studentToken); useEffect(() => { if (url) { diff --git a/utils/webview.ts b/utils/webview.ts index 7d6f847..35cb69b 100644 --- a/utils/webview.ts +++ b/utils/webview.ts @@ -12,3 +12,44 @@ export const appendSessionId = (url: string, sessionId: string): string => { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}session_id=${encodeURIComponent(sessionId)}`; }; + +const WEBVIEW_URL = process.env.EXPO_PUBLIC_WEBVIEW_URL || 'https://moadong.com'; + +/** 웹뷰가 로드할 URL이 모아동 오리진인지. 학생 토큰을 주입해도 되는지의 기준이다. */ +export const isWebViewOrigin = (url: string): boolean => { + try { + return new URL(url).origin === new URL(WEBVIEW_URL).origin; + } catch { + return false; + } +}; + +/** + * 웹이 첫 API 호출 시점에 앱과 같은 학생 신원을 쓰도록 토큰을 주입하는 스크립트. + * + * 가드가 두 겹이다. + * 1. 진입 URL이 외부일 수 있어(webview/[slug]의 external) RN 쪽에서 먼저 막는다. + * 비교 기준은 진입 URL이 아니라 모아동 URL이어야 한다 - 진입 URL끼리 비교하면 + * 외부 URL로 진입했을 때 "외부 == 외부"로 통과해 버린다. + * 2. 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로 스크립트 안에서도 + * 실행 시점 origin을 다시 본다. 없으면 외부 사이트로 이동했을 때 토큰이 노출된다. + * + * 2번의 origin 비교는 웹뷰 안에서 한다. RN의 URL 폴리필은 호스트 대소문자와 기본 포트를 + * 정규화하지 않아, RN에서 만든 origin 문자열이 window.location.origin과 어긋날 수 있다. + * 그래서 모아동 URL을 그대로 넘겨 웹뷰의 URL로 파싱한다. 파싱에 실패하면 주입하지 않는다. + */ +export const buildStudentTokenInjection = ( + targetUrl: string, + token: string | null, +): string | undefined => { + if (!token || !isWebViewOrigin(targetUrl)) return undefined; + + return `(function(){ + try { + if (new URL(${JSON.stringify(WEBVIEW_URL)}).origin !== window.location.origin) return; + } catch (e) { + return; + } + window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(token)}; + })(); true;`; +}; From e537a3485466645a25149b5561747e814471af09 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 16:43:52 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EC=99=B8=EB=B6=80=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=EC=97=90=20session=5Fid=EB=A5=BC=20=EB=B6=99=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20Android=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8F=84=20=EC=97=B4=EB=A6=AC=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 증상이 한 경로에서 나온다. 홈 웹뷰에서 외부 링크를 탭하면 handleShouldStartLoadWithRequest 가 가로채 /webview/[slug] 로 넘기는데, 그 화면이 목적지를 가리지 않고 appendSessionId 를 붙였다. iOS: session_id 는 웹 Mixpanel 의 distinct_id 다. 그게 쿼리에 실려 제3자 도메인으로 나가 상대 액세스 로그에 남는다. 모아동 오리진일 때만 붙인다. Android: setSupportMultipleWindows 기본값이 true 라 target=_blank 가 onCreateWindow 로 간다. onOpenWindow 핸들러가 없으면 RNCWebChromeClient 가 WebViewClient 도 없는 new WebView(context) 를 만들어 transport 로 넘기는데, 그 뷰는 어떤 계층에도 붙지 않는다. 그래서 링크를 눌러도 아무 일이 안 일어난다. false 로 두면 같은 요청이 onShouldStartLoadWithRequest 를 타 iOS 와 같은 경로가 된다. 둘을 같이 고친다. Android 링크만 살리면 session_id 가 나가는 경로가 Android 로도 번진다. 대상은 preventDefault 없는 생 들이다(ClubUnionPage 의 인스타·카톡, IntroducePage 의 문의하기). useNavigator 를 거치는 링크는 requestOpenExternalUrl -> WebBrowser 로 나가므로 원래 영향이 없다. Co-Authored-By: Claude Opus 5 (1M context) --- app/webview/[slug].tsx | 9 ++++++++- ui/home/home-webview-screen.tsx | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index a5c4e70..d7ad962 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -76,7 +76,14 @@ export default function WebViewScreen() { ? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : "")) : ""; - const url = useMemo(() => appendSessionId(baseUrl, sessionId), [baseUrl, sessionId]); + /** + * session_id 는 웹 Mixpanel 의 distinct_id 라 모아동 밖으로 나가면 안 된다. + * 이 화면은 slug=external 로 임의 외부 URL 도 로드하므로 오리진을 확인하고 붙인다. + */ + const url = useMemo( + () => (isWebViewOrigin(baseUrl) ? appendSessionId(baseUrl, sessionId) : baseUrl), + [baseUrl, sessionId], + ); /** * 우체통은 앱이 주입한 학생 토큰을 먼저 쓴다(웹 studentFetch). 주입이 없으면 웹이 diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index 5c79613..668f211 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -230,6 +230,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { onHttpError={handleError} javaScriptEnabled domStorageEnabled + // Android 기본값(true)이면 target=_blank 가 onCreateWindow 로 가는데, + // onOpenWindow 핸들러가 없으면 화면에 붙지 않는 WebView 로 빨려들어가 링크가 죽는다. + // false 로 두면 같은 요청이 onShouldStartLoadWithRequest 를 타 iOS 와 같은 경로가 된다. + setSupportMultipleWindows={false} pullToRefreshEnabled allowsBackForwardNavigationGestures /> From 00423979c4e6a85701a0eac2bcbbe18c89de3898 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 22 Sep 2026 16:56:06 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=9B=B9=EB=B7=B0=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20URL=20=ED=8C=90=EC=A0=95=EC=9D=84=20prefix=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20origin=20=EB=B9=84=EA=B5=90=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit request.url.startsWith(baseOrigin) 은 https://moadong.com.evil.com 을 내부 URL 로 승인한다. 그 페이지가 홈 웹뷰에 뜨면 window.ReactNativeWebView.postMessage 로 브리지를 그대로 쓸 수 있다. 이 웹뷰는 onMessage 에서 SUBSCRIBE_TOGGLE, NAVIGATE_WEBVIEW, OPEN_EXTERNAL_URL, SHARE 를 origin 검증 없이 처리한다. 파싱한 origin 끼리 비교한다. #40 에서 추가한 isWebViewOrigin 을 그대로 쓴다. 주입 토큰 쪽은 원래 new URL(...).origin 으로 비교하고 있어 영향이 없었다. setSupportMultipleWindows={false} 로 Android 의 target=_blank 요청도 이 검사에 들어오므로 같이 고친다. CodeRabbit 지적 반영. Co-Authored-By: Claude Opus 5 (1M context) --- ui/home/home-webview-screen.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index 668f211..e76751c 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -2,7 +2,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-co import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { ensureAccessToken } from '@/services/auth-token.service'; -import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent } from '@/utils/webview'; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; @@ -174,8 +174,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const handleShouldStartLoadWithRequest = useCallback( (request: ShouldStartLoadRequest) => { - const baseOrigin = (process.env.EXPO_PUBLIC_WEBVIEW_URL ?? 'https://moadong.com').replace(/\/$/, ''); - if (request.url.startsWith('http') && !request.url.startsWith(baseOrigin)) { + // origin 을 문자열 prefix 로 판정하면 moadong.com.evil.com 이 내부로 통과한다. + // 그 페이지가 이 웹뷰에 뜨면 window.ReactNativeWebView.postMessage 로 브리지를 + // 그대로 쓸 수 있다(SUBSCRIBE_TOGGLE, OPEN_EXTERNAL_URL 등). 파싱해서 비교한다. + if (request.url.startsWith('http') && !isWebViewOrigin(request.url)) { // iOS: navigationType === 'click' 은 사용자가 직접 링크를 탭한 경우만 해당 // 초기 로드·서버 리다이렉트는 'other' 이므로 인터셉트하지 않음 // Android: navigationType이 항상 'other'이므로 loaded 상태로 구분 From d386506bc63b873c925fb43ca4945d722b39b2ca Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 29 Sep 2026 17:16:31 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=ED=99=88=20=EC=9B=B9=EB=B7=B0?= =?UTF-8?q?=EC=9D=98=20=EC=99=B8=EB=B6=80=20=EB=A7=81=ED=81=AC=EB=A5=BC=20?= =?UTF-8?q?OS=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 외부 사이트를 앱 화면(WebView)에 띄우고 있었다. 사용자에게는 모아동 헤더가 붙은 채로 뜨고 제목은 기본값 "웹페이지" 라 어느 사이트인지 알 단서가 없다. 주소창도 없다. 기술적으로 더 문제인 건 그 WebView 가 앱 프로세스 안이라는 것이다. onMessage 가 붙어 있어 외부 페이지가 window.ReactNativeWebView.postMessage 로 SUBSCRIBE_TOGGLE, NAVIGATE_WEBVIEW, OPEN_EXTERNAL_URL 을 그대로 호출할 수 있다. WebBrowser.openBrowserAsync 로 넘긴다. iOS SFSafariViewController, Android Chrome Custom Tabs 라 OS 브라우저 프로세스에서 돌고 앱 JS 에 닿지 못한다. 도메인이 표시되고 시스템 브라우저 세션을 공유한다. 새로 도입하는 방식이 아니다. 배너(banner.tsx), 동아리 SNS(useNavigator -> OPEN_EXTERNAL_URL), external-link.tsx 가 이미 같은 함수를 쓴다. slug=external 경로만 예외로 남아 있었다. 열기 실패는 로그만 남긴다. 실패해도 웹뷰 이동은 이미 취소된 상태라 조용히 아무 일도 안 일어나면 원인을 알 수 없다. router 는 이 콜백에서 더 쓰이지 않아 deps 에서 뺀다. handleMessage 의 내부 라우팅에는 그대로 쓴다. Co-Authored-By: Claude Opus 5 (1M context) --- ui/home/home-webview-screen.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index e76751c..8161ea0 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -185,14 +185,22 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { ? request.navigationType === 'click' : loaded; if (isUserInitiated) { - router.push({ pathname: '/webview/[slug]', params: { slug: 'external', url: request.url } }); + // 외부 사이트는 OS 브라우저로 넘긴다. 앱 화면(WebView)에 띄우면 모아동 헤더가 + // 붙어 어디인지 구분이 안 되고, 앱 프로세스 안이라 그 페이지가 + // window.ReactNativeWebView 로 브리지를 쓸 수 있다. + // 배너·동아리 SNS·OPEN_EXTERNAL_URL 이 이미 같은 방식이다. + WebBrowser.openBrowserAsync(request.url, { + presentationStyle: WebBrowser.WebBrowserPresentationStyle.AUTOMATIC, + }).catch((error) => { + console.warn('[HomeWebView] 외부 링크 열기 실패:', request.url, error); + }); return false; } return true; } return true; }, - [router, loaded], + [loaded], ); // Android 하드웨어 뒤로가기: 웹뷰 히스토리가 있으면 웹뷰 back, 없으면 기본 동작(종료) From e9d99890fb76c2c729367389725fe6fb1b62df9b Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 29 Sep 2026 18:06:31 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=9B=B9=EB=B7=B0=20=EB=AA=A9?= =?UTF-8?q?=EC=A0=81=EC=A7=80=EB=A5=BC=20url=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EB=A1=9C=20=EB=B0=9B=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋으로 홈의 외부 링크가 OS 브라우저로 가면서, 앱 안에서 이 화면에 url 파라미터를 넘기는 곳이 없어졌다. /webview/[slug] 로 가는 호출 4곳은 모두 path 나 slug 만 넘긴다. 그런데 파라미터를 받는 쪽이 남아 있으면 moadongapp://webview/x?url=https://... 딥링크로 임의 사이트를 이 화면에 띄울 수 있다. 이 화면은 onMessage 가 붙어 있어서 그 페이지가 window.ReactNativeWebView.postMessage 로 앱 브리지를 그대로 쓴다. 사용자에게는 모아동 헤더가 붙은 화면으로 보인다. 목적지를 path 또는 pageConfig 로만 정한다. 등록되지 않은 slug 는 기존 오류 화면으로 간다. 오리진 가드는 그대로 둔다. pageConfig 에 외부 URL 항목(privacy-policy 의 notion 주소)이 남아 있어 baseUrl 이 모아동이 아닐 수 있다. Co-Authored-By: Claude Opus 5 (1M context) --- app/webview/[slug].tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index d7ad962..5b1e7f0 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -50,13 +50,11 @@ export default function WebViewScreen() { const { slug, path, - url: urlParam, title, hideHeader, } = useLocalSearchParams<{ slug?: string; path?: string; - url?: string; title?: string; hideHeader?: string; }>(); @@ -68,13 +66,16 @@ export default function WebViewScreen() { const config = pageConfig[slug || ""]; - const baseUrl = urlParam - ? String(urlParam) - : path - ? `${webviewUrl}${String(path).startsWith("/") ? "" : "/"}${String(path)}` - : config - ? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : "")) - : ""; + /** + * 목적지는 path 또는 pageConfig 로만 정한다. 예전에는 url 파라미터를 그대로 받았는데, + * 그러면 moadongapp://webview/x?url=... 딥링크로 임의 사이트를 이 화면에 띄울 수 있다. + * 이 화면은 onMessage 가 붙어 있어 그 페이지가 앱 브리지를 그대로 쓴다. + */ + const baseUrl = path + ? `${webviewUrl}${String(path).startsWith("/") ? "" : "/"}${String(path)}` + : config + ? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : "")) + : ""; /** * session_id 는 웹 Mixpanel 의 distinct_id 라 모아동 밖으로 나가면 안 된다. @@ -146,7 +147,7 @@ export default function WebViewScreen() { onNavigateWebview: handleNavigateWebview, }); - if (!config && !path && !urlParam) { + if (!config && !path) { return (