inblog logo
|
찬찬잉
    Front-end Developer

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

    왜 AccessToken은 HttpOnly로 설정하지 않았는가, 중복 Refresh 문제와 쿨다운 도입 배경
    찬찬잉's avatar
    찬찬잉
    Jan 12, 2026
    Next.js에서 JWT 인증을 설계하며 겪은 문제와 해결 과정
    Contents
    1. 로그인 프로세스2. 토큰 종류 및 저장 방식3. 토큰 갱신 (Refresh)4. 동기화 메커니즘5. 중복 Refresh 방지6. 로그아웃 프로세스토큰 관리 구조도전체 흐름 요약주의사항왜 이런 구조를 선택했는가SPA(Vue3) vs Next.js 인증 구조 비교이 구조가 방어하는 것 / 방어하지 못하는 것왜 AccessToken은 HttpOnly로 설정하지 않았는가중복 Refresh 문제와 쿨다운 도입 배경이 구조를 추천하는 경우추천하지 않는 경우

    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 성공 후 즉시 동기화
    동기화 내용:
    • accessToken → useAuthStore.accessToken
    • userRole → useLoginStore.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.tsx ↓ 1. 로그인 상태 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

    찬찬잉

    RSS·Powered by Inblog