GitHub

JWT access token 저장 = localStorage?ㅋㅋㅋ feat. XSS

hojun lee · 11/09/2025
커버이미지

새로고침마다 API 호출? Zustand와 sessionStorage를 활용한 실무형 JWT 인증 아키텍처**

JWT 로그인 액세스 토큰 관리에 대하여

Next.js (CSR) 프로젝트에서 accessTokenuserInfo를 어디에 저장할지 고민. 보안을 챙기자니 UX가 박살 나고, UX를 챙기자니 보안이 걱정되는 딜레마였음.

"교과서적인 보안"과 "현실적인 UX" 사이에서 찾은 최적의 절충안, Zustand + persist + sessionStorage 조합에 대해 이야기해 봅시다.


개발엔 정답이 없다.

1. 딜레마: 어디에 저장해야 할까?

인증 상태를 관리하는 방법은 크게 세 가지이다.

그리고 세 가지 다 명확한 함정이 있다.

💀 함정 1: localStorage (가장 흔한 함정)

"새로고침해도 로그인 유지되니까 편하잖아?"

  • 문제점: XSS 공격에 치명적. localStorage는 JS로 너무 쉽게 접근할 수 있어서, 악성 스크립트 한 줄에 accessTokenuserInfo가 통째로 털릴 수 있다. 토큰 유효기간(15분)이 짧아도, 그 15분 동안 해커는 '나' 자신이 된다.

😩 함정 2: 순수 Zustand (JS 메모리)

"XSS 막으려고 persist(영속성)를 아예 안 썼어"

  • 문제점: UX가 박살. 사용자가 실수로 새로고침(F5)만 눌러도 accessToken이 메모리에서 증발한다.
  • 결과: apiClient 인터셉터는 accessToken이 없으니, 만료되지도 않았는데 매번 /auth/refresh API를 호출해야 함. 새로고침마다 API 콜이 발생하는 건 끔찍한 낭비.

🤔 함정 3: sessionStorage 직접 사용

"그럼 sessionStorage에 직접 저장하면?"

  • 문제점: '상태 관리의 분산'이 발생. accessToken은 Zustand에, userInfosessionStorage에? 혹은 둘 다 sessionStorage에? 이러면 Zustand라는 중앙 관리탑을 둔 의미가 없어진다. 로그아웃할 때도 두 곳을 다 정리해야 하고, 상태가 꼬이기 쉽다. ( 경험담 )

2. 실무에서는 어떻게 사용해요...! 토큰관리

: Zustand + persist(sessionStorage)

그래서 Zustand의 강력한 persist 미들웨어를 사용하되, 저장소를 localStorage가 아닌 sessionStorage로 지정했다.

sessionStorage인가?

  1. UX 해결 (새로고침 대응): sessionStorage는 탭이 활성 상태인 동안(새로고침 포함) 데이터를 유지. 덕분에 불필요한 /auth/refresh 호출이 사라짐.
  2. 보안성 향상 (탭 종료 시 자동 파기): localStorage와 달리, 사용자가 탭이나 브라우저를 닫으면 데이터가 자동으로 삭제. XSS로 토큰이 유출되더라도, 공격자가 사용할 수 있는 시간이 탭의 생명주기로 한정.

store/authStore.ts (최종 코드)

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

// 1. 유저 정보 타입 (예시)
export interface UserInfo {
  name: string;
  role: 'admin' | 'user';
}

interface AuthState {
  accessToken: string | null;
  userInfo: UserInfo | null;
  setAuth: (token: string | null, user: UserInfo | null) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  // 2. persist 미들웨어 적용
  persist(
    (set) => ({
      accessToken: null,
      userInfo: null,
      // 3. 토큰과 유저 정보를 중앙에서 함께 관리
      setAuth: (token, user) => set({ accessToken: token, userInfo: user }),
      logout: () => set({ accessToken: null, userInfo: null }),
    }),
    {
      name: 'auth-session-storage', // 스토리지에 저장될 키 이름
      // 4. (핵심) 저장소를 localStorage가 아닌 sessionStorage로 지정
      storage: createJSONStorage(() => sessionStorage), 
    }
  )
);

이로써 '상태 중앙화', '새로고침 시 UX', '보안성 향상'을 모두 획득!


3. 보너스: "그럼 refreshToken은 언제 써요?"

여기까지 읽으신 분들은 "탭 닫으면 로그아웃되는데 refreshToken은 왜 필요해?"라고 생각하실 듯.

refreshToken의 역할은 accessToken이 만료(Expired)되었을 때

  • sessionStorage (Zustand): 사용자가 새로고침(F5)해도 accessToken을 지켜준다. (단기 기억)
  • refreshToken (HttpOnly 쿠키): 사용자가 탭을 닫지만 않았다면, 15분이 지나 accessToken이 만료되더라도, 자동으로 새 accessToken을 발급받아 재로그인 없이 세션을 유지시켜 줌. (장기 기억 + 보안)

이 두 가지 메커니즘이 합쳐져 비로소 견고하고 실무적인 인증 아키텍처 완성

결론

코드만 생각하고 보안만 생각하고 개발자만 생각하면 정작 유저의 경험을 놓치는 일이 빈번하다. 이 경우가 그렇다. 아 새로고침했는데 매번 로그인해야한다고?

아 안쓸래... 그것보단 적당히 타협하는게 낫다.