웹앱 로그인 유지 오류: 쿠키·CORS·토큰 갱신 점검

핵심 요약
로그인 응답 200만으로는 충분하지 않으며 브라우저 저장, 후속 요청 전송, SameSite·Secure·도메인, 동시 토큰 갱신을 순서대로 확인해야 합니다.

검수 범위
특정 구현을 보편적인 정답처럼 제시하지 않고 쿠키 기반 세션과 토큰 갱신의 공통 진단 절차를 보안 문서와 대조했습니다.

웹앱을 개발하면서 로그인 자체는 정상적으로 됐지만, 새로고침하거나 일정 시간이 지나면 로그인 상태가 풀리는 문제가 발생했습니다. 처음에는 단순한 토큰 만료 문제라고 생각했는데, 확인할수록 원인이 하나가 아니었습니다. 결국 쿠키 저장, 쿠키 전송, 토큰 갱신 충돌까지 세 가지 문제가 겹쳐 있었고, 마지막 원인까지 잡았을 때는 며칠 동안 신경 쓰이던 문제가 한 번에 사라지는 기분이었습니다.

쿠키 저장 오류

처음 문제가 생겼을 때 가장 먼저 의심한 것은 Access Token 만료 시간이었습니다. 로그인 직후에는 잘 되다가 새로고침하면 로그인 페이지로 돌아가는 경우가 있었기 때문에 토큰 시간이 잘못 들어갔다고 생각했습니다. 그래서 서버에서 토큰을 발급하는 부분부터 확인했습니다. 발급 시간과 만료 시간을 로그로 찍어보고 JWT 값도 직접 확인해봤는데 특별한 문제는 없었습니다.

로그인 API도 다시 확인했습니다. 요청은 정상적으로 200 OK가 떨어졌고, 서버 로그에는 Refresh Token까지 정상적으로 만들어졌다고 나왔습니다. 서버 쪽만 보면 아무 문제가 없어 보였습니다. 그런데 브라우저에서는 계속 로그인이 풀렸습니다.

처음에는 인증 상태를 불러오는 프론트 코드에 문제가 있나 싶어서 로그인 상태를 관리하는 코드도 한참 들여다봤습니다. 로그인 직후 사용자 정보를 저장하는 부분도 바꿔보고, 새로고침할 때 인증 확인 API가 실행되는 시점도 바꿔봤습니다. 그런데 아무리 수정해도 상황은 비슷했습니다.

그러다가 개발자 도구의 Application 탭에서 쿠키를 확인해봤습니다. 여기서 처음 이상한 점을 발견했습니다. 서버에서는 Refresh Token을 쿠키에 넣었다고 나오는데 실제 브라우저 쿠키 목록에는 해당 값이 없었습니다.

처음에는 제가 잘못 본 줄 알았습니다. 쿠키를 전부 지우고 다시 로그인해보고, 브라우저를 껐다가 다시 실행해보고, 시크릿 모드에서도 테스트했습니다. 그런데 결과는 같았습니다. 서버에서는 쿠키를 만들고 있었지만 브라우저에는 저장되지 않았습니다.

그제야 쿠키 옵션을 하나씩 확인하기 시작했습니다.

res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: true, sameSite: "none", });

문제는 secure: true였습니다. 운영 환경에서는 HTTPS를 사용하기 때문에 별문제가 없지만, 당시 로컬 개발 환경은 HTTP를 사용하고 있었습니다. 운영 환경을 생각해서 미리 넣어둔 옵션이 개발 환경에서는 오히려 쿠키 저장을 방해하고 있었습니다.

개발 환경과 운영 환경에서 옵션을 다르게 적용하도록 수정했습니다.

const isProduction = process.env.NODE_ENV === "production"; res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: isProduction, sameSite: isProduction ? "none" : "lax", });

수정한 뒤 서버를 다시 실행하고 로그인했습니다. Application 탭을 열었는데 이번에는 refreshToken이 바로 보였습니다. 새로고침을 해도 로그인 상태가 그대로였습니다. 몇 번 더 새로고침을 해봤는데 계속 유지됐습니다.

그 순간에는 정말 이 문제 하나 때문이라고 생각했습니다. “결국 쿠키 옵션 하나였네” 싶어서 꽤 허탈하기도 했습니다. 몇 시간 동안 프론트 인증 코드까지 건드린 게 괜한 일이었다고 생각하면서 다른 기능 개발로 넘어갔습니다.

그런데 얼마 지나지 않아 다시 로그인 페이지가 나타났습니다.

쿠키 전송 누락

두 번째 문제부터는 조금 더 당황스러웠습니다. 이번에는 Application 탭을 확인해도 Refresh Token이 멀쩡하게 남아 있었습니다. 쿠키가 없어서 로그인이 풀렸던 첫 번째 문제와는 상황이 달랐습니다.

이번에는 서버에서 직접 쿠키 값을 확인해보기로 했습니다. 인증 상태를 확인하는 API에 임시 로그를 추가했습니다.

console.log(req.cookies);

그리고 다시 요청을 보내봤습니다. 결과는 예상 밖이었습니다.

{}

브라우저에는 쿠키가 있는데 서버에서는 아무것도 받지 못하고 있었습니다.

이때부터 조금씩 헷갈리기 시작했습니다. 처음에는 cookie-parser가 제대로 적용되지 않은 것 같아서 미들웨어 위치를 바꿔봤습니다. 서버 시작 코드에서 선언 순서도 확인했고, 쿠키 이름에 오타가 있는지도 다시 봤습니다. Refresh Token 대신 단순한 문자열을 쿠키에 넣어보기도 했습니다.

그래도 서버에서는 계속 빈 값이 들어왔습니다.

답이 안 보여서 Network 탭을 열고 요청 하나를 처음부터 끝까지 확인했습니다. 로그인 이후 인증 확인 API를 선택하고 Request Headers를 보는데, 거기에 있어야 할 Cookie가 아예 없었습니다.

그제야 서버에서 쿠키를 못 읽는 문제가 아니라 브라우저가 애초에 쿠키를 보내지 않고 있다는 것을 알게 됐습니다.

당시 프론트엔드는 localhost:3000, 백엔드는 localhost:8080을 사용하고 있었습니다. axios 설정을 다시 보니 withCredentials가 빠져 있었습니다.

axios.get("http://localhost:8080/api/auth/me");

바로 옵션을 추가했습니다.

axios.get("http://localhost:8080/api/auth/me", { withCredentials: true, });

매번 요청할 때 넣는 것보다 공통 인스턴스에 적용하는 편이 낫다고 판단해서 axios 설정 자체도 변경했습니다.

const api = axios.create({ baseURL: process.env.REACT_APP_API_URL, withCredentials: true, });

이제 되겠다고 생각하고 다시 실행했는데 이번에는 CORS 오류가 나타났습니다. 문제 하나를 잡으니 바로 다음 문제가 나오는 상황이었습니다.

백엔드 CORS 옵션도 다시 확인했습니다.

app.use( cors({ origin: "http://localhost:3000", credentials: true, }) );

credentials: true를 적용하고 서버를 다시 실행했습니다. 로그인한 뒤 Network 탭에서 인증 요청을 열어봤습니다. 이번에는 Request Headers에 Cookie가 붙어 있었습니다. 서버 로그에도 Refresh Token 값이 제대로 찍혔습니다.

여기서는 꽤 기분이 좋았습니다. 브라우저에 있던 쿠키가 실제 요청에 포함되고, 서버 로그에 그대로 나타나는 것까지 눈으로 확인하니 그동안 막혀 있던 부분이 바로 해결됐다는 확신이 들었습니다.

새로고침도 여러 번 해보고 페이지를 옮겨 다니면서 테스트했습니다. 로그인 상태가 계속 유지됐습니다. 이번에는 정말 해결됐다고 생각했습니다.

그런데 며칠 뒤 테스트를 같이 하던 사람이 “가끔 쓰다가 갑자기 로그아웃된다”고 이야기했습니다.

가장 난감했던 건 제 환경에서는 아무리 테스트해도 재현되지 않았다는 점이었습니다.

토큰 갱신 충돌

세 번째 문제는 원인을 찾는 데 가장 오래 걸렸습니다. 로그인 직후에는 아무 문제가 없었습니다. 새로고침도 잘 됐고, 여러 페이지를 돌아다녀도 로그인 상태가 유지됐습니다. 그런데 일정 시간이 지나면 정말 가끔 로그아웃됐습니다.

처음에는 서버가 순간적으로 불안정한가 싶었습니다. 당시 로그를 찾아봐도 눈에 띄는 서버 오류는 없었습니다. 그래서 로그아웃이 발생했다는 상황을 최대한 비슷하게 만들어보기 시작했습니다.

게시판에 들어갔다가 마이페이지로 이동하고, 알림을 열고, 다시 메인 화면으로 돌아오는 식으로 계속 테스트했습니다. 그래도 아무 일도 일어나지 않았습니다. 이 상태로는 원인을 찾기 어렵겠다고 생각했습니다.

그러다가 로그아웃이 로그인 직후가 아니라 어느 정도 시간이 지난 뒤 발생한다는 점이 계속 마음에 걸렸습니다. Access Token 만료 시점과 관련이 있을 것 같았습니다.

기존 만료 시간을 그대로 기다리는 대신 테스트할 때만 Access Token 만료 시간을 10초로 줄였습니다. 로그인하고 10초를 기다린 뒤 여러 API를 동시에 호출하는 메인 화면으로 이동했습니다.

그 순간 Network 탭에 401이 연달아 찍혔습니다.

GET /api/profile 401 GET /api/posts 401 GET /api/notifications 401 GET /api/bookmarks 401

여기까지는 정상이라고 생각했습니다. Access Token이 만료됐으니 API에서 401을 반환하는 게 당연했습니다. 프론트에서는 401이 발생하면 Refresh Token으로 새 Access Token을 발급받도록 만들어둔 상태였습니다.

그런데 Network 탭 아래쪽을 보다 이상한 요청을 발견했습니다.

POST /api/auth/refresh POST /api/auth/refresh POST /api/auth/refresh POST /api/auth/refresh

Refresh API가 한 번이 아니라 네 번 호출되고 있었습니다.

그 순간 바로 문제가 보이진 않았습니다. 여러 API가 동시에 401을 받았으니 각각 Refresh를 요청할 수도 있다고 생각했습니다. 그런데 서버에는 Refresh Token Rotation을 적용해둔 상태였습니다.

첫 번째 Refresh 요청이 들어오면 기존 Refresh Token을 폐기하고 새로운 Refresh Token을 발급합니다. 문제는 거의 같은 순간에 두 번째 Refresh 요청도 들어오고 있었다는 점입니다. 두 번째 요청에서 가지고 온 토큰은 첫 번째 요청에서 이미 폐기된 토큰이었습니다.

서버 로그를 조금 더 자세히 남겨봤습니다.

refresh success invalid refresh token invalid refresh token invalid refresh token

첫 번째 요청은 성공했지만 나머지 요청은 모두 실패하고 있었습니다. 더 큰 문제는 프론트에서 Refresh 요청이 실패하면 사용자를 바로 로그아웃시키도록 만들어둔 부분이었습니다.

결국 새 토큰 발급에는 성공했는데, 직후에 들어온 다른 Refresh 요청이 실패하면서 다시 로그아웃시키고 있었습니다.

이 로그를 보고 나서야 며칠 동안 가끔 발생하던 로그아웃 문제가 이해됐습니다. 랜덤하게 발생한 게 아니었습니다. Access Token이 만료되는 순간과 여러 API 요청이 동시에 실행되는 상황이 겹쳤을 때만 나타나는 문제였습니다.

그리고 재현 조건을 알아낸 순간부터 갑자기 일이 쉬워졌습니다. Access Token을 10초로 설정하고 기다린 뒤 메인 화면으로 이동하면 거의 매번 같은 문제가 발생했습니다. 전에는 언제 다시 나올지 몰라 답답했는데, 이제는 제가 원할 때 오류를 만들 수 있게 된 셈이었습니다.

갱신 요청 제한

해결 방법은 Refresh API를 동시에 여러 번 호출하지 않게 만드는 것이었습니다. 이미 토큰 갱신 요청이 진행 중이라면 새로운 Refresh 요청을 만들지 않고 기존 요청 결과를 기다리도록 수정했습니다.

let refreshPromise = null; async function refreshAccessToken() { if (!refreshPromise) { refreshPromise = api .post("/auth/refresh") .finally(() => { refreshPromise = null; }); } return refreshPromise; }

코드를 수정하고 바로 같은 조건으로 테스트했습니다. Access Token 만료 시간은 다시 10초로 설정했습니다. 로그인하고 기다렸다가 메인 페이지를 열었습니다.

예상대로 API 여러 개에서 401이 발생했습니다.

GET /api/profile 401 GET /api/posts 401 GET /api/notifications 401 GET /api/bookmarks 401

그리고 바로 아래쪽 Refresh 요청을 확인했습니다.

POST /api/auth/refresh 200

이번에는 딱 하나였습니다.

잠시 뒤 기존에 실패했던 요청들이 다시 실행됐습니다.

GET /api/profile 200 GET /api/posts 200 GET /api/notifications 200 GET /api/bookmarks 200

로그아웃도 되지 않았습니다.

혹시 우연인가 싶어서 다시 토큰을 만료시키고 테스트했습니다. 새로고침도 연속으로 해봤고, 페이지도 빠르게 이동해봤습니다. 계속 Refresh 요청은 하나만 나갔고 로그인 상태는 그대로 유지됐습니다.

이때는 진짜 속이 시원했습니다. 며칠 동안 “가끔 로그아웃된다”는 말 하나 때문에 계속 찜찜했는데, Network 탭에서 네 줄씩 찍히던 Refresh 요청이 한 줄만 찍히는 걸 보는 순간 제대로 잡았다는 생각이 들었습니다.

특히 좋았던 건 오류를 없앤 것 자체보다 원인을 완전히 이해했다는 점이었습니다. 어떤 상황에서 문제가 발생하고, 왜 로그아웃됐으며, 지금 수정한 코드가 왜 필요한지까지 설명할 수 있게 되니 그제야 정말 끝났다는 생각이 들었습니다.

결론

로그인 유지 오류는 단순히 토큰 하나만 확인해서 해결되는 문제가 아니었습니다. 쿠키 저장 여부부터 요청 전송, 서버 수신, Refresh Token 갱신까지 하나씩 확인하면서 원인을 좁혀야 했습니다. 특히 마지막에 중복 Refresh 요청까지 잡고 나니 같은 증상이 다시 나타나지 않았고, 그제야 제대로 해결했다는 확신이 들었습니다.


검증 기준과 참고 자료

이 글은 2026-09-08에 아래 공식 문서와 공개 기술 문서를 기준으로 내용과 용어를 다시 검수했습니다. 제품 버전과 기기 제조사에 따라 화면 이름은 달라질 수 있으므로 실제 화면과 공식 문서를 함께 확인하세요.

관련 글