JWT2026년 4월 30일5분 읽기

체크 누르면 로그아웃되는 버그 — 204 No Content가 토큰을 날린 이야기

성공 응답인데 클라이언트만 강제 로그아웃되던 버그를 추적해보니, refresh 트리거 조건을 너무 단순하게 짠 게 원인이었습니다.

#JWT#Refresh Token#Frontend#디버깅#Next.js

체크 누르면 로그아웃되는 버그 — 204 No Content가 토큰을 날린 이야기


1. 사용자 보고

"루틴 체크 누르면 로그아웃되는데, 다시 들어가면 체크는 되어있어요."

이 한 문장이 디버깅의 시작이었습니다. 백엔드 로그를 보니 POST /routines/{id}/log204 No Content로 정상 응답하고 있었습니다. 즉, 서버는 잘 처리했는데 클라이언트만 자기 멋대로 로그인 페이지로 튕겨나가는 상황입니다.


2. 1차 시도가 어떻게 잘못됐나

처음 refresh 로직을 짤 때 이런 가정을 했습니다.

"401이 와도 직접적이지 않을 수 있다. 백엔드 앞단(Nginx, gateway)이 인증 실패 시 로그인 HTML을 그대로 흘려보낼 수도 있으니, JSON이 아닌 응답이 오면 일단 refresh를 시도하자."

그래서 트리거 조건을 이렇게 짰습니다.

ts
// 1차 시도 (잘못된 코드)
if (res.status === 401 || !isJson(res)) {
  await tryRefresh()
  // 원래 요청 재시도...
}

문제는 204 No Content도 비-JSON 응답이라는 점입니다. body가 아예 없으니 content-type: application/json이 붙어있어도 파싱할 게 없습니다. 결과적으로:

  1. 사용자가 루틴 체크박스 클릭
  2. POST /routines/{id}/log → 백엔드 정상 처리, 204 반환
  3. 클라이언트: "비-JSON이네? 인증 실패인가 보다" → tryRefresh() 호출
  4. RefreshToken은 rotation 방식이라 한 번 쓰면 새 토큰 쌍으로 교체됨
  5. 잠시 뒤 다른 요청에서 또 204 → 다시 refresh 시도 → 이미 rotation된 옛 refresh token이라 만료
  6. clearToken() + clearRefreshToken() + /login 리다이렉트

서버는 두 번 다 200/204로 성공했는데, 클라이언트가 자기를 로그아웃시킨 겁니다.


3. 진짜 함정은 "성공인데 silent 처리"

이 버그가 한 시간 넘게 안 잡힌 이유가 있습니다. 콘솔에 에러가 안 찍힙니다. 네트워크 탭에 200/204가 깔끔하게 찍힙니다. 그런데 화면만 /login으로 갑니다.

교훈을 정리하면:

  • 성공(2xx)인 응답은 반드시 성공으로 다뤄야 한다. body가 비어 있어도 성공입니다.
  • 실패(4xx/5xx)인 응답만 인증 실패 추정 대상이 되어야 한다.
  • "인증 실패의 신호"를 너무 폭넓게 잡으면, 정상 케이스가 그 그물에 걸립니다.

4. 2차 수정 — 트리거 조건 정밀화

조건을 두 가지 신호로 쪼갰습니다.

ts
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 Contentrefresh 트리거 ❌정상 ✅
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을 받으면 어떻게 될까요?

text
요청 A → 401 → refresh 시작
요청 B → 401 → refresh 시작 (또?!)
요청 C → 401 → refresh 시작 (또또?!)

RefreshToken rotation 방식에서는 재앙입니다. 첫 번째가 토큰을 새로 받으면, 두 번째·세 번째가 들고 있던 옛 토큰은 이미 무효입니다.

해결은 refreshing 프로미스 하나로 모든 호출을 직렬화하는 것입니다.

ts
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 → 재시도라는 왕복을 할 필요가 없습니다.

ts
async function request(url: string, init: RequestInit) {
  if (!getAccessToken() && getRefreshToken()) {
    await ensureRefreshed()  // 먼저 갱신
  }
  // 요청 시작
}

이 한 줄로 첫 페이지 진입 후 모든 요청의 깜빡임이 사라집니다.


7. 무한루프 방지 — retried 플래그

마지막 안전장치. refresh 후 재시도한 요청이 또 401을 받으면? 무한루프가 됩니다.

ts
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. 정리된 요청 흐름

text
요청
  │
  ├─ accessToken 없고 refreshToken만 있음? ─ Yes ─▶ ensureRefreshed()
  │
  ▼
fetch
  │
  ├─ 2xx (JSON/비-JSON 무관) ─▶ 그대로 반환 ✅
  │
  ├─ 4xx + JSON 에러 ─▶ 그대로 반환 (앱 에러)
  │
  └─ 401 OR (실패 + HTML)
        │
        ├─ retried? ─ Yes ─▶ /login 리다이렉트
        │
        └─ No ─▶ ensureRefreshed()
                    │
                    ├─ 성공 ─▶ retried=true 로 재시도
                    └─ 실패 ─▶ 토큰 모두 삭제 + /login

9. 마치며

이 버그에서 얻은 룰을 두 가지로 정리합니다.

  1. 성공 응답을 실패로 추정하지 마라. 204, 200 + 빈 body, 200 + 텍스트 — 다 정상입니다. 인증 실패를 추정할 거면 4xx부터 보세요.
  2. rotation 방식 토큰을 쓰면 동시 호출은 반드시 직렬화하라. 한 프로미스로 묶지 않으면 첫 갱신이 다른 요청을 무력화시킵니다.

추가로, 이런 종류의 버그는 콘솔 에러가 안 찍힙니다. 네트워크 탭만 보면 모두 정상입니다. 의심해야 할 신호는 "사용자 화면에서만 이상한 일이 벌어진다"는 보고 그 자체입니다. 다음번에 비슷한 상황을 만나면, 토큰 라이프사이클의 어디쯤에서 클라이언트가 자기를 차단했는지부터 점검해보세요.

#JWT#Refresh Token#Frontend#디버깅#Next.js

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js