현상 정리
어느 날 보니 분명 refreshToken은 쿠키에 살아 있다.
브라우저 Application 탭을 열어보면 멀쩡히 존재한다. 그런데 화면은 로그아웃 상태처럼 보인다. 헤더에는 Login만 덩그러니 떠 있고, 마치 인증이 완전히 끊긴 것처럼 동작한다. 심지어 페이지를 이동해도 마찬가지였다. URL은 잘 바뀌는데, 인증 상태는 복구되지 않는다.
이건 뭐지?
토큰은 있는데 로그인은 아니다?
마치 지갑에 카드가 있는데 단말기가 결제를 거부하는 느낌.
인증 / 갱신 구조 요약
우리 구조는 이렇게 되어 있었다.
-
accessToken실제 API 요청에 사용되는 단기 토큰 -
refreshTokenaccess 만료 시 재발급을 위한 장기 토큰 (HttpOnly 쿠키)
클라이언트에서는 AuthProvider가 /auth/me를 호출해 로그인 여부를 판단한다.
서버 쪽에서는 BFF(프록시)가 access 만료 시 refresh API를 호출하고 성공하면 새로운 access를 세팅해준다. 이론상 완벽하다. 말로 하면 아주 그럴싸하다. 하지만 실제로는 토큰은 있는데 로그인처럼 안 보였다.
원인
1) Transient 실패
서버에서 refresh가 실패해도 쿠키를 명시적으로 지우지 않고 그냥 401만 반환하고 있었다.
즉,
-
refreshToken은 쿠키에 남아 있음
-
access는 만료됨
-
서버는 401 반환
-
클라이언트는 unauthenticated 상태로 전환
결과적으로 토큰은 있는데 로그아웃처럼 보이는 기묘한 상태가 만들어졌다. 이건 거의 쿠키는 살아있는데 세션은 죽은 상태 인증 좀비 상태랄까.
2) 페이지 이동 시 refetch 없음
더 큰 문제는 이거였다.
pathname이 바뀌어도 /auth/me를 다시 호출하지 않았다.
즉 한 번 unauthenticated 상태가 되면 복구 기회가 없었다. 마치 한 번 삐끗하면 다시 로그인 전까지 영원히 Login 버튼만 보이는 구조. 아이 셋 키우면서 한 번 삐끗하면 하루가 무너지는 것처럼, 인증도 한 번 무너지면 그대로 끝이었다.
적용한 조치
이제부터가 진짜다.
1) 리프레시 성공 시 accessrefresh 모두 재설정
-
Next
/api/auth/refresh -
phpFetch
-
BFF
이 세 군데 모두에서
refresh 성공 시 access + refresh 둘 다 쿠키로 재설정하도록 통일했다.
단일 소스 오브 트루스는 쿠키.
흐름은 하나로 정리.
토큰은 흩어지면 안 된다.
육아도 그렇고 인증도 그렇다.
일관성이 생명이다.
2) 클라이언트에서 401 발생 시 refresh 1회 시도
클라이언트 fetch에서 401이 오면
-
refresh 1회 시도
-
성공하면
/auth/me재호출 -
인증 상태 복구
한 번의 기회를 주는 구조다.
인생도, 인증도 리트라이 한 번은 있어야 한다.
3) pathname 변경 시 refetch
라우팅 변경 시 /auth/me를 다시 호출하도록 수정했다.
페이지 이동할 때마다 인증 상태를 재검증.
이제는 한 번 깨져도 복구 기회가 생겼다.
정리 (원인과 조치 요약)
refresh 실패 시 쿠키를 정리하지 않고 401만 반환하면서 access는 만료되고 refreshToken만 남는 불일치 상태가 발생했고, 클라이언트에서는 401 이후 인증 재시도 로직과 pathname 변경 시 refetch가 없어 한 번 unauthenticated 상태가 되면 복구되지 않는 구조였다. 이를 해결하기 위해 refresh 성공 시 accessrefresh를 모두 쿠키로 재설정하도록 통일하고, 클라이언트에서 401 발생 시 refresh를 1회 시도 후 /auth/me를 재호출하도록 수정했으며, pathname 변경 시에도 인증을 재확인하도록 보완했다.
결국 이 문제는 토큰 문제가 아니라 흐름의 문제였다. 나는 또 하나 배웠다. 인증은 기술이고, 복구는 설계다. 오늘도 나는
코드와 책임 사이에서 버그를 줄이고 가족을 지킨다. 완벽하진 않아도 적어도 흐름은 바로 세웠다. 그 정도면, 오늘의 나는 분명 한 단계는 성장했다.