localStorage에 토큰을 저장했는데도 똑같다고 하신 부분이 원인을 거의 알려줍니다. localStorage는 새로고침해도 지워지지 않으니, 토큰은 남아 있는데 앱이 그걸 안 읽고 있는 상황입니다.
정리하면 저장은 되는데 복원이 안 되고 있는 겁니다. 로그인할 때 토큰을 넣는 코드는 있는데, 앱이 처음 켜질 때 그 토큰을 꺼내 로그인 상태로 되돌리는 코드가 빠진 경우가 대부분입니다.
확인은 30초면 됩니다. 개발자도구를 열고 Application 탭의 Local Storage를 보세요. 새로고침 직후에도 토큰이 그대로 있으면 저장은 정상이고, 문제는 프론트 코드 쪽입니다.
그런데 여기서 훨씬 더 흔한 함정이 하나 있습니다. 복원하는 코드를 이미 넣으셨는데도 똑같이 튕기는 경우인데, 리액트의 실행 순서 때문입니다.
리액트는 화면을 먼저 한 번 그리고 나서 useEffect를 실행합니다. 그래서 첫 렌더 순간에는 user 상태가 아직 null입니다. 이때 로그인 여부를 검사하는 라우트가 null을 보고 로그인 안 했다고 판단해 곧바로 리다이렉트해 버립니다. 토큰을 읽는 코드는 그다음에야 돌아가니 이미 늦은 겁니다.
해결 방법은 로딩 상태를 하나 두는 것입니다. isLoading 같은 상태를 만들고 초기값을 true로 주세요. 그리고 토큰 확인이 끝났을 때 false로 바꿉니다.
그다음 로그인 검사하는 컴포넌트에서 isLoading이 true인 동안에는 리다이렉트도 하지 말고 화면도 그리지 말고 로딩 중이라는 표시만 반환하게 하세요. 확인이 끝난 뒤에만 판단하도록 만드는 것이 핵심입니다.
세션 방식도 함께 쓰고 계시다면 하나 더 보셔야 합니다. 프론트에서 서버로 요청을 보낼 때 쿠키를 같이 실어 보내야 하는데, axios는 withCredentials를 true로, fetch는 credentials를 include로 지정해야 합니다. 이걸 빼면 서버 세션이 살아 있어도 요청에 쿠키가 안 붙습니다.
서버 쪽에서도 짝을 맞춰야 합니다. Express에서 CORS 설정할 때 origin을 별표가 아니라 프론트 주소로 명확히 적으시고 credentials를 true로 켜셔야 합니다. 별표를 쓰면 브라우저가 쿠키를 아예 보내지 않습니다.
점검 순서를 정리하면 이렇습니다. 첫째 localStorage에 토큰이 남아 있는지, 둘째 앱 시작 시 그 토큰을 읽는 코드가 있는지, 셋째 로딩 상태 처리가 있는지, 넷째 요청에 쿠키가 실려 가는지를 Network 탭에서 확인하시면 됩니다. 경험상 둘째와 셋째에서 해결되는 경우가 대부분입니다.