From a571f690e708b9baa2539c75ac9596fdee8fda79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:09:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=BF=A0=ED=82=A4=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EA=B0=80=20=EB=81=9D=EB=82=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9C=BC=EB=A9=B4=20=EC=95=B1=EC=9D=B4=20=EB=B9=88=20?= =?UTF-8?q?=EC=9B=B9=EB=B7=B0=EB=A1=9C=20=EA=B3=A0=EC=B0=A9=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱을 켜면 화면이 멈춘 채, 서버에는 요청이 한 건도 오지 않는 상태가 prod 에서 관측됐다. 부팅은 sync() 가 끝나야 isSynced 가 true 가 되고 그때 웹뷰 source 가 { html: '' } 에서 실제 URL 로 교체되는 구조다. 기존 .finally 방어는 promise 가 reject 될 때만 동작해, 네이티브 호출이 pending 으로 남으면 걸리지 않는다. sync() 안의 CookieManager · TokenStorage 호출에는 타임아웃이 없다. postTokenRefresh 는 이미 같은 이유(부팅이 요청에 묶임)로 5초 타임아웃을 두고 있어, 그 방어를 sync() 전체로 넓힌다. 타임아웃이 걸리면 쿠키가 심기지 않은 채 웹뷰가 떠 로그인 화면이 보일 수 있지만, 빈 화면으로 고착되는 것보다 낫다. --- apps/app/hooks/useWebviewCookieSync.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/hooks/useWebviewCookieSync.ts b/apps/app/hooks/useWebviewCookieSync.ts index f47da7f5..b70e3814 100644 --- a/apps/app/hooks/useWebviewCookieSync.ts +++ b/apps/app/hooks/useWebviewCookieSync.ts @@ -7,6 +7,7 @@ import { postTokenRefresh } from '@/apis/postTokenRefresh'; import { TokenStorage } from '@/utils/tokenStorage'; const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? 'http://localhost:3000'; +const SYNC_TIMEOUT_MS = 8000; /** * SecureStore ↔ WebView 쿠키 저장소 양방향 동기화 @@ -87,7 +88,7 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { if (refreshToken) await setAuthCookie('refresh_token', refreshToken); }; - sync() + Promise.race([sync(), new Promise(resolve => setTimeout(resolve, SYNC_TIMEOUT_MS))]) .catch(error => { if (__DEV__) console.warn('[COOKIE_SYNC] 동기화 실패:', String(error)); }) From 18ff49702115d10b78b9208cf869ff7add8c317b Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Wed, 26 Aug 2026 09:29:01 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=B6=80=ED=8C=85=20watchdog=20?= =?UTF-8?q?=EC=9D=84=20=EB=A7=88=EC=9A=B4=ED=8A=B8=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=95=84=EC=9B=83=20=EB=8B=A8=EA=B3=84=EB=A5=BC=20Sen?= =?UTF-8?q?try=20=EC=97=90=20=EB=B3=B4=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app/hooks/useWebviewCookieSync.ts | 42 +++++++++++++++++++++++--- apps/app/index.js | 3 ++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/app/hooks/useWebviewCookieSync.ts b/apps/app/hooks/useWebviewCookieSync.ts index b70e3814..b674c7a1 100644 --- a/apps/app/hooks/useWebviewCookieSync.ts +++ b/apps/app/hooks/useWebviewCookieSync.ts @@ -1,13 +1,15 @@ import { getTokenExpiresIso, isFresherToken, isTokenUnexpired } from '@piki/core'; import CookieManager from '@react-native-cookies/cookies'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { AppState, Platform } from 'react-native'; import { postTokenRefresh } from '@/apis/postTokenRefresh'; +import { captureError } from '@/utils/captureError'; import { TokenStorage } from '@/utils/tokenStorage'; const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? 'http://localhost:3000'; -const SYNC_TIMEOUT_MS = 8000; +/** 마운트 기준 부팅 상한 — 워밍업 로드(<1s) + 토큰 갱신 타임아웃(5s) 을 감안한 값 */ +const BOOT_SYNC_TIMEOUT_MS = 10_000; /** * SecureStore ↔ WebView 쿠키 저장소 양방향 동기화 @@ -19,6 +21,29 @@ const SYNC_TIMEOUT_MS = 8000; */ export const useWebviewCookieSync = (isWebviewReady: boolean) => { const [isSynced, setIsSynced] = useState(false); + /** 부팅 동기화 진행 단계 — 타임아웃 시 어느 await 에서 멈췄는지 Sentry 태그로 보고 */ + const stepRef = useRef('warmup'); + const isSettledRef = useRef(false); + + const settle = useCallback(() => { + isSettledRef.current = true; + setIsSynced(true); + }, []); + + /** 부팅 watchdog — 워밍업 로드가 끝나지 않거나 sync 의 네이티브 호출이 매달려도 상한 뒤 부팅을 진행 */ + useEffect(() => { + const timeoutId = setTimeout(() => { + if (isSettledRef.current) return; + + captureError(new Error('[COOKIE_SYNC] 부팅 동기화 타임아웃'), { + tags: { source: 'cookie-sync', step: stepRef.current }, + extra: { timeoutMs: BOOT_SYNC_TIMEOUT_MS }, + }); + settle(); + }, BOOT_SYNC_TIMEOUT_MS); + + return () => clearTimeout(timeoutId); + }, [settle]); /** SecureStore → 웹뷰 쿠키 동기화 */ useEffect(() => { @@ -31,10 +56,12 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { const useWebKit = Platform.OS === 'ios'; const sync = async () => { + stepRef.current = 'read-store'; let accessToken = await TokenStorage.getAccessToken(); let refreshToken = await TokenStorage.getRefreshToken(); /** SecureStore vs 웹뷰 쿠키 중 최신 토큰 채택 */ + stepRef.current = 'read-cookie'; const cookies = await CookieManager.get(WEB_URL, useWebKit); const cookieAccessToken = cookies['access_token']?.value ?? null; const cookieRefreshToken = cookies['refresh_token']?.value ?? null; @@ -46,15 +73,18 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { ) { accessToken = cookieAccessToken; refreshToken = cookieRefreshToken; + stepRef.current = 'save-cookie-token'; await TokenStorage.setTokens(accessToken, refreshToken); } /** access 가 아직 유효하면 refresh 생략 */ if (refreshToken && !isTokenUnexpired(accessToken, 60_000)) { try { + stepRef.current = 'refresh'; const refreshResponse = await postTokenRefresh(refreshToken); if (refreshResponse.ok) { + stepRef.current = 'parse-refresh'; const refreshBody = (await refreshResponse.json()) as { data: { accessToken: string; refreshToken: string }; }; @@ -63,6 +93,7 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { await TokenStorage.setTokens(accessToken, refreshToken); } else if (refreshResponse.status === 401) { /** 토큰 갱신 401 응답 시 만료된 토큰 정리 */ + stepRef.current = 'clear-expired'; await TokenStorage.clearTokens(); await CookieManager.clearAll(useWebKit); if (useWebKit) await CookieManager.clearAll(false); @@ -84,17 +115,18 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { }; /** 앱 진입 시 유효한 최신 토큰을 웹뷰 쿠키에 주입 */ + stepRef.current = 'set-cookie'; if (accessToken) await setAuthCookie('access_token', accessToken); if (refreshToken) await setAuthCookie('refresh_token', refreshToken); }; - Promise.race([sync(), new Promise(resolve => setTimeout(resolve, SYNC_TIMEOUT_MS))]) + sync() .catch(error => { if (__DEV__) console.warn('[COOKIE_SYNC] 동기화 실패:', String(error)); }) /** 싱크 실패하더라도 스플래시가 무한으로 뜨는 현상을 방지 */ - .finally(() => setIsSynced(true)); - }, [isWebviewReady]); + .finally(settle); + }, [isWebviewReady, settle]); /** * 웹뷰 쿠키 → SecureStore 동기화 diff --git a/apps/app/index.js b/apps/app/index.js index 2ad9ffe6..f8abbe77 100644 --- a/apps/app/index.js +++ b/apps/app/index.js @@ -16,6 +16,9 @@ Sentry.init({ tracesSampleRate: 0, /** PII 기본 마스킹 (Session Replay 는 web 만, 앱은 에러/크래시만) */ sendDefaultPii: false, + /** 메인 스레드가 5초 이상 멈추면 스택을 떠서 다음 실행 때 보고 — 부팅 고착 진단용 (iOS) */ + enableAppHangTracking: true, + appHangTimeoutInterval: 5, }); /** 백그라운드 FCM 메시지 핸들러 */ From e350035a0fa96c7d3d5175c88e0b963e5e50c275 Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Wed, 26 Aug 2026 10:02:32 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=EB=92=A4=20=EC=9E=AC=EA=B0=9C=EB=90=9C=20=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=EA=B0=80=20=EC=83=88=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=84=B8=EC=85=98=EC=9D=84=20=EB=8D=AE?= =?UTF-8?q?=EC=96=B4=EC=93=B0=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app/hooks/useWebviewCookieSync.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/app/hooks/useWebviewCookieSync.ts b/apps/app/hooks/useWebviewCookieSync.ts index b674c7a1..d0cbf345 100644 --- a/apps/app/hooks/useWebviewCookieSync.ts +++ b/apps/app/hooks/useWebviewCookieSync.ts @@ -24,6 +24,8 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { /** 부팅 동기화 진행 단계 — 타임아웃 시 어느 await 에서 멈췄는지 Sentry 태그로 보고 */ const stepRef = useRef('warmup'); const isSettledRef = useRef(false); + /** 타임아웃 뒤 늦게 재개된 sync 가 사용자의 새 로그인 세션을 덮어쓰지 않도록 변경 작업을 차단 */ + const isAbortedRef = useRef(false); const settle = useCallback(() => { isSettledRef.current = true; @@ -39,6 +41,7 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { tags: { source: 'cookie-sync', step: stepRef.current }, extra: { timeoutMs: BOOT_SYNC_TIMEOUT_MS }, }); + isAbortedRef.current = true; settle(); }, BOOT_SYNC_TIMEOUT_MS); @@ -65,6 +68,7 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { const cookies = await CookieManager.get(WEB_URL, useWebKit); const cookieAccessToken = cookies['access_token']?.value ?? null; const cookieRefreshToken = cookies['refresh_token']?.value ?? null; + if (isAbortedRef.current) return; if ( cookieAccessToken && @@ -82,12 +86,14 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { try { stepRef.current = 'refresh'; const refreshResponse = await postTokenRefresh(refreshToken); + if (isAbortedRef.current) return; if (refreshResponse.ok) { stepRef.current = 'parse-refresh'; const refreshBody = (await refreshResponse.json()) as { data: { accessToken: string; refreshToken: string }; }; + if (isAbortedRef.current) return; accessToken = refreshBody.data.accessToken; refreshToken = refreshBody.data.refreshToken; await TokenStorage.setTokens(accessToken, refreshToken); @@ -115,6 +121,7 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { }; /** 앱 진입 시 유효한 최신 토큰을 웹뷰 쿠키에 주입 */ + if (isAbortedRef.current) return; stepRef.current = 'set-cookie'; if (accessToken) await setAuthCookie('access_token', accessToken); if (refreshToken) await setAuthCookie('refresh_token', refreshToken);