Next.js에서 JWT 인증을 설계하며 겪은 문제와 해결 과정
왜 AccessToken은 HttpOnly로 설정하지 않았는가, 중복 Refresh 문제와 쿨다운 도입 배경
Jan 12, 2026
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()함수로 동기화
호출 시점:
- 페이지 이동 시 (
src/pages/_app.tsx) router.pathname변경 시마다 호출- Middleware가 갱신한 쿠키를 스토어에 반영
- 토큰 갱신 후 (
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 진행
적용 위치:
- Middleware (
src/middleware.ts103-113줄) - 쿨다운 체크 → Skip 또는 Refresh
- Axios (
src/utils/axiosSetting.ts45-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() 호출
→ 쿠키 → 스토어 동기화
주의사항
- RefreshToken은 절대 클라이언트에서 읽을 수 없음
- HttpOnly 쿠키로 설정됨
- XSS 공격으로부터 안전
- 쿨다운 5초
- Middleware와 Axios 간 중복 refresh 방지
- 동시 요청 시 1번만 refresh 수행
- 동기화 필수
- Middleware가 쿠키를 갱신해도 스토어는 자동 업데이트 안 됨
syncTokenFromCookie()호출 필요
- 로그아웃 시 쿠키 삭제 필수
- 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