앱에 로그인 붙이면 항상 나오는 문제. "로그인 안 한 사람이 홈 탭에 접근하면 어떻게 막지?"
예전엔 각 화면 상단에서 useEffect로 토큰 확인하고 router.replace('/login') 때리는 식으로 처리했다. 화면마다 같은 코드가 반복되고, 리다이렉트되기 전에 보호된 화면이 한 프레임 깜빡이는 것도 거슬렸다. 레이아웃 마운트 순서 때문에 "Attempted to navigate before mounting the Root Layout" 에러도 종종 만났다.
Expo Router v5(SDK 53)부터는 Stack.Protected라는 선언적 가드가 생겼다. 조건이 안 맞으면 그 그룹 자체가 라우팅에서 빠져서, 접근하면 자동으로 다른 화면으로 튕긴다.
인증 상태부터
먼저 로그인 여부를 어디서든 읽을 수 있게 context로 빼둔다. 실제 토큰은 expo-secure-store에 넣는 걸 추천.
// context/auth.tsx
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext<{
isLoggedIn: boolean;
signIn: () => void;
signOut: () => void;
}>(null!);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState(false);
return (
<AuthContext.Provider
value={{
isLoggedIn,
signIn: () => setIsLoggedIn(true),
signOut: () => setIsLoggedIn(false),
}}
>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
라우트를 그룹으로 감싸기
핵심은 루트 레이아웃이다. guard prop에 조건을 주고, 그 안에 보호할 화면(또는 (app) 같은 그룹)을 넣는다.
// app/_layout.tsx
import { Stack } from 'expo-router';
import { AuthProvider, useAuth } from '../context/auth';
function RootNavigator() {
const { isLoggedIn } = useAuth();
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="login" />
</Stack.Protected>
</Stack>
);
}
export default function RootLayout() {
return (
<AuthProvider>
<RootNavigator />
</AuthProvider>
);
}
guard가 false가 되면 그 안의 라우트는 존재하지 않는 것처럼 취급된다. 로그아웃하면 (app) 그룹이 통째로 빠지면서 자동으로 login으로 이동한다. router.replace를 직접 부를 일이 없다.
걸렸던 부분
- 가드 조건이 준비되기 전(예: SecureStore에서 토큰 읽는 중)에는
null을 리턴하거나 스플래시를 유지해야 한다. 로딩 중에isLoggedIn이false로 잡히면 로그인 화면이 한 번 깜빡인다. Stack.Protected는Stack안에서만 동작한다.Tabs에 바로는 못 쓰니, 탭 전체를 보호하려면 탭 그룹을Stack.Screen으로 감싸서 그 위에 가드를 건다.
화면마다 흩어져 있던 인증 체크가 레이아웃 한 곳으로 모이니까 훨씬 읽기 쉬워졌다. 조건만 바꾸면 온보딩 완료 여부, 결제 여부 같은 다른 가드도 똑같은 패턴으로 추가할 수 있다.