Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions apps/app/hooks/useWebviewCookieSync.ts
Original file line number Diff line number Diff line change
@@ -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 쿠키 저장소 양방향 동기화
Expand All @@ -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 },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
isAbortedRef.current = true;
settle();
}, BOOT_SYNC_TIMEOUT_MS);

return () => clearTimeout(timeoutId);
}, [settle]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** SecureStore → 웹뷰 쿠키 동기화 */
useEffect(() => {
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (
cookieAccessToken &&
Expand All @@ -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);
Expand All @@ -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);
};
Expand All @@ -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 동기화
Expand Down
3 changes: 3 additions & 0 deletions apps/app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ Sentry.init({
tracesSampleRate: 0,
/** PII 기본 마스킹 (Session Replay 는 web 만, 앱은 에러/크래시만) */
sendDefaultPii: false,
/** 메인 스레드가 5초 이상 멈추면 스택을 떠서 다음 실행 때 보고 — 부팅 고착 진단용 (iOS) */
enableAppHangTracking: true,
appHangTimeoutInterval: 5,
});

/** 백그라운드 FCM 메시지 핸들러 */
Expand Down
Loading