발행일

JWT exp를 프런트가 읽던 함수를 지웠다 — 만료 판단을 서버에 맡기기까지 한 달

JWT exp를 프런트가 읽던 함수를 지웠다

이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.

TripTune에는 2024년 9월 4일부터 이런 함수가 있었습니다(67973cf).

const isTokenExpired = (token: string) => {
  try {
    const [, payload] = token.split('.');
    const decoded = JSON.parse(atob(payload));
    return decoded.exp * 1000 < Date.now();
  } catch (error) {
    return true;
  }
};

액세스 토큰을 점으로 쪼개 가운데 조각을 base64로 풀고, exp를 현재 시각과 비교합니다. 요청을 보내기 전에 프런트가 먼저 "이 토큰 아직 살아 있나"를 판단하려고 넣은 것으로 보입니다. 서명 검증은 없으니 위조를 막는 코드는 아니고, 만료된 토큰으로 헛 요청을 안 보내려는 용도였습니다.

이 함수가 2025년 1월 11일에 사라지고, 2월 3일에는 화면마다 쿠키를 읽던 로그인 유도가 훅 하나로 모입니다. 한 달 동안의 커밋 셋을 따라가 보면 이렇습니다.

1월 3일 — alert 대신 모달, 그리고 주석 처리

bf8c434는 "로그인 유도 페이지 만들기"입니다. 여행지 목록·상세 화면에서 북마크를 누르면 토큰을 확인하는데, 없으면 이렇게 하고 있었어요.

       const accessToken = Cookies.get('trip-tune_at');
       if (!accessToken) {
-        alert('로그인이 필요합니다.');
-        router.push('/login');
+        setShowLoginModal(true);
         return;
       }

alert를 모달로 바꾼 겁니다. 그런데 같은 커밋에 이런 diff도 있습니다.

 const getAuthHeaders = (): HeadersInit => {
   const accessToken = Cookies.get('trip-tune_at');
-  if (!accessToken) {
-    throw new Error('액세스 토큰이 없습니다. 다시 로그인 해주세요.');
-  }
+  // if (!accessToken) {
+  //   throw new Error('액세스 토큰이 없습니다. 다시 로그인 해주세요.');
+  // }

공통 요청 함수에서 토큰 없을 때 던지던 예외를 주석으로 막았습니다. 왜 막았는지는 커밋에 없어요. 지금 보면 화면에서 모달을 띄우기로 했으니 요청 함수가 먼저 터지면 안 된다고 생각한 것 같은데, 그러면 토큰 없이 Bearer undefined가 나갑니다. 이때는 그 차이를 몰랐습니다.

1월 11일 — exp 비교를 지웠다

917e847, 제목은 "로그인 모달 안에서 만료 비교 함수 삭제". useAuth에서 위의 isTokenExpired를 통째로 지우고 조건을 바꿨습니다.

-    if (!accessToken || isTokenExpired(accessToken)) {
+    // 액세스 토큰이 없는 경우 처리
+    if (!accessToken) {
       if (refreshToken && !isRefreshing.current) {

이제 프런트는 토큰이 있는지만 봅니다. 살아 있는지는 안 봐요. 만료됐으면 서버가 401을 줄 테고, 그때 리프레시를 타면 됩니다.

왜 지웠는지도 커밋에는 없습니다. 지금 다시 정리하면 이유는 두 가지예요. 하나는 브라우저 시계와 서버 시계가 같다는 보장이 없다는 것. 다른 하나는 만료 판단을 하는 곳이 둘(프런트의 exp 비교, 서버의 401)이면 둘이 어긋날 때 어느 쪽을 믿을지 정해야 한다는 것. 서버가 어차피 최종 판단을 하니 프런트 쪽을 없애는 게 맞습니다. 다만 이건 2026년의 정리이고, 2025년 1월의 저는 "모달 안에 있을 코드가 아니다" 정도로 생각했던 것 같습니다. 커밋 제목이 그렇게 읽혀요.

2월 3일 — 화면이 쿠키를 읽지 않게

그 사이 useAuth는 두 번 더 바뀌어(5bd2d7f·4f761e5) isAuthenticated·isLoading을 돌려주는 훅이 됐고, isAuthenticated는 "확인 중"을 뜻하는 null을 가질 수 있게 됐습니다.

그리고 2월 3일 밤 11시 53분(b1463bc), 여행지 화면 둘이 쿠키를 직접 읽는 대신 이 훅을 씁니다.

-  const { getDecryptedCookie } = saveLocalContent();
-  const accessToken = getDecryptedCookie('trip-tune_at');
-  const requiresAuth = !!accessToken;
-  const [showLoginModal, setShowLoginModal] = useState(false);
+  const { isAuthenticated } = useAuth();
+  const requiresAuth = !!isAuthenticated;

여기까지는 맞는 방향입니다. 그런데 모달을 띄우던 자리는 이렇게 바꿨어요.

   const toggleBookmark = async (placeId: number, bookmarkStatus = false) => {
-    if (!accessToken) {
-      setShowLoginModal(true);
-      return;
+    if (!isAuthenticated) {
+      return <LoginModal />;
     }

이벤트 핸들러 안에서 JSX를 return한 겁니다. 반환값을 받아서 그리는 곳이 없으니 이 코드는 모달을 띄우지 않습니다. 게다가 같은 커밋에서 JSX 아래쪽의 {showLoginModal && <LoginModal />}도 지웠어요. 2월 3일 밤부터 여행지 화면에서는 로그인 안 한 채 북마크를 누르면 아무 일도 안 일어났을 겁니다.

사흘 뒤 2월 6일(280c9a8)에 showLoginModal 상태와 조건부 렌더가 다시 들어왔습니다. 그 커밋 제목은 "Alert first"예요.

검증

  • isTokenExpired가 저장소에 들어온 커밋과 나간 커밋은 git log -S isTokenExpired로 확인했습니다. 2024-09-04 도입, 2025-01-11 삭제.
  • 2월 3일 커밋이 모달을 못 띄운다는 건 diff를 읽고 판단한 것입니다. 당시에 그 화면을 눌러 봤는지는 기록이 없어요. 사흘 뒤 되돌린 커밋이 있으니 눌러 보고 알았을 가능성이 높지만, 그것도 추정입니다.
  • 이 시기에 테스트는 없었습니다. 전부 손으로 확인했습니다.

남은 것 · 한계

  • getAuthHeaders의 예외를 주석으로 막은 채 넘어갔습니다. 토큰이 없으면 Bearer undefined가 나가는 상태가 이 커밋 뒤로 이어졌어요. 4월 20일 토큰 방식을 바꾼 커밋의 코드에는 예외가 다시 살아 있는데, 그 사이 언제 되살렸는지는 따로 추적하지 않았습니다.
  • 훅이 60초마다 리프레시 API를 호출합니다. 2월 2일 버전의 useAuthsetInterval(checkAuth, 60000)으로 확인 중인데, checkAuth가 곧 refreshApi() 호출입니다. 훅을 쓰는 컴포넌트마다 1분에 한 번씩 토큰을 새로 받는 셈이에요. 얼마나 나갔는지는 세어 보지 않았습니다.
  • 2월 3일 커밋은 리팩토링과 회귀를 한 커밋에 담았습니다. 쿠키 읽기를 훅으로 옮긴 건 맞는 방향이었는데, 옮기면서 모달이 사라진 걸 못 봤어요. 옮기는 커밋과 동작을 바꾸는 커밋을 나눴으면 diff에서 보였을 겁니다.
  • 이 훅의 null 초기값이 나중에 어떤 문제를 만들었는지는 인증이 확정되기 전에 쏜 요청에, 401 처리가 결국 어디로 모였는지는 로그인이 자꾸 풀리던 서비스에 있습니다. 이 글은 그 두 글의 첫 장면입니다.

관련 글: 인증이 확정되기 전에 쏜 요청 — 북마크가 가끔 초기화되던 이유 · 로그인이 자꾸 풀리던 서비스 — 401 처리를 한곳으로 모으기까지 · 액세스 토큰을 7일 쿠키에 넣고 있었다 · 리프레시 토큰을 프런트가 지울 수 있나요