diff --git a/.gitignore b/.gitignore
index bda49dc..22faf30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,5 +45,14 @@ out/
.claude/
CLAUDE.md
+### OMC ###
+.omc/
+
### HTTP Tests ###
*.http
+
+### Temporary Files ###
+.gradle-user/
+status.txt
+status_utf8.txt
+docs/Backend/memo/
diff --git a/build.gradle b/build.gradle
index 061391c..0bd9a7a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -26,16 +26,19 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
- implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
- implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
implementation 'org.jsoup:jsoup:1.17.2'
+
+ // JWT
+ implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
implementation platform('org.springframework.ai:spring-ai-bom:1.1.2')
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'
diff --git a/docs/Backend/02. jwt-oauth2-architecture.md b/docs/Backend/02. jwt-oauth2-architecture.md
new file mode 100644
index 0000000..ec8bded
--- /dev/null
+++ b/docs/Backend/02. jwt-oauth2-architecture.md
@@ -0,0 +1,897 @@
+# JWT/OAuth2 소셜 로그인 아키텍처 가이드
+
+> **브랜치**: `feat/SW-65`
+> **대상**: 세션 기반 인증 → JWT + OAuth2 Stateless 아키텍처 전환
+> **최종 수정**: 2026-03-22
+
+---
+
+## 목차
+
+1. [전체 구조 한눈에 보기](#1-전체-구조-한눈에-보기)
+2. [시나리오별 인증 플로우](#2-시나리오별-인증-플로우)
+3. [백엔드 핵심 코드](#3-백엔드-핵심-코드)
+4. [프론트엔드 핵심 코드](#4-프론트엔드-핵심-코드)
+5. [보안 메커니즘](#5-보안-메커니즘)
+6. [설정 파일 가이드](#6-설정-파일-가이드)
+7. [파일 맵](#7-파일-맵)
+
+---
+
+## 1. 전체 구조 한눈에 보기
+
+### 인증 방식 비교
+
+| 항목 | 변경 전 (세션) | 변경 후 (JWT) |
+|------|---------------|--------------|
+| 인증 상태 저장 | 서버 세션 (메모리) | Access Token (클라이언트 메모리) |
+| 세션 유지 | JSESSIONID 쿠키 | Refresh Token (HttpOnly 쿠키) |
+| 로그인 방식 | 폼 로그인 + OAuth2 | **OAuth2 소셜 로그인만** |
+| 서버 확장 | 세션 공유 필요 (Redis 등) | Stateless (제약 없음) |
+| 로그아웃 | 세션 삭제 | DB에서 Refresh Token 삭제 |
+
+### 토큰 구조
+
+```mermaid
+block-beta
+ columns 2
+
+ block:access["Access Token (JWT)"]:2
+ a1["저장: 프론트엔드 메모리 (Zustand)"]
+ a2["만료: 30분 (설정 가능)"]
+ a3["용도: API 요청 시 Authorization 헤더"]
+ a4["내용: memberId(sub), role(claim)"]
+ end
+
+ block:refresh["Refresh Token (JWT)"]:2
+ r1["저장: HttpOnly 쿠키 + SHA-256 해시(DB)"]
+ r2["만료: 14일 (설정 가능)"]
+ r3["용도: Access Token 만료 시 재발급"]
+ r4["내용: memberId(sub), sid(Session ID), ver(Version) 포함"]
+ end
+
+ style access fill:#e8f5e9,stroke:#2e7d32
+ style refresh fill:#e3f2fd,stroke:#1565c0
+```
+
+### 핵심 컴포넌트 관계도
+
+```mermaid
+graph TB
+ Browser["🌐 브라우저"]
+
+ subgraph OAuth2["OAuth2 소셜 로그인 플로우"]
+ direction TB
+ CookieRepo["HttpCookieOAuth2
AuthorizationRequestRepository
state를 쿠키에 저장"]
+ MemberService["CustomOAuth2MemberService
소셜 응답 파싱
→ 회원가입/로그인"]
+ SuccessHandler["OAuth2SuccessHandler
RT 발급 → 쿠키 설정
→ 리다이렉트"]
+ FailureHandler["OAuth2FailureHandler
에러 메시지
→ /login 리다이렉트"]
+ end
+
+ subgraph JwtFilter["JWT 인증 필터"]
+ direction TB
+ Filter["JwtAuthenticationFilter
Bearer 토큰 파싱 → 검증"]
+ Provider["JwtUtils
토큰 생성/파싱/검증"]
+ EntryPoint["JwtAuthenticationEntryPoint
401 JSON 응답"]
+ end
+
+ subgraph AuthAPI["인증 API"]
+ direction TB
+ Controller["AuthController"]
+ Service["AuthServiceImpl
토큰 발급/갱신/로그아웃"]
+ DAO["RefreshTokenDao
DB 접근 (FOR UPDATE)"]
+ end
+
+ Social["☁️ 소셜 제공자
Google / Naver / Kakao"]
+ DB[("🗄️ PostgreSQL
refresh_token")]
+
+ Browser -->|"/oauth2/authorization/
{provider}"| CookieRepo
+ CookieRepo --> Social
+ Social -->|"콜백"| MemberService
+ MemberService -->|"성공"| SuccessHandler
+ MemberService -->|"실패"| FailureHandler
+ SuccessHandler -->|"302 + RT 쿠키"| Browser
+
+ Browser -->|"/api/** (Bearer Token)"| Filter
+ Filter --> Provider
+ Filter -->|"인증 실패"| EntryPoint
+
+ Browser -->|"/api/auth/refresh
(RT 쿠키)"| Controller
+ Browser -->|"/api/auth/logout
(RT 쿠키)"| Controller
+ Controller --> Service
+ Service --> DAO
+ DAO --> DB
+
+ style Browser fill:#fff3e0,stroke:#e65100,stroke-width:2px
+ style Social fill:#f3e5f5,stroke:#7b1fa2
+ style DB fill:#e8eaf6,stroke:#283593
+ style OAuth2 fill:#fce4ec,stroke:#c62828,color:#c62828
+ style JwtFilter fill:#e8f5e9,stroke:#2e7d32,color:#2e7d32
+ style AuthAPI fill:#e3f2fd,stroke:#1565c0,color:#1565c0
+```
+
+---
+
+## 2. 시나리오별 인증 플로우
+
+### 시나리오 1: OAuth2 소셜 로그인 (최초 진입)
+
+> 사용자가 "구글로 로그인" 버튼을 클릭하고, 인증 완료 후 대시보드로 이동하기까지의 전체 흐름
+
+```mermaid
+sequenceDiagram
+ actor User as 사용자
+ participant FE as 프론트엔드
(Next.js)
+ participant SB as Spring Boot
+ participant DB as PostgreSQL
+ participant Social as 소셜 제공자
(Google)
+
+ Note over User,Social: 1단계: OAuth2 인증
+
+ User->>FE: "구글로 로그인" 클릭
+ FE->>SB: GET /oauth2/authorization/google
+
+ Note over SB: state 생성 → 쿠키에 저장 (3분)
[HttpCookieOAuth2AuthorizationRequestRepository]
+
+ SB->>Social: 302 → 구글 인증 페이지
+ Social->>User: 로그인 및 권한 동의 화면
+ User->>Social: 동의
+ Social->>SB: 인증 코드와 함께 콜백
+
+ Note over SB,DB: 2단계: 회원 처리
+
+ Note over SB: CustomOAuth2MemberService.loadUser()
• 소셜 응답 파싱 (GoogleResponse)
• loginId: "google{providerId}"
+ SB->>DB: 회원 조회 (by loginId)
+ DB-->>SB: 신규 → 회원가입 / 기존 → 정보 업데이트
+
+ Note over SB,DB: 3단계: 토큰 발급
+
+ Note over SB: OAuth2SuccessHandler
+ SB->>DB: 기존 RT 삭제 (단일 세션 강제/고아 정리)
+ SB->>SB: Refresh Token 생성 (sid 신규 생성, Version 1)
+ SB->>DB: SHA-256 해시 후 저장
+ SB->>SB: 임시 쿠키 삭제
+ SB->>FE: 302 → /auth/callback
+ Set-Cookie: refreshToken (HttpOnly)
+
+ Note over FE,DB: 4단계: Access Token 획득
+
+ FE->>FE: AuthCallbackPage → initialize()
+ FE->>SB: POST /api/auth/refresh (RT 쿠키 포함)
+ SB->>DB: SELECT ... FOR UPDATE
+ SB->>DB: rotatedAt 설정 + 새 RT 저장
+ SB-->>FE: { accessToken } + Set-Cookie: 새 RT
+
+ FE->>SB: GET /api/auth/member (Bearer AT)
+ SB-->>FE: { data: { memberId, name, email, role } }
+
+ FE->>FE: isAuthenticated = true
+ FE->>User: /my-links 대시보드 표시
+```
+
+> 💡
+> **백엔드 개발자를 위한 'OAuth2 리다이렉트' 심층 이해**
+>
+> **1. 왜 백엔드가 구글에 직접 요청하지 않고 브라우저를 이동시키나요?**
+> - **보안(Security)**: 사용자의 구글 비밀번호가 우리 서버에 절대 노출되지 않도록 하기 위함입니다. 사용자는 오직 구글 서버와 직접 통신(브라우저를 통해)하여 본인을 인증합니다.
+>
+> **2. 브라우저 리다이렉트(302 Redirect)의 역할**
+> - 우리 서버는 브라우저에게 `"302 Redirect"` 상태 코드와 함께 구글 로그인 주소를 보냅니다.
+> - 브라우저는 이 명령을 받자마자 **자동으로 구글 로그인 페이지로 접속**합니다. (주소창이 구글로 바뀜)
+> - 즉, 브라우저는 우리 서버와 구글 사이를 오가며 **사용자 본인임을 증명하는 통로** 역할을 합니다.
+>
+> **3. 코드(Code) vs 토큰(Token) 흐름 구분**
+> - **사용자 브라우저 경로**: 로그인 과정에서 보안을 위해 일회용 **'코드(Authorization Code)'**만 전달받습니다. (보안 노출 위험 최소화)
+> - **서버 대 서버(Server-to-Server) 경로**: 백엔드는 브라우저가 들고 온 코드를 가로채서, 구글 서버와 직접 통신하여 실제 **'토큰(Access Token)'**으로 교환합니다.
+> - 결과적으로, 중요한 토큰과 사용자 개인정보는 브라우저를 거치지 않고 **구글 서버 ↔ 우리 서버** 사이에서만 안전하게 오고 가게 됩니다.
+
+**핵심 포인트:**
+- OAuth2 성공 시 **Access Token은 즉시 발급하지 않음** → Refresh Token만 쿠키에 설정
+- 프론트엔드가 `/api/auth/refresh`를 호출하여 Access Token을 받아오는 구조
+- 이유: OAuth2 리다이렉트 응답에서는 JSON body를 줄 수 없고, 쿠키나 URL 파라미터로만 전달 가능. Access Token을 URL에 노출하는 것은 보안 위험이므로 별도 요청으로 분리
+
+---
+
+### 시나리오 2: 페이지 새로고침 시 인증 복구
+
+> 브라우저를 새로고침하면 Zustand 메모리가 초기화됨. Refresh Token 쿠키로 로그인 상태를 복구하는 과정
+
+```mermaid
+sequenceDiagram
+ participant FE as 프론트엔드
+ participant SB as Spring Boot
+
+ FE->>FE: F5 새로고침
Zustand 초기화
accessToken = null
+
+ Note over FE: AuthProvider 마운트
→ authStore.initialize()
+
+ FE->>SB: POST /api/auth/refresh
(Cookie: refreshToken)
+ SB-->>FE: { accessToken } + Set-Cookie: 새 RT
+
+ FE->>SB: GET /api/auth/member
(Authorization: Bearer AT)
+ SB-->>FE: { data: { memberId, ... } }
+
+ FE->>FE: isAuthenticated = true
isInitializing = false
+```
+
+**핵심 포인트:**
+- `AuthProvider`가 앱 최상위에서 `initialize()`를 호출
+- `initializePromise` 싱글톤으로 중복 호출 방지 (AuthProvider + AuthCallbackPage 동시 마운트 대응)
+- Refresh Token 쿠키가 없거나 만료되면 → `isAuthenticated = false` → `/login`으로 리다이렉트
+
+
+
+
+> 💡
+> **백엔드 개발자를 위한 '새로고침' 인증 복구 요약**
+>
+> **1. 핵심 이유 (왜 필요한가?)**
+> - **백엔드**: Stateless 방식이라 서버 세션이 없습니다. (브라우저를 기억하지 않음)
+> - **프론트엔드**: `Access Token`을 자바스크립트 변수(Zustand)에 담고 있는데, **새로고침(F5) 시 이 변수가 초기화**되어 증발합니다.
+>
+> **2. 해결 방법 (비상용 열쇠)**
+> - 새로고침해도 사라지지 않는 **쿠키(HttpOnly Refresh Token)**를 활용합니다.
+>
+> **3. 인증 복구 흐름**
+> 1. **F5 (새로고침)**: 프론트엔드 메모리가 비워짐 (`accessToken = null`).
+> 2. **인증 복구 시작**: 앱이 켜지는 순간(`Mount`) 쿠키 속의 `Refresh Token`을 실어 백엔드에 갱신 요청을 보냅니다.
+> 3. **백엔드 확인**: 백엔드가 쿠키를 검증하고 새로운 `Access Token`을 발급합니다.
+> 4. **상태 복구**: 프론트엔드가 받은 토큰을 다시 메모리에 저장하여 로그인 상태를 유지합니다.
+>
+> **※ 참고 (마운트란?):** 리액트 컴포넌트 객체가 생성되어 실제 화면에 배치되는 시점으로, 스프링의 **`@PostConstruct`** 호출 타이밍과 유사합니다.
+
+---
+
+### 시나리오 3: API 요청 중 Access Token 만료
+
+> Access Token이 만료된 상태에서 API를 호출하면 자동으로 갱신하고 재시도하는 과정
+
+```mermaid
+sequenceDiagram
+ participant FE as fetchClient
+ participant SB as Spring Boot
+
+ FE->>SB: GET /api/bookmarks
(Bearer: 만료된 AT)
+ Note over SB: JwtAuthenticationFilter
→ ExpiredJwtException
+ SB-->>FE: 401 Unauthorized
+
+ Note over FE: 401 감지 + AT 존재
→ refreshAccessToken()
+
+ FE->>SB: POST /api/auth/refresh
(Cookie: refreshToken)
+ SB-->>FE: { accessToken: "새토큰" }
+
+ Note over FE: Zustand에 새 AT 저장
+
+ FE->>SB: GET /api/bookmarks (재시도)
(Bearer: 새 AT)
+ SB-->>FE: { bookmarks: [...] }
+```
+
+**핵심 포인트:**
+- `fetchClient`가 **401을 자동 감지**하여 토큰 갱신 후 원래 요청을 재시도 (1회만)
+- 여러 API가 동시에 401을 받으면 `refreshPromise` 공유로 갱신 요청 1회만 발생
+- 갱신 실패(Refresh Token도 만료) → `authStore.logout()` → `/login` 이동
+
+> 💡 **백엔드 개발자를 위한 프론트엔드 로직 상세 (`fetchClient.ts`)**
+>
+> 1. **401 감지 (`response.status === 401`)**:
+> - 모든 API 요청은 공통 함수인 `fetchClient`를 통과합니다. 여기서 응답 코드가 401인지를 실시간으로 체크합니다.
+> 2. **AT 존재 조건 (`&& accessToken`)**:
+> - "이미 AT를 가지고 있던 사용자(로그인했던 사용자)"인 경우에만 갱신을 시도합니다.
+> - **이유**: 비로그인 사용자가 권한 없는 API에 접근했을 때 발생하는 401과 구분하여, 불필요한 무한 갱신 루프를 방지하기 위함입니다.
+> 3. **투명한 재시도 (Transparent Retry)**:
+> - 사용자가 모르게 `POST /api/auth/refresh`를 먼저 호출해 토큰을 갈아 끼운 후, **실패했던 원래 API를 똑같은 파라미터로 다시 호출**합니다.
+> - 결과적으로 사용자는 끊김 없는 서비스를 경험하게 됩니다.
+>
+> - **관련 프론트 코드**: `frontend/src/lib/api/fetchClient.ts` 내의 `fetchClient` 함수와 `refreshAccessToken` 함수
+
+---
+
+### 시나리오 4: Refresh Token Rotation과 Grace Period
+
+> 토큰 갱신 시 보안을 위해 기존 토큰을 무효화하고, 네트워크 지연에 대응하는 과정
+
+#### Case 1: 정상 갱신
+
+```mermaid
+sequenceDiagram
+ participant C as 클라이언트
+ participant S as 서버
+ participant DB as DB
+
+ C->>S: refresh(RT-A)
+ S->>DB: SELECT RT-A FOR UPDATE
+ S->>S: 새 버전 토큰(RT-B) 생성 (sid 유지, ver=2)
+ S->>DB: markRotated: RT-A 상태 업데이트
(rotatedAt=NOW, replacedByVersion=2, graceUntil=NOW+10s)
+ S->>DB: INSERT 새 RT-B (해시)
+ S-->>C: { AT, RT-B }
+```
+
+> 💡 **백엔드 로직 상세 (Case 1: 정상 갱신)**
+> 1. **요청 (RT-A 제출)**: 클라이언트가 쿠키에 담긴 `RT-A`를 실어 `/api/auth/refresh`로 요청합니다.
+> 2. **검증 및 락 (Lock 획득)**: JWT 유효성을 검토하고, DB에서 **비관적 락(`FOR UPDATE`)**으로 해당 토큰을 조회해 동시성(Race Condition) 문제를 방지합니다.
+> 3. **판단 (`rotated_at == null`)**: DB 레코드의 `rotated_at`이 비어있으면 "최초 갱신 시도"인 정상 상황으로 판단합니다.
+> 4. **회전 (Rotation)**: `RT-A`를 즉시 삭제하지 않고 `rotated_at`에 현재 시각을 기록하여 '사용됨' 도장을 찍습니다. (이때부터 10초간의 **Grace Period**가 시작됩니다.)
+> 5. **발급 (RT-B 생성)**: 새로운 `Access Token`과 `Refresh Token(RT-B)`을 생성하고, `RT-B`를 DB에 새 로우로 삽입합니다.
+> 6. **응답**: 새 토큰 쌍을 클라이언트에게 전달하며, 이후 클라이언트는 `RT-B`를 사용하게 됩니다.
+
+#### Case 2: Grace Period 내 재사용 (동시 요청 또는 네트워크 지연)
+
+```mermaid
+sequenceDiagram
+ participant C as 클라이언트
+ participant S as 서버
+ participant DB as DB
+
+ Note over C,S: [T+0초] 첫 번째 요청 시도 (Tab 1 등)
+ C->>S: refresh(RT-A)
+ S->>S: RT-B 발급 및 RT-A 상태 업데이트 (Grace Period 시작)
+
+ Note right of C: ⚠️ 응답 지연(네트워크) 또는 다른 탭의 동시 요청
+
+ Note over C,S: [T+1초] 중복/동시 요청 (Tab 2 또는 Retry)
+ C->>S: refresh(RT-A)
+
+ S->>DB: rotatedAt 확인 → 10초 이내 (Grace Period)
+ S->>DB: SELECT RT-B (sid, ver=2) - 이미 발급된 후속 토큰 조회
+ S-->>C: { AT, RT-B } (이전과 동일한 결과 재전달!)
+
+ Note over C,S: [T+10초 초과] 이후 시도 (유예 기간 만료)
+ C->>S: refresh(RT-A)
+ S--xC: AUTH_REFRESH_TOKEN_NOT_FOUND (재사용 차단)
+```
+
+> 💡 **백엔드 로직 상세 (Case 2: Grace Period 내 재사용)**
+>
+> **1. 상황 가정 (왜 발생하나요?)**
+> - **멀티탭 동시 요청**: 사용자가 여러 탭을 열어둔 상태에서 토큰이 만료되면, 모든 탭이 **동시에** 갱신을 시도합니다. 비관적 락에 의해 순차 처리되더라도, 첫 번째 요청 이후의 나머지 요청들은 이미 '사용된 토큰'으로 들어오게 됩니다.
+> - **네트워크 지연/유실**: 클라이언트가 요청을 보냈으나 응답 수신 직전 연결이 끊겨 즉시 재시도하는 경우입니다.
+> - **기타**: 프론트엔드(`fetchClient`)가 결과 미수신 등으로 인해 똑같은 `RT-A`로 재시도 요청을 보내는 모든 경우를 포함합니다.
+>
+> **2. 백엔드의 판단 (Grace Period 로직)**
+> - **조회/상태 확인**: "이미 한 번 갱신에 쓰였던(`rotated_at != null`) 토큰이네?"
+> - **시간 계산**: "하지만 갱신된 지 겨우 3초(10초 이내)뿐이라 **정당한 재시도**로 확실시되네."
+> - **코드 로직**: `if (rotatedAt.plusSeconds(10).isAfter(now))`
+>
+> **3. 처리 결과 (멱등성 보장 결과 재전달)**
+> - **상태 유지**: Grace Period 내라면 횟수에 상관없이 동일한 `RT-B`를 받게 되어, 네트워크 불안정으로 인한 중복 요청 상황에서도 클라이언트가 안정적으로 최신 토큰을 확보할 수 있습니다.
+> - **보안**: 유예 기간이 지나면 해당 토큰을 이용한 모든 요청은 거부됩니다. (이미 Successor가 발급된 상태이므로)
+>
+> **4. 만약 이 로직이 없다면?**
+> - 지하철이나 엘리베이터 등 네트워크가 불안정한 환경에서 사용자가 시시때때로 로그아웃되어 로그인 페이지로 쫓겨나는 열악한 UX를 경험하게 됩니다.
+>
+> 💡 **멀티탭 환경에서의 멱등성 보장 (Seamless UX)**
+> - 사용자가 한 브라우저에 여러 탭(예: 대시보드, 설정 페이지 등)을 열어놓은 채 토큰이 만료되면, 모든 탭이 **거의 동시에** 갱신을 시도합니다.
+> - 최신 멱등성 로직은 가장 먼저 도착한 요청이 토큰을 회전시키더라도, 뒤이어 온 다른 탭의 요청들에 대해 **이미 발급된 동일한 새 토큰을 재전달**합니다.
+> - 결과적으로 사용자는 어떤 탭에서도 로그인 끊김 없이 자연스러운 서비스를 이용할 수 있습니다.
+
+#### Case 3: Grace Period 초과 (토큰 탈취 의심)
+
+```mermaid
+sequenceDiagram
+ participant A as 공격자
+ participant S as 서버
+ participant DB as DB
+
+ Note over A,S: T+15초: 탈취된 토큰 사용
+ A->>S: refresh(RT-A)
+ S->>DB: rotatedAt 확인 → 10초 초과
+ S--xA: AUTH_REFRESH_TOKEN_NOT_FOUND
+```
+
+#### Case 4: 동시 요청 제어 (DB 비관적 락)
+
+```mermaid
+sequenceDiagram
+ participant R1 as 요청 1
+ participant R2 as 요청 2
+ participant DB as DB
+
+ R1->>DB: SELECT RT-A FOR UPDATE (락 획득)
+ R2->>DB: SELECT RT-A FOR UPDATE (락 획득 대기...)
+
+ Note over R1,DB: 요청 1 처리 중 (Transaction)
+ R1->>DB: UPDATE (rotatedAt 설정) + INSERT (새 토큰)
+ R1->>DB: COMMIT (락 해제)
+
+ Note over R2,DB: 요청 2 진행 (락 획득 성공)
+ DB-->>R2: RT-A (이미 rotatedAt != null 인 상태)
+ Note over R2: Case 2: Grace Period 로직으로 이동
+```
+
+> 💡 **백엔드 로직 상세 (Case 4: 동시 요청 제어)**
+>
+> **1. 동시성 문제의 발생**
+> - 아주 찰나의 순간에 똑같은 `RT-A`로 두 개의 요청(R1, R2)이 들어올 때 발생합니다. (예: 멀티탭 환경에서 모든 탭이 동시에 갱신을 시도하는 경우)
+>
+> **2. 비관적 락 (`FOR UPDATE`)의 역할**
+> - **R1**: 먼저 DB 로우에 접근하여 **배타적 락(Exclusive Lock)**을 획득합니다.
+> - **R2**: R1의 트랜잭션이 끝나기 전까지 동일한 로우에 대한 조회를 시도하며 **대기(Wait)** 상태에 빠집니다.
+>
+> **3. 락 보호 아래의 안전한 갱신**
+> - **R1의 작업**: 락을 쥐고 있는 상태에서 안전하게 `UPDATE`(기존 토큰 무효화)와 `INSERT`(신규 토큰 저장)를 수행한 후 커밋합니다.
+> - **R2의 작업**: R1의 커밋이 종료되면 비로소 락을 얻고 조회를 완료합니다. 이때 이미 **`rotated_at`이 기록된 결과**를 받게 되므로, 자연스럽게 **Case 2(Grace Period 내 재사용)** 로직으로 분기되어 안전하게 처리됩니다.
+
+---
+
+**핵심 포인트:**
+- **Rotation**: 매 갱신마다 새 Refresh Token 발급 → 탈취된 토큰의 유효 시간 최소화
+- **Grace Period (10초)**: 네트워크 지연으로 인한 정당한 중복 요청 허용 (유예 기간 내 횟수 제한 없음)
+- **FOR UPDATE**: DB 수준 비관적 락으로 동시 갱신 요청의 경합 조건 방지
+
+---
+
+### 시나리오 5: 로그아웃
+
+> 사용자가 로그아웃 버튼을 클릭하여 서버와 클라이언트 모두에서 인증 정보를 제거하는 과정
+
+```mermaid
+sequenceDiagram
+ actor User as 사용자
+ participant FE as 프론트엔드
+ participant SB as Spring Boot
+ participant DB as DB
+
+ User->>FE: 로그아웃 클릭
+ FE->>FE: authStore.logout()
+
+ FE->>SB: POST /api/auth/logout
(Cookie: refreshToken)
+
+ Note over SB: 1단계: Refresh Token 무효화
+ SB->>SB: 원본 토큰 SHA-256 해시화
+ SB->>DB: DELETE FROM refresh_token
WHERE token_hash = ?
+
+ Note over SB: 2단계: 브라우저 쿠키 제거
+ SB->>SB: Set-Cookie: refreshToken=""
(maxAge=0, httpOnly=true)
+ SB-->>FE: 200 OK
+
+ Note over FE: 3단계: 클라이언트 메모리 초기화
+ FE->>FE: authStore 초기화
(accessToken=null, isAuthenticated=false)
+ FE->>User: /login 페이지로 이동
+```
+
+> 💡
+> **백엔드 개발자를 위한 '로그아웃' 처리 요약**
+>
+> **1. 핵심 이유 (왜 이렇게 처리하는가?)**
+> - **Stateless 인증**: 서버는 클라이언트의 상태를 기억하지 않으므로, 더 이상 유효하지 않은 `Refresh Token`을 서버 DB(무효화 목록)에서 명시적으로 삭제해야 합니다.
+> - **보안 강화**: 브라우저에 남은 `HttpOnly` 쿠키를 강제로 만료(`Max-Age=0`)시켜 클라이언트 측의 접근 권한도 즉시 회수합니다.
+>
+> **2. 백엔드 처리 상세 (`AuthController.java`)**
+> - **쿠키 추출**: `@CookieValue`를 통해 브라우저가 보낸 `refreshToken`을 읽습니다.
+> - **DB 삭제**: 해당 토큰을 해시화하여 DB에서 삭제(`deleteByTokenHash`)합니다. 이제 이 토큰으로는 더 이상 Access Token을 갱신할 수 없습니다.
+> - **쿠키 만료**: 응답 헤더에 `Set-Cookie`를 빈 값(`""`)과 `maxAge(0)`으로 설정하여 브라우저가 쿠키를 삭제하도록 명령합니다.
+>
+> **3. 프론트엔드와의 협업**
+> - **메모리 삭제**: 프론트엔드는 자바스크립트 변수(`accessToken`)에 담긴 인증 정보를 지워야 합니다.
+> - **강제 진행**: 네트워크 오류 등으로 서버 요청이 실패하더라도, 사용자가 로그아웃을 눌렀다면 클라이언트 상태는 무조건 초기화하여 보안 사고를 예방합니다 (`finally` 블록 활용).
+
+**핵심 포인트:**
+- **서버/네트워크 오류 대응**: 서버 응답과 무관하게(또는 실패하더라도) 클라이언트 상태는 반드시 초기화하여 보안 유지
+- **DB 무효화**: DB에서 `Refresh Token`을 삭제하여, 이후 해당 토큰이 탈취되더라도 Access Token 발급 시도 원천 차단
+- **쿠키 제거**: `HttpOnly` 쿠키는 자바스크립트가 지울 수 없으므로, 반드시 서버가 `maxAge=0` 응답을 주어야 함
+
+---
+
+## 3. 백엔드 핵심 코드
+
+### 3.1 SecurityConfig — 필터 체인 설정
+
+> `config/security/SecurityConfig.java`
+
+Spring Security의 전체 보안 규칙을 정의하는 핵심 설정 클래스.
+
+```
+필터 체인 순서:
+ JwtAuthenticationFilter → UsernamePasswordAuthenticationFilter → ... → AuthorizationFilter
+ ↑ (우리가 추가한 필터)
+```
+
+**주요 설정:**
+
+| 설정 | 값 | 이유 |
+|------|---|------|
+| CSRF | 비활성화 | JWT Bearer 토큰 방식은 CSRF 공격 대상 아님 |
+| 세션 | STATELESS | JWT 사용, 서버 메모리 점유 안 함 |
+| 폼 로그인 | 비활성화 | 소셜 로그인만 사용 |
+| `/api/auth/refresh`, `/api/auth/logout` | permitAll | 토큰 없이 접근 필요 |
+| `/api/**` | authenticated | 그 외 모든 API 인증 필수 |
+
+---
+
+### 3.2 JwtAuthenticationFilter — 매 요청마다 토큰 검증
+
+> `config/jwt/JwtAuthenticationFilter.java`
+
+모든 HTTP 요청을 가로채서 JWT를 검증하는 필터. `OncePerRequestFilter` 상속으로 요청당 1회만 실행.
+
+**동작 방식:**
+1. `Authorization: Bearer {token}` 헤더에서 토큰 추출
+2. 토큰이 없으면 → 필터 통과 (공개 API는 접근 가능)
+3. 토큰이 있으면 → `JwtUtils.validateToken()` 검증
+4. 유효하면 → `JwtMemberPrincipal` 생성 후 `SecurityContext`에 저장
+5. 만료/위변조 → request attribute에 에러 코드 저장, 필터 통과
+6. 이후 `JwtAuthenticationEntryPoint`가 에러 코드를 읽어 401 JSON 응답
+
+**왜 예외를 던지지 않고 attribute에 저장하는가?**
+- 필터에서 예외를 던지면 Spring Security의 ExceptionTranslationFilter가 처리
+- 세밀한 에러 메시지(만료 vs 위변조)를 전달하기 위해 attribute 방식 사용
+
+---
+
+### 3.3 JwtUtils — 토큰 생성과 검증
+
+> `config/jwt/JwtUtils.java`
+
+JWT 토큰의 생성, 파싱, 검증을 담당하는 유틸리티 클래스.
+
+| 메서드 | 역할 |
+|--------|------|
+| `generateAccessToken(memberId, role)` | Access Token 생성 (sub: memberId, claim: role, id: random) |
+| `generateRefreshToken(memberId, sid, ver, iat, exp)` | Refresh Token 생성 (sid, ver 메타데이터 포함, id: sid:ver) |
+| `parseAccessToken(token)` | 토큰 파싱 → `JwtMemberPrincipal(memberId, role)` 반환 |
+| `validateToken(token)` | 서명 + 만료 검증. 실패 시 예외 throw (void 반환) |
+| `extractMemberId(token)` | 토큰에서 memberId 추출 |
+
+**결정적 토큰 생성 (Deterministic Token Generation):**
+- 리프레시 토큰 생성 시 랜덤 요소(UUID)를 배제하고 `sid`, `ver`, `iat`, `exp`를 조합하여 생성합니다.
+- 이는 서버가 DB에 저장된 메타데이터만으로 **동일한 토큰 값을 재현**할 수 있게 하여, 멱등성 응답을 보장하는 기술적 토대가 됩니다.
+
+**서명 알고리즘:** HMAC-SHA256 (대칭키, `jwt.secret` 프로퍼티에서 주입)
+
+---
+
+### 3.4 AuthController — 인증 API 엔드포인트
+
+> `auth/controller/AuthController.java`
+
+| 엔드포인트 | 메서드 | 인증 | 역할 |
+|-----------|--------|------|------|
+| `POST /api/auth/refresh` | `refresh()` | 불필요 | Refresh Token으로 새 토큰 쌍 발급 |
+| `POST /api/auth/logout` | `logout()` | 불필요 | 토큰 삭제 + 쿠키 제거 |
+| `GET /api/auth/member` | `getAuthenticatedMemberInfo()` | **필요** | 현재 사용자 정보 반환 |
+
+**Refresh Token 쿠키 설정:**
+```
+httpOnly=true ← JavaScript 접근 차단 (XSS 방어)
+secure={프로필별} ← local: false, prod: true (HTTPS 전용)
+sameSite=Lax ← 외부 사이트에서의 쿠키 전송 제한 (CSRF 방어)
+path=/api/auth ← 이 경로의 요청에만 쿠키 포함 (노출 최소화)
+maxAge={14일} ← Refresh Token 만료와 동일
+```
+
+---
+
+### 3.5 AuthServiceImpl — 토큰 비즈니스 로직
+
+> `auth/service/AuthServiceImpl.java`
+
+**`refresh()` 메서드 — 핵심 로직 상세:**
+
+```java
+// 1. JWT 자체 검증 (서명, 만료)
+jwtUtils.validateToken(refreshToken);
+
+// 2. DB에서 해시로 조회 (비관적 락으로 동시성 제어)
+String hashedToken = SecurityUtils.hashToken(refreshToken);
+RefreshToken storedRefreshToken = refreshTokenDao.findByTokenHashForUpdate(hashedToken);
+
+// 3. DB 만료 확인
+if (storedRefreshToken.getExpiresAt().isBefore(Instant.now())) { /* 삭제 후 에러 */ }
+
+// 4. 로테이션 이력 확인 (Grace Period 처리)
+if (storedRefreshToken.getReplacedByVersion() != null) {
+ if (storedRefreshToken.getGraceUntil() != null && !storedRefreshToken.getGraceUntil().isBefore(now)) {
+ // [분기 A] 유예 기간 내 재사용 -> 기존 발급된 후속 토큰(Successor) 재전달
+ return buildTokenPair(refreshTokenDao.findBySessionIdAndVersion(sid, replacedByVersion));
+ }
+ // 유예 기간 초과 -> 에러 (탈취 의심)
+ throw AuthException.of(AuthErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND);
+}
+
+// 5. [분기 B] 처음 수행되는 정상 로테이션
+// 새 버전의 후속 토큰 생성 (sid 유지, version + 1)
+RefreshToken successor = createAndSaveRefreshToken(memberId, sessionId, currentVersion + 1);
+
+// 부모 토큰 상태를 '회전됨'으로 업데이트 (replacedByVersion, graceUntil 설정)
+refreshTokenDao.markRotated(parentTokenWithMetadata);
+
+return buildTokenPair(successor);
+```
+
+**`issueRefreshToken()` 메서드 — OAuth2 로그인 후 호출:**
+1. 기존 회원의 모든 Refresh Token 삭제 (고아 토큰 정리)
+2. 새 Refresh Token 생성
+3. SHA-256 해시 후 DB 저장
+4. 원본 토큰 반환 (쿠키에 저장될 값)
+
+---
+
+### 3.6 OAuth2 로그인 처리 클래스
+
+#### CustomOAuth2MemberService — 소셜 응답 파싱 및 회원 처리
+
+> `member/service/CustomOAuth2MemberService.java`
+
+Spring Security의 `DefaultOAuth2UserService`를 확장. 소셜 제공자에서 사용자 정보를 받아온 후 호출됨.
+
+**처리 흐름:**
+1. `registrationId`로 소셜 제공자 판별 (naver / google / kakao)
+2. 제공자별 `OAuth2Response` 구현체 생성 (각 API 응답 포맷 파싱)
+3. 회원 고유 ID 생성: `{provider}{providerId}` (예: `google123456789`)
+4. DB 조회 → 신규면 회원가입, 기존이면 정보 업데이트
+5. `CustomOAuth2Member` 반환 (Spring Security의 Principal로 사용)
+
+#### OAuth2SuccessHandler — 로그인 성공 후 처리
+
+> `config/jwt/OAuth2SuccessHandler.java`
+
+1. `CustomOAuth2Member`에서 `memberId` 추출
+2. `AuthService.issueRefreshToken()` 호출
+3. Refresh Token을 HttpOnly 쿠키에 설정
+4. `redirect_uri` 쿠키 검증 (`isAuthorizedRedirectUri` — 스킴+호스트+포트 3중 비교)
+5. 임시 OAuth2 쿠키 삭제
+6. 프론트엔드 `/auth/callback`으로 리다이렉트
+
+#### HttpCookieOAuth2AuthorizationRequestRepository — STATELESS + OAuth2 호환
+
+> `config/security/HttpCookieOAuth2AuthorizationRequestRepository.java`
+
+**왜 필요한가?**
+- OAuth2 Authorization Code 플로우는 `state` 파라미터를 저장해야 함 (CSRF 방지)
+- 기본 구현(`HttpSessionOAuth2AuthorizationRequestRepository`)은 서버 세션에 저장
+- STATELESS 정책에서는 세션이 없으므로 **쿠키에 저장하는 커스텀 구현**이 필요
+
+| 시점 | 메서드 | 역할 |
+|------|--------|------|
+| 소셜 로그인 시작 | `saveAuthorizationRequest()` | 인증 요청 정보를 쿠키에 직렬화하여 저장 (3분 만료) |
+| 소셜 인증 콜백 | `loadAuthorizationRequest()` | 쿠키에서 인증 요청 정보를 역직렬화하여 반환 |
+| 인증 완료 후 | `removeAuthorizationRequestCookies()` | 임시 쿠키 삭제 |
+
+---
+
+### 3.7 에러 처리
+
+#### AuthErrorCode — 인증 도메인 에러 코드
+
+> `auth/error/AuthErrorCode.java`
+
+| 코드 | HTTP 상태 | 설명 |
+|------|-----------|------|
+| `AUTH_UNAUTHORIZED` | 401 | 인증이 필요한 요청 |
+| `AUTH_TOKEN_EXPIRED` | 401 | Access/Refresh Token 만료 |
+| `AUTH_INVALID_TOKEN` | 401 | 토큰 위변조 또는 파싱 실패 |
+| `AUTH_REFRESH_TOKEN_NOT_FOUND` | 401 | DB에 Refresh Token 없음 |
+| `AUTH_ACCESS_DENIED` | 403 | 접근 권한 없음 |
+| `AUTH_INVALID_REDIRECT_URI` | 400 | 허용되지 않은 리다이렉트 URI |
+| `AUTH_UNSUPPORTED_PROVIDER` | 400 | 지원하지 않는 소셜 제공자 |
+
+#### JwtAuthenticationEntryPoint — 인증 실패 JSON 응답
+
+> `config/jwt/JwtAuthenticationEntryPoint.java`
+
+`JwtAuthenticationFilter`가 request attribute에 저장한 에러 코드를 읽어서 JSON 응답:
+```json
+{ "code": "A002", "message": "토큰이 만료되었습니다.", "success": false }
+```
+
+---
+
+## 4. 프론트엔드 핵심 코드
+
+### 4.1 authStore.ts — 전역 인증 상태 (Zustand)
+
+> `frontend/src/lib/store/authStore.ts`
+
+| 상태 | 타입 | 설명 |
+|------|------|------|
+| `accessToken` | `string \| null` | API 인증용 토큰 (메모리에만 존재) |
+| `isAuthenticated` | `boolean` | 로그인 여부 |
+| `isInitializing` | `boolean` | 초기화 진행 여부 (로딩 표시용) |
+| `member` | `Member \| null` | 사용자 프로필 정보 |
+| `authError` | `string \| null` | 서버/네트워크 오류 메시지 |
+
+| 액션 | 역할 |
+|------|------|
+| `initialize()` | `/api/auth/refresh` → `/api/auth/member` 순서로 호출하여 로그인 상태 복구 |
+| `logout()` | `/api/auth/logout` 호출 후 상태 초기화 |
+| `setAccessToken(token)` | 토큰 갱신 시 새 토큰 저장 |
+
+**`initializePromise` 패턴:**
+```typescript
+let initializePromise: Promise | null = null;
+
+// AuthProvider와 AuthCallbackPage가 동시에 initialize() 호출해도
+// 실제 API 요청은 1회만 발생
+initialize: async () => {
+ if (initializePromise) return initializePromise;
+ initializePromise = (async () => { /* ... */ })();
+ return initializePromise;
+}
+```
+
+---
+
+### 4.2 fetchClient.ts — API 클라이언트
+
+> `frontend/src/lib/api/fetchClient.ts`
+
+모든 API 호출을 감싸는 래퍼. 인증 토큰 주입과 자동 갱신을 담당.
+
+**핵심 기능:**
+
+1. **자동 토큰 주입**: `Authorization: Bearer {accessToken}` 헤더 추가
+2. **401 자동 갱신**: 만료 시 `/api/auth/refresh` 호출 후 원래 요청 재시도 (1회)
+3. **동시 갱신 방지**: `refreshPromise` 공유로 여러 401이 동시에 발생해도 갱신 1회
+4. **갱신 실패 시 로그아웃**: `authStore.logout()` → `/login` 이동
+
+```
+요청 ──→ 성공(200) ──→ 응답 반환
+ │
+ └──→ 실패(401) ──→ refreshAccessToken() ──┬──→ 성공 ──→ 원래 요청 재시도
+ │
+ └──→ 실패 ──→ logout() → /login
+```
+
+---
+
+### 4.3 AuthProvider.tsx — 인증 초기화 및 라우트 가드
+
+> `frontend/src/components/providers/AuthProvider.tsx`
+
+앱 최상위에서 인증 상태를 초기화하고, 미인증 사용자의 접근을 제어.
+
+**역할:**
+1. 앱 시작 시 `authStore.initialize()` 호출 (1회)
+2. 초기화 중 → 로딩 스피너 표시
+3. 초기화 완료 후:
+ - 미인증 + 보호 경로 → `/login` 리다이렉트
+ - 인증 완료 → 자식 컴포넌트 렌더링
+
+**공개 경로:** `/`, `/login`, `/auth/callback` (접두사 매칭)
+
+---
+
+### 4.4 AuthCallbackPage — OAuth2 콜백 처리
+
+> `frontend/src/app/auth/callback/page.tsx`
+
+OAuth2 인증 완료 후 Spring Boot가 리다이렉트하는 페이지.
+
+**동작:**
+1. 마운트 시 `authStore.initialize()` 호출 (`useRef`로 중복 방지)
+2. `initializePromise` 싱글톤으로 AuthProvider와의 이중 호출 방지
+3. 인증 성공 → `/my-links` 이동
+4. 인증 실패 → `/login` 이동
+
+---
+
+## 5. 보안 메커니즘
+
+### 토큰 저장 전략
+
+| 토큰 | 저장 위치 | 접근 가능 | XSS 노출 | CSRF 노출 |
+|------|----------|----------|---------|---------|
+| Access Token | Zustand (메모리) | JavaScript | O (단, 새로고침 시 소멸) | X |
+| Refresh Token | HttpOnly 쿠키 | 서버만 | X | △ (SameSite=Lax로 완화) |
+| Refresh Token 해시 | PostgreSQL DB | 서버만 | X | X |
+
+### 공격 방어 매트릭스
+
+| 공격 유형 | 방어 수단 |
+|----------|----------|
+| **XSS** | Refresh Token: HttpOnly 쿠키 (JS 접근 불가). Access Token: 메모리 저장 (localStorage보다 안전) |
+| **CSRF** | SameSite=Lax 쿠키 + Bearer 토큰 방식 (쿠키가 자동 전송되지 않는 구조) |
+| **토큰 탈취** | Rotation (매번 새 토큰) + Grace Period (멱등성 재전달) + Session/Version 추적 (복제 감지) |
+| **DB 유출** | SHA-256 해시 저장 (원본 토큰 복합 키: [해시, sid, ver] 등으로 추적 가능) |
+| **동시 요청 공격** | SELECT ... FOR UPDATE 비관적 락 + @Transactional |
+| **Open Redirect** | OAuth2 redirect_uri 검증 (스킴+호스트+포트 3중 비교) |
+| **세션 고정** | STATELESS (세션 자체가 없음) |
+| **OAuth2 CSRF** | state 파라미터 쿠키 저장 + 콜백 시 검증 |
+| **멀티 디바이스 보호** | 현재 단일 세션 강제 (Login 시 기존 RT 전체 삭제). 향후 최대 세션 수 제한(FIFO) 방식으로 확장 가능. |
+
+---
+
+### 5.3 멱등성(Idempotency) 보장 메커니즘 상세
+
+네트워크 장애나 클라이언트의 중복 요청(멀티탭 등)에도 시스템이 일관된 응답을 제공하는 핵심 원리입니다.
+
+#### 1단계: 비관적 락을 통한 상호 배제
+`SELECT ... FOR UPDATE`를 사용하여 동일한 토큰에 대한 여러 요청 중 단 하나만 트랜잭션을 진행하도록 보장합니다.
+
+#### 2단계: 결정적 토큰 생성 (Deterministic Generation)
+랜덤 값이 아닌 고정된 세션 정보(`sid`)와 순차적 버전(`ver`)을 기반으로 JWT를 생성합니다. 이를 통해 서버는 이미 발급된 토큰이라도 DB에 기록된 메타데이터를 사용하여 **정확히 동일한 토큰 문자열**을 얻을 수 있습니다.
+
+#### 3단계: 유예 기간(Grace Period) 내 결과 재전달
+이미 회전된(`replaced_by_version != null`) 토큰으로 요청이 들어오면:
+1. 설정된 유예 기간(10초) 이내인지 확인합니다.
+2. 기간 내라면 DB에서 해당 세션의 다음 버전(`replaced_by_version`) 토큰을 찾아 **새로 생성하지 않고 그대로 반환**합니다.
+3. 클라이언트는 지연된 첫 번째 요청의 결과든, 재시도한 두 번째 요청의 결과든 동일한 최신 토큰을 받게 되어 인증 흐름이 중단되지 않습니다.
+
+> **📝 향후 확장 고려사항: 멀티 디바이스 로그인 정책**
+>
+> **1. 현재 동작 (보안 우선 단일 세션)**
+> - 사용자가 소셜 로그인 시, `AuthServiceImpl.issueRefreshToken`에서 `refreshTokenDao.deleteByMemberId(memberId)`를 호출하여 **해당 사용자의 기존 모든 Refresh Token을 즉시 무효화**합니다.
+> - 이는 고아 토큰(사용되지 않는 토큰)의 누적을 방지하고, 타 기기에서의 부정 사용 가능성을 원천 차단하는 가장 안전한 방식입니다.
+>
+> **2. 향후 확장: 멀티 디바이스 지원 (최대 세션 수 제한)**
+> - PC, 모바일, 태블릿 등 여러 환경에서 동시 로그인을 유지하려면 다음과 같은 개편이 필요합니다.
+> - **세션 카운팅**: 전체 삭제 대신 사용자의 현재 유효 세션 수를 확인합니다.
+> - **FIFO 삭제**: 설정된 상한선(예: 3~5개)을 초과할 때만 가장 오래된(또는 만료 시점이 가장 빠른) 세션을 삭제합니다.
+> - **기기 식별**: `User-Agent` 등을 이용해 기기별 정보를 기록하면 사용자가 설정에서 기기를 직접 관리(선택적 로그아웃)할 수도 있습니다.
+>
+
+---
+
+## 6. 설정 파일 가이드
+
+### 환경변수 (필수)
+
+| 변수명 | 설명 | 예시 |
+|--------|------|------|
+| `JWT_SECRET` | JWT 서명 비밀키 (Base64, 256bit 이상) | `dGhpcyBpcyBhIHNlY3JldCBrZXkgZm9yIGp3dA==` |
+| `JWT_ACCESS_TOKEN_EXPIRY` | Access Token 만료 (ms) | `1800000` (30분) |
+| `JWT_REFRESH_TOKEN_EXPIRY` | Refresh Token 만료 (ms) | `1209600000` (14일) |
+
+### 프로필별 설정 차이
+
+| 설정 | local | dev | prod |
+|------|-------|-----|------|
+| `app.cookie.secure` | `false` | `${DEV_COOKIE_SECURE:false}` | `${PROD_COOKIE_SECURE:true}` |
+| `app.oauth2.redirect-uri` | `http://localhost:3000/auth/callback` | 환경변수 | 환경변수 |
+
+---
+
+## 7. 파일 맵
+
+### 읽는 순서 추천
+
+> 처음 코드를 파악할 때는 아래 순서로 읽으면 흐름이 자연스럽습니다.
+
+**1단계: 설정과 진입점** (전체 구조 파악)
+```
+config/security/SecurityConfig.java ← 필터 체인, URL 접근 규칙, OAuth2 설정
+config/jwt/JwtProperties.java ← JWT 설정값 (secret, expiry, path)
+```
+
+**2단계: JWT 인증 흐름** (매 요청마다 실행)
+```
+config/jwt/JwtAuthenticationFilter.java ← 요청 가로채기 → 토큰 검증
+config/jwt/JwtUtils.java ← 토큰 생성, 파싱, 검증 로직
+config/jwt/JwtMemberPrincipal.java ← 토큰에서 추출한 사용자 정보
+config/jwt/JwtAuthenticationEntryPoint.java ← 인증 실패 시 JSON 응답
+```
+
+**3단계: OAuth2 소셜 로그인** (로그인 시 1회 실행)
+```
+member/service/CustomOAuth2MemberService.java ← 소셜 응답 파싱 → 회원가입/로그인
+member/dto/CustomOAuth2Member.java ← OAuth2 Principal 구현체
+member/dto/Response/OAuth2Response.java ← 소셜 제공자별 응답 인터페이스
+config/jwt/OAuth2SuccessHandler.java ← 성공: 토큰 발급 + 리다이렉트
+config/jwt/OAuth2FailureHandler.java ← 실패: 에러 메시지 + 리다이렉트
+config/security/HttpCookieOAuth2AuthorizationRequestRepository.java ← STATELESS + OAuth2 호환
+config/security/CookieUtils.java ← 쿠키 직렬화/역직렬화 유틸
+```
+
+**4단계: 토큰 관리 비즈니스 로직**
+```
+auth/controller/AuthController.java ← API 엔드포인트 (refresh, logout, member)
+auth/service/AuthServiceImpl.java ← 핵심 로직 (토큰 발급, 갱신, Grace Period)
+auth/dao/RefreshTokenDao.java ← DB 접근 인터페이스
+auth/domain/RefreshToken.java ← 토큰 엔티티
+auth/error/AuthErrorCode.java ← 에러 코드 정의
+config/security/SecurityUtils.java ← 사용자 ID 추출, 토큰 해싱 유틸
+```
+
+**5단계: 프론트엔드 인증**
+```
+frontend/src/lib/store/authStore.ts ← 전역 인증 상태 (Zustand)
+frontend/src/lib/api/fetchClient.ts ← API 클라이언트 (자동 토큰 갱신)
+frontend/src/components/providers/AuthProvider.tsx ← 인증 초기화 + 라우트 가드
+frontend/src/app/auth/callback/page.tsx ← OAuth2 콜백 처리 페이지
+```
+
+**6단계: DB/설정**
+```
+resources/mapper/refresh-token-mapper.xml ← MyBatis SQL (CRUD + FOR UPDATE)
+resources/sql/V001__create_refresh_token.sql ← Flyway 마이그레이션
+resources/application-{profile}.properties ← 프로필별 설정
+```
diff --git a/docs/Backend/03. jwt-oauth2-test-scenarios.md b/docs/Backend/03. jwt-oauth2-test-scenarios.md
new file mode 100644
index 0000000..f2d40c7
--- /dev/null
+++ b/docs/Backend/03. jwt-oauth2-test-scenarios.md
@@ -0,0 +1,1282 @@
+# JWT + OAuth2 인증 시스템 테스트 시나리오
+
+## 목적
+본 문서는 SearchWeb의 JWT + OAuth2 인증 시스템에 대한 QA 검증용 테스트 시나리오를 정의한다.
+추후 JUnit 테스트 코드 작성 시 가이드로도 활용한다.
+
+## 대상 시스템 개요
+
+### 대상 컴포넌트 (15개)
+| # | 컴포넌트 | 패키지 | 역할 |
+|---|---------|--------|------|
+| 1 | AuthController | auth.controller | 인증 API 엔드포인트 (refresh, logout, member info) |
+| 2 | AuthServiceImpl | auth.service | 토큰 발급/갱신/로그아웃 비즈니스 로직 |
+| 3 | RefreshTokenCleanupScheduler | auth.service | 만료/고아 토큰 정리 스케줄러 (매일 3AM) |
+| 4 | RefreshTokenDao (MybatisRefreshTokenDao) | auth.dao | Refresh Token DB 접근 (MyBatis) |
+| 5 | JwtUtils | config.jwt | Access/Refresh Token 생성, 파싱, 검증 (HMAC-SHA256) |
+| 6 | JwtAuthenticationFilter | config.jwt | Bearer 토큰 추출 및 SecurityContext 설정 |
+| 7 | JwtAuthenticationEntryPoint | config.jwt | 인증 실패 시 401 응답 처리 |
+| 8 | OAuth2SuccessHandler | config.jwt | OAuth2 성공 후 RefreshToken 발급 및 쿠키 설정 |
+| 9 | OAuth2FailureHandler | config.jwt | OAuth2 실패 후 에러 리다이렉트 |
+| 10 | SecurityConfig | config.security | 필터 체인, 권한 설정, CORS/CSRF |
+| 11 | SecurityUtils | config.security | memberId 추출, 토큰 해싱 (SHA-256) |
+| 12 | CookieUtils | config.security | 쿠키 CRUD, 직렬화/역직렬화 |
+| 13 | HttpCookieOAuth2AuthorizationRequestRepository | config.security | OAuth2 상태 쿠키 관리 (세션 없이 상태 유지) |
+| 14 | CustomOAuth2MemberService | member.service | 소셜 로그인 회원 조회/가입/업데이트 |
+| 15 | OwnerCheckAspect | aop | @OwnerCheck AOP 기반 리소스 소유권 인가 |
+
+### 에러 코드 레퍼런스
+| Code | HTTP Status | Message | 발생 위치 |
+|------|------------|---------|----------|
+| A001 | 401 | 인증이 필요합니다 | JwtAuthenticationEntryPoint (기본값) |
+| A002 | 401 | 토큰이 만료되었습니다 | JwtAuthenticationFilter (ExpiredJwtException) |
+| A003 | 401 | 유효하지 않은 토큰입니다 | JwtAuthenticationFilter (JwtException / IllegalArgumentException) |
+| A004 | 401 | Refresh Token이 없습니다 | AuthController, AuthServiceImpl (토큰 미존재/만료/재사용 초과) |
+| A005 | 403 | 접근 권한이 없습니다 | OwnerCheckAspect (리소스 소유권 불일치) |
+| A006 | 400 | 유효하지 않은 리다이렉트 주소입니다 | OAuth2SuccessHandler (Open Redirect 차단) |
+| A007 | 400 | 지원하지 않는 소셜 로그인 제공자입니다 | CustomOAuth2MemberService |
+
+### API 응답 형식
+```json
+{
+ "code": "200",
+ "message": "success",
+ "data": { ... }
+}
+```
+
+---
+
+## 🛠️ E2E 시나리오 HTTP 테스트 가이드
+
+본 문서의 **Part A (S1~S5 시나리오)**에 대한 HTTP 테스트는 실제 실행 가능한 전용 `.http` 파일로 분리되어 관리됩니다.
+
+👉 **테스트 파일 위치:** [`src/test/api-test/auth-test.http`](../../src/test/api-test/auth-test.http)
+
+**[사용 방법]**
+1. 테스트 파일을 IntelliJ HTTP Client 또는 VS Code REST Client에서 엽니다.
+2. 파일 최상단의 **[필독] 테스트 세션 연동 가이드**를 따라 브라우저 쿠키와 토큰 변수를 설정합니다.
+3. 문서에 기재된 시나리오 번호 (예: `[S1-Step 1]`)를 테스트 파일에서 찾아 순서대로 실행 버튼(▶️)을 클릭합니다.
+
+---
+
+---
+
+## 목차
+
+### Part A: 시나리오 기반 E2E 테스트
+0. [시나리오 기반 E2E 테스트](#시나리오-기반-e2e-테스트)
+ - [S1: OAuth2 소셜 로그인 (최초 진입)](#s1-oauth2-소셜-로그인-최초-진입)
+ - [S2: 페이지 새로고침 시 인증 복구](#s2-페이지-새로고침-시-인증-복구)
+ - [S3: API 요청 중 Access Token 만료](#s3-api-요청-중-access-token-만료)
+ - [S4: Refresh Token Rotation과 Grace Period](#s4-refresh-token-rotation과-grace-period)
+ - [S5: 로그아웃](#s5-로그아웃)
+
+### Part B: 컴포넌트별 상세 테스트
+1. [그룹 1: Token Refresh](#그룹-1-token-refresh)
+2. [그룹 2: Logout](#그룹-2-logout)
+3. [그룹 3: 인증 정보 조회](#그룹-3-인증-정보-조회)
+4. [그룹 4: JWT 필터 & 인가](#그룹-4-jwt-필터--인가)
+5. [그룹 5: OAuth2 소셜 로그인](#그룹-5-oauth2-소셜-로그인)
+6. [그룹 6: 보안 & 인프라](#그룹-6-보안--인프라)
+7. [부록: 컴포넌트 크로스레퍼런스](#부록-컴포넌트-크로스레퍼런스)
+
+---
+
+## 시나리오 기반 E2E 테스트
+
+> 아키텍처 문서([02. jwt-oauth2-architecture.md](./02.%20jwt-oauth2-architecture.md))에 정의된 5개 인증 시나리오를 기반으로 한 End-to-End 테스트 케이스.
+> 프론트엔드 → 백엔드 → DB 전체 플로우를 검증한다.
+
+### 요약 표
+| ID | 시나리오 | 주요 플로우 | 우선순위 | 관련 상세 케이스 |
+|----|---------|-----------|---------|---------------|
+| S1 | OAuth2 소셜 로그인 (최초 진입) | 구글 로그인 → 회원 처리 → 토큰(RT/AT) 획득 | Critical | AUTH-034, AUTH-037, AUTH-041, AUTH-001, AUTH-018 |
+| S2 | 페이지 새로고침 시 인증 복구 | 화면 새로고침 → 쿠키 확인 → 로그인 상태 복구 | High | AUTH-001, AUTH-018 |
+| S3 | API 요청 중 Access Token 만료 | 요청 만료(401) → 자동 갱신 → 즉시 재시도 | High | AUTH-026, AUTH-001, AUTH-024 |
+| S4 | Refresh Token Rotation + Grace Period | 보안 회전(RTR) → 10초 유예 허용 → 도난 집중 감시 | Critical | AUTH-001~004, AUTH-011 |
+| S5 | 로그아웃 | 로그아웃 → DB 기록 파기 → 쿠키/메모리 초기화 | High | AUTH-013, AUTH-017 |
+
+---
+
+### S1: OAuth2 소셜 로그인 (최초 진입)
+
+> **시나리오 요약:**
+> 1. **로그인 페이지(`/login`):** "구글로 로그인" 버튼 클릭
+> 2. **구글 인증 서버:** 계정 선택 및 본인 인증 완료
+> 3. **백엔드 처리:** 인증 성공 및 **'임시 열쇠(RT)'** 쿠키 발급
+> 4. **로그인 처리 중(`/auth/callback`):** 앱 구동 및 서버에 `refresh` 요청 (임시 RT 전송)
+> 5. **열쇠 교체 및 입장권 수령: 새로운 **'정식 열쇠(RT)'로 교체(Rotation) 및 '입장권(AT)'** 발급 완료**
+> 6. **내 링크 페이지 `/my-links`:** 대시보드 진입 및 서비스 이용 시작
+
+#### S1-01: Google OAuth2 최초 로그인 전체 플로우 (신규 회원)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S1-01 |
+| **컴포넌트** | HttpCookieOAuth2AuthorizationRequestRepository → Google OAuth2 → CustomOAuth2MemberService → OAuth2SuccessHandler → AuthController.refresh() → AuthController.getAuthenticatedMemberInfo() |
+| **우선순위** | Critical |
+| **사전조건** | DB에 해당 Google 계정의 회원이 존재하지 않음 |
+| **테스트 데이터** | Google OAuth2 테스트 계정 |
+
+**실행 단계 및 검증:**
+
+| Step | 요청 | 기대 결과 | 검증 포인트 |
+|------|------|----------|------------|
+| 1. 인가 요청 | `GET /oauth2/authorization/google` | 302 → Google 인증 페이지 | `oauth2_auth_request` 쿠키 생성 (180초 만료) |
+| 2. 사용자 동의 | Google에서 동의 후 콜백 | `GET /login/oauth2/code/google?code=...&state=...` | state 파라미터가 쿠키의 값과 일치 |
+| 3. 회원 처리 | (서버 내부) CustomOAuth2MemberService.loadUser() | 신규 회원 DB 삽입 | member 테이블에 loginId="google{providerId}" 레코드 생성 |
+| 4. 토큰 발급 | (서버 내부) OAuth2SuccessHandler | 302 → /auth/callback + Set-Cookie | refreshToken 쿠키: HttpOnly, SameSite=Lax, Path=/api/auth |
+| 5. AT 획득 | `POST /api/auth/refresh` (RT 쿠키 포함) | 200 + accessToken | DB에 새 RT(sid, ver=2) 저장, 기존 RT(ver=1)의 rotated_at 갱신 |
+| 6. 회원 정보 | `GET /api/auth/member` (Bearer AT) | 200 + MemberInfo | memberId, name, email, role 반환 |
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S1-Step 1]** 부터 **[S1-Step 6]** 까지 순서대로 실행하세요.
+*(💡 참고: 실제 프론트엔드 환경은 로그인 직후 자동으로 1회 `/api/auth/refresh`를 수행하여 입장권(AT)을 받지만, 이 테스트 파일에서는 원활한 변수 연동과 단계별 검증을 위해 해당 과정을 수동으로 분리해 두었습니다.)*
+
+**DB 검증:**
+- member 테이블: `loginId="google{providerId}"`, `role="ROLE_USER"` 레코드 존재
+- refresh_token 테이블: `member_id=1`, `rotated_at` 갱신됨, 새 토큰 해시 삽입됨
+- 임시 쿠키 (`oauth2_auth_request`, `redirect_uri`) 삭제됨
+
+---
+
+#### S1-02: OAuth2 로그인 (기존 회원 — 정보 업데이트)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S1-02 |
+| **컴포넌트** | CustomOAuth2MemberService → OAuth2SuccessHandler |
+| **우선순위** | High |
+| **사전조건** | DB에 해당 loginId("google{providerId}")로 회원이 이미 존재 |
+| **테스트 데이터** | Google 계정 (이름/이메일이 기존과 다르게 변경된 상태) |
+| **실행 단계** | S1-01과 동일한 OAuth2 플로우 실행 |
+| **기대 결과** | SocialjoinProcess 대신 **updateSocialMember** 호출. DB의 email/name이 최신 정보로 업데이트됨. 기존 RT 삭제 후 새 RT 발급 |
+
+---
+
+#### S1-03: OAuth2 로그인 실패 — 쿠키 만료 (3분 초과)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S1-03 |
+| **컴포넌트** | HttpCookieOAuth2AuthorizationRequestRepository → OAuth2FailureHandler |
+| **우선순위** | High |
+| **사전조건** | OAuth2 인가 요청 후 3분(180초) 이상 경과하여 oauth2_auth_request 쿠키 만료 |
+| **테스트 데이터** | 만료된 oauth2_auth_request 쿠키 |
+| **실행 단계** | 인가 요청 → 3분 이상 대기 → OAuth2 콜백 도착 |
+| **기대 결과** | **302 리다이렉트** `/login?error=true&message=인증 요청을 찾을 수 없습니다...` (authorization_request_not_found) |
+
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S1-Step 1]** 부터 **[S1-Step 3]** 까지 실행하여 갱신 실패 케이스를 확인하세요.
+
+
+---
+
+### S2: 페이지 새로고침 시 인증 복구
+
+> **시나리오 요약:**
+> 1. **현재 페이지:** 사용자의 새로고침(F5) 수행
+> 2. **메모리 초기화:** 기존 입장권(AT) 증발 및 앱 재실행
+> 3. **로그인 확인:** 브라우저 쿠키에서 **'재로그인용 열쇠(RT)'** 감지
+> 4. **서버 요청:** 서버에 `refresh` 요청 및 기존 열쇠 전송
+> 5. **토큰 갱신:** **새 입장권(AT)** 수령 및 **새 열쇠(RT)** 쿠키 교체 (Rotation)
+> 6. **상태 복구:** 로그인 상태 유지 및 기존 화면 데이터 재호출 완료
+
+#### S2-01: 새로고침 후 인증 복구 성공
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S2-01 |
+| **컴포넌트** | (FE) AuthProvider.initialize() → AuthController.refresh() → AuthController.getAuthenticatedMemberInfo() |
+| **우선순위** | High |
+| **사전조건** | 사용자가 이미 로그인된 상태 (유효한 refreshToken 쿠키 존재) |
+| **테스트 데이터** | 유효한 refreshToken 쿠키 |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 | 검증 포인트 |
+|------|------|----------|------------|
+| 1. 새로고침 | F5 → Zustand 초기화 (accessToken=null) | AuthProvider 마운트 시 initialize() 호출 | - |
+| 2. 토큰 갱신 | `POST /api/auth/refresh` (RT 쿠키 자동 전송) | 200 + 새 accessToken | Set-Cookie에 새 RT 포함 |
+| 3. 회원 정보 | `GET /api/auth/member` (Bearer 새 AT) | 200 + MemberInfo | isAuthenticated=true 설정 |
+| 4. 화면 표시 | 대시보드 렌더링 | /my-links 페이지 정상 표시 | isInitializing=false |
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S2-Step 2]** 부터 **[S2-Step 3]** 까지 순서대로 실행하세요.
+
+---
+
+#### S2-02: 새로고침 — RT 쿠키 만료 시 로그인 페이지 이동
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S2-02 |
+| **컴포넌트** | (FE) AuthProvider.initialize() → AuthController.refresh() |
+| **우선순위** | High |
+| **사전조건** | refreshToken 쿠키가 만료되어 브라우저가 쿠키를 전송하지 않음 |
+| **테스트 데이터** | 만료된 refreshToken 쿠키 (브라우저에서 자동 삭제) |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 | 검증 포인트 |
+|------|------|----------|------------|
+| 1. 새로고침 | F5 → initialize() 호출 | - | - |
+| 2. 토큰 갱신 시도 | `POST /api/auth/refresh` (쿠키 없음) | 401 A004 | "Refresh Token이 없습니다" |
+| 3. 로그아웃 처리 | (FE) authStore.logout() | isAuthenticated=false | /login 페이지로 리다이렉트 |
+
+---
+
+### S3: API 요청 중 Access Token 만료
+
+> **시나리오 요약:**
+> 1. **서비스 이용 중:** 입장권(AT) 만료 상태로 API 호출
+> 2. **인증 거절:** 서버로부터 401(Unauthorized) 응답 수신
+> 3. **자동 가로채기:** 통신 모듈(`fetchClient`)이 에러를 감지하고 요청 일시 중단
+> 4. **토큰 교환:** 쿠키의 **열쇠(RT)**를 보내 서버로부터 **새 입장권(AT)** 수령
+> 5. **요청 재시도:** 새 입장권으로 기존 API 요청 자동 재전송
+> 6. **정상 응답:** 작업 성공 및 사용자 중단 없는 서비스 이용 지속
+
+#### S3-01: AT 만료 → 자동 갱신 → 원래 요청 재시도 성공
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S3-01 |
+| **컴포넌트** | (FE) fetchClient → JwtAuthenticationFilter → AuthController.refresh() → 원래 API |
+| **우선순위** | High |
+| **사전조건** | 유효한 RT 쿠키 존재, AT가 만료된 상태 |
+| **테스트 데이터** | 만료된 accessToken, 유효한 refreshToken 쿠키 |
+
+**실행 단계 및 검증:**
+
+| Step | 요청 | 기대 결과 | 검증 포인트 |
+|------|------|----------|------------|
+| 1. API 호출 | `GET /api/bookmarks` (Bearer: 만료된 AT) | 401 A002 | JwtAuthenticationFilter → ExpiredJwtException |
+| 2. 자동 갱신 | (FE) `POST /api/auth/refresh` (RT 쿠키) | 200 + 새 AT | Zustand에 새 AT 저장 |
+| 3. 재시도 | `GET /api/bookmarks` (Bearer: 새 AT) | 200 + 북마크 목록 | 사용자에게 끊김 없는 경험 |
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S3-Step 1]** 부터 **[S3-Step 3]** 까지 순서대로 실행하세요.
+
+---
+
+#### S3-02: AT 만료 → 자동 갱신 실패 (RT도 만료) → 로그아웃
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S3-02 |
+| **컴포넌트** | (FE) fetchClient → AuthController.refresh() |
+| **우선순위** | High |
+| **사전조건** | AT 만료, RT 쿠키도 만료됨 |
+| **테스트 데이터** | 만료된 accessToken, 만료된/없는 refreshToken |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 | 검증 포인트 |
+|------|------|----------|------------|
+| 1. API 호출 | `GET /api/bookmarks` (Bearer: 만료 AT) | 401 A002 | - |
+| 2. 갱신 시도 | `POST /api/auth/refresh` (쿠키 없음/만료) | 401 A004 | Refresh Token 없음 |
+| 3. 강제 로그아웃 | (FE) authStore.logout() | isAuthenticated=false | /login 리다이렉트 |
+
+---
+
+#### S3-03: 동시 다중 API 401 → 갱신 요청 1회만 발생
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S3-03 |
+| **컴포넌트** | (FE) fetchClient.refreshPromise 싱글톤 |
+| **우선순위** | Medium |
+| **사전조건** | AT 만료 상태에서 여러 API 동시 호출 |
+| **테스트 데이터** | 만료된 AT, 유효한 RT, 동시 3개 API 호출 |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 | 검증 포인트 |
+|------|------|----------|------------|
+| 1. 동시 호출 | GET /api/bookmarks, GET /api/folders, GET /api/tags (모두 만료 AT) | 3개 모두 401 | - |
+| 2. 갱신 | `POST /api/auth/refresh` | **1회만 호출** | refreshPromise 공유로 중복 방지 |
+| 3. 재시도 | 3개 API 모두 새 AT로 재시도 | 3개 모두 200 OK | 모든 대기 중인 요청이 새 AT 사용 |
+
+---
+
+### S4: Refresh Token Rotation과 Grace Period
+
+> **시나리오 요약:**
+> 1. **열쇠 교체:** 토큰 갱신 시마다 **새로운 열쇠(RT)** 발급 및 기존 열쇠 폐기
+> 2. **지연 대비:** 통신 장애를 고려해 **직전 열쇠도 10초간** 유효성 인정 (Grace Period)
+> 3. **중복 요청:** 10초 이내 재사용 시 **새로운 입장권(AT)** 재발급 완료
+> 4. **이상 감지:** 10초 초과 혹은 중복 사용 횟수 초과 시 즉시 거부
+> 5. **도난 차단:** 도난 의심 토큰 발견 시 해당 사용자의 모든 세션 강제 종료
+> 6. **보안 강화:** 주기적인 열쇠 교체 및 유예 시간을 통한 안정적 세션 관리
+
+#### S4-01: Case 1 — 정상 Rotation (첫 사용)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S4-01 |
+| **컴포넌트** | AuthController.refresh() → AuthServiceImpl.refresh() (rotatedAt==null 분기) |
+| **우선순위** | Critical |
+| **사전조건** | 유효한 RT-A 쿠키, DB에 rotated_at=NULL |
+| **테스트 데이터** | RT-A: 미사용 Refresh Token |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 | DB 상태 |
+|------|------|----------|---------|
+| 1 | `POST /api/auth/refresh` (Cookie: RT-A) | 200 + {AT, RT-B} | RT-A: rotated_at = NOW() |
+| 2 | RT-B 쿠키 수신 | Set-Cookie: RT-B | 새 RT-B 해시 삽입됨 |
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S4-Case 1] 정상 Rotation (토큰 교체)** 블록을 실행하세요.
+
+**DB 검증:**
+```sql
+-- RT-A: rotated_at이 갱신됨
+SELECT rotated_at FROM refresh_token WHERE token_hash = SHA256('RT-A');
+-- → NOT NULL (현재 시간)
+
+-- RT-B: 새로 삽입됨
+SELECT * FROM refresh_token WHERE token_hash = SHA256('RT-B');
+-- → 존재, rotated_at = NULL
+```
+
+---
+
+#### S4-02: Case 2 — Grace Period 내 재사용 (동시 요청 또는 네트워크 지연)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S4-02 |
+| **컴포넌트** | AuthServiceImpl.refresh() (rotatedAt != null, 10초 이내 분기) |
+| **우선순위** | Critical |
+| **사전조건** | S4-01 완료 후 10초 이내 |
+| **테스트 데이터** | RT-A (이미 회전됨, rotated_at이 10초 이내) |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 | DB 상태 |
+|------|------|----------|---------|
+| 1 (T+0s) | S4-01 실행 (RT-A → RT-B) | 성공 | RT-A: rotated_at = T |
+| 2 (T+3s) | `POST /api/auth/refresh` (Cookie: RT-A) — 중복/동시 요청 | 200 + {AT, RT-B} | **RT-B 재전달 (멱등성)** |
+| 3 (T+15s) | `POST /api/auth/refresh` (Cookie: RT-A) — 유예 기간 만료 후 | **401 A004** | AUTH_REFRESH_TOKEN_NOT_FOUND |
+
+**핵심 검증:**
+- Step 2에서 새로운 토큰을 만들지 않고 **이미 발급된 RT-B를 그대로 반환**함 (결정적 생성 및 DB 조회)
+- 10초 이내라면 몇 번을 요청해도 동일한 RT-B를 응답 (멱등성)
+- 유예 기간이 지나면 폐기된 토큰으로 간주하여 거부
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S4-Case 2] Grace Period (10초) 내 재사용 시도** 블록을 실행하세요. (Case 1 직후 10초 이내 실행)
+
+---
+
+#### S4-03: Case 3 — Grace Period 초과 (토큰 탈취 의심)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S4-03 |
+| **컴포넌트** | AuthServiceImpl.refresh() (rotatedAt != null, 10초 초과 분기) |
+| **우선순위** | Critical |
+| **사전조건** | S4-01 완료 후 10초 초과 경과 |
+| **테스트 데이터** | RT-A (rotated_at이 10초 초과) |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 |
+|------|------|----------|
+| 1 (T+0s) | S4-01 실행 (RT-A 회전) | 성공 |
+| 2 (T+15s) | `POST /api/auth/refresh` (Cookie: RT-A) | **401 A004** |
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S4-Case 3] Grace Period 초과 (10초 후 재사용 시도)** 블록을 실행하세요. (Case 1, 2 실행 후 10초 이상 대기 후 실행)
+
+**보안 의미:** 10초를 넘긴 재사용은 네트워크 지연이 아닌 **토큰 탈취**로 간주하여 즉시 거부.
+
+---
+
+#### S4-04: Case 4 — 동시 요청 제어 (DB 비관적 락)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S4-04 |
+| **컴포넌트** | AuthServiceImpl.refresh(), RefreshTokenDao.findByTokenHashForUpdate() |
+| **우선순위** | High |
+| **사전조건** | 유효한 RT-A, 멀티탭 환경에서 동시 갱신 |
+| **테스트 데이터** | 동일 RT-A로 2개 동시 요청 (R1, R2) |
+
+**실행 단계 및 검증:**
+
+| Step | R1 (먼저 도착) | R2 (약간 늦게 도착) |
+|------|---------------|-------------------|
+| 1 | `SELECT ... FOR UPDATE` → **락 획득** | `SELECT ... FOR UPDATE` → **대기** |
+| 2 | rotatedAt = NOW(), INSERT RT-B, **COMMIT** | (대기 중...) |
+| 3 | 200 + {AT, RT-B} 응답 | 락 획득 → RT-A 조회 (rotatedAt != null) |
+| 4 | - | Grace Period 로직 → **200 + {AT, RT-B} (동일 결과 재전달)** |
+
+**핵심 검증:**
+- `FOR UPDATE`로 인해 두 요청이 동시에 같은 토큰을 처리하지 않음
+- R2는 R1의 트랜잭션 완료 후 이미 회전된 상태의 RT-A를 만남 → Case 2 (Grace Period) 로직으로 분기되어 **R1과 동일한 토큰**을 받음
+- 결과적으로 멀티탭에서 동시에 요청이 가더라도 모든 탭이 동일한 인증 상태를 공유하게 됨
+
+---
+
+### S5: 로그아웃
+
+> **시나리오 요약:**
+> 1. **서비스 화면:** 사용자의 로그아웃 버튼 클릭
+> 2. **로그아웃 요청:** 서버에 `POST /api/auth/logout` 호출
+> 3. **서버 파기:** DB 내 **'열쇠(RT) 기록'** 즉시 삭제
+> 4. **쿠키 제거:** 브라우저 쿠키 삭제 명령 및 **물리적 제거** 완료
+> 5. **클라이언트 정리:** 메모리 내 입장권(AT) 및 인증 상태 초기화
+> 6. **페이지 이동:** `/login` 화면으로 리다이렉트 및 접근 차단 확인
+
+#### S5-01: 정상 로그아웃 전체 플로우
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S5-01 |
+| **컴포넌트** | (FE) authStore.logout() → AuthController.logout() → AuthServiceImpl.logout() |
+| **우선순위** | High |
+| **사전조건** | 사용자가 로그인된 상태 (유효한 AT + RT 쿠키) |
+| **테스트 데이터** | 유효한 refreshToken 쿠키 |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 | 검증 포인트 |
+|------|------|----------|------------|
+| 1. 로그아웃 요청 | `POST /api/auth/logout` (Cookie: RT) | 200 OK | - |
+| 2. 서버 처리 | RT → SHA-256 해시 → DB DELETE | 토큰 레코드 삭제 | refresh_token 테이블에서 해당 해시 없음 |
+| 3. 쿠키 제거 | Set-Cookie: refreshToken=""; maxAge=0 | 브라우저 쿠키 삭제 | HttpOnly, SameSite=Lax 유지 |
+| 4. 클라이언트 | (FE) accessToken=null, isAuthenticated=false | /login 이동 | Zustand 스토어 초기화 |
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S5-Step 1] 로그아웃 실행** 블록을 실행하세요.
+
+**DB 검증:**
+```sql
+-- 로그아웃 후 토큰 삭제 확인
+SELECT * FROM refresh_token WHERE token_hash = SHA256('로그아웃한_토큰');
+-- → 0 rows (삭제됨)
+```
+
+---
+
+#### S5-02: 로그아웃 후 토큰 재사용 불가 검증
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | S5-02 |
+| **컴포넌트** | AuthController.logout() → AuthController.refresh() |
+| **우선순위** | High |
+| **사전조건** | S5-01 완료 상태 |
+| **테스트 데이터** | S5-01에서 사용한 refreshToken |
+
+**실행 단계 및 검증:**
+
+| Step | 동작 | 기대 결과 |
+|------|------|----------|
+| 1 | `POST /api/auth/logout` (Cookie: RT) | 200 OK (로그아웃 성공) |
+| 2 | `POST /api/auth/refresh` (Cookie: 동일 RT) | **401 A004** (DB에서 삭제됨) |
+| 3 | `GET /api/bookmarks` (Bearer: 이전 AT) | **401 A002** (AT 만료 시) 또는 정상 (AT 미만료 시) |
+
+**보안 의미:** 로그아웃으로 RT가 DB에서 삭제되므로, AT가 만료되면 더 이상 갱신할 수 없어 완전한 세션 종료가 보장됨.
+
+**▶️ 테스트 실행 가이드:**
+👉 `jwt-oauth2-e2e.http` 파일의 **[S5-Step 2] 로그아웃 후 다시 토큰 갱신 시도** 블록을 실행하세요.
+
+---
+
+### 시나리오 ↔ 상세 케이스 매핑
+
+| E2E 시나리오 | 관련 상세 테스트 케이스 (Part B) |
+|-------------|-------------------------------|
+| S1-01 | AUTH-034, AUTH-037, AUTH-038, AUTH-039, AUTH-041, AUTH-042, AUTH-001, AUTH-018 |
+| S1-02 | AUTH-035 |
+| S1-03 | AUTH-044, AUTH-055 |
+| S2-01 | AUTH-001, AUTH-018 |
+| S2-02 | AUTH-007 |
+| S3-01 | AUTH-026, AUTH-001, AUTH-024 |
+| S3-02 | AUTH-026, AUTH-007 |
+| S3-03 | (FE 전용, 백엔드 단일 AUTH-001) |
+| S4-01 | AUTH-001 |
+| S4-02 | AUTH-002, AUTH-003 |
+| S4-03 | AUTH-004 |
+| S4-04 | AUTH-011 |
+| S5-01 | AUTH-013 |
+| S5-02 | AUTH-017 |
+
+---
+
+## 그룹 1: Token Refresh
+
+`POST /api/auth/refresh` — Refresh Token 쿠키를 사용하여 Access Token을 갱신하는 플로우.
+
+### 요약 표
+| ID | 시나리오 | 요청 | 기대 응답 | 검증 포인트 |
+|----|---------|------|----------|------------|
+| AUTH-001 | 정상 갱신 (첫 사용) | POST /api/auth/refresh + 유효한 쿠키 | 200 + 새 토큰 | accessToken 반환, 쿠키 갱신, DB rotated_at 갱신 |
+| AUTH-002 | Grace Period 내 재사용 (10초 이내) | POST /api/auth/refresh + 이미 회전된 토큰 | 200 + 새 토큰 | 기존 토큰 DB 삭제, 새 토큰 발급 (1회만 허용) |
+| AUTH-003 | Grace Period 재사용 후 3번째 사용 | POST /api/auth/refresh + 삭제된 토큰 | 401 A004 | 이미 삭제된 토큰이므로 실패 |
+| AUTH-004 | Grace Period 초과 재사용 (10초 초과) | POST /api/auth/refresh + 회전 후 10초 초과 토큰 | 401 A004 | 보안 위반 감지, 거부 |
+| AUTH-005 | 만료된 Refresh Token | POST /api/auth/refresh + 만료 JWT | 401 A002 | "토큰이 만료되었습니다" |
+| AUTH-006 | 위변조된 Refresh Token | POST /api/auth/refresh + 서명 변조 쿠키 | 401 A003 | "유효하지 않은 토큰입니다" |
+| AUTH-007 | 쿠키 없이 요청 | POST /api/auth/refresh (쿠키 없음) | 401 A004 | "Refresh Token이 없습니다" |
+| AUTH-008 | DB에 없는 토큰 해시 | POST /api/auth/refresh + 유효한 JWT이나 DB 미존재 | 401 A004 | 토큰 해시 조회 실패 |
+| AUTH-009 | 만료된 DB 레코드 | POST /api/auth/refresh + expiresAt < NOW | 401 A002 | DB 레코드 만료 체크 |
+| AUTH-010 | 삭제된 회원의 토큰 | POST /api/auth/refresh + 삭제된 회원 | 401 A001 | getMemberRole → member null → AUTH_UNAUTHORIZED |
+| AUTH-011 | 동시 갱신 요청 | 2개 동시 POST /api/auth/refresh (동일 토큰) | 모두 성공 (200) | 멱등성 로직에 의해 동일한 토큰 세트 응답 |
+| AUTH-012 | 새 로그인 후 이전 토큰 사용 | POST /api/auth/refresh + 이전 세션 토큰 | 401 A004 | issueRefreshToken → deleteByMemberId로 이미 삭제 |
+
+### 상세 케이스
+
+---
+
+#### AUTH-001: 정상 토큰 갱신 (첫 사용)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-001 |
+| **컴포넌트** | AuthController.refresh(), AuthServiceImpl.refresh(), JwtUtils |
+| **우선순위** | High |
+| **사전조건** | 회원(memberId=1)이 OAuth2 로그인 완료. DB에 유효한 refresh_token 레코드 존재 (rotated_at=NULL, expires_at > NOW()) |
+| **테스트 데이터** | refreshToken: 유효한 JWT (memberId=1, 미만료, 서명 정상) |
+| **실행 단계** | POST /api/auth/refresh (Cookie: refreshToken=...) |
+| **기대 결과** | 200 OK, 새 accessToken 반환, 새 refreshToken 쿠키 설정, DB에서 기존 토큰의 rotated_at이 현재 시간으로 갱신됨 |
+
+
+
+**DB 검증:**
+- 기존 refresh_token 레코드의 `rotated_at`이 NULL → 현재 시간으로 갱신됨
+- 새 refresh_token 레코드가 삽입됨 (token_hash = SHA-256(new_token))
+
+---
+
+#### AUTH-002: Grace Period 내 재사용 (10초 이내)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-002 |
+| **컴포넌트** | AuthServiceImpl.refresh() |
+| **우선순위** | High |
+| **사전조건** | AUTH-001 이후 상태. DB에 rotated_at != NULL이고 (NOW() - rotated_at < 10초)인 refresh_token 레코드 존재 |
+| **테스트 데이터** | refreshToken: AUTH-001에서 사용한 (회전된) 동일 토큰 |
+| **실행 단계** | AUTH-001 성공 직후 10초 이내에 동일 토큰으로 POST /api/auth/refresh |
+| **기대 결과** | 200 OK, **이전 발급 값과 동일한 {AT, RT-B} 반환** (멱등성 보장) |
+
+**DB 검증:**
+- 기존 rotated 토큰 레코드는 그대로 유지됨 (rotated_at 상태)
+- 새로운 토큰 레코드가 추가로 생성되지 않음 (기존 Successor 조회)
+
+---
+
+#### AUTH-003: Grace Period 재사용 후 3번째 사용 시도
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-003 |
+| **컴포넌트** | AuthServiceImpl.refresh() (line 87-88) |
+| **우선순위** | High |
+| **사전조건** | AUTH-002 이후 상태. AUTH-001에서 사용한 원본 토큰은 AUTH-002에서 DB 삭제됨 |
+| **테스트 데이터** | refreshToken: AUTH-001/002에서 사용한 동일 원본 토큰 (3번째 사용 시도) |
+| **실행 단계** | AUTH-002 성공 이후 동일 토큰으로 POST /api/auth/refresh |
+| **기대 결과** | 401, A004 에러. 토큰 해시가 DB에 없으므로 실패 |
+
+
+
+---
+
+#### AUTH-004: Grace Period 초과 재사용 (10초 초과, 보안 위반)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-004 |
+| **컴포넌트** | AuthServiceImpl.refresh() (lines 104-106) |
+| **우선순위** | Critical |
+| **사전조건** | AUTH-001 이후 상태. rotated_at != NULL이고 (NOW() - rotated_at > 10초) |
+| **테스트 데이터** | refreshToken: 10초 전에 회전된 토큰 |
+| **실행 단계** | AUTH-001 성공 후 **10초 이상 대기** 후 동일 토큰으로 POST /api/auth/refresh |
+| **기대 결과** | 401, A004 에러. 토큰 재사용 공격 감지 |
+
+
+
+---
+
+#### AUTH-005: 만료된 Refresh Token
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-005 |
+| **컴포넌트** | AuthServiceImpl.refresh(), JwtUtils.validateToken() |
+| **우선순위** | High |
+| **사전조건** | refreshToken의 JWT exp 클레임이 현재 시간 이전 |
+| **테스트 데이터** | refreshToken: 만료된 JWT (exp < NOW) |
+| **실행 단계** | POST /api/auth/refresh + 만료 쿠키 |
+| **기대 결과** | 401, A002 에러 |
+
+
+
+---
+
+#### AUTH-006: 위변조된 Refresh Token
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-006 |
+| **컴포넌트** | AuthServiceImpl.refresh(), JwtUtils.validateToken() |
+| **우선순위** | Critical |
+| **사전조건** | 없음 |
+| **테스트 데이터** | refreshToken: 서명이 변조된 JWT (payload 변경 후 서명 불일치) |
+| **실행 단계** | POST /api/auth/refresh + 변조 쿠키 |
+| **기대 결과** | 401, A003 에러 |
+
+
+
+---
+
+#### AUTH-007: 쿠키 없이 요청
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-007 |
+| **컴포넌트** | AuthController.refresh() (line 47) |
+| **우선순위** | High |
+| **사전조건** | 없음 |
+| **테스트 데이터** | 요청에 refreshToken 쿠키 없음 |
+| **실행 단계** | POST /api/auth/refresh (쿠키 헤더 없음) |
+| **기대 결과** | 401, A004 에러 |
+
+
+
+---
+
+#### AUTH-008: DB에 없는 토큰 해시
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-008 |
+| **컴포넌트** | AuthServiceImpl.refresh() (line 87-88) |
+| **우선순위** | Medium |
+| **사전조건** | 없음 |
+| **테스트 데이터** | refreshToken: 유효한 JWT (서명 정상, 미만료)이나 DB에 해당 해시 없음 |
+| **실행 단계** | POST /api/auth/refresh + 유효하지만 DB에 없는 토큰 |
+| **기대 결과** | 401, A004 에러 |
+
+---
+
+#### AUTH-009: 만료된 DB 레코드 (expiresAt < NOW)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-009 |
+| **컴포넌트** | AuthServiceImpl.refresh() (lines 92-95) |
+| **우선순위** | Medium |
+| **사전조건** | DB에 refresh_token 레코드 존재하나 expires_at < NOW() |
+| **테스트 데이터** | refreshToken: JWT는 유효하나 DB 레코드의 expires_at이 과거 시간 |
+| **실행 단계** | POST /api/auth/refresh |
+| **기대 결과** | 401, A002 에러. DB 만료 체크에서 걸림 |
+
+---
+
+#### AUTH-010: 삭제된 회원의 토큰으로 갱신 시도
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-010 |
+| **컴포넌트** | AuthServiceImpl.refresh() → getMemberRole() (lines 120-126) |
+| **우선순위** | Medium |
+| **사전조건** | DB에 refresh_token 레코드 존재, 그러나 해당 member가 soft-deleted (deleted_at IS NOT NULL) |
+| **테스트 데이터** | refreshToken: 유효한 JWT, memberId가 삭제된 회원 |
+| **실행 단계** | POST /api/auth/refresh |
+| **기대 결과** | 401, A001 에러. findByMemberId → null → AUTH_UNAUTHORIZED |
+
+---
+
+#### AUTH-011: 동시 갱신 요청 (Pessimistic Lock)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-011 |
+| **컴포넌트** | AuthServiceImpl.refresh(), RefreshTokenDao.findByTokenHashForUpdate() |
+| **우선순위** | High |
+| **사전조건** | DB에 유효한 refresh_token 레코드 존재 |
+| **테스트 데이터** | 동일한 refreshToken으로 2개 동시 요청 |
+| **실행 단계** | 2개의 POST /api/auth/refresh를 동시에 전송 (동일 토큰) |
+| **기대 결과** | 1개 요청 성공 (200), 다른 요청은 FOR UPDATE 락에 의해 블록 후 실패 (토큰 상태 변경됨) |
+
+**DB 검증:**
+- `findByTokenHashForUpdate`의 `SELECT ... FOR UPDATE`가 동시성을 제어
+- 첫 번째 요청이 rotated_at을 갱신하면, 두 번째 요청은 이미 회전된 토큰을 만나 멱등성 응답을 수행함
+- 두 요청 모두 동일한 토큰 응답을 받으며 클라이언트 상태가 일치됨
+
+---
+
+#### AUTH-012: 새 로그인 후 이전 토큰으로 갱신 시도
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-012 |
+| **컴포넌트** | AuthServiceImpl.issueRefreshToken() → deleteByMemberId(), AuthServiceImpl.refresh() |
+| **우선순위** | High |
+| **사전조건** | 회원이 OAuth2 재로그인하여 issueRefreshToken이 호출됨 → deleteByMemberId로 기존 토큰 모두 삭제 |
+| **테스트 데이터** | refreshToken: 재로그인 전에 발급된 이전 세션 토큰 |
+| **실행 단계** | POST /api/auth/refresh + 이전 세션 토큰 |
+| **기대 결과** | 401, A004 에러. deleteByMemberId로 이미 삭제되어 DB 조회 실패 |
+
+---
+
+## 그룹 2: Logout
+
+`POST /api/auth/logout` — Refresh Token 삭제 및 쿠키 제거.
+
+### 요약 표
+| ID | 시나리오 | 요청 | 기대 응답 | 검증 포인트 |
+|----|---------|------|----------|------------|
+| AUTH-013 | 정상 로그아웃 | POST /api/auth/logout + 유효한 쿠키 | 200 OK | DB 토큰 삭제, 쿠키 maxAge=0 |
+| AUTH-014 | 이미 삭제된 토큰으로 로그아웃 | POST /api/auth/logout + DB에 없는 토큰 | 200 OK | 에러 없이 처리 (멱등성) |
+| AUTH-015 | 쿠키 없이 로그아웃 | POST /api/auth/logout (쿠키 없음) | 200 OK | refreshToken 파라미터 null, 삭제 스킵 |
+| AUTH-016 | 빈 문자열 쿠키로 로그아웃 | POST /api/auth/logout + 빈 쿠키값 | 200 OK | isBlank() 체크 → 삭제 스킵, 쿠키만 제거 |
+| AUTH-017 | 로그아웃 후 refresh 시도 | POST /api/auth/logout → POST /api/auth/refresh | 401 A004 | 토큰 삭제 후 갱신 불가 |
+
+### 상세 케이스
+
+---
+
+#### AUTH-013: 정상 로그아웃
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-013 |
+| **컴포넌트** | AuthController.logout() (lines 72-85), AuthServiceImpl.logout() |
+| **우선순위** | High |
+| **사전조건** | DB에 유효한 refresh_token 레코드 존재 |
+| **테스트 데이터** | refreshToken: 유효한 JWT 쿠키 |
+| **실행 단계** | POST /api/auth/logout (Cookie: refreshToken=...) |
+| **기대 결과** | 200 OK, DB에서 토큰 해시로 레코드 삭제, Set-Cookie로 refreshToken maxAge=0 |
+
+**HTTP 요청:**
+```http
+POST /api/auth/logout HTTP/1.1
+Host: localhost:8080
+Cookie: refreshToken=eyJhbGciOiJIUzI1NiJ9...
+```
+
+**HTTP 응답 (성공):**
+```http
+HTTP/1.1 200 OK
+Set-Cookie: refreshToken=; HttpOnly; SameSite=Lax; Path=/api/auth; Max-Age=0
+
+{
+ "code": "200",
+ "message": "success",
+ "data": null
+}
+```
+
+**DB 검증:**
+- refresh_token 테이블에서 해당 token_hash 레코드 삭제됨
+
+---
+
+#### AUTH-016: 빈 문자열 쿠키로 로그아웃
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-016 |
+| **컴포넌트** | AuthController.logout() (line 76, isBlank() 체크) |
+| **우선순위** | Low |
+| **사전조건** | 없음 |
+| **테스트 데이터** | refreshToken 쿠키 값이 빈 문자열 ("") |
+| **실행 단계** | POST /api/auth/logout (Cookie: refreshToken=) |
+| **기대 결과** | 200 OK. isBlank() 체크에 의해 DB 삭제 스킵, 쿠키만 maxAge=0으로 제거 |
+
+---
+
+#### AUTH-017: 로그아웃 후 refresh 시도
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-017 |
+| **컴포넌트** | AuthController.logout() → AuthController.refresh() |
+| **우선순위** | High |
+| **사전조건** | AUTH-013 완료 상태 |
+| **테스트 데이터** | refreshToken: AUTH-013에서 로그아웃한 토큰 |
+| **실행 단계** | 1. POST /api/auth/logout (성공) → 2. POST /api/auth/refresh (동일 토큰) |
+| **기대 결과** | Step 2에서 401, A004 에러. DB에서 이미 삭제됨 |
+
+---
+
+## 그룹 3: 인증 정보 조회
+
+`GET /api/auth/member` — 현재 인증된 회원 정보를 조회.
+
+### 요약 표
+| ID | 시나리오 | 요청 | 기대 응답 | 검증 포인트 |
+|----|---------|------|----------|------------|
+| AUTH-018 | 유효한 토큰으로 조회 | GET /api/auth/member + Bearer 토큰 | 200 + MemberInfo | memberId, name, email, role 반환 |
+| AUTH-019 | 만료된 토큰으로 조회 | GET /api/auth/member + 만료 토큰 | 401 A002 | "토큰이 만료되었습니다" |
+| AUTH-020 | 토큰 없이 조회 | GET /api/auth/member (헤더 없음) | 401 A001 | "인증이 필요합니다" |
+| AUTH-021 | 위변조 토큰으로 조회 | GET /api/auth/member + 변조 토큰 | 401 A003 | "유효하지 않은 토큰입니다" |
+| AUTH-022 | 삭제된 회원 토큰 | GET /api/auth/member + 삭제된 회원 ID 토큰 | 에러 | 회원 없음 에러 |
+| AUTH-023 | anonymousUser 접근 | SecurityUtils.extractMemberId(anonymousUser) | 401 A001 | anonymousUser 체크 → AUTH_UNAUTHORIZED |
+
+### 상세 케이스
+
+---
+
+#### AUTH-018: 유효한 Access Token으로 회원 정보 조회
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-018 |
+| **컴포넌트** | AuthController.getAuthenticatedMemberInfo() (lines 92-97), SecurityUtils.extractMemberId() |
+| **우선순위** | High |
+| **사전조건** | 회원(memberId=1) 존재, 유효한 Access Token 보유 |
+| **테스트 데이터** | accessToken: 유효한 JWT (sub=1, role=ROLE_USER, 미만료) |
+| **실행 단계** | GET /api/auth/member (Authorization: Bearer {accessToken}) |
+| **기대 결과** | 200 OK, MemberInfo(memberId, name, email, role) 반환 |
+
+
+
+---
+
+#### AUTH-023: anonymousUser Principal 접근
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-023 |
+| **컴포넌트** | SecurityUtils.extractMemberId() (lines 26-28) |
+| **우선순위** | Medium |
+| **사전조건** | 인증되지 않은 요청이 Spring Security의 AnonymousAuthenticationFilter를 통과한 상태 |
+| **테스트 데이터** | Authentication.getName() == "anonymousUser" |
+| **실행 단계** | SecurityUtils.extractMemberId()에 anonymousUser principal 전달 |
+| **기대 결과** | AUTH_UNAUTHORIZED (A001) 예외 발생 |
+
+---
+
+## 그룹 4: JWT 필터 & 인가
+
+JwtAuthenticationFilter, JwtAuthenticationEntryPoint, SecurityConfig, OwnerCheckAspect 관련 시나리오.
+
+### 요약 표
+| ID | 시나리오 | 요청 | 기대 응답 | 검증 포인트 |
+|----|---------|------|----------|------------|
+| AUTH-024 | 유효한 Bearer 토큰 → 보호된 API | GET /api/bookmarks + Bearer | 200 OK | SecurityContext에 JwtMemberPrincipal 설정 |
+| AUTH-025 | 토큰 없이 보호된 API | GET /api/bookmarks (헤더 없음) | 401 A001 | EntryPoint 기본값 |
+| AUTH-026 | 만료 토큰 → 보호된 API | GET /api/bookmarks + 만료 Bearer | 401 A002 | ExpiredJwtException 경로 |
+| AUTH-027 | 서명 위변조 토큰 | GET /api/bookmarks + 변조 Bearer | 401 A003 | JwtException 경로 |
+| AUTH-028 | 구조적 비정상 토큰 | GET /api/bookmarks + 비Base64 문자열 | 401 A003 | IllegalArgumentException 경로 |
+| AUTH-029 | 토큰 없이 공개 API | GET /api/auth/refresh | 허용 | 필터 통과, 인가 필터에서 허용 |
+| AUTH-030 | "Bearer " 접두사 없는 토큰 | GET /api/bookmarks + Authorization: {token} | 401 A001 | 토큰 추출 실패 → 인증 없이 통과 → 인가에서 차단 |
+| AUTH-031 | 빈 Authorization 헤더 | GET /api/bookmarks + Authorization: | 401 A001 | 토큰 없음 처리 |
+| AUTH-032 | @OwnerCheck — 본인 리소스 | DELETE /api/bookmarks/1 (본인) | 200 OK | 소유권 일치 → 정상 처리 |
+| AUTH-033 | @OwnerCheck — 타인 리소스 | DELETE /api/bookmarks/1 (타인) | 403 A005 | 소유권 불일치 → AUTH_ACCESS_DENIED |
+
+### 상세 케이스
+
+---
+
+#### AUTH-024: 유효한 Bearer 토큰으로 보호된 API 접근
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-024 |
+| **컴포넌트** | JwtAuthenticationFilter (lines 31-58), SecurityConfig |
+| **우선순위** | High |
+| **사전조건** | 회원(memberId=1) 존재, 유효한 Access Token 보유 |
+| **테스트 데이터** | accessToken: 유효한 JWT (sub=1, role=ROLE_USER) |
+| **실행 단계** | GET /api/bookmarks (Authorization: Bearer {accessToken}) |
+| **기대 결과** | 200 OK. Filter가 토큰 파싱 → JwtMemberPrincipal → SecurityContext 설정 → 인가 통과 |
+
+
+
+---
+
+#### AUTH-027: 서명 위변조 토큰 (JwtException 경로)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-027 |
+| **컴포넌트** | JwtAuthenticationFilter (line 52, JwtException catch) |
+| **우선순위** | Critical |
+| **사전조건** | 없음 |
+| **테스트 데이터** | accessToken: JWT payload는 정상이나 서명이 다른 secret key로 생성됨 |
+| **실행 단계** | GET /api/bookmarks (Authorization: Bearer {tampered_token}) |
+| **기대 결과** | 401, A003 에러. request.setAttribute("exception", AUTH_INVALID_TOKEN) |
+
+
+
+---
+
+#### AUTH-028: 구조적으로 잘못된 토큰 (IllegalArgumentException 경로)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-028 |
+| **컴포넌트** | JwtAuthenticationFilter (line 50, IllegalArgumentException catch) |
+| **우선순위** | Medium |
+| **사전조건** | 없음 |
+| **테스트 데이터** | accessToken: "not-a-valid-jwt-at-all" (비Base64, JWT 구조 아님) |
+| **실행 단계** | GET /api/bookmarks (Authorization: Bearer not-a-valid-jwt-at-all) |
+| **기대 결과** | 401, A003 에러. 동일한 AUTH_INVALID_TOKEN이나 다른 예외 경로 (IllegalArgumentException) |
+
+---
+
+#### AUTH-029: 토큰 없이 공개 API 접근
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-029 |
+| **컴포넌트** | JwtAuthenticationFilter, SecurityConfig (permitAll 설정) |
+| **우선순위** | High |
+| **사전조건** | 없음 |
+| **테스트 데이터** | 없음 (토큰 없이 요청) |
+| **실행 단계** | POST /api/auth/refresh 또는 GET /oauth2/authorization/naver |
+| **기대 결과** | 정상 허용. Filter는 토큰 없으면 SecurityContext 설정 없이 통과, permitAll 엔드포인트이므로 인가도 통과 |
+
+
+
+---
+
+#### AUTH-032: @OwnerCheck — 본인 리소스 접근 (정상)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-032 |
+| **컴포넌트** | OwnerCheckAspect |
+| **우선순위** | High |
+| **사전조건** | 회원(memberId=1) 인증 완료, bookmarkId=10의 소유자가 memberId=1 |
+| **테스트 데이터** | accessToken: memberId=1, 요청 대상: bookmarkId=10 (본인 소유) |
+| **실행 단계** | DELETE /api/bookmarks/10 (Authorization: Bearer {token_memberId_1}) |
+| **기대 결과** | 200 OK. AOP가 소유권 확인 후 정상 진행 |
+
+---
+
+#### AUTH-033: @OwnerCheck — 타인 리소스 접근 (403 A005)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-033 |
+| **컴포넌트** | OwnerCheckAspect (line 56) |
+| **우선순위** | Critical |
+| **사전조건** | 회원(memberId=2) 인증 완료, bookmarkId=10의 소유자가 memberId=1 |
+| **테스트 데이터** | accessToken: memberId=2, 요청 대상: bookmarkId=10 (타인 소유) |
+| **실행 단계** | DELETE /api/bookmarks/10 (Authorization: Bearer {token_memberId_2}) |
+| **기대 결과** | 403, A005 에러. AuthException.of(AUTH_ACCESS_DENIED) 발생 |
+
+
+
+---
+
+## 그룹 5: OAuth2 소셜 로그인
+
+OAuth2 인가 → 콜백 → 회원 처리 → 토큰 발급 → 리다이렉트 전체 플로우.
+
+### 요약 표
+| ID | 시나리오 | 트리거 | 기대 결과 | 검증 포인트 |
+|----|---------|--------|----------|------------|
+| AUTH-034 | Naver 로그인 (신규 회원) | OAuth2 콜백 (Naver) | 302 redirect + 쿠키 | SocialjoinProcess, RefreshToken 쿠키 |
+| AUTH-035 | Google 로그인 (기존 회원) | OAuth2 콜백 (Google) | 302 redirect + 쿠키 | updateSocialMember, RefreshToken 쿠키 |
+| AUTH-036 | Kakao 로그인 (nullable 필드) | OAuth2 콜백 (Kakao) | 302 redirect + 쿠키 | nullable email/nickname 처리 |
+| AUTH-037 | OAuth2 인가 시 쿠키 저장 | GET /oauth2/authorization/naver | 302 to Naver | oauth2_auth_request 쿠키 (180초) |
+| AUTH-038 | OAuth2 인가 시 redirect_uri 저장 | GET /oauth2/authorization/naver?redirect_uri=... | 302 to Naver | redirect_uri 쿠키 저장 |
+| AUTH-039 | OAuth2 콜백 state 검증 성공 | OAuth2 콜백 + 유효한 state | 정상 처리 | 쿠키 역직렬화 성공 |
+| AUTH-040 | OAuth2 콜백 state 불일치 | OAuth2 콜백 + 변조된 쿠키 | 실패 | 보안: CSRF 방지 |
+| AUTH-041 | OAuth2 성공 후 쿠키 설정 | OAuth2 성공 핸들러 | 302 + Set-Cookie | HttpOnly, SameSite=Lax, Path=/api/auth |
+| AUTH-042 | OAuth2 성공 후 기존 토큰 삭제 | OAuth2 성공 핸들러 | 기존 토큰 삭제 | deleteByMemberId 호출 |
+| AUTH-043 | redirect_uri 쿠키 없을 때 | OAuth2 성공 + redirect_uri 쿠키 없음 | 기본 URL로 redirect | oauth2RedirectUri 설정값 사용 |
+| AUTH-044 | authorization_request_not_found | OAuth2 콜백 + 쿠키 만료 | 302 /login?error=true | 빈번한 프로덕션 에러 |
+| AUTH-045 | OAuth2 일반 실패 | OAuth2 콜백 + 인가 에러 | 302 /login?error=true | 에러 메시지 전달 |
+| AUTH-046 | 지원하지 않는 provider | CustomOAuth2MemberService | 400 A007 | 미지원 provider 예외 |
+| AUTH-047 | Open Redirect 차단 | OAuth2 성공 + 악의적 redirect_uri | 400 A006 | URI 검증 실패 |
+
+### 상세 케이스
+
+---
+
+#### AUTH-034: Naver 로그인 성공 (신규 회원)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-034 |
+| **컴포넌트** | CustomOAuth2MemberService.loadUser() (lines 58-114), OAuth2SuccessHandler |
+| **우선순위** | High |
+| **사전조건** | Naver OAuth2 인증 성공, 해당 loginId("naver{providerId}")로 회원이 존재하지 않음 |
+| **테스트 데이터** | Naver OAuth2User: name="홍길동", email="hong@naver.com", providerId="12345" |
+| **실행 단계** | OAuth2 콜백 → CustomOAuth2MemberService.loadUser() → SocialjoinProcess() |
+| **기대 결과** | 신규 회원 DB 삽입 (loginId="naver12345"), RefreshToken 발급, 302 redirect to frontend callback |
+
+**플로우 검증:**
+1. member 테이블에 loginId="naver12345" 레코드 삽입됨
+2. refresh_token 테이블에 새 토큰 해시 삽입됨
+3. Set-Cookie: refreshToken=...; HttpOnly; SameSite=Lax; Path=/api/auth
+4. 302 Redirect to `{oauth2RedirectUri}` (예: http://localhost:3000/auth/callback)
+
+---
+
+#### AUTH-036: Kakao 로그인 (nullable email/nickname)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-036 |
+| **컴포넌트** | CustomOAuth2MemberService, KakaoResponse |
+| **우선순위** | Medium |
+| **사전조건** | Kakao OAuth2 인증 성공, email/nickname이 null (Kakao 정책상 선택적 동의) |
+| **테스트 데이터** | Kakao OAuth2User: email=null, nickname=null, providerId="67890" |
+| **실행 단계** | OAuth2 콜백 → CustomOAuth2MemberService.loadUser() |
+| **기대 결과** | null 필드를 빈 문자열 또는 기본값으로 처리, 회원 가입/업데이트 정상 완료 |
+
+---
+
+#### AUTH-041: OAuth2 성공 후 RefreshToken 쿠키 설정 검증
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-041 |
+| **컴포넌트** | OAuth2SuccessHandler.onAuthenticationSuccess() (lines 52-78) |
+| **우선순위** | High |
+| **사전조건** | OAuth2 인증 성공 |
+| **테스트 데이터** | 인증된 CustomOAuth2Member |
+| **실행 단계** | OAuth2 성공 핸들러 실행 |
+| **기대 결과** | RefreshToken 쿠키 설정 확인 |
+
+**Set-Cookie 검증 항목:**
+| 속성 | 기대값 | 이유 |
+|------|--------|------|
+| HttpOnly | true | XSS 방지: JavaScript에서 접근 불가 |
+| SameSite | Lax | CSRF 방지: 크로스 사이트 POST 차단 |
+| Path | /api/auth | 스코프 제한: 인증 API에만 전송 |
+| Secure | true (prod) / false (local) | HTTPS 강제 (운영 환경) |
+| Max-Age | refreshTokenExpiry / 1000 | 만료 시간 |
+
+---
+
+#### AUTH-043: redirect_uri 쿠키 없을 때 기본값 폴백
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-043 |
+| **컴포넌트** | OAuth2SuccessHandler.determineTargetUrl() (lines 99-100) |
+| **우선순위** | High |
+| **사전조건** | OAuth2 인증 성공, redirect_uri 쿠키가 존재하지 않음 |
+| **테스트 데이터** | Cookie에 redirect_uri 없음 |
+| **실행 단계** | OAuth2 성공 핸들러 → determineTargetUrl() |
+| **기대 결과** | application.properties의 `app.oauth2.redirect-uri` 설정값으로 리다이렉트 (예: http://localhost:3000/auth/callback) |
+
+---
+
+#### AUTH-044: OAuth2 실패 — authorization_request_not_found
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-044 |
+| **컴포넌트** | OAuth2FailureHandler (lines 29-31), HttpCookieOAuth2AuthorizationRequestRepository, CookieUtils |
+| **우선순위** | High |
+| **사전조건** | OAuth2 인가 요청 쿠키(oauth2_auth_request)가 만료됨 (180초 초과) 또는 없음 |
+| **테스트 데이터** | OAuth2 콜백 도착 시 oauth2_auth_request 쿠키 부재 |
+| **실행 단계** | OAuth2 provider에서 콜백 도착, 그러나 쿠키가 이미 만료 |
+| **기대 결과** | **302 리다이렉트** (JSON 아님!) to /login?error=true&message=인증+요청을+찾을+수+없습니다... |
+
+**Null-propagation chain:**
+1. `CookieUtils.deserialize()` → 쿠키 없거나 파싱 실패 → `null` 반환
+2. `HttpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest()` → `null` 반환
+3. Spring Security → `authorization_request_not_found` 에러 발생
+4. `OAuth2FailureHandler` → 사용자 친화적 메시지로 교체 → 302 리다이렉트
+
+**참고:** 이 에러는 프로덕션에서 가장 빈번하게 발생하는 OAuth2 에러 (사용자가 인가 페이지에서 3분 이상 체류 시 쿠키 만료)
+
+---
+
+#### AUTH-047: Open Redirect 시도 차단 (A006)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-047 |
+| **컴포넌트** | OAuth2SuccessHandler.isAuthorizedRedirectUri() (lines 107-126) |
+| **우선순위** | Critical |
+| **사전조건** | OAuth2 인증 성공, redirect_uri 쿠키에 악의적 URL 포함 |
+| **테스트 데이터** | redirect_uri: "https://evil.com/callback" 또는 "//evil.com" (protocol-relative) |
+| **실행 단계** | OAuth2 성공 핸들러 → determineTargetUrl() → isAuthorizedRedirectUri() 검증 |
+| **기대 결과** | 400, A006 에러 (AUTH_INVALID_REDIRECT_URI). 허용된 도메인과 불일치 |
+
+**테스트 데이터 변형:**
+| redirect_uri | 기대 결과 | 이유 |
+|-------------|----------|------|
+| `https://evil.com/callback` | A006 차단 | 허용되지 않은 도메인 |
+| `//evil.com` | A006 차단 | Protocol-relative URL |
+| `http://localhost:3000/auth/callback` | 허용 | 설정된 oauth2RedirectUri와 일치 |
+
+---
+
+## 그룹 6: 보안 & 인프라
+
+토큰 해싱, 쿠키 보안 속성, 스케줄러, 교차 컴포넌트 상호작용 검증.
+
+### 요약 표
+| ID | 시나리오 | 대상 | 기대 결과 | 검증 포인트 |
+|----|---------|------|----------|------------|
+| AUTH-048 | SHA-256 해시 저장 | SecurityUtils.hashToken() | 해시값 저장 | DB에 평문 토큰 없음 |
+| AUTH-049 | HttpOnly 쿠키 | CookieUtils | JavaScript 접근 불가 | XSS 방지 |
+| AUTH-050 | SameSite=Lax | CookieUtils | 크로스 사이트 POST 차단 | CSRF 방지 |
+| AUTH-051 | Secure 속성 (환경별) | CookieUtils | prod=true, local=false | HTTPS 강제 |
+| AUTH-052 | Path=/api/auth | CookieUtils | 인증 API에만 전송 | 스코프 제한 |
+| AUTH-053 | 스케줄러 토큰 정리 | RefreshTokenCleanupScheduler | 만료+고아 토큰 삭제 | 단일 SQL OR절 |
+| AUTH-054 | 10초~60초 중간 상태 | AuthServiceImpl + Scheduler | 사용 불가, DB 존재 | Grace Period vs 정리 간격 |
+| AUTH-055 | CookieUtils null 전파 | CookieUtils → Repository → FailureHandler | authorization_request_not_found | 교차 컴포넌트 |
+
+### 상세 케이스
+
+---
+
+#### AUTH-048: Refresh Token SHA-256 해시 저장 검증
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-048 |
+| **컴포넌트** | SecurityUtils.hashToken(), AuthServiceImpl |
+| **우선순위** | Critical |
+| **사전조건** | OAuth2 로그인 성공 후 RefreshToken 발급됨 |
+| **테스트 데이터** | 발급된 refreshToken 원본 값 |
+| **실행 단계** | DB의 refresh_token 테이블에서 token_hash 컬럼 조회 |
+| **기대 결과** | token_hash 값이 원본 토큰과 다름 (SHA-256 해시). 원본 토큰을 SHA-256으로 해시한 값과 일치 |
+
+**검증 방법:**
+```sql
+SELECT token_hash FROM refresh_token WHERE member_id = 1;
+-- 결과: "a3f2b8c..." (64자 hex string)
+-- 원본 토큰 "eyJhbG..." 과 다름
+-- SHA-256("eyJhbG...") == "a3f2b8c..." 확인
+```
+
+---
+
+#### AUTH-051: 쿠키 Secure 속성 (환경별)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-051 |
+| **컴포넌트** | OAuth2SuccessHandler, CookieUtils, application-{profile}.properties |
+| **우선순위** | High |
+| **사전조건** | 환경별 설정: `app.cookie.secure` |
+| **테스트 데이터** | local: `app.cookie.secure=false`, prod: `app.cookie.secure=true` |
+| **실행 단계** | 각 환경에서 OAuth2 로그인 후 Set-Cookie 헤더 확인 |
+| **기대 결과** | local: Secure 플래그 없음 (HTTP 허용), prod: Secure 플래그 있음 (HTTPS만 전송) |
+
+---
+
+#### AUTH-053: RefreshTokenCleanupScheduler — 만료 토큰 + 고아 회전 토큰 동시 정리
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-053 |
+| **컴포넌트** | RefreshTokenCleanupScheduler (lines 23-27), RefreshTokenDao.deleteExpired(), refresh-token-mapper.xml (lines 41-45) |
+| **우선순위** | High |
+| **사전조건** | DB에 다음 레코드 존재: (1) expires_at < NOW()인 만료 토큰, (2) rotated_at이 1분 이상 경과한 고아 토큰 |
+| **테스트 데이터** | 만료 토큰 2개 + 고아 회전 토큰 1개 + 유효한 토큰 1개 |
+| **실행 단계** | cleanupExpiredTokens() 스케줄러 메서드 실행 (또는 cron 트리거: 매일 03:00) |
+| **기대 결과** | 만료 토큰 2개 + 고아 토큰 1개 삭제, 유효한 토큰 1개 유지 |
+
+**SQL 동작:**
+```sql
+DELETE FROM refresh_token
+WHERE expires_at < NOW()
+ OR (rotated_at IS NOT NULL AND rotated_at < NOW() - INTERVAL '1 minute')
+```
+
+**참고:** 두 조건은 **단일 SQL의 OR절**로 처리됨 (별도 메서드 아님). `deleteExpired()` 한 번의 호출로 두 유형 모두 정리.
+
+---
+
+#### AUTH-054: 10초~60초 중간 상태 검증
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-054 |
+| **컴포넌트** | AuthServiceImpl (Grace Period 10초), RefreshTokenCleanupScheduler (정리 1분) |
+| **우선순위** | Medium |
+| **사전조건** | AUTH-001로 토큰 회전 후 10초 초과 ~ 60초 미만 경과 |
+| **테스트 데이터** | rotated_at이 20초 전인 refresh_token 레코드 |
+| **실행 단계** | 1. 해당 토큰으로 POST /api/auth/refresh 시도 → 2. DB에서 레코드 존재 여부 확인 |
+| **기대 결과** | Step 1: 401 A004 (Grace Period 10초 초과). Step 2: DB에 레코드 **아직 존재** (스케줄러 정리 1분 미도달) |
+
+**참고:** 10초(코드 `AuthServiceImpl.java:100`)와 60초(SQL `refresh-token-mapper.xml:44`)는 **의도적 차이**:
+- 10초: 네트워크 지연 등을 고려한 토큰 재사용 허용 윈도우
+- 60초: 스케줄러 정리 버퍼 (10초 Grace Period 이후에도 약간의 여유를 두고 삭제)
+
+---
+
+#### AUTH-055: CookieUtils.deserialize() 실패 → null 전파 (교차 컴포넌트)
+
+| 항목 | 내용 |
+|------|------|
+| **ID** | AUTH-055 |
+| **컴포넌트** | CookieUtils (line 103), HttpCookieOAuth2AuthorizationRequestRepository, OAuth2FailureHandler |
+| **우선순위** | Medium |
+| **사전조건** | oauth2_auth_request 쿠키가 변조되어 역직렬화 불가 |
+| **테스트 데이터** | oauth2_auth_request 쿠키 값을 잘못된 Base64/JSON으로 설정 |
+| **실행 단계** | OAuth2 provider에서 콜백 도착 |
+| **기대 결과** | 302 리다이렉트 /login?error=true |
+
+**Null-propagation chain:**
+```
+CookieUtils.deserialize(corruptedCookie)
+ → catch Exception → return null
+ → HttpCookieOAuth2AuthorizationRequestRepository.loadAuthorizationRequest()
+ → Optional.empty → return null
+ → Spring Security: authorization_request_not_found
+ → OAuth2FailureHandler: 302 redirect /login?error=true&message=...
+```
+
+**AUTH-044와의 관계:** AUTH-044는 "쿠키 만료/부재"로 동일한 결과에 도달. AUTH-055는 "쿠키 존재하나 내용 변조"로 CookieUtils의 null 반환 경로를 구체적으로 검증.
+
+---
+
+## 부록: 컴포넌트 크로스레퍼런스
+
+추후 JUnit 테스트 코드 작성 시 참조용. 각 컴포넌트에 대응하는 테스트 시나리오 ID 목록.
+
+| # | 컴포넌트 | 관련 시나리오 | JUnit 테스트 클래스 (추후) |
+|---|---------|-------------|-------------------------|
+| 1 | AuthController | AUTH-001~012, AUTH-013~017, AUTH-018~022 | AuthControllerTest |
+| 2 | AuthServiceImpl | AUTH-001~012, AUTH-013~014, AUTH-042 | AuthServiceImplTest |
+| 3 | RefreshTokenCleanupScheduler | AUTH-053, AUTH-054 | RefreshTokenCleanupSchedulerTest |
+| 4 | RefreshTokenDao (MybatisRefreshTokenDao) | AUTH-001, AUTH-008, AUTH-011, AUTH-053 | RefreshTokenDaoTest |
+| 5 | JwtUtils | AUTH-001, AUTH-005, AUTH-006 | JwtUtilsTest |
+| 6 | JwtAuthenticationFilter | AUTH-024~031 | JwtAuthenticationFilterTest |
+| 7 | JwtAuthenticationEntryPoint | AUTH-020, AUTH-025, AUTH-026 | JwtAuthenticationEntryPointTest |
+| 8 | OAuth2SuccessHandler | AUTH-034~035, AUTH-041~043, AUTH-047 | OAuth2SuccessHandlerTest |
+| 9 | OAuth2FailureHandler | AUTH-044, AUTH-045 | OAuth2FailureHandlerTest |
+| 10 | SecurityConfig | AUTH-024, AUTH-029 | SecurityConfigTest |
+| 11 | SecurityUtils | AUTH-018, AUTH-023, AUTH-048 | SecurityUtilsTest |
+| 12 | CookieUtils | AUTH-041, AUTH-049~052, AUTH-055 | CookieUtilsTest |
+| 13 | HttpCookieOAuth2AuthorizationRequestRepository | AUTH-037~040, AUTH-055 | HttpCookieOAuth2AuthRepoTest |
+| 14 | CustomOAuth2MemberService | AUTH-034~036, AUTH-046 | CustomOAuth2MemberServiceTest |
+| 15 | OwnerCheckAspect | AUTH-032, AUTH-033 | OwnerCheckAspectTest |
+
+### 에러 코드 커버리지 매핑
+
+| Code | 시나리오 ID |
+|------|-----------|
+| A001 | AUTH-010, AUTH-020, AUTH-023, AUTH-025, AUTH-030, AUTH-031 |
+| A002 | AUTH-005, AUTH-009, AUTH-019, AUTH-026 |
+| A003 | AUTH-006, AUTH-021, AUTH-027, AUTH-028 |
+| A004 | AUTH-003, AUTH-004, AUTH-007, AUTH-008, AUTH-012, AUTH-017 |
+| A005 | AUTH-033 |
+| A006 | AUTH-047 |
+| A007 | AUTH-046 |
+
+### 추후 JUnit 전환 시 테스트 계층 매핑
+
+| 문서 그룹 | JUnit 테스트 클래스 | Unit Test | Integration Test | E2E Test |
+|----------|-------------------|-----------|-----------------|----------|
+| 1. Token Refresh | AuthServiceImplTest, JwtUtilsTest | JwtUtils 토큰 생성/검증, AuthServiceImpl.refresh() mock | MockMvc POST /api/auth/refresh + TestDB | 전체 refresh 플로우 |
+| 2. Logout | AuthServiceImplTest | AuthServiceImpl.logout() mock | MockMvc POST /api/auth/logout + TestDB | 로그아웃 후 refresh 실패 |
+| 3. 인증 정보 조회 | SecurityUtilsTest | SecurityUtils.extractMemberId() | MockMvc GET /api/auth/member + JWT | - |
+| 4. JWT 필터 & 인가 | JwtAuthenticationFilterTest, OwnerCheckAspectTest | Filter 단독 doFilterInternal() | SecurityFilterChain MockMvc 전체 | 인증 필요 API 호출 |
+| 5. OAuth2 | CustomOAuth2MemberServiceTest | loadUser() mock | OAuth2 콜백 시뮬레이션 | (외부 의존성으로 E2E 제외) |
+| 6. 보안 & 인프라 | SecurityUtilsTest, RefreshTokenCleanupSchedulerTest | hashToken(), 스케줄러 단독 | TestDB + 스케줄러 실행 | - |
+
+---
+
+
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index 2c55c21..71b7c1e 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,12 +1,29 @@
import type { NextConfig } from "next";
+const isDev = process.env.NODE_ENV === 'development';
+const backendOrigin = (process.env.NEXT_PUBLIC_BACKEND_ORIGIN || (isDev ? 'http://localhost:8080' : '')).replace(/\/+$/, '');
+
const nextConfig: NextConfig = {
- // [BACKEND_CONNECT] 백엔드 API 프록시 설정 (CORS 해결)
+ // [BACKEND_CONNECT] 백엔드 API 프록시 설정
+ // 인증 관련 엔드포인트(/api/auth/*)는 프론트엔드에서 백엔드로 직접 호출한다.
+ // (buildBackendUrl 참고) Set-Cookie 가 프록시를 거치며 누락되는 문제를 방지하기 위함.
+ // 아래 rewrite 는 인증 외 일반 API 용이며, Phase 3(Nginx 단일 origin) 도입 시 제거된다.
async rewrites() {
+ // 운영 환경에서 백엔드 주소가 없는 경우(예: Nginx 단일 오리진 사용) rewrite 생략
+ if (!backendOrigin) return [];
+
return [
{
source: '/api/:path*',
- destination: 'http://localhost:8080/api/:path*',
+ destination: `${backendOrigin}/api/:path*`,
+ },
+ {
+ source: '/oauth2/:path*',
+ destination: `${backendOrigin}/oauth2/:path*`,
+ },
+ {
+ source: '/login/oauth2/:path*',
+ destination: `${backendOrigin}/login/oauth2/:path*`,
},
];
},
diff --git a/frontend/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx
new file mode 100644
index 0000000..a0c4890
--- /dev/null
+++ b/frontend/src/app/auth/callback/page.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useEffect, useRef } from 'react';
+import { useRouter } from 'next/navigation';
+import { useAuthStore } from '@/lib/store/authStore';
+
+/**
+ * OAuth 로그인 완료 후 백엔드가 리디렉션하는 콜백 페이지
+ * - 마운트 시 initialize()를 직접 호출하여 토큰 갱신 및 상태 초기화 진행 (authStore 내부에서 중복 호출 방지)
+ * - initialize()는 내부적으로 한 번만 실행되도록 싱글톤(Promise) 처리되어 있어 안전함
+ */
+export default function AuthCallbackPage() {
+ const router = useRouter();
+ const initialize = useAuthStore((s) => s.initialize);
+ const isInitializing = useAuthStore((s) => s.isInitializing);
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const initialized = useRef(false);
+
+ // 컴포넌트 마운트 시 초기화 실행
+ // - authStore.initialize() 내부에서 이미 진행 중인 Promise가 있다면 재사용하므로 안전함
+ useEffect(() => {
+ if (!initialized.current) {
+ initialize();
+ initialized.current = true;
+ }
+ }, [initialize]);
+
+ useEffect(() => {
+ // 초기화가 끝날 때까지 대기
+ if (isInitializing) return;
+
+ // 인증 성공 여부에 따라 페이지 이동
+ router.replace(isAuthenticated ? '/my-links' : '/login');
+ }, [isInitializing, isAuthenticated, router]);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index e877158..6b35da6 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -4,6 +4,7 @@ import "./globals.css";
import { AppLayout } from "@/components/layout/AppLayout";
import { SaveLinkDialog } from "@/components/dialogs/SaveLinkDialog";
import { CreateFolderDialog } from "@/components/dialogs/CreateFolderDialog";
+import { AuthProvider } from "@/components/providers/AuthProvider";
import { QueryProvider } from "@/components/providers/QueryProvider";
@@ -31,11 +32,13 @@ export default function RootLayout({
-
- {children}
-
-
-
+
+
+ {children}
+
+
+
+