체크 누르면 로그아웃되는 버그 — 204 No Content가 토큰을 날린 이야기
성공 응답인데 클라이언트만 강제 로그아웃되던 버그를 추적해보니, refresh 트리거 조건을 너무 단순하게 짠 게 원인이었습니다.
체크 누르면 로그아웃되는 버그 — 204 No Content가 토큰을 날린 이야기
1. 사용자 보고
"루틴 체크 누르면 로그아웃되는데, 다시 들어가면 체크는 되어있어요."
이 한 문장이 디버깅의 시작이었습니다. 백엔드 로그를 보니 POST /routines/{id}/log는 204 No Content로 정상 응답하고 있었습니다. 즉, 서버는 잘 처리했는데 클라이언트만 자기 멋대로 로그인 페이지로 튕겨나가는 상황입니다.
2. 1차 시도가 어떻게 잘못됐나
처음 refresh 로직을 짤 때 이런 가정을 했습니다.
"401이 와도 직접적이지 않을 수 있다. 백엔드 앞단(Nginx, gateway)이 인증 실패 시 로그인 HTML을 그대로 흘려보낼 수도 있으니, JSON이 아닌 응답이 오면 일단 refresh를 시도하자."
그래서 트리거 조건을 이렇게 짰습니다.
// 1차 시도 (잘못된 코드)
if (res.status === 401 || !isJson(res)) {
await tryRefresh()
// 원래 요청 재시도...
}문제는 204 No Content도 비-JSON 응답이라는 점입니다. body가 아예 없으니 content-type: application/json이 붙어있어도 파싱할 게 없습니다. 결과적으로:
- 사용자가 루틴 체크박스 클릭
POST /routines/{id}/log→ 백엔드 정상 처리, 204 반환- 클라이언트: "비-JSON이네? 인증 실패인가 보다" →
tryRefresh()호출 - RefreshToken은 rotation 방식이라 한 번 쓰면 새 토큰 쌍으로 교체됨
- 잠시 뒤 다른 요청에서 또 204 → 다시 refresh 시도 → 이미 rotation된 옛 refresh token이라 만료
clearToken()+clearRefreshToken()+/login리다이렉트
서버는 두 번 다 200/204로 성공했는데, 클라이언트가 자기를 로그아웃시킨 겁니다.
3. 진짜 함정은 "성공인데 silent 처리"
이 버그가 한 시간 넘게 안 잡힌 이유가 있습니다. 콘솔에 에러가 안 찍힙니다. 네트워크 탭에 200/204가 깔끔하게 찍힙니다. 그런데 화면만 /login으로 갑니다.
교훈을 정리하면:
- 성공(2xx)인 응답은 반드시 성공으로 다뤄야 한다. body가 비어 있어도 성공입니다.
- 실패(4xx/5xx)인 응답만 인증 실패 추정 대상이 되어야 한다.
- "인증 실패의 신호"를 너무 폭넓게 잡으면, 정상 케이스가 그 그물에 걸립니다.
4. 2차 수정 — 트리거 조건 정밀화
조건을 두 가지 신호로 쪼갰습니다.
const isHtml = res.headers.get("content-type")?.includes("text/html")
const looksLikeAuthFail = res.status === 401 || (!res.ok && isHtml)
if (looksLikeAuthFail) {
await ensureRefreshed()
// 1회만 재시도
}핵심 변경:
| 응답 | 1차 시도 | 2차 수정 |
|---|---|---|
| 200 + JSON | 정상 | 정상 |
| 204 No Content | refresh 트리거 ❌ | 정상 ✅ |
| 401 + 무엇이든 | refresh 트리거 | refresh 트리거 |
| 500 + HTML 에러 페이지 | refresh 트리거 ❌ | refresh 안 함 (서버 오류) |
| 302 + HTML 로그인 페이지 | refresh 트리거 | refresh 트리거 |
성공(2xx) + 비-JSON은 정상 응답으로 인정하고 null을 반환합니다. 실패 + HTML(로그인 리다이렉트 추정) 일 때만 refresh를 시도합니다.
5. 동시 다발 401 직렬화 — refreshing: Promise | null 패턴
여기서 끝이 아닙니다. 페이지가 처음 로드될 때 5~6개 API가 동시에 나갑니다. 만약 5개가 동시에 401을 받으면 어떻게 될까요?
요청 A → 401 → refresh 시작
요청 B → 401 → refresh 시작 (또?!)
요청 C → 401 → refresh 시작 (또또?!)
RefreshToken rotation 방식에서는 재앙입니다. 첫 번째가 토큰을 새로 받으면, 두 번째·세 번째가 들고 있던 옛 토큰은 이미 무효입니다.
해결은 refreshing 프로미스 하나로 모든 호출을 직렬화하는 것입니다.
let refreshing: Promise<boolean> | null = null
async function ensureRefreshed() {
if (refreshing) return refreshing // 진행 중이면 그 결과 공유
refreshing = tryRefresh()
try {
return await refreshing
} finally {
refreshing = null
}
}요청 A가 refresh를 시작하면, 같은 시점에 들어온 B·C·D는 그 프로미스를 그대로 await 합니다. 토큰은 한 번만 갱신되고, 모든 요청이 새 토큰으로 재시도됩니다.
6. 사전 refresh — 401을 받기도 전에 갱신
또 한 가지 케이스. accessToken은 만료됐는데 refreshToken은 살아있는 상태에서 새 요청을 보내면, 굳이 401 받고 → refresh → 재시도라는 왕복을 할 필요가 없습니다.
async function request(url: string, init: RequestInit) {
if (!getAccessToken() && getRefreshToken()) {
await ensureRefreshed() // 먼저 갱신
}
// 요청 시작
}이 한 줄로 첫 페이지 진입 후 모든 요청의 깜빡임이 사라집니다.
7. 무한루프 방지 — retried 플래그
마지막 안전장치. refresh 후 재시도한 요청이 또 401을 받으면? 무한루프가 됩니다.
async function request(url: string, init: RequestInit, retried = false) {
const res = await fetch(url, init)
if (looksLikeAuthFail(res) && !retried) {
const ok = await ensureRefreshed()
if (ok) return request(url, init, true) // 1회만 재시도
}
return res
}또 하나, 이미 /login 경로에 있을 땐 리다이렉트하지 않습니다. 로그인 페이지에서 또 로그인 페이지로 보내면 사용자가 한 번 더 화면 깜빡임을 보게 됩니다.
8. 정리된 요청 흐름
요청
│
├─ accessToken 없고 refreshToken만 있음? ─ Yes ─▶ ensureRefreshed()
│
▼
fetch
│
├─ 2xx (JSON/비-JSON 무관) ─▶ 그대로 반환 ✅
│
├─ 4xx + JSON 에러 ─▶ 그대로 반환 (앱 에러)
│
└─ 401 OR (실패 + HTML)
│
├─ retried? ─ Yes ─▶ /login 리다이렉트
│
└─ No ─▶ ensureRefreshed()
│
├─ 성공 ─▶ retried=true 로 재시도
└─ 실패 ─▶ 토큰 모두 삭제 + /login
9. 마치며
이 버그에서 얻은 룰을 두 가지로 정리합니다.
- 성공 응답을 실패로 추정하지 마라. 204, 200 + 빈 body, 200 + 텍스트 — 다 정상입니다. 인증 실패를 추정할 거면 4xx부터 보세요.
- rotation 방식 토큰을 쓰면 동시 호출은 반드시 직렬화하라. 한 프로미스로 묶지 않으면 첫 갱신이 다른 요청을 무력화시킵니다.
추가로, 이런 종류의 버그는 콘솔 에러가 안 찍힙니다. 네트워크 탭만 보면 모두 정상입니다. 의심해야 할 신호는 "사용자 화면에서만 이상한 일이 벌어진다"는 보고 그 자체입니다. 다음번에 비슷한 상황을 만나면, 토큰 라이프사이클의 어디쯤에서 클라이언트가 자기를 차단했는지부터 점검해보세요.