“점심 먹고 돌아오면 왜 앱이 하얀 화면이 되죠?”

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 앱이 사용자가 돌아오는 순간 망가지지 않게 해 줍니다.