diff --git a/apps/app/hooks/useWebviewCookieSync.ts b/apps/app/hooks/useWebviewCookieSync.ts index f47da7f5..d0cbf345 100644 --- a/apps/app/hooks/useWebviewCookieSync.ts +++ b/apps/app/hooks/useWebviewCookieSync.ts @@ -1,12 +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'; +/** 마운트 기준 부팅 상한 — 워밍업 로드(<1s) + 토큰 갱신 타임아웃(5s) 을 감안한 값 */ +const BOOT_SYNC_TIMEOUT_MS = 10_000; /** * SecureStore ↔ WebView 쿠키 저장소 양방향 동기화 @@ -18,6 +21,32 @@ const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? 'http://localhost:3000'; */ export const useWebviewCookieSync = (isWebviewReady: boolean) => { const [isSynced, setIsSynced] = useState(false); + /** 부팅 동기화 진행 단계 — 타임아웃 시 어느 await 에서 멈췄는지 Sentry 태그로 보고 */ + const stepRef = useRef('warmup'); + const isSettledRef = useRef(false); + /** 타임아웃 뒤 늦게 재개된 sync 가 사용자의 새 로그인 세션을 덮어쓰지 않도록 변경 작업을 차단 */ + const isAbortedRef = 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 }, + }); + isAbortedRef.current = true; + settle(); + }, BOOT_SYNC_TIMEOUT_MS); + + return () => clearTimeout(timeoutId); + }, [settle]); /** SecureStore → 웹뷰 쿠키 동기화 */ useEffect(() => { @@ -30,13 +59,16 @@ 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; + if (isAbortedRef.current) return; if ( cookieAccessToken && @@ -45,23 +77,29 @@ 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 (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); } else if (refreshResponse.status === 401) { /** 토큰 갱신 401 응답 시 만료된 토큰 정리 */ + stepRef.current = 'clear-expired'; await TokenStorage.clearTokens(); await CookieManager.clearAll(useWebKit); if (useWebKit) await CookieManager.clearAll(false); @@ -83,6 +121,8 @@ 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); }; @@ -92,8 +132,8 @@ export const useWebviewCookieSync = (isWebviewReady: boolean) => { 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 메시지 핸들러 */