JWT 인증의 구조와, 내가 빠졌던 함정을 실제 "멀쩡히 쓰다 갑자기 로그아웃되는" 버그를 겪으며 이해한 대로 정리한 글입니다.

JWT는 어떻게 로그인을 기억하는가
전통적인 세션 방식은 서버가 "누가 로그인했는지"를 직접 기억합니다. 로그인하면 서버 메모리나 저장소에 세션을 남기고, 브라우저에는 그 세션을 가리키는 열쇠만 쥐여 주죠. 반면 JWT는 접근을 뒤집습니다. 서버가 아무것도 기억하지 않는 대신, "이 사람은 누구이고 무엇을 할 수 있다"는 정보를 토큰 자체에 적어 클라이언트에게 통째로 건네줍니다. 요청이 올 때마다 서버는 그 토큰이 위조되지 않았는지만 검증하면 되죠.
이 토큰은 점 두 개로 나뉜 세 조각으로 되어 있습니다.

- 헤더(header) — 어떤 알고리즘으로 서명했는지 같은 메타 정보가 들어갑니다.
- 페이로드(payload) — 사용자 id, 권한, 만료 시각(
exp) 등 실제 담고 싶은 내용이 들어갑니다. - 서명(signature) — 헤더와 페이로드를 서버만 아는 비밀 키로 서명한 값입니다. 누군가 페이로드를 몰래 바꾸면 이 서명이 어긋나서 위조가 들통나죠.
예를 들어 페이로드를 풀어 보면, 대략 아래처럼 사람이 읽을 수 있는 형태가 그대로 들어 있습니다.
{
"sub": "user_1024",
"role": "member",
"exp": 1751520000
}
여기서 제가 처음에 크게 오해했던 부분이 있습니다. 저는 페이로드가 "암호화"된다고 막연히 믿었습니다. 그런데 페이로드는 그냥 인코딩만 되어 있어서, 토큰 문자열을 아무 디코더에나 붙여 넣으면 내용이 그대로 보입니다. 실제로 브라우저 콘솔에서 제 토큰을 풀어 봤을 때, 안에 든 사용자 정보가 평문으로 쫙 나오는 걸 보고 등골이 서늘했죠. 그날 이후로 저는 페이로드에 민감한 값은 절대 넣지 않습니다. 서명은 위조를 막을 뿐, 내용을 숨겨 주지는 않는다—이 한 줄이 JWT를 다루는 출발점이라고 생각합니다.
처음 이 방식을 택했을 때 저를 사로잡은 매력은 "서버가 가벼워진다"는 점이었습니다. 세션 저장소를 따로 두지 않아도 되고, 서버를 여러 대로 늘려도 어느 서버가 요청을 받든 토큰만 검증하면 되니까요. 실제로 서버를 확장하는 상황에서 이 구조는 분명한 이점이 있었습니다. 다만 그 가벼움에는 대가가 따른다는 걸, 저는 조금 뒤에 아주 아프게 배우게 됐죠.
새벽에 터진 '갑자기 로그아웃' 버그
문제가 터진 건 서비스를 열고 한 주쯤 지났을 때였습니다. "한참 잘 쓰다가 갑자기 로그아웃돼요"라는 문의가 하나둘 들어오기 시작했죠. 재현이 까다로웠습니다. 방금 로그인해 이것저것 눌러 보면 멀쩡한데, 자리를 비우고 한 시간쯤 뒤에 돌아와 버튼을 누르면 로그인 화면으로 튕겨 나갔으니까요.
처음에는 프런트엔드 문제인지 서버 문제인지조차 헷갈렸습니다. 그래서 서버 로그와 브라우저 네트워크 탭을 나란히 놓고, 로그아웃이 튀는 순간의 요청을 하나하나 따라가 봤죠. 그랬더니 로그아웃 직전에 늘 401 응답이 찍혀 있었고, 그 401이 뜨는 시점이 하나같이 로그인한 지 한 시간을 넘긴 뒤라는 공통점이 보였습니다. 만료 시간을 일부러 5분으로 줄여 놓고 테스트하니, 5분마다 어김없이 같은 증상이 재현됐죠. 그제야 범인이 잡혔습니다.
원인은 제가 설정한 만료 시간에 있었습니다. access token의 수명을 한 시간으로 짧게 잡아 놓고, 정작 만료됐을 때 조용히 새 토큰을 받아 오는 처리를 빼먹었던 겁니다. 서버는 만료된 토큰에 정직하게 401 Unauthorized를 돌려줬고, 제 코드는 401만 보면 무조건 로그인 화면으로 보내 버렸죠. 사용자 입장에서는 "아무 잘못도 안 했는데 쫓겨나는" 셈이었습니다.
제대로 된 흐름은 이렇습니다. access token이 만료되면, 수명이 훨씬 긴 refresh token을 서버에 제시해 새 access token을 조용히 발급받아야 합니다.

그래서 응답 인터셉터를 두어, 401이 오면 곧바로 로그아웃시키는 대신 먼저 토큰 갱신을 시도하도록 고쳤습니다.
// 401을 만나면 refresh로 새 토큰을 받아 원래 요청을 재시도
api.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401 && !error.config._retried) {
error.config._retried = true;
const { accessToken } = await refresh(); // refresh token으로 갱신
setToken(accessToken);
error.config.headers.Authorization = `Bearer ${accessToken}`;
return api(error.config); // 원래 요청 재시도
}
return Promise.reject(error);
});
여기서 한 번 더 데였습니다. 페이지가 열리자마자 만료된 토큰으로 여러 요청이 동시에 나가면, 그 요청들이 저마다 refresh를 호출해 갱신이 여러 번 겹쳤죠. 심하면 한 요청이 갱신한 새 토큰을, 뒤늦게 끝난 다른 갱신이 또 다른 토큰으로 덮어써 버리는 일까지 생겼습니다. 결국 갱신은 한 번만 돌리고 나머지 요청은 그 결과를 기다렸다가 재시도하도록, 진행 중인 refresh를 하나의 프로미스로 묶어 공유하는 방식으로 마무리했습니다.
고치고 나서는 만료 시간을 다시 짧게 줄여 놓고, 일부러 여러 요청을 동시에 던지며 로그아웃이 재현되지 않는지 확인했습니다. 갱신 요청이 딱 한 번만 나가고 나머지가 새 토큰으로 조용히 재시도되는 걸 네트워크 탭에서 확인했을 때에야 비로소 마음을 놓았죠. 인증처럼 사용자가 매 순간 부딪히는 기능은, 이렇게 실패 상황을 일부러 만들어 확인하지 않으면 꼭 나중에 문의로 돌아오더군요.
토큰을 다루며 지켜야 할 것들
그때의 삽질을 정리하면, JWT를 쓸 때 저는 다음을 꼭 챙깁니다.
- 수명은 짧게, 갱신은 길게 — access token은 짧게(수십 분 단위), refresh token은 그보다 길게 둡니다. access token이 유출돼도 금방 만료되도록 하는 안전장치죠.
- refresh token 저장 위치를 고민하기 —
localStorage는 편하지만 XSS에 노출되면 그대로 털립니다. 가능하면 자바스크립트로 못 읽는httpOnly쿠키에 두는 편이 안전하죠. - 완전한 무상태라는 환상은 버리기 — 로그아웃이나 탈취 대응처럼 "특정 토큰을 즉시 무효화"하려면 결국 서버가 무효화 목록을 어딘가에 들고 있어야 합니다. JWT를 쓴다고 서버가 정말 아무것도 기억하지 않아도 되는 건 아니더군요.
- 만료 여유(clock skew) 두기 — 서버와 클라이언트의 시계가 미세하게 어긋날 수 있으니, 만료 판정에 약간의 여유를 두는 편이 안심입니다.
- 같은 401이라도 이유를 구분하기 — "토큰이 만료됐다"와 "애초에 권한이 없다"는 똑같이 401로 올 수 있지만 대응이 정반대입니다. 만료는 갱신으로 풀고, 진짜 권한 문제는 로그아웃으로 보내야 하죠. 이걸 뭉뚱그리면 갱신을 무한히 반복하는 루프에 빠지기 쉽습니다.
개인적으로는, JWT를 "로그인 상태를 안 들고 있어도 되는 마법"처럼 소개하는 글들이 저를 오래 헤매게 했다고 봅니다. 실제로는 편의와 안전 사이에서 계속 저울질해야 하는 도구에 가깝죠. 그 저울질을 회피하지 않는 것—그게 갑작스러운 로그아웃 같은 사고를 막는 가장 확실한 길이라고 느낍니다.