Next.js에서 JWT 인증을 설계하며 겪은 문제와 해결 과정

왜 AccessToken은 HttpOnly로 설정하지 않았는가, 중복 Refresh 문제와 쿨다운 도입 배경
윤여찬's avatar
Jan 12, 2026
Next.js에서 JWT 인증을 설계하며 겪은 문제와 해결 과정

1. 로그인 프로세스

단계별 흐름

[사용자] 입력 (ID/PW) ↓ [Frontend] HookAuth.tsx ↓ [Backend API] POST /api/v1/auth/user/login ↓ [응답] accessToken, refreshToken, userInfo ↓ [Frontend] 토큰 저장 ├─ 서버 쿠키: /api/auth/set-cookies (HttpOnly) ├─ 클라이언트 쿠키: tokenCookie.setTokens() (일반) └─ Zustand 스토어: useAuthStore, useLoginStore ↓ [페이지 이동] /dashboard 또는 /main

주요 파일

  • src/hooks/HookAuth.tsx: 로그인 로직 처리
  • src/pages/api/auth/set-cookies.ts: 서버에서 HttpOnly 쿠키 설정

2. 토큰 종류 및 저장 방식

2.1 AccessToken (액세스 토큰)

  • 만료 시간: 1시간
  • 저장 위치:
    • 일반 쿠키 (accessToken) - 클라이언트에서 읽기 가능
    • Zustand 스토어 (useAuthStore)
  • 용도: API 호출 시 Authorization 헤더에 포함
  • 특징: 클라이언트에서 읽고 수정 가능

2.2 RefreshToken (리프레시 토큰)

  • 만료 시간: 7일
  • 저장 위치:
    • HttpOnly 쿠키 (refreshToken) - 클라이언트에서 읽기 불가능
    • 클라이언트 쿠키 (제거됨)
    • Zustand 스토어 (제거됨)
  • 용도: AccessToken 갱신용 (서버에서만 사용)
  • 특징: XSS 공격으로부터 안전 (JavaScript로 접근 불가)

2.3 UserRole (사용자 역할)

  • 만료 시간: 7일
  • 저장 위치:
    • 일반 쿠키 (userRole)
    • Zustand 스토어 (useLoginStore)
  • 용도: 권한 체크 (예: ROLE_ADMIN, ROLE_USER)

3. 토큰 갱신 (Refresh)

토큰 갱신은 2가지 경로에서 발생합니다.

3.1 Middleware에서 갱신 (서버 사이드)

언제?
  • 보호된 경로(/dashboard, /main, /admin 등) 접근 시
  • AccessToken이 만료되거나 없을 때
과정:
[페이지 요청] → [Middleware] ↓ AccessToken 만료 확인 ↓ 쿨다운 체크 (5초 이내면 skip) ↓ [Backend API] POST /api/v1/auth/user/token (HttpOnly 쿠키의 refreshToken 자동 전송) ↓ 새 토큰 받기 ↓ 쿠키에 저장 (서버에서 직접 설정) ├─ accessToken (일반 쿠키) ├─ refreshToken (HttpOnly 쿠키) ├─ userRole (일반 쿠키) └─ lastTokenRefresh (쿨다운용 타임스탬프) ↓ 페이지 렌더링
주요 파일:
  • src/middleware.ts (102-195줄)

3.2 Axios에서 갱신 (클라이언트 사이드)

언제?
  • API 호출 중 401 에러 발생 시
  • AccessToken이 없거나 만료된 상태로 API 호출 시
과정:
[API 호출] → [401 에러] ↓ [Response Interceptor] ↓ 쿨다운 체크 (클라이언트 쿠키 확인) ↓ [/api/auth/refresh] POST (HttpOnly 쿠키의 refreshToken 자동 전송) ↓ 새 토큰 받기 (JSON 응답) ↓ 쿠키/스토어에 저장 ├─ accessToken (일반 쿠키 + Zustand) ├─ userRole (일반 쿠키 + Zustand) └─ [/api/auth/set-cookies] 호출 (refreshToken HttpOnly 쿠키 설정) ↓ 원래 API 재시도
주요 파일:
  • src/utils/axiosSetting.ts (216-270줄)

4. 동기화 메커니즘

4.1 쿠키 → Zustand 스토어 동기화

문제:
  • Middleware가 쿠키를 갱신해도 Zustand 스토어는 자동으로 업데이트되지 않음
해결:
  • syncTokenFromCookie() 함수로 동기화
호출 시점:
  1. 페이지 이동 시 (src/pages/_app.tsx)
      • router.pathname 변경 시마다 호출
      • Middleware가 갱신한 쿠키를 스토어에 반영
  1. 토큰 갱신 후 (src/utils/axiosSetting.ts, src/hooks/HookAuth.tsx)
      • Refresh 성공 후 즉시 동기화
동기화 내용:
  • accessTokenuseAuthStore.accessToken
  • userRoleuseLoginStore.userRole
  • accessToken 존재 여부 → useLoginStore.isLogin
주요 파일:
  • src/utils/tokenSync.ts

5. 중복 Refresh 방지

5.1 문제 상황

[페이지 진입] → Middleware가 refresh ↓ [동시에] → CSR에서 API 호출 → 401 → Axios가 refresh ↓ Refresh API 2번 호출 (중복!)

5.2 해결 방법

쿨다운 메커니즘 (5초)
  • lastTokenRefresh 쿠키에 타임스탬프 저장
  • Refresh 전에 확인:
    • 현재 시간 - 마지막 refresh 시간 < 5초 → Skip
    • 5초 이상 경과 → Refresh 진행
적용 위치:
  1. Middleware (src/middleware.ts 103-113줄)
      • 쿨다운 체크 → Skip 또는 Refresh
  1. Axios (src/utils/axiosSetting.ts 45-61줄)
      • 클라이언트에서 쿨다운 체크
      • /api/auth/refresh도 쿨다운 체크 (10-20줄)
추가 방지:
  • Axios 내부: isRefreshing 플래그로 동시 요청 제어

6. 로그아웃 프로세스

단계별 흐름

[사용자] 로그아웃 버튼 클릭 ↓ [Frontend] TokenManager.tsx1. 로그인 상태 false 설정 (refresh 방지) ↓ 2. [Backend API] POST /api/v1/auth/user/logout ↓ 3. [서버 쿠키 삭제] /api/auth/clear-cookies ├─ accessToken 삭제 ├─ refreshToken 삭제 (HttpOnly) └─ userRole 삭제 ↓ 4. 클라이언트 정리 ├─ localStorage.clear('id') ├─ tokenCookie.clearAll() └─ Zustand 스토어 리셋 ↓ [페이지 이동] /login
주요 파일:
  • src/components/auth/TokenManager.tsx (69-96줄)
  • src/pages/api/auth/clear-cookies.ts

토큰 관리 구조도

┌─────────────────────────────────────────────────────────┐ │ 쿠키 (Cookies) │ ├─────────────────────────────────────────────────────────┤ │ accessToken │ 일반 쿠키 │ 클라이언트 읽기 가능 │ │ refreshToken │ HttpOnly │ 클라이언트 읽기 불가 │ │ userRole │ 일반 쿠키 │ 클라이언트 읽기 가능 │ │ lastTokenRefresh│ 일반 쿠키 │ 쿨다운 체크용 │ └─────────────────────────────────────────────────────────┘ (syncTokenFromCookie) ┌─────────────────────────────────────────────────────────┐ │ Zustand 스토어 │ ├─────────────────────────────────────────────────────────┤ │ useAuthStore │ accessToken (클라이언트 상태) │ │ useLoginStore │ isLogin, userRole, tokenIssued │ └─────────────────────────────────────────────────────────┘

전체 흐름 요약

로그인 후 일반 사용 흐름

1. 로그인 → 토큰 쿠키 설정 (서버 HttpOnly + 클라이언트 일반) → Zustand 스토어 동기화 2. 페이지 접근 → Middleware: AccessToken 만료 체크 → 만료 시: Refresh (쿨다운 체크 후) → 쿠키 갱신 → syncTokenFromCookie() → 스토어 동기화 3. API 호출 → Request Interceptor: AccessToken 헤더 추가 → 401 에러 시: Response Interceptor에서 Refresh → 쿨다운 체크 → Refresh → 쿠키/스토어 갱신 → 재시도 4. 페이지 이동 → _app.tsx: syncTokenFromCookie() 호출 → 쿠키 → 스토어 동기화

주의사항

  1. RefreshToken은 절대 클라이언트에서 읽을 수 없음
      • HttpOnly 쿠키로 설정됨
      • XSS 공격으로부터 안전
  1. 쿨다운 5초
      • Middleware와 Axios 간 중복 refresh 방지
      • 동시 요청 시 1번만 refresh 수행
  1. 동기화 필수
      • Middleware가 쿠키를 갱신해도 스토어는 자동 업데이트 안 됨
      • syncTokenFromCookie() 호출 필요
  1. 로그아웃 시 쿠키 삭제 필수
      • HttpOnly 쿠키는 서버에서만 삭제 가능
      • /api/auth/clear-cookies 호출 필요

왜 이런 구조를 선택했는가

기존 Vue3 SPA 환경에서는 refreshToken을 클라이언트에 저장할 수밖에 없는 구조적 한계가 있었다. 이는 XSS 공격 시 장기간 악용 가능한 refreshToken 노출 위험으로 이어졌다.
Next.js로 전환하면서,
  • 서버 실행 환경(API Route, Middleware)을 활용할 수 있게 되었고
  • refreshToken을 HttpOnly 쿠키로만 관리하는 구조가 가능해졌다.
이 글은 단순한 구현 공유가 아니라, '프레임워크 실행 모델 차이를 활용한 인증 구조 개선 사례'를 다룬다.

SPA(Vue3) vs Next.js 인증 구조 비교

항목
Vue3 SPA
Next.js
서버 실행 환경
❌ 없음
✅ API Route / Middleware
HttpOnly 쿠키 접근
❌ 불가
✅ 가능
refreshToken 클라이언트 저장
거의 필수
❌ 불필요
XSS 대응
취약
상대적으로 안전
인증 로직 위치
프론트 중심
서버 + 프론트 분리

이 구조가 방어하는 것 / 방어하지 못하는 것

방어 가능한 위협

  • XSS 공격으로 인한 refreshToken 탈취
  • 장기간 세션 탈취
  • 클라이언트 코드 실수로 인한 refreshToken 노출

방어하지 못하는 위협

  • HTTP 환경에서의 네트워크 패킷 스니핑
  • 중간자 공격(MITM)
본 구조는 HTTPS가 불가능한 환경에서 취할 수 있는 최선의 보안 설계에 가깝다.

왜 AccessToken은 HttpOnly로 설정하지 않았는가

AccessToken을 HttpOnly로 설정하면 XSS 관점에서는 더 안전해질 수 있다. 그러나 현재 구조에서는 다음과 같은 현실적 비용이 발생한다.
  • Authorization 헤더 기반 API 호출 구조 전면 수정 필요
  • BFF 프록시 구조로의 아키텍처 변경 필요
  • 개발/유지보수 복잡도 증가
  • 성능 오버헤드 가능성
본 프로젝트에서는
  • refreshToken을 HttpOnly로 보호하고
  • accessToken 만료 시간을 짧게 유지함으로써 보안과 생산성의 균형을 선택했다.

중복 Refresh 문제와 쿨다운 도입 배경

Middleware와 Axios에서 동시에 refresh가 발생하면서 refresh API가 중복 호출되는 문제가 발생했다.
이를 해결하기 위해
  • lastTokenRefresh 쿠키 기반 쿨다운
  • 클라이언트 Promise lock(isRefreshing)
두 가지를 조합해 중복 호출을 실질적으로 제거했다.

이 구조를 추천하는 경우

  • Next.js를 사용 중이거나 도입 예정인 경우
  • refreshToken을 클라이언트에 저장하고 싶지 않은 경우
  • SSR / Middleware를 활용한 인증 제어가 필요한 경우

추천하지 않는 경우

  • HTTPS가 필수적인 고보안 서비스
  • 단순한 토이 프로젝트
  • 서버 실행 레이어를 둘 수 없는 순수 SPA
 
Share article

찬찬잉