React 상태는 외부 저장소를 모른다. localStorage에 값을 써도 컴포넌트는 다시 그려지지 않는다. 거기엔 구독이 없으니까. 평소엔 이 사실이 안 보이다가, 외부 저장소에 쓴 값이 화면에 반영되어야 하는 순간 단절이 드러난다. 로그인 후 무한 로딩이 그 단절이었다.
증상
로그인을 완료하면 “앱을 불러오는 중…” 에서 멈췄다. 새로고침하면 정상이었다.
if (accessToken && refreshToken) { localStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', refreshToken); navigate('/vacation', { replace: true }); // 토큰만 쓰고 바로 이동}navigate는 성공한다. URL도 바뀐다. 그런데 화면은 로딩 스피너에 멈춰 있다. App.tsx가 이 조건을 보기 때문이다.
if (loading || !permissions) return <LoadingScreen />;permissions는 PermissionProvider가 관리하는데, 그 값이 여전히 null이다.
인증 상태는 두 곳에 산다
문제의 핵심은 인증 상태가 두 가지 표현으로 존재한다는 데 있다.
- 출처(source of truth):
localStorage의 토큰. 새로고침을 견디는 durable한 값. - 파생(derived): Context의
permissions. 화면이 실제로 보는 reactive한 값.
로그인 코드는 출처만 바꾸고 파생은 안 깨웠다. 토큰은 저장됐지만 permissions는 그대로 null이라, 화면은 “아직 로그인 안 된 상태”를 그린다. 데이터는 맞는데 화면만 안 따라온다.
왜 파생이 자동으로 안 따라올까. localStorage는 React 바깥이고, setItem은 렌더를 유발하지 않는다. PermissionProvider의 useEffect는 마운트 시 한 번 도는데, 이미 마운트된 뒤 localStorage만 바꾸면 다시 돌 이유가 없다. 흔히 기대하는 storage 이벤트조차 여기선 안 뜬다. 그 이벤트는 다른 탭에서만 발생하고, 값을 쓴 바로 그 탭에서는 발생하지 않는다. 그래서 React는 토큰이 바뀐 걸 알 길이 없다.
새로고침이 “고친” 것처럼 보인 이유도 이걸로 설명된다. 새로고침하면 PermissionProvider가 처음부터 마운트되며 useEffect가 돌고, 이미 저장된 토큰으로 세션을 불러온다. 버그를 고친 게 아니라 마운트를 다시 시켰을 뿐이다.
수정 — 파생을 명시적으로 깨운다
출처를 바꿨으면 파생도 명시적으로 갱신해야 한다. PermissionProvider가 노출하는 refreshPermissions로 세션을 다시 끌어온 뒤 이동한다.
if (accessToken && refreshToken) { localStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', refreshToken);
// 파생 상태(permissions)를 갱신한 뒤에 이동한다 refreshPermissions().then(() => { navigate('/vacation', { replace: true }); });}갱신이 끝난 뒤 navigate하니 permissions가 이미 채워져 있어 로딩 화면을 건너뛴다. navigate 타이밍이 상태 갱신 이후여야 한다는 게 핵심이다. 그 전에 이동하면 파생이 비어 있는 화면으로 들어간다.
더 근본적인 길도 있다. React 18의 useSyncExternalStore는 외부 저장소를 React가 구독하게 해, 저장소가 바뀌면 자동으로 리렌더하게 만든다. 단절 자체를 없애는 방법이다. 다만 인증처럼 “쓰는 쪽이 어디인지 분명하고, 그 자리에서 명시적으로 갱신하면 되는” 경우엔 구독 인프라를 까는 것보다 트리거 한 번이 단순하다. 외부 상태가 여러 곳에서 바뀌고 여러 컴포넌트가 그걸 봐야 하면 구독이, 변경 지점이 좁으면 명시적 트리거가 맞다.
그래서
외부 저장소에 쓴 값이 화면에 반영되려면 둘 중 하나다. React가 그 저장소를 구독하거나, 누군가 파생 상태를 명시적으로 갱신하거나. localStorage는 조용하다. 쓴다고 알려주지 않고, 같은 탭에선 이벤트도 안 뜬다. 출처와 파생이 따로 사는 구조에선, 출처를 바꿀 때마다 “파생은 누가 깨우나”를 같이 물어야 한다. 안 물으면 데이터는 맞고 화면만 멈춘다.