diff --git a/app.json b/app.json
index 16486f8..88f4926 100644
--- a/app.json
+++ b/app.json
@@ -77,6 +77,10 @@
[
"expo-build-properties",
{
+ "android": {
+ "enableMinifyInReleaseBuilds": true,
+ "enableShrinkResourcesInReleaseBuilds": true
+ },
"ios": {
"useFrameworks": "static",
"buildReactNativeFromSource": true,
@@ -97,7 +101,8 @@
],
"expo-tracking-transparency",
"./plugins/withFcmNotificationColorFix",
- "./plugins/withAndroidReleaseSigning"
+ "./plugins/withAndroidReleaseSigning",
+ "./plugins/withIosPrebuildFixes"
],
"experiments": {
"typedRoutes": true,
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 4f3b026..bb7a7c8 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -3,10 +3,10 @@ import HomeIcon from "@/assets/icons/ic_home.svg";
import { HapticTab } from "@/components/haptic-tab";
import { USER_EVENT } from "@/constants/eventname";
import { Colors } from "@/constants/theme";
-import { useMixpanelTrack } from "@/hooks";
import { useColorScheme } from "@/hooks/use-color-scheme";
+import { useMixpanelTrack } from "@/hooks/use-mixpanel-track";
import { Tabs } from "expo-router";
-import React from "react";
+import React, { useEffect } from "react";
import { Image } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -17,6 +17,10 @@ export default function TabLayout() {
const TAB_BAR_BASE_HEIGHT = 56;
const TAB_BAR_BASE_PADDING_VERTICAL = 6;
+ useEffect(() => {
+ console.log('[StartupTiming] tabsMounted', Date.now());
+ }, []);
+
const handleTabPress = (tabName: string) => {
trackEvent(USER_EVENT.BOTTOM_TAB_CLICKED, {
tab: tabName,
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index a32e779..9057aef 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,12 +1,17 @@
-import { HomeScreen } from '@/ui/home/home-screen';
import { HomeWebViewScreen } from '@/ui/home/home-webview-screen';
-import React, { useState } from 'react';
+import React, { Suspense, lazy, useState } from 'react';
+
+const LazyHomeScreen = lazy(() => import('@/ui/home/home-screen'));
export default function HomeTab() {
const [webViewFailed, setWebViewFailed] = useState(false);
if (webViewFailed) {
- return ;
+ return (
+
+
+
+ );
}
return setWebViewFailed(true)} />;
diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx
index 42954c4..30d66b1 100644
--- a/app/(tabs)/more.tsx
+++ b/app/(tabs)/more.tsx
@@ -1,7 +1,8 @@
import MoadongIcon from '@/assets/icons/ic-moadong.svg';
import { MoaText } from '@/components/moa-text';
import { PAGE_VIEW_EVENT, USER_EVENT } from '@/constants/eventname';
-import { useMixpanelTrack, useTrackScreenView } from '@/hooks';
+import { useMixpanelTrack } from '@/hooks/use-mixpanel-track';
+import { useTrackScreenView } from '@/hooks/use-track-screen-view';
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
@@ -162,4 +163,3 @@ const VersionItem = styled.View`
const VersionValue = styled(MoaText)`
color: #888888;
`;
-
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 978d29f..8f3cbb4 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -3,8 +3,8 @@ import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync } from 'expo-tracking-transparency';
-import { useCallback, useEffect, useState } from 'react';
-import { Platform } from 'react-native';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { InteractionManager, Platform } from 'react-native';
import 'react-native-get-random-values';
import 'react-native-reanimated';
import { SafeAreaProvider } from 'react-native-safe-area-context';
@@ -15,8 +15,11 @@ import { ForceUpdateDialog } from '@/components/force-update-dialog';
import { MixpanelProvider } from '@/contexts/mixpanel-context';
import { SubscribedClubsProvider } from '@/contexts/subscribed-clubs-context';
import { useFcm } from '@/hooks/use-fcm';
-import { runAppBootstrap } from '@/services/app-bootstrap.service';
-import { checkForceUpdateRequired } from '@/services/force-update.service';
+import { BootstrapResult, runAppBootstrap } from '@/services/app-bootstrap.service';
+import {
+ getCachedForceUpdateRequired,
+ refreshForceUpdateRequired,
+} from '@/services/force-update.service';
// 네이티브 스플래시 화면을 자동으로 숨기지 않도록 설정
// 이것은 앱이 로드되자마자 실행되어야 합니다
@@ -30,14 +33,17 @@ export const unstable_settings = {
type BootstrapStatus = 'idle' | 'running' | 'success' | 'failed';
+const EMPTY_SUBSCRIBED_CLUB_IDS: string[] = [];
+
export default function RootLayout() {
- const [appIsReady, setAppIsReady] = useState(false);
const [showSplash, setShowSplash] = useState(true);
const [forceUpdateRequired, setForceUpdateRequired] = useState(false);
const [forceUpdateChecked, setForceUpdateChecked] = useState(false);
const [bootstrapStatus, setBootstrapStatus] = useState('idle');
const [bootstrapErrorMessage, setBootstrapErrorMessage] = useState(undefined);
- const [subscribedClubsRefreshKey, setSubscribedClubsRefreshKey] = useState(0);
+ const [bootstrapResult, setBootstrapResult] = useState(null);
+ const bootstrapStatusRef = useRef('idle');
+ const nativeSplashHiddenRef = useRef(false);
const bootstrapSucceeded = bootstrapStatus === 'success';
const shouldBlockSplash = forceUpdateRequired || !bootstrapSucceeded;
@@ -45,69 +51,135 @@ export default function RootLayout() {
// 강제 업데이트가 필요한 경우(또는 체크 전)에는 FCM 권한 프롬프트/핸들러 설정이 뜨지 않도록 비활성화
useFcm(forceUpdateChecked && !forceUpdateRequired && bootstrapSucceeded);
+ useEffect(() => {
+ bootstrapStatusRef.current = bootstrapStatus;
+ }, [bootstrapStatus]);
+
const runBootstrapSequence = useCallback(async () => {
+ if (bootstrapStatusRef.current === 'running') {
+ return;
+ }
+
+ bootstrapStatusRef.current = 'running';
setBootstrapStatus('running');
setBootstrapErrorMessage(undefined);
- const { subscribedClubCount } = await runAppBootstrap();
- setSubscribedClubsRefreshKey((prev) => prev + 1);
+ const result = await runAppBootstrap();
+ setBootstrapResult(result);
+ bootstrapStatusRef.current = 'success';
setBootstrapStatus('success');
- console.log('✅ 부트스트랩 완료 - 구독 동아리 수:', subscribedClubCount);
+ console.log('✅ 부트스트랩 완료', {
+ subscribedClubCount: result.subscribedClubIds.length,
+ timings: result.timings,
+ });
+ }, []);
+
+ const handleBootstrapFailure = useCallback((error: unknown) => {
+ console.warn('❌ 앱 초기화 중 오류:', error);
+ bootstrapStatusRef.current = 'failed';
+ setBootstrapStatus('failed');
+ setBootstrapErrorMessage(getUserFriendlyBootstrapMessage(error));
}, []);
+ const refreshForceUpdateInBackground = useCallback(() => {
+ refreshForceUpdateRequired()
+ .then((required) => {
+ setForceUpdateRequired(required);
+
+ if (!required && bootstrapStatusRef.current === 'idle') {
+ runBootstrapSequence().catch(handleBootstrapFailure);
+ }
+ })
+ .catch((error) => {
+ console.warn('⚠️ 강제 업데이트 백그라운드 갱신 실패:', error);
+ });
+ }, [handleBootstrapFailure, runBootstrapSequence]);
+
useEffect(() => {
+ let cancelled = false;
+
async function prepare() {
try {
console.log('📱 앱 초기화 시작...');
- // 1) 강제 업데이트 체크 (Remote Config)
- const required = await checkForceUpdateRequired();
+ // 1) 강제 업데이트 캐시 판정 (Remote Config fetch는 백그라운드에서 갱신)
+ const required = await getCachedForceUpdateRequired();
+ if (cancelled) {
+ return;
+ }
+
setForceUpdateRequired(required);
setForceUpdateChecked(true);
+ refreshForceUpdateInBackground();
if (required) {
- console.log('⛔️ 강제 업데이트 필요: 부트스트랩 중단');
+ console.log('⛔️ 캐시 기준 강제 업데이트 필요: 부트스트랩 대기');
return;
}
- // 2) 강제 업데이트가 아닐 때만 ATT 요청
- await requestTrackingPermissionOnLaunch();
-
- // 3) Access Token -> FCM -> 구독 목록 -> Mixpanel 순서 부트스트랩
+ // 2) Access Token -> FCM/구독 목록/Mixpanel 병렬 부트스트랩
await runBootstrapSequence();
-
- // 최소 로딩 시간 보장 (너무 빨리 사라지지 않도록)
- await new Promise(resolve => setTimeout(resolve, 500));
console.log('✅ 앱 초기화 완료');
} catch (e) {
- console.warn('❌ 앱 초기화 중 오류:', e);
- setBootstrapStatus('failed');
- setBootstrapErrorMessage(getUserFriendlyBootstrapMessage(e));
- } finally {
- // 앱 준비 완료
- setAppIsReady(true);
+ if (!cancelled) {
+ handleBootstrapFailure(e);
+ }
}
}
prepare();
- }, [runBootstrapSequence]);
- // 앱이 준비되면 네이티브 스플래시를 숨기고 커스텀 스플래시 시작
+ return () => {
+ cancelled = true;
+ };
+ }, [handleBootstrapFailure, refreshForceUpdateInBackground, runBootstrapSequence]);
+
+ // 커스텀 스플래시가 렌더된 다음 네이티브 스플래시를 바로 숨긴다.
useEffect(() => {
- if (appIsReady) {
- console.log('🎨 앱 준비 완료, 네이티브 스플래시 숨기고 커스텀 스플래시 표시');
- // 약간의 지연을 두어 커스텀 스플래시가 먼저 렌더링되도록 함
- setTimeout(() => {
- SplashScreen.hideAsync().catch(() => {
- console.warn('⚠️ 네이티브 스플래시가 이미 숨겨짐');
- });
- }, 100);
+ if (!showSplash || nativeSplashHiddenRef.current) {
+ return;
}
- }, [appIsReady]);
+
+ const animationFrame = requestAnimationFrame(() => {
+ nativeSplashHiddenRef.current = true;
+ console.log('🎨 커스텀 스플래시 렌더 완료, 네이티브 스플래시 숨김', {
+ nativeSplashHidden: Date.now(),
+ });
+ SplashScreen.hideAsync().catch(() => {
+ console.warn('⚠️ 네이티브 스플래시가 이미 숨겨짐');
+ });
+ });
+
+ return () => {
+ cancelAnimationFrame(animationFrame);
+ };
+ }, [showSplash]);
+
+ useEffect(() => {
+ if (!bootstrapSucceeded || forceUpdateRequired || showSplash || Platform.OS !== 'ios') {
+ return;
+ }
+
+ let timeoutId: ReturnType | undefined;
+ const interactionTask = InteractionManager.runAfterInteractions(() => {
+ timeoutId = setTimeout(() => {
+ requestTrackingPermissionAfterLaunch();
+ }, 250);
+ });
+
+ return () => {
+ interactionTask.cancel();
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ };
+ }, [bootstrapSucceeded, forceUpdateRequired, showSplash]);
const onFinishSplash = useCallback(() => {
// 커스텀 스플래시 애니메이션이 완료되면
- console.log('🎭 커스텀 스플래시 종료, 메인 화면으로 전환');
+ console.log('🎭 커스텀 스플래시 종료, 메인 화면으로 전환', {
+ customSplashDismissed: Date.now(),
+ });
if (shouldBlockSplash) {
console.log('⛔️ 스플래시 유지:', { forceUpdateRequired, bootstrapStatus });
return;
@@ -124,24 +196,28 @@ export default function RootLayout() {
await runBootstrapSequence();
} catch (error) {
console.warn('❌ 부트스트랩 재시도 실패:', error);
- setBootstrapStatus('failed');
- setBootstrapErrorMessage(getUserFriendlyBootstrapMessage(error));
+ handleBootstrapFailure(error);
}
- }, [bootstrapStatus, runBootstrapSequence]);
+ }, [bootstrapStatus, handleBootstrapFailure, runBootstrapSequence]);
if (__DEV__) {
console.log('🔄 RootLayout 렌더링', {
showSplash,
- appIsReady,
bootstrapStatus,
forceUpdateRequired,
});
}
+ const initialSubscribedClubIds =
+ bootstrapResult?.subscribedClubIds ?? EMPTY_SUBSCRIBED_CLUB_IDS;
+
return (
-
-
+
+
@@ -157,7 +233,7 @@ export default function RootLayout() {
{/* 부트스트랩 오류 다이얼로그 (재시도 가능) */}
@@ -178,7 +254,7 @@ export default function RootLayout() {
);
}
-async function requestTrackingPermissionOnLaunch() {
+async function requestTrackingPermissionAfterLaunch() {
if (Platform.OS !== 'ios') {
return;
}
diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx
index cd406ae..e2bfed3 100644
--- a/app/webview/[slug].tsx
+++ b/app/webview/[slug].tsx
@@ -4,8 +4,8 @@
*/
import { MoaText } from "@/components/moa-text";
-import { useMixpanelContext } from "@/contexts";
-import { useWebViewMessageHandler } from "@/hooks";
+import { useMixpanelContext } from "@/contexts/mixpanel-context";
+import { useWebViewMessageHandler } from "@/hooks/use-webview-message-handler";
import { appendSessionId, getWebViewUserAgent } from "@/utils/webview";
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
diff --git a/components/custom-splash-screen.tsx b/components/custom-splash-screen.tsx
index 8137253..28f63a3 100644
--- a/components/custom-splash-screen.tsx
+++ b/components/custom-splash-screen.tsx
@@ -5,9 +5,10 @@
import MoadongIcon from '@/assets/icons/ic-moadong.svg';
import { LinearGradient } from 'expo-linear-gradient';
-import { useCallback, useEffect, useRef } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
+ cancelAnimation,
Easing,
runOnJS,
useAnimatedStyle,
@@ -25,20 +26,42 @@ interface CustomSplashScreenProps {
blockFinish?: boolean;
}
+type IntroAnimationStep = 'logoOpacity' | 'logoScale' | 'textOpacity' | 'textTranslateY';
+
+const INTRO_ANIMATION_STEP_COUNT = 4;
+const MIN_SPLASH_VISIBLE_DURATION_MS = 700;
+const SPLASH_FADE_OUT_DURATION_MS = 300;
+
export function CustomSplashScreen({ onFinish, isReady, blockFinish = false }: CustomSplashScreenProps) {
const logoScale = useSharedValue(0.3);
const logoOpacity = useSharedValue(0);
const textOpacity = useSharedValue(0);
const textTranslateY = useSharedValue(20);
const fadeOutOpacity = useSharedValue(1);
+ const [introCompleted, setIntroCompleted] = useState(false);
const hasAnimatedOnce = useRef(false);
+ const hasStartedFadeOut = useRef(false);
+ const animationStartedAt = useRef(Date.now());
+ const completedIntroAnimationSteps = useRef(new Set());
+
+ const markIntroAnimationStepComplete = useCallback((step: IntroAnimationStep) => {
+ if (completedIntroAnimationSteps.current.has(step)) {
+ return;
+ }
+
+ completedIntroAnimationSteps.current.add(step);
+
+ if (completedIntroAnimationSteps.current.size === INTRO_ANIMATION_STEP_COUNT) {
+ setIntroCompleted(true);
+ }
+ }, []);
const triggerFadeOut = useCallback((delayMs: number) => {
fadeOutOpacity.value = withDelay(
delayMs,
withTiming(
0,
- { duration: 300, easing: Easing.in(Easing.ease) },
+ { duration: SPLASH_FADE_OUT_DURATION_MS, easing: Easing.in(Easing.ease) },
(finished) => {
if (finished) {
runOnJS(onFinish)();
@@ -48,43 +71,93 @@ export function CustomSplashScreen({ onFinish, isReady, blockFinish = false }: C
);
}, [fadeOutOpacity, onFinish]);
- const startAnimation = useCallback((shouldBlockFinish: boolean) => {
- logoOpacity.value = withTiming(1, {
- duration: 500,
- easing: Easing.out(Easing.ease),
- });
+ const startAnimation = useCallback(() => {
+ animationStartedAt.current = Date.now();
+
+ logoOpacity.value = withTiming(
+ 1,
+ {
+ duration: 500,
+ easing: Easing.out(Easing.ease),
+ },
+ (finished) => {
+ if (finished) {
+ runOnJS(markIntroAnimationStepComplete)('logoOpacity');
+ }
+ }
+ );
- logoScale.value = withSpring(1, {
- damping: 12,
- stiffness: 120,
- mass: 0.7,
- });
+ logoScale.value = withSpring(
+ 1,
+ {
+ damping: 12,
+ stiffness: 120,
+ mass: 0.7,
+ },
+ (finished) => {
+ if (finished) {
+ runOnJS(markIntroAnimationStepComplete)('logoScale');
+ }
+ }
+ );
textOpacity.value = withDelay(
300,
- withTiming(1, { duration: 400, easing: Easing.out(Easing.ease) })
+ withTiming(
+ 1,
+ { duration: 400, easing: Easing.out(Easing.ease) },
+ (finished) => {
+ if (finished) {
+ runOnJS(markIntroAnimationStepComplete)('textOpacity');
+ }
+ }
+ )
);
textTranslateY.value = withDelay(
300,
- withTiming(0, { duration: 400, easing: Easing.out(Easing.ease) })
+ withTiming(
+ 0,
+ { duration: 400, easing: Easing.out(Easing.ease) },
+ (finished) => {
+ if (finished) {
+ runOnJS(markIntroAnimationStepComplete)('textTranslateY');
+ }
+ }
+ )
);
+ }, [
+ logoOpacity,
+ logoScale,
+ markIntroAnimationStepComplete,
+ textOpacity,
+ textTranslateY,
+ ]);
- if (!shouldBlockFinish) {
- triggerFadeOut(2200);
+ useEffect(() => {
+ if (!hasAnimatedOnce.current) {
+ hasAnimatedOnce.current = true;
+ startAnimation();
}
- }, [logoOpacity, logoScale, textOpacity, textTranslateY, triggerFadeOut]);
+ }, [startAnimation]);
useEffect(() => {
- if (!isReady) return;
+ if (blockFinish) {
+ hasStartedFadeOut.current = false;
+ cancelAnimation(fadeOutOpacity);
+ fadeOutOpacity.value = 1;
+ return;
+ }
- if (!hasAnimatedOnce.current) {
- hasAnimatedOnce.current = true;
- startAnimation(blockFinish);
- } else if (!blockFinish) {
- triggerFadeOut(0);
+ if (!isReady || !introCompleted || hasStartedFadeOut.current) {
+ return;
}
- }, [isReady, blockFinish, startAnimation, triggerFadeOut]);
+
+ hasStartedFadeOut.current = true;
+ const elapsedMs = Date.now() - animationStartedAt.current;
+ const remainingMinimumMs = Math.max(0, MIN_SPLASH_VISIBLE_DURATION_MS - elapsedMs);
+ triggerFadeOut(remainingMinimumMs);
+ }, [isReady, introCompleted, blockFinish, fadeOutOpacity, triggerFadeOut]);
const logoAnimatedStyle = useAnimatedStyle(() => ({
opacity: logoOpacity.value,
@@ -182,4 +255,3 @@ const styles = StyleSheet.create({
fontWeight: '500',
},
});
-
diff --git a/components/permission-dialog.tsx b/components/permission-dialog.tsx
index 6c89736..a41e3e5 100644
--- a/components/permission-dialog.tsx
+++ b/components/permission-dialog.tsx
@@ -5,7 +5,7 @@
import { MoaText } from '@/components/moa-text';
import { USER_EVENT } from '@/constants/eventname';
import { Colors } from '@/constants/theme';
-import { useMixpanelTrack } from '@/hooks';
+import { useMixpanelTrack } from '@/hooks/use-mixpanel-track';
import React, { useEffect } from 'react';
import { Linking, Modal, TouchableOpacity } from 'react-native';
import styled from 'styled-components/native';
@@ -139,4 +139,3 @@ const ConfirmButton = styled(TouchableOpacity)`
const ConfirmButtonText = styled(MoaText)`
color: ${Colors.light.tint};
`;
-
diff --git a/components/ui/column.tsx b/components/ui/column.tsx
index a44f886..ae31f77 100644
--- a/components/ui/column.tsx
+++ b/components/ui/column.tsx
@@ -9,6 +9,13 @@ export interface ColumnProps extends ViewProps {
wrap?: 'nowrap' | 'wrap' | 'wrap-reverse';
}
+type StyledColumnProps = {
+ gap: number;
+ align: NonNullable;
+ justify: NonNullable;
+ wrap: NonNullable;
+};
+
/**
* 세로 방향 레이아웃 컴포넌트
*
@@ -40,15 +47,10 @@ export function Column({
);
}
-const StyledColumn = styled.View<{
- gap: number;
- align: string;
- justify: string;
- wrap: string;
-}>`
+const StyledColumn = styled.View`
flex-direction: column;
- align-items: ${props => props.align};
- justify-content: ${props => props.justify};
- flex-wrap: ${props => props.wrap};
- gap: ${props => props.gap}px;
+ align-items: ${(props: StyledColumnProps) => props.align};
+ justify-content: ${(props: StyledColumnProps) => props.justify};
+ flex-wrap: ${(props: StyledColumnProps) => props.wrap};
+ gap: ${(props: StyledColumnProps) => props.gap}px;
`;
diff --git a/components/ui/row.tsx b/components/ui/row.tsx
index a0fa87b..e0ccbe1 100644
--- a/components/ui/row.tsx
+++ b/components/ui/row.tsx
@@ -9,6 +9,13 @@ export interface RowProps extends ViewProps {
wrap?: 'nowrap' | 'wrap' | 'wrap-reverse';
}
+type StyledRowProps = {
+ gap: number;
+ align: NonNullable;
+ justify: NonNullable;
+ wrap: NonNullable;
+};
+
/**
* 가로 방향 레이아웃 컴포넌트
*
@@ -40,15 +47,10 @@ export function Row({
);
}
-const StyledRow = styled.View<{
- gap: number;
- align: string;
- justify: string;
- wrap: string;
-}>`
+const StyledRow = styled.View`
flex-direction: row;
- align-items: ${props => props.align};
- justify-content: ${props => props.justify};
- flex-wrap: ${props => props.wrap};
- gap: ${props => props.gap}px;
+ align-items: ${(props: StyledRowProps) => props.align};
+ justify-content: ${(props: StyledRowProps) => props.justify};
+ flex-wrap: ${(props: StyledRowProps) => props.wrap};
+ gap: ${(props: StyledRowProps) => props.gap}px;
`;
diff --git a/contexts/mixpanel-context.tsx b/contexts/mixpanel-context.tsx
index 5ae3952..7a47d9d 100644
--- a/contexts/mixpanel-context.tsx
+++ b/contexts/mixpanel-context.tsx
@@ -1,6 +1,5 @@
import { getJwtSubject, getStoredAccessToken } from '@/services/auth-token-storage';
-import { identifyMixpanel } from '@/utils/mixpanel';
-import AsyncStorage from '@react-native-async-storage/async-storage';
+import { getOrCreateMixpanelSessionId, identifyMixpanel } from '@/utils/mixpanel';
import React, { createContext, useContext, useEffect, useState } from 'react';
interface MixpanelContextType {
@@ -20,42 +19,10 @@ export const useMixpanelContext = () => {
interface MixpanelProviderProps {
children: React.ReactNode;
+ initialSessionId?: string;
+ initialReady?: boolean;
}
-const SESSION_ID_KEY = '@moadong_session_id';
-
-/**
- * 랜덤 세션 ID 생성
- * 앱 최초 실행 시 1회만 생성하고 이후에는 저장된 값 사용
- */
-const generateSessionId = (): string => {
- const timestamp = Date.now().toString(36);
- const randomStr = Math.random().toString(36).substring(2, 15);
- return `moadong_${timestamp}_${randomStr}`;
-};
-
-/**
- * AsyncStorage에서 session_id 가져오기 또는 생성
- */
-const getOrCreateSessionId = async (): Promise => {
- try {
- const storedSessionId = await AsyncStorage.getItem(SESSION_ID_KEY);
-
- if (storedSessionId) {
- return storedSessionId;
- }
-
- const newSessionId = generateSessionId();
- await AsyncStorage.setItem(SESSION_ID_KEY, newSessionId);
- console.log('[MixpanelProvider] 새 Session ID 생성:', newSessionId);
- return newSessionId;
- } catch (error) {
- console.error('[MixpanelProvider] Session ID 로드/저장 실패:', error);
- // 에러 시 임시 ID 생성 (저장 안 함)
- return generateSessionId();
- }
-};
-
async function getMixpanelDistinctId(sessionId: string): Promise {
const accessToken = await getStoredAccessToken();
if (accessToken) {
@@ -68,14 +35,27 @@ async function getMixpanelDistinctId(sessionId: string): Promise {
return sessionId;
}
-export const MixpanelProvider: React.FC = ({ children }) => {
- const [sessionId, setSessionId] = useState('');
- const [isLoading, setIsLoading] = useState(true);
+export const MixpanelProvider: React.FC = ({
+ children,
+ initialSessionId,
+ initialReady,
+}) => {
+ const usesBootstrapState = initialReady !== undefined;
+ const [sessionId, setSessionId] = useState(initialSessionId ?? '');
+ const [isLoading, setIsLoading] = useState(
+ usesBootstrapState ? !initialReady : true,
+ );
useEffect(() => {
+ if (usesBootstrapState) {
+ setSessionId(initialSessionId ?? '');
+ setIsLoading(!initialReady);
+ return;
+ }
+
const initializeMixpanel = async () => {
try {
- const id = await getOrCreateSessionId();
+ const id = await getOrCreateMixpanelSessionId();
setSessionId(id);
const distinctId = await getMixpanelDistinctId(id);
@@ -91,7 +71,7 @@ export const MixpanelProvider: React.FC = ({ children })
};
initializeMixpanel();
- }, []);
+ }, [initialReady, initialSessionId, usesBootstrapState]);
return (
diff --git a/contexts/subscribed-clubs-context.tsx b/contexts/subscribed-clubs-context.tsx
index 4daccc0..4d4f0bf 100644
--- a/contexts/subscribed-clubs-context.tsx
+++ b/contexts/subscribed-clubs-context.tsx
@@ -36,14 +36,20 @@ const SubscribedClubsContext = createContext([]);
+export function SubscribedClubsProvider({
+ children,
+ initialClubIds,
+ refreshKey = 0,
+}: SubscribedClubsProviderProps) {
+ const usesBootstrapState = initialClubIds !== undefined;
+ const [subscribedClubIds, setSubscribedClubIds] = useState(initialClubIds ?? []);
const [loading, setLoading] = useState(false);
/**
@@ -171,8 +177,13 @@ export function SubscribedClubsProvider({ children, refreshKey = 0 }: Subscribed
* 초기 데이터 로드
*/
useEffect(() => {
+ if (usesBootstrapState) {
+ setSubscribedClubIds(initialClubIds ?? []);
+ return;
+ }
+
loadSubscribedClubs();
- }, [loadSubscribedClubs, refreshKey]);
+ }, [initialClubIds, loadSubscribedClubs, refreshKey, usesBootstrapState]);
const value: SubscribedClubsContextType = {
subscribedClubIds,
@@ -199,4 +210,3 @@ export function useSubscribedClubsContext(): SubscribedClubsContextType {
}
return context;
}
-
diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj
index 009cb08..871139c 100644
--- a/ios/app.xcodeproj/project.pbxproj
+++ b/ios/app.xcodeproj/project.pbxproj
@@ -7,31 +7,31 @@
objects = {
/* Begin PBXBuildFile section */
- 0FF7848F61AA47FAA6C76782 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 77AE80BD50D547E1A8A56CC6 /* GoogleService-Info.plist */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
- 44EF9C0703654D6F5A657A3A /* Pods_app.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 037983D3A2BD448B8BE103E8 /* Pods_app.framework */; };
- 92B9A9B164FF5EF0323F1E31 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ADA102A06D5EB446FB6A0A9 /* ExpoModulesProvider.swift */; };
+ 4EB67CDF4FA2FA6DB261C2AF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F04226BB632D1877217E4BF /* ExpoModulesProvider.swift */; };
+ 51E8237EA68C9F421821F3F2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FFC3E90B83FE5881E7191A28 /* PrivacyInfo.xcprivacy */; };
+ 7418872E76C24C9D99DBCDBA /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 129B2FC68CE94DD4AF3E8F46 /* GoogleService-Info.plist */; };
+ 82AA7221D96D78101B265EA1 /* Pods_app.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A2FE34E4E96E79AB979AE79F /* Pods_app.framework */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
- C3E8B42E42522C1805BC6F18 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A3AFB8CC99CA24AB73835436 /* PrivacyInfo.xcprivacy */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
- 037983D3A2BD448B8BE103E8 /* Pods_app.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_app.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 129B2FC68CE94DD4AF3E8F46 /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "app/GoogleService-Info.plist"; sourceTree = ""; };
13B07F961A680F5B00A75B9A /* app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = app.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = app/Images.xcassets; sourceTree = ""; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = app/Info.plist; sourceTree = ""; };
- 1E8B6F5BC9FA6562BD53A21F /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = ""; };
- 77AE80BD50D547E1A8A56CC6 /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "app/GoogleService-Info.plist"; sourceTree = ""; };
- 7ADA102A06D5EB446FB6A0A9 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift"; sourceTree = ""; };
- A3AFB8CC99CA24AB73835436 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = app/PrivacyInfo.xcprivacy; sourceTree = ""; };
+ 29529F95C3B27A8A28148768 /* Pods-app.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.release.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.release.xcconfig"; sourceTree = ""; };
+ 5F04226BB632D1877217E4BF /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-app/ExpoModulesProvider.swift"; sourceTree = ""; };
+ 9603A13841B849497BEFA438 /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = ""; };
+ A2FE34E4E96E79AB979AE79F /* Pods_app.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_app.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = app/SplashScreen.storyboard; sourceTree = ""; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = app/AppDelegate.swift; sourceTree = ""; };
F11748442D0722820044C1D9 /* app-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "app-Bridging-Header.h"; path = "app/app-Bridging-Header.h"; sourceTree = ""; };
- FC924C491C52003CCFC65F38 /* Pods-app.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-app.debug.xcconfig"; path = "Target Support Files/Pods-app/Pods-app.debug.xcconfig"; sourceTree = ""; };
+ FFC3E90B83FE5881E7191A28 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = app/PrivacyInfo.xcprivacy; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -39,7 +39,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 44EF9C0703654D6F5A657A3A /* Pods_app.framework in Frameworks */,
+ 82AA7221D96D78101B265EA1 /* Pods_app.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -55,8 +55,8 @@
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
- 77AE80BD50D547E1A8A56CC6 /* GoogleService-Info.plist */,
- A3AFB8CC99CA24AB73835436 /* PrivacyInfo.xcprivacy */,
+ 129B2FC68CE94DD4AF3E8F46 /* GoogleService-Info.plist */,
+ FFC3E90B83FE5881E7191A28 /* PrivacyInfo.xcprivacy */,
);
name = app;
sourceTree = "";
@@ -65,19 +65,17 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
- 037983D3A2BD448B8BE103E8 /* Pods_app.framework */,
+ A2FE34E4E96E79AB979AE79F /* Pods_app.framework */,
);
name = Frameworks;
sourceTree = "";
};
- 7B483D38F1EB58EF70E93FE8 /* Pods */ = {
+ 3E02BF4B97A12C72A249A00C /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
- FC924C491C52003CCFC65F38 /* Pods-app.debug.xcconfig */,
- 1E8B6F5BC9FA6562BD53A21F /* Pods-app.release.xcconfig */,
+ 956CE7A84C8CBD77BFF65CED /* app */,
);
- name = Pods;
- path = Pods;
+ name = ExpoModulesProviders;
sourceTree = "";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
@@ -94,8 +92,8 @@
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
- 7B483D38F1EB58EF70E93FE8 /* Pods */,
- EE09FE91477B80C913C55DE8 /* ExpoModulesProviders */,
+ F66767058A3B50A3480FF459 /* Pods */,
+ 3E02BF4B97A12C72A249A00C /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "";
@@ -110,29 +108,31 @@
name = Products;
sourceTree = "";
};
- BB2F792B24A3F905000567C9 /* Supporting */ = {
+ 956CE7A84C8CBD77BFF65CED /* app */ = {
isa = PBXGroup;
children = (
- BB2F792C24A3F905000567C9 /* Expo.plist */,
+ 5F04226BB632D1877217E4BF /* ExpoModulesProvider.swift */,
);
- name = Supporting;
- path = app/Supporting;
+ name = app;
sourceTree = "";
};
- EE09FE91477B80C913C55DE8 /* ExpoModulesProviders */ = {
+ BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
- FEDE40ACF6BA5FFEF4F9A2E0 /* app */,
+ BB2F792C24A3F905000567C9 /* Expo.plist */,
);
- name = ExpoModulesProviders;
+ name = Supporting;
+ path = app/Supporting;
sourceTree = "";
};
- FEDE40ACF6BA5FFEF4F9A2E0 /* app */ = {
+ F66767058A3B50A3480FF459 /* Pods */ = {
isa = PBXGroup;
children = (
- 7ADA102A06D5EB446FB6A0A9 /* ExpoModulesProvider.swift */,
+ 9603A13841B849497BEFA438 /* Pods-app.debug.xcconfig */,
+ 29529F95C3B27A8A28148768 /* Pods-app.release.xcconfig */,
);
- name = app;
+ name = Pods;
+ path = Pods;
sourceTree = "";
};
/* End PBXGroup section */
@@ -143,15 +143,15 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "app" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
- 87DD351A4DF46AD393C4EAA4 /* [Expo] Configure project */,
+ FE867D8A5FA752A269599C43 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
- A2E0F80BF351A6826D2E6743 /* [CP] Embed Pods Frameworks */,
- 18634755B0FACEB20FE6C4F3 /* [CP-User] [RNFB] Core Configuration */,
- 83972AB9ECA9DF424A3C857B /* [CP-User] [RNFB] Crashlytics Configuration */,
+ 8EECECEFB71129A522FE163D /* [CP] Embed Pods Frameworks */,
+ 1D0C50A2BD995DC812AD2D44 /* [CP-User] [RNFB] Core Configuration */,
+ 97ACC5400792088F8397C15F /* [CP-User] [RNFB] Crashlytics Configuration */,
);
buildRules = (
);
@@ -201,8 +201,8 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
- 0FF7848F61AA47FAA6C76782 /* GoogleService-Info.plist in Resources */,
- C3E8B42E42522C1805BC6F18 /* PrivacyInfo.xcprivacy in Resources */,
+ 7418872E76C24C9D99DBCDBA /* GoogleService-Info.plist in Resources */,
+ 51E8237EA68C9F421821F3F2 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -248,7 +248,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- 18634755B0FACEB20FE6C4F3 /* [CP-User] [RNFB] Core Configuration */ = {
+ 1D0C50A2BD995DC812AD2D44 /* [CP-User] [RNFB] Core Configuration */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@@ -338,7 +338,25 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- 83972AB9ECA9DF424A3C857B /* [CP-User] [RNFB] Crashlytics Configuration */ = {
+ 8EECECEFB71129A522FE163D /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-frameworks.sh",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputPaths = (
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 97ACC5400792088F8397C15F /* [CP-User] [RNFB] Crashlytics Configuration */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@@ -353,7 +371,7 @@
shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n";
};
- 87DD351A4DF46AD393C4EAA4 /* [Expo] Configure project */ = {
+ FE867D8A5FA752A269599C43 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@@ -377,24 +395,6 @@
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-app/expo-configure-project.sh\"\n";
};
- A2E0F80BF351A6826D2E6743 /* [CP] Embed Pods Frameworks */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-frameworks.sh",
- "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
- );
- name = "[CP] Embed Pods Frameworks";
- outputPaths = (
- "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-app/Pods-app-frameworks.sh\"\n";
- showEnvVarsInLog = 0;
- };
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -403,7 +403,7 @@
buildActionMask = 2147483647;
files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
- 92B9A9B164FF5EF0323F1E31 /* ExpoModulesProvider.swift in Sources */,
+ 4EB67CDF4FA2FA6DB261C2AF /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -412,7 +412,7 @@
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = FC924C491C52003CCFC65F38 /* Pods-app.debug.xcconfig */;
+ baseConfigurationReference = 9603A13841B849497BEFA438 /* Pods-app.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
@@ -449,7 +449,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 1E8B6F5BC9FA6562BD53A21F /* Pods-app.release.xcconfig */;
+ baseConfigurationReference = 29529F95C3B27A8A28148768 /* Pods-app.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
diff --git a/plugins/ios-prebuild-support/IDEWorkspaceChecks.plist b/plugins/ios-prebuild-support/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/plugins/ios-prebuild-support/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/plugins/ios-prebuild-support/ci_post_clone.sh b/plugins/ios-prebuild-support/ci_post_clone.sh
new file mode 100644
index 0000000..cbf127f
--- /dev/null
+++ b/plugins/ios-prebuild-support/ci_post_clone.sh
@@ -0,0 +1,134 @@
+#!/bin/sh
+
+set -eu
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
+
+cd "$REPO_ROOT"
+
+prepend_homebrew_paths() {
+ if [ -d /opt/homebrew/bin ]; then
+ PATH="/opt/homebrew/bin:$PATH"
+ fi
+
+ if [ -d /usr/local/bin ]; then
+ PATH="/usr/local/bin:$PATH"
+ fi
+
+ export PATH
+}
+
+install_with_homebrew() {
+ FORMULA="$1"
+
+ if ! command -v brew >/dev/null 2>&1; then
+ echo "Homebrew is required to install $FORMULA, but brew is not available." >&2
+ exit 1
+ fi
+
+ if ! brew list "$FORMULA" >/dev/null 2>&1; then
+ brew install "$FORMULA"
+ fi
+}
+
+ensure_node() {
+ prepend_homebrew_paths
+
+ if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
+ export NODE_BINARY="$(command -v node)"
+ return
+ fi
+
+ NODE_FORMULA="${NODE_FORMULA:-node@22}"
+
+ echo "Node.js/npm not found in PATH. Installing $NODE_FORMULA with Homebrew..."
+ install_with_homebrew "$NODE_FORMULA"
+
+ NODE_PREFIX="$(brew --prefix "$NODE_FORMULA" 2>/dev/null || true)"
+ if [ -n "$NODE_PREFIX" ]; then
+ PATH="$NODE_PREFIX/bin:$PATH"
+ export PATH
+ fi
+
+ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
+ echo "Failed to make Node.js/npm available after installing $NODE_FORMULA." >&2
+ exit 1
+ fi
+
+ export NODE_BINARY="$(command -v node)"
+}
+
+ensure_cocoapods() {
+ prepend_homebrew_paths
+
+ if command -v pod >/dev/null 2>&1; then
+ return
+ fi
+
+ echo "CocoaPods not found in PATH. Installing cocoapods with Homebrew..."
+ install_with_homebrew cocoapods
+}
+
+ensure_node
+node --version
+npm --version
+npm ci
+
+decode_google_service_info_plist() {
+ if printf '%s' "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | base64 --decode > GoogleService-Info.plist 2>/dev/null; then
+ return
+ fi
+
+ if printf '%s' "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | base64 -D > GoogleService-Info.plist 2>/dev/null; then
+ return
+ fi
+
+ if command -v openssl >/dev/null 2>&1 &&
+ printf '%s' "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | openssl base64 -d -A > GoogleService-Info.plist 2>/dev/null; then
+ return
+ fi
+
+ echo "Failed to decode GOOGLE_SERVICE_INFO_PLIST_BASE64" >&2
+ exit 1
+}
+
+if [ -n "${GOOGLE_SERVICE_INFO_PLIST_BASE64:-}" ]; then
+ decode_google_service_info_plist
+elif [ -n "${GOOGLE_SERVICE_INFO_PLIST:-}" ]; then
+ printf '%s' "$GOOGLE_SERVICE_INFO_PLIST" > GoogleService-Info.plist
+else
+ echo "Missing GOOGLE_SERVICE_INFO_PLIST_BASE64 or GOOGLE_SERVICE_INFO_PLIST for iOS archive" >&2
+ exit 1
+fi
+
+mkdir -p ios/app
+cp GoogleService-Info.plist ios/app/GoogleService-Info.plist
+
+: "${EXPO_PUBLIC_BASE_URL:?Missing EXPO_PUBLIC_BASE_URL for iOS archive}"
+: "${EXPO_PUBLIC_WEBVIEW_URL:?Missing EXPO_PUBLIC_WEBVIEW_URL for iOS archive}"
+: "${EXPO_PUBLIC_MIXPANEL_TOKEN:?Missing EXPO_PUBLIC_MIXPANEL_TOKEN for iOS archive}"
+
+{
+ echo "EXPO_PUBLIC_BASE_URL=${EXPO_PUBLIC_BASE_URL}"
+ echo "EXPO_PUBLIC_WEBVIEW_URL=${EXPO_PUBLIC_WEBVIEW_URL}"
+ echo "EXPO_PUBLIC_MIXPANEL_TOKEN=${EXPO_PUBLIC_MIXPANEL_TOKEN}"
+} > .env
+
+APS_ENVIRONMENT="${IOS_APS_ENVIRONMENT:-development}"
+ENTITLEMENTS_FILE="${IOS_ENTITLEMENTS_FILE:-ios/app/app.entitlements}"
+
+if [ -f "$ENTITLEMENTS_FILE" ]; then
+ /usr/libexec/PlistBuddy -c "Set :aps-environment $APS_ENVIRONMENT" "$ENTITLEMENTS_FILE" 2>/dev/null ||
+ /usr/libexec/PlistBuddy -c "Add :aps-environment string $APS_ENVIRONMENT" "$ENTITLEMENTS_FILE"
+fi
+
+cd ios
+ensure_cocoapods
+pod install --deployment
+
+PODS_RELEASE_XCCONFIG="Pods/Target Support Files/Pods-app/Pods-app.release.xcconfig"
+if [ ! -f "$PODS_RELEASE_XCCONFIG" ]; then
+ echo "Missing $PODS_RELEASE_XCCONFIG after pod install. Xcode archive cannot continue without CocoaPods base configuration." >&2
+ exit 1
+fi
diff --git a/plugins/ios-prebuild-support/xcode.env b/plugins/ios-prebuild-support/xcode.env
new file mode 100644
index 0000000..a274d65
--- /dev/null
+++ b/plugins/ios-prebuild-support/xcode.env
@@ -0,0 +1,46 @@
+# This `.xcode.env` file is versioned and is used to source the environment
+# used when running script phases inside Xcode.
+# To customize your local environment, you can create an `.xcode.env.local`
+# file that is not versioned.
+
+# NODE_BINARY variable contains the PATH to the node executable.
+#
+# Customize the NODE_BINARY variable here.
+# For example, to use nvm with brew, add the following line
+# . "$(brew --prefix nvm)/nvm.sh" --no-use
+NODE_FORMULA="${NODE_FORMULA:-node@22}"
+
+if [ -z "${NODE_BINARY:-}" ]; then
+ if command -v node >/dev/null 2>&1; then
+ export NODE_BINARY="$(command -v node)"
+ else
+ NODE_BINARY_CANDIDATE=""
+
+ if command -v brew >/dev/null 2>&1; then
+ NODE_PREFIX="$(brew --prefix "$NODE_FORMULA" 2>/dev/null || true)"
+ if [ -n "$NODE_PREFIX" ]; then
+ NODE_BINARY_CANDIDATE="$NODE_PREFIX/bin/node"
+ fi
+ fi
+
+ if [ -z "$NODE_BINARY_CANDIDATE" ] || [ ! -x "$NODE_BINARY_CANDIDATE" ]; then
+ for NODE_BINARY_CANDIDATE in \
+ "/opt/homebrew/opt/$NODE_FORMULA/bin/node" \
+ "/usr/local/opt/$NODE_FORMULA/bin/node" \
+ /opt/homebrew/bin/node \
+ /usr/local/bin/node; do
+ if [ -x "$NODE_BINARY_CANDIDATE" ]; then
+ break
+ fi
+ done
+ fi
+
+ if [ -x "$NODE_BINARY_CANDIDATE" ]; then
+ export PATH="$(dirname "$NODE_BINARY_CANDIDATE"):$PATH"
+ export NODE_BINARY="$NODE_BINARY_CANDIDATE"
+ else
+ echo "error: Node.js was not found. Run ci_post_clone.sh or install Node.js." >&2
+ exit 1
+ fi
+ fi
+fi
diff --git a/plugins/withIosPrebuildFixes.js b/plugins/withIosPrebuildFixes.js
new file mode 100644
index 0000000..fd4fa57
--- /dev/null
+++ b/plugins/withIosPrebuildFixes.js
@@ -0,0 +1,215 @@
+const fs = require('fs');
+const path = require('path');
+const { withFinalizedMod, withPodfile, withXcodeProject } = require('@expo/config-plugins');
+
+const SUPPORT_DIR = path.join(__dirname, 'ios-prebuild-support');
+
+const RNFB_SCRIPT_PHASE_NAMES = new Set([
+ '[CP-User] [RNFB] Core Configuration',
+ '[CP-User] [RNFB] Crashlytics Configuration',
+]);
+
+const RNFB_PODFILE_BLOCK = `RNFB_SCRIPT_PHASES = [
+ '[CP-User] [RNFB] Core Configuration',
+ '[CP-User] [RNFB] Crashlytics Configuration',
+].freeze
+
+def configure_rnfb_script_phases(installer)
+ installer.aggregate_targets.each do |aggregate_target|
+ project = aggregate_target.user_project
+ next unless project
+
+ updated = false
+
+ project.targets.each do |target|
+ next unless target.respond_to?(:shell_script_build_phases)
+
+ target.shell_script_build_phases.each do |phase|
+ next unless RNFB_SCRIPT_PHASES.include?(phase.name)
+
+ phase.always_out_of_date = '1'
+ updated = true
+ end
+ end
+
+ project.save if updated
+ end
+end
+`;
+
+const PODFILE_BUILD_FIXES_BLOCK = `def set_xcconfig_build_setting(path, key, value)
+ return unless path && File.exist?(path)
+
+ contents = File.read(path)
+ setting = "#{key} = #{value}"
+
+ updated_contents =
+ if contents.match?(/^#{Regexp.escape(key)}\\s*=/)
+ contents.gsub(/^#{Regexp.escape(key)}\\s*=.*$/, setting)
+ else
+ "#{setting}\\n#{contents}"
+ end
+
+ File.write(path, updated_contents) if updated_contents != contents
+end
+
+def configure_fmt_for_xcode_26(installer)
+ fmt_target = installer.pods_project.targets.find { |target| target.name == 'fmt' }
+ return unless fmt_target
+
+ fmt_target.build_configurations.each do |config|
+ config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17'
+ set_xcconfig_build_setting(
+ config.base_configuration_reference&.real_path,
+ 'CLANG_CXX_LANGUAGE_STANDARD',
+ 'c++17'
+ )
+ end
+end
+
+def normalize_pods_deployment_target(installer, deployment_target)
+ installer.pods_project.targets.each do |target|
+ target.build_configurations.each do |config|
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = deployment_target
+ end
+ end
+end
+`;
+
+const CCACHE_FUNCTION_END = ` podfile_properties['apple.ccacheEnabled'] == 'true'
+end
+
+`;
+
+const GENERATED_PLATFORM_LINE = "platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'";
+const DEPLOYMENT_TARGET_LINES = `ios_deployment_target = podfile_properties['ios.deploymentTarget'] || '15.1'
+platform :ios, ios_deployment_target`;
+
+const POST_INSTALL_ANCHOR = ` react_native_post_install(
+ installer,
+ config[:reactNativePath],
+ :mac_catalyst_enabled => false,
+ :ccache_enabled => ccache_enabled?(podfile_properties),
+ )`;
+
+const POST_INSTALL_FIXES = `${POST_INSTALL_ANCHOR}
+
+ configure_fmt_for_xcode_26(installer)
+ normalize_pods_deployment_target(installer, ios_deployment_target)`;
+
+const POST_INTEGRATE_BLOCK = `post_integrate do |installer|
+ configure_rnfb_script_phases(installer)
+end
+`;
+
+function insertAfter(source, anchor, insertion) {
+ if (!source.includes(anchor)) {
+ throw new Error(`Unable to find expected Podfile anchor: ${anchor.split('\n')[0]}`);
+ }
+
+ return source.replace(anchor, `${anchor}${insertion}`);
+}
+
+function applyPodfileFixes(source) {
+ let contents = source;
+
+ if (!contents.includes('RNFB_SCRIPT_PHASES = [')) {
+ contents = insertAfter(contents, CCACHE_FUNCTION_END, `${RNFB_PODFILE_BLOCK}\n`);
+ }
+
+ if (!contents.includes(DEPLOYMENT_TARGET_LINES)) {
+ contents = contents.replace(GENERATED_PLATFORM_LINE, DEPLOYMENT_TARGET_LINES);
+ }
+
+ if (!contents.includes('def configure_fmt_for_xcode_26(installer)')) {
+ contents = insertAfter(contents, `${DEPLOYMENT_TARGET_LINES}\n\n`, `${PODFILE_BUILD_FIXES_BLOCK}\n`);
+ }
+
+ if (!contents.includes(' configure_fmt_for_xcode_26(installer)')) {
+ contents = contents.replace(POST_INSTALL_ANCHOR, POST_INSTALL_FIXES);
+ }
+
+ if (!contents.includes('post_integrate do |installer|')) {
+ contents = `${contents.trimEnd()}\n\n${POST_INTEGRATE_BLOCK}`;
+ }
+
+ return contents;
+}
+
+function normalizeXcodeString(value) {
+ return String(value || '')
+ .replace(/^"/, '')
+ .replace(/"$/, '')
+ .replace(/\\"/g, '"');
+}
+
+function applyXcodeProjectFixes(project) {
+ const shellScriptBuildPhases = project.hash?.project?.objects?.PBXShellScriptBuildPhase;
+
+ for (const phase of Object.values(shellScriptBuildPhases || {})) {
+ if (!phase || typeof phase !== 'object' || phase.isa !== 'PBXShellScriptBuildPhase') {
+ continue;
+ }
+
+ const phaseName = normalizeXcodeString(phase.name);
+ if (RNFB_SCRIPT_PHASE_NAMES.has(phaseName)) {
+ phase.alwaysOutOfDate = 1;
+ }
+ }
+
+ return project;
+}
+
+function copySupportFile(sourceName, destinationPath, mode) {
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
+ fs.copyFileSync(path.join(SUPPORT_DIR, sourceName), destinationPath);
+
+ if (mode) {
+ fs.chmodSync(destinationPath, mode);
+ }
+}
+
+function removeAppTestsFromScheme(schemeContents) {
+ return schemeContents.replace(/\n\s*/g, (block) => {
+ if (block.includes('BuildableName = "appTests.xctest"') || block.includes('BlueprintName = "appTests"')) {
+ return '';
+ }
+
+ return block;
+ });
+}
+
+function applyFinalizedFileFixes(platformProjectRoot) {
+ copySupportFile('xcode.env', path.join(platformProjectRoot, '.xcode.env'));
+ copySupportFile('ci_post_clone.sh', path.join(platformProjectRoot, 'ci_scripts', 'ci_post_clone.sh'), 0o755);
+ copySupportFile(
+ 'IDEWorkspaceChecks.plist',
+ path.join(platformProjectRoot, 'app.xcworkspace', 'xcshareddata', 'IDEWorkspaceChecks.plist')
+ );
+
+ const schemePath = path.join(platformProjectRoot, 'app.xcodeproj', 'xcshareddata', 'xcschemes', 'app.xcscheme');
+ if (fs.existsSync(schemePath)) {
+ const schemeContents = fs.readFileSync(schemePath, 'utf8');
+ fs.writeFileSync(schemePath, removeAppTestsFromScheme(schemeContents), 'utf8');
+ }
+}
+
+module.exports = function withIosPrebuildFixes(config) {
+ config = withPodfile(config, (config) => {
+ config.modResults.contents = applyPodfileFixes(config.modResults.contents);
+ return config;
+ });
+
+ config = withXcodeProject(config, (config) => {
+ config.modResults = applyXcodeProjectFixes(config.modResults);
+ return config;
+ });
+
+ return withFinalizedMod(config, [
+ 'ios',
+ (config) => {
+ applyFinalizedFileFixes(config.modRequest.platformProjectRoot);
+ return config;
+ },
+ ]);
+};
diff --git a/services/app-bootstrap.service.ts b/services/app-bootstrap.service.ts
index 1cce1d0..fdcf007 100644
--- a/services/app-bootstrap.service.ts
+++ b/services/app-bootstrap.service.ts
@@ -2,30 +2,74 @@ import { ensureAccessToken } from './auth-token.service';
import { getJwtSubject } from './auth-token-storage';
import { getFcmToken, initializeFcm, sendFcmTokenToServer } from './fcm.service';
import { fetchSubscribedClubIdsByAccessToken, saveSubscribedClubIdsToStorage } from './subscription.service';
-import { identifyMixpanel } from '@/utils/mixpanel';
+import { getOrCreateMixpanelSessionId, identifyMixpanel } from '@/utils/mixpanel';
-export async function runAppBootstrap(): Promise<{ subscribedClubCount: number }> {
- const accessToken = await ensureAccessToken();
+export type BootstrapTimings = Record;
+
+export interface BootstrapResult {
+ accessToken: string;
+ subject: string;
+ sessionId: string;
+ subscribedClubIds: string[];
+ timings: BootstrapTimings;
+}
+async function registerFcmToken(): Promise {
await initializeFcm({ strict: false, promptForPermission: false });
const fcmToken = await getFcmToken();
if (fcmToken) {
await sendFcmTokenToServer(fcmToken);
}
+}
+async function syncSubscribedClubIds(): Promise {
const subscribedClubIds = await fetchSubscribedClubIdsByAccessToken();
await saveSubscribedClubIdsToStorage(subscribedClubIds);
+ return subscribedClubIds;
+}
- const subject = getJwtSubject(accessToken);
- if (!subject) {
- throw new Error('JWT에서 식별 가능한 사용자 ID를 찾을 수 없습니다.');
- }
-
+async function initializeMixpanelIdentity(subject: string): Promise {
+ const sessionId = await getOrCreateMixpanelSessionId();
const identified = await identifyMixpanel(`user:${subject}`);
if (!identified) {
throw new Error('Mixpanel 초기화에 실패했습니다.');
}
- return { subscribedClubCount: subscribedClubIds.length };
+ return sessionId;
}
+export async function runAppBootstrap(): Promise {
+ const timings: BootstrapTimings = {
+ bootstrapStart: Date.now(),
+ };
+
+ const accessToken = await ensureAccessToken();
+ timings.accessTokenReady = Date.now();
+
+ const subject = getJwtSubject(accessToken);
+ if (!subject) {
+ throw new Error('JWT에서 식별 가능한 사용자 ID를 찾을 수 없습니다.');
+ }
+
+ const [subscribedClubIds, sessionId] = await Promise.all([
+ syncSubscribedClubIds().finally(() => {
+ timings.subscriptionsReady = Date.now();
+ }),
+ initializeMixpanelIdentity(subject).finally(() => {
+ timings.mixpanelReady = Date.now();
+ }),
+ registerFcmToken().finally(() => {
+ timings.fcmReady = Date.now();
+ }),
+ ]);
+
+ timings.bootstrapEnd = Date.now();
+
+ return {
+ accessToken,
+ subject,
+ sessionId,
+ subscribedClubIds,
+ timings,
+ };
+}
diff --git a/services/auth-token-storage.ts b/services/auth-token-storage.ts
index 3a6c774..0d9220a 100644
--- a/services/auth-token-storage.ts
+++ b/services/auth-token-storage.ts
@@ -3,6 +3,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
const ACCESS_TOKEN_KEY = '@access_token';
const AUTH_SUBJECT_KEY = '@auth_subject';
+let cachedAccessToken: string | null | undefined;
+let cachedAuthSubject: string | null | undefined;
+
function generateUuidV4(): string {
const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';
return template.replace(/[xy]/g, (char) => {
@@ -13,31 +16,45 @@ function generateUuidV4(): string {
}
export async function getStoredAccessToken(): Promise {
+ if (cachedAccessToken !== undefined) {
+ return cachedAccessToken;
+ }
+
try {
- return await AsyncStorage.getItem(ACCESS_TOKEN_KEY);
+ cachedAccessToken = await AsyncStorage.getItem(ACCESS_TOKEN_KEY);
+ return cachedAccessToken;
} catch (error) {
console.error('❌ Access Token 조회 실패:', error);
+ cachedAccessToken = null;
return null;
}
}
export async function saveAccessToken(token: string): Promise {
+ cachedAccessToken = token;
await AsyncStorage.setItem(ACCESS_TOKEN_KEY, token);
}
export async function getOrCreateAuthSubject(): Promise {
+ if (cachedAuthSubject !== undefined && cachedAuthSubject !== null) {
+ return cachedAuthSubject;
+ }
+
try {
const stored = await AsyncStorage.getItem(AUTH_SUBJECT_KEY);
if (stored) {
+ cachedAuthSubject = stored;
return stored;
}
const subject = generateUuidV4();
+ cachedAuthSubject = subject;
await AsyncStorage.setItem(AUTH_SUBJECT_KEY, subject);
return subject;
} catch (error) {
console.warn('⚠️ auth subject 저장 실패, 임시 값 사용:', error);
- return generateUuidV4();
+ cachedAuthSubject = generateUuidV4();
+ return cachedAuthSubject;
}
}
@@ -70,4 +87,3 @@ export function getJwtSubject(accessToken: string): string | null {
return null;
}
}
-
diff --git a/services/auth-token.service.ts b/services/auth-token.service.ts
index 8d4efd1..254b401 100644
--- a/services/auth-token.service.ts
+++ b/services/auth-token.service.ts
@@ -16,27 +16,28 @@ type IssueAccessTokenPayload = {
};
function extractAccessToken(response: IssueAccessTokenResponse): string | null {
- if (!response || typeof response !== 'object') {
+ const payload = response as any;
+ if (!payload || typeof payload !== 'object') {
return null;
}
const directToken =
- 'accessToken' in response && typeof response.accessToken === 'string'
- ? response.accessToken
- : 'token' in response && typeof response.token === 'string'
- ? response.token
+ typeof payload.accessToken === 'string'
+ ? payload.accessToken
+ : typeof payload.token === 'string'
+ ? payload.token
: null;
if (directToken) {
return directToken;
}
- if ('data' in response && response.data && typeof response.data === 'object') {
- if (typeof response.data.accessToken === 'string') {
- return response.data.accessToken;
+ if (payload.data && typeof payload.data === 'object') {
+ if (typeof payload.data.accessToken === 'string') {
+ return payload.data.accessToken;
}
- if (typeof response.data.token === 'string') {
- return response.data.token;
+ if (typeof payload.data.token === 'string') {
+ return payload.data.token;
}
}
@@ -68,4 +69,3 @@ export async function ensureAccessToken(): Promise {
return issueAccessToken();
}
-
diff --git a/services/force-update.service.ts b/services/force-update.service.ts
index 9dd29fb..7ecbbd1 100644
--- a/services/force-update.service.ts
+++ b/services/force-update.service.ts
@@ -14,7 +14,10 @@ import { firebaseConfig } from '@/constants/firebase-config';
type FirebaseApp = any;
+const MIN_SUPPORTED_VERSION_KEY = 'min_supported_version';
+
let firebaseAppPromise: Promise | null = null;
+let remoteConfigSetupPromise: Promise | null = null;
const ensureFirebaseApp = (): Promise => {
if (firebaseAppPromise) {
@@ -40,6 +43,30 @@ const ensureFirebaseApp = (): Promise => {
return firebaseAppPromise;
};
+const ensureRemoteConfigSetup = async (): Promise => {
+ if (remoteConfigSetupPromise) {
+ return remoteConfigSetupPromise;
+ }
+
+ remoteConfigSetupPromise = (async () => {
+ await ensureFirebaseApp();
+
+ await remoteConfig().setDefaults({
+ [MIN_SUPPORTED_VERSION_KEY]: '',
+ });
+
+ await remoteConfig().setConfigSettings({
+ fetchTimeMillis: 5000,
+ minimumFetchIntervalMillis: __DEV__ ? 0 : 60 * 60 * 1000, // 1h
+ });
+ })().catch((error) => {
+ remoteConfigSetupPromise = null;
+ throw error;
+ });
+
+ return remoteConfigSetupPromise;
+};
+
function getNativeAppVersion(): string | undefined {
const version =
Application.nativeApplicationVersion ??
@@ -65,65 +92,80 @@ function isVersionLessThan(current: string, min: string): boolean {
return false;
}
-export async function checkForceUpdateRequired(): Promise {
+function evaluateForceUpdateRequired(options: {
+ mode: 'cached' | 'refresh';
+ fetchedRemotely?: boolean;
+}): boolean {
+ const rcMinVersion = remoteConfig().getValue(MIN_SUPPORTED_VERSION_KEY);
+ const minSupportedVersion = rcMinVersion.asString();
+
+ const currentVersion = getNativeAppVersion();
+ const versionBlocked =
+ !!currentVersion &&
+ !!minSupportedVersion &&
+ isVersionLessThan(currentVersion, minSupportedVersion);
+
+ const required = versionBlocked;
+
+ console.log('🧩 Remote Config (force update)', {
+ mode: options.mode,
+ fetchedRemotely: options.fetchedRemotely,
+ keys: {
+ [MIN_SUPPORTED_VERSION_KEY]: {
+ valueRaw: minSupportedVersion,
+ source: rcMinVersion.getSource?.() ?? 'unknown',
+ },
+ },
+ app: {
+ currentVersion,
+ versionBlocked,
+ },
+ required,
+ lastFetchStatus: remoteConfig().lastFetchStatus,
+ fetchTimeMillis: remoteConfig().fetchTimeMillis,
+ });
+
+ return required;
+}
+
+export async function getCachedForceUpdateRequired(): Promise {
// 네이티브 앱(iOS/Android)만 대상. 웹/기타 플랫폼은 false 처리.
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
return false;
}
try {
- console.log('🧪 Remote Config(force update) 시작');
- await ensureFirebaseApp();
-
- await remoteConfig().setDefaults({
- // 권장: 최소 지원 버전(예: "1.2.0")
- min_supported_version: '',
- });
+ console.log('🧪 Remote Config(force update) 캐시 판정 시작');
+ await ensureRemoteConfigSetup();
+ return evaluateForceUpdateRequired({ mode: 'cached' });
+ } catch (error) {
+ console.warn('⚠️ 강제 업데이트 캐시 체크 실패 (force update):', error);
+ return false;
+ }
+}
- await remoteConfig().setConfigSettings({
- fetchTimeMillis: 5000,
- minimumFetchIntervalMillis: __DEV__ ? 0 : 60 * 60 * 1000, // 1h
- });
+export async function refreshForceUpdateRequired(): Promise {
+ // 네이티브 앱(iOS/Android)만 대상. 웹/기타 플랫폼은 false 처리.
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
+ return false;
+ }
+ try {
+ console.log('🧪 Remote Config(force update) 백그라운드 갱신 시작');
+ await ensureRemoteConfigSetup();
// fetch 실패해도 기존 활성값/기본값으로 판단 가능하도록 catch
const fetchedRemotely = await remoteConfig().fetchAndActivate().catch((error) => {
console.warn('⚠️ Remote Config fetchAndActivate 실패 (force update):', error);
return false;
});
- const rcMinVersion = remoteConfig().getValue('min_supported_version');
- const minSupportedVersion = rcMinVersion.asString();
-
- const currentVersion = getNativeAppVersion();
- const versionBlocked =
- !!currentVersion &&
- !!minSupportedVersion &&
- isVersionLessThan(currentVersion, minSupportedVersion);
-
- const required = versionBlocked;
-
- // 디버깅 로그: Remote Config에서 무엇을 받았는지 확인
- console.log('🧩 Remote Config (force update)', {
- fetchedRemotely,
- keys: {
- min_supported_version: {
- valueRaw: minSupportedVersion,
- source: rcMinVersion.getSource?.() ?? 'unknown',
- },
- },
- app: {
- currentVersion,
- versionBlocked,
- },
- required,
- lastFetchStatus: remoteConfig().lastFetchStatus,
- fetchTimeMillis: remoteConfig().fetchTimeMillis,
- });
-
- return required;
+ return evaluateForceUpdateRequired({ mode: 'refresh', fetchedRemotely });
} catch (error) {
- console.warn('⚠️ 강제 업데이트 체크 실패 (force update):', error);
+ console.warn('⚠️ 강제 업데이트 백그라운드 갱신 실패 (force update):', error);
return false;
}
}
+export async function checkForceUpdateRequired(): Promise {
+ return refreshForceUpdateRequired();
+}
diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx
index df92b89..fb39dd0 100644
--- a/ui/club-detail/club-detail-screen.tsx
+++ b/ui/club-detail/club-detail-screen.tsx
@@ -2,9 +2,10 @@ import { MoaImage } from "@/components/moa-image";
import { MoaText } from "@/components/moa-text";
import { PermissionDialog } from "@/components/permission-dialog";
import { USER_EVENT } from "@/constants/eventname";
-import { useMixpanelContext } from "@/contexts";
+import { useMixpanelContext } from "@/contexts/mixpanel-context";
import { useSubscribedClubsContext } from "@/contexts/subscribed-clubs-context";
-import { useMixpanelTrack, useWebViewMessageHandler } from "@/hooks";
+import { useMixpanelTrack } from "@/hooks/use-mixpanel-track";
+import { useWebViewMessageHandler } from "@/hooks/use-webview-message-handler";
import { Ionicons } from "@expo/vector-icons";
import { appendSessionId, getWebViewUserAgent } from "@/utils/webview";
import { useLocalSearchParams, useRouter } from "expo-router";
diff --git a/ui/home/components/banner.tsx b/ui/home/components/banner.tsx
index 69f26a5..25cdd42 100644
--- a/ui/home/components/banner.tsx
+++ b/ui/home/components/banner.tsx
@@ -1,7 +1,7 @@
import { MoaImage } from "@/components/moa-image";
import { USER_EVENT } from "@/constants/eventname";
import { BorderRadius, Spacing } from "@/constants/theme";
-import { useMixpanelTrack } from "@/hooks";
+import { useMixpanelTrack } from "@/hooks/use-mixpanel-track";
import { publicApi } from "@/services/api";
import { BannerProps, HomeBannerItem } from "@/ui/home/model/banner";
import { useRouter } from "expo-router";
diff --git a/ui/home/components/club-list.tsx b/ui/home/components/club-list.tsx
index 682d3f1..5a8a3e0 100644
--- a/ui/home/components/club-list.tsx
+++ b/ui/home/components/club-list.tsx
@@ -4,7 +4,7 @@
import { MoaText } from '@/components/moa-text';
import { Club } from '@/types/club.types';
-import { ClubCard } from '@/ui/home/components';
+import { ClubCard } from '@/ui/home/components/club-card';
import React, { ReactElement, RefObject } from 'react';
import { FlatList, RefreshControl, TouchableOpacity } from 'react-native';
import styled from 'styled-components/native';
diff --git a/ui/home/home-screen.tsx b/ui/home/home-screen.tsx
index fa438c2..a1f5214 100644
--- a/ui/home/home-screen.tsx
+++ b/ui/home/home-screen.tsx
@@ -8,17 +8,16 @@ import { useRouter } from 'expo-router';
import { CategoryType } from '@/components/icon';
import { PermissionDialog } from '@/components/permission-dialog';
import { PAGE_VIEW_EVENT, USER_EVENT } from '@/constants/eventname';
-import { useMixpanelTrack, useTrackScreenView } from '@/hooks';
+import { useMixpanelTrack } from '@/hooks/use-mixpanel-track';
+import { useTrackScreenView } from '@/hooks/use-track-screen-view';
import { Club } from '@/types/club.types';
-import {
- Banner,
- CategoryFilter,
- ClubList,
- MainHeader,
- Tab,
- TabType
-} from '@/ui/home/components';
-import { useClubs, useSubscribedClubs } from '@/ui/home/hook';
+import { Banner } from '@/ui/home/components/banner';
+import { CategoryFilter } from '@/ui/home/components/category-filter';
+import { ClubList } from '@/ui/home/components/club-list';
+import { MainHeader } from '@/ui/home/components/main-header';
+import { Tab, TabType } from '@/ui/home/components/tab';
+import { useClubs } from '@/ui/home/hook/use-clubs';
+import { useSubscribedClubs } from '@/ui/home/hook/use-subscribed-clubs';
export function HomeScreen() {
// SafeArea insets
@@ -246,4 +245,4 @@ const CategorySection = styled.View`
margin-bottom: 20px;
`;
-export default HomeScreen;
\ No newline at end of file
+export default HomeScreen;
diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx
index c6da484..587a2be 100644
--- a/ui/home/home-webview-screen.tsx
+++ b/ui/home/home-webview-screen.tsx
@@ -1,4 +1,4 @@
-import { useMixpanelContext } from '@/contexts';
+import { useMixpanelContext } from '@/contexts/mixpanel-context';
import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context';
import { appendSessionId, getWebViewUserAgent } from '@/utils/webview';
import { useRouter } from 'expo-router';
@@ -27,6 +27,12 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) {
const url = sessionLoading ? null : appendSessionId(BASE_URL, sessionId);
+ useEffect(() => {
+ if (url) {
+ console.log('[StartupTiming] homeWebViewSourceReady', Date.now());
+ }
+ }, [url]);
+
const sendMessage = useCallback((data: object) => {
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(JSON.stringify(data))} })); true;`,
@@ -90,6 +96,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) {
);
const handleLoadEnd = useCallback(() => {
+ console.log('[StartupTiming] homeWebViewLoadEnd', Date.now());
setLoaded(true);
}, []);
diff --git a/ui/subscribe/components/empty-state.tsx b/ui/subscribe/components/empty-state.tsx
index 74d73ca..27926c3 100644
--- a/ui/subscribe/components/empty-state.tsx
+++ b/ui/subscribe/components/empty-state.tsx
@@ -6,7 +6,7 @@ import { MoaImage } from '@/components/moa-image';
import { MoaText } from '@/components/moa-text';
import { USER_EVENT } from '@/constants/eventname';
import { MainColors } from '@/constants/theme';
-import { useMixpanelTrack } from '@/hooks';
+import { useMixpanelTrack } from '@/hooks/use-mixpanel-track';
import { useRouter } from 'expo-router';
import React from 'react';
import { TouchableOpacity } from 'react-native';
@@ -87,4 +87,3 @@ const HomeButton = styled(TouchableOpacity)`
const ButtonText = styled(MoaText)`
color: #FFFFFF;
`;
-
diff --git a/ui/subscribe/subscribe-screen.tsx b/ui/subscribe/subscribe-screen.tsx
index 7cb9e07..dc40b57 100644
--- a/ui/subscribe/subscribe-screen.tsx
+++ b/ui/subscribe/subscribe-screen.tsx
@@ -5,7 +5,8 @@
import { MoaText } from '@/components/moa-text';
import { PermissionDialog } from '@/components/permission-dialog';
import { PAGE_VIEW_EVENT, USER_EVENT } from '@/constants/eventname';
-import { useMixpanelTrack, useTrackScreenView } from '@/hooks';
+import { useMixpanelTrack } from '@/hooks/use-mixpanel-track';
+import { useTrackScreenView } from '@/hooks/use-track-screen-view';
import { Club } from '@/types/club.types';
import { useRouter } from 'expo-router';
import React, { RefObject, useCallback, useRef, useState } from 'react';
@@ -203,4 +204,3 @@ const RetryButtonText = styled(MoaText)`
`;
export default SubscribeScreen;
-
diff --git a/utils/mixpanel.ts b/utils/mixpanel.ts
index 72a5b3a..2a80ecb 100644
--- a/utils/mixpanel.ts
+++ b/utils/mixpanel.ts
@@ -1,9 +1,35 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { Mixpanel } from 'mixpanel-react-native';
+const SESSION_ID_KEY = '@moadong_session_id';
+
let mixpanelInstance: Mixpanel | null = null;
let isInitialized = false;
let initializationPromise: Promise | null = null;
+const generateSessionId = (): string => {
+ const timestamp = Date.now().toString(36);
+ const randomStr = Math.random().toString(36).substring(2, 15);
+ return `moadong_${timestamp}_${randomStr}`;
+};
+
+export const getOrCreateMixpanelSessionId = async (): Promise => {
+ try {
+ const storedSessionId = await AsyncStorage.getItem(SESSION_ID_KEY);
+ if (storedSessionId) {
+ return storedSessionId;
+ }
+
+ const newSessionId = generateSessionId();
+ await AsyncStorage.setItem(SESSION_ID_KEY, newSessionId);
+ console.log('[Mixpanel] 새 Session ID 생성:', newSessionId);
+ return newSessionId;
+ } catch (error) {
+ console.error('[Mixpanel] Session ID 로드/저장 실패:', error);
+ return generateSessionId();
+ }
+};
+
export const getMixpanel = async (): Promise => {
if (mixpanelInstance && isInitialized) {
return mixpanelInstance;