“점심 먹고 돌아오면 왜 앱이 하얀 화면이 되죠?”
Cursor로 멋진 Expo 앱을 만들었다. Claude Code가 auth flow, dashboard, settings screen까지 전부 생성해 줬다. 배포했다. 사용자가 설치했다. 그리고 DM이 오기 시작한다.
- “점심 먹고 돌아와서 열면 왜 하얀 화면이 뜨죠?”
- “왜 아침마다 다시 로그인해야 하죠?”
- “Instagram 보고 돌아왔더니 왜 데이터가 사라졌죠?”
망가진 앱을 만든 게 아닙니다. 네이티브 앱 컨테이너 안에 웹 앱 사고방식을 넣은 것뿐입니다. 브라우저에는 app lifecycle이 없습니다. 모바일 앱에는 있습니다. 사용자가 홈 버튼을 눌러도 앱은 죽지 않고 잠듭니다. 여섯 시간 뒤 아이콘을 다시 탭하면 앱은 깨어납니다. 그리고 여섯 시간 전에 참이던 전제는 이제 더 이상 참이 아닙니다.
Resume 문제
사용자가 몇 시간 뒤에 다시 돌아오면, 앱은 몇 가지 결정을 내려야 합니다. session은 아직 유효한가? 데이터는 아직 충분히 최신인가? 대부분의 Vibe-coded 앱은 이 판단을 screen마다 제각각 처리하고, 결과도 제각각입니다. 어떤 screen은 다시 fetch합니다. 어떤 screen은 stale data를 그대로 보여 줍니다. 또 다른 screen은 session이 아직 남아 있다고 가정하고 크래시가 납니다.
해결책은 간단합니다. 사용자가 마지막으로 active 상태였던 시점으로부터 경과한 시간으로 resumeStatus를 계산하고, 앱 전체가 그 값에 반응하게 만드는 것입니다.
// src/context/app-lifecycle-provider.tsx
import { AppState } from 'react-native'
export type AppResumeStatus =
'fresh' | 'resumed' | 'stale' | 'expired'
export function getResumeStatus(
lastActiveAt: number,
now: number,
staleMs: number = 5 * 60 * 1000, // 5 minutes
expireMs: number = 24 * 60 * 60 * 1000 // 24 hours
): AppResumeStatus {
const idleMs = now - lastActiveAt
if (idleMs >= expireMs) return 'expired'
if (idleMs >= staleMs) return 'stale'
return lastActiveAt === 0 ? 'fresh' : 'resumed'
}
의미가 분명한 네 가지 상태가 있습니다.
- fresh: 첫 실행. 이전 session이 없다.
- resumed: 5분 이내 복귀. 데이터는 대체로 문제없다.
- stale: 5분 이상 비활성 상태였다. 핵심 데이터를 다시 fetch할 시점이다.
- expired: 24시간 이상 비활성 상태였다. 사실상 새로 실행한 것처럼 다룬다.
Lifecycle Provider
AppState에는 한 번만 subscribe하고, 계산된 status를 어디서나 읽을 수 있게 노출합니다.
export function AppLifecycleProvider({
children,
}: PropsWithChildren) {
const { storage } = useServices()
const [state, setState] = useState({
appState: AppState.currentState,
lastActiveAt: 0,
resumeStatus: 'fresh' as AppResumeStatus,
ready: false,
})
useEffect(() => {
const sub = AppState.addEventListener(
'change',
(nextAppState) => {
if (nextAppState === 'active') {
const resumeStatus = getResumeStatus(
state.lastActiveAt,
Date.now()
)
setState((s) => ({
...s,
appState: nextAppState,
resumeStatus,
}))
} else if (nextAppState === 'background') {
storage.fastStorage.set('lastActiveAt', Date.now())
setState((s) => ({
...s,
appState: nextAppState,
lastActiveAt: Date.now(),
}))
}
}
)
storage.fastStorage
.get<number>('lastActiveAt')
.then((ts) => {
setState((s) => ({
...s,
lastActiveAt: ts ?? 0,
resumeStatus: getResumeStatus(ts ?? 0, Date.now()),
ready: true,
}))
})
return sub.remove
}, [storage])
return (
<LifecycleContext.Provider value={state}>
{children}
</LifecycleContext.Provider>
)
}
핵심은 이것입니다. auth, update, data refresh처럼 resume 상태를 신경 써야 하는 provider는 모두 이 context의 resumeStatus를 읽습니다. 어떤 screen도 자체적으로 AppState.addEventListener를 등록하지 않습니다. 어떤 feature도 앱이 background에 있던 시간을 제멋대로 추측하지 않습니다.
Route Guard는 둘 다 기다려야 한다
route guard는 auth 복원과 lifecycle 준비 완료를 둘 다 기다린 뒤에 렌더링해야 합니다.
// app/_layout.tsx
function RootNavigator() {
const { session, loading } = useAuth()
const { ready: lifecycleReady } = useAppLifecycle()
if (loading || !lifecycleReady) return null
return (
<Stack>
<Stack.Protected guard={!session}>
<Stack.Screen name='login' />
</Stack.Protected>
<Stack.Protected guard={!!session}>
<Stack.Screen name='(app)' />
</Stack.Protected>
</Stack>
)
}
웹 개발자들이 이 문제를 잘 체감하지 못하는 이유는 브라우저에는 app lifecycle이 없기 때문입니다. 모바일 개발에서는 그런 사치가 없습니다. Autotomy Expo Starter Pack은 lifecycle 관리를 처음부터 내장하고 있어서, Vibe-coded 앱이 사용자가 돌아오는 순간 망가지지 않게 해 줍니다.