From 157d1b1ad95fa9490aa6714f9c6d88b6f4dee169 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 12 Feb 2026 14:35:55 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B3=90=20docs=20=EC=BB=A4=EB=B0=8B=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.html | 788 +++++++++++++++++++++++++++++++++++++++++ docs/architecture.pptx | Bin 0 -> 97923 bytes docs/generate_ppt.py | 551 ++++++++++++++++++++++++++++ 3 files changed, 1339 insertions(+) create mode 100644 docs/architecture.html create mode 100644 docs/architecture.pptx create mode 100644 docs/generate_ppt.py diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 0000000..86ca116 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,788 @@ + + + + + + O2O CastAD Backend - 인프라 아키텍처 + + + + + + + +
+
+

O2O CastAD Backend

+

인프라 아키텍처 및 비용 산출 문서

+
+ + +
+
+

1DB 및 서버 부하 분산 방법

+

Nginx 로드밸런싱, 커넥션 풀 관리, 단계별 수평 확장 전략

+
+ +
+
+

현재 구현 현황 (단일 인스턴스)

+
    +
  • API 커넥션 풀: pool_size=20, max_overflow=20 → 최대 40
  • +
  • 백그라운드 풀: pool_size=10, max_overflow=10 → 최대 20
  • +
  • 인스턴스당 총 DB 연결: 40 + 20 = 60
  • +
  • 풀 리사이클: 280초 (MySQL wait_timeout 300초 이전), pre-ping 활성화
  • +
+ +

단계별 확장 전략

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
단계동시접속App ServerLBDB ( MySQL Flexible)
S1~50명x1Nginx x1Burstable B1ms
S250~200명x2~4NginxGP D2ds_v4 + Replica x1
S3200~1,000명API ServerxN
+ Scheduler
NginxBC D4ds_v4 + Replica x2 + Redis P1
+
+ +
+

커넥션 풀 수치 계산

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
항목Stage 1 (1대)Stage 2 (4대)Stage 3 (8대)
Main Pool / 인스턴스20+20 = 4010+10 = 205+5 = 10
BG Pool / 인스턴스10+10 = 205+5 = 103+3 = 6
인스턴스당 소계603016
Primary 총 연결604 x 30 = 1208 x 16 = 128
max_connections 권장100200300
+ +
+ 핵심: + JWT Stateless 설계로 Nginx 세션 어피니티 불필요 (round-robin / least_conn). + Stage 2부터 Read Replica로 읽기 분산, Redis는 Stage 3에서 캐싱/Rate Limiting 도입. +
+
+
+ + +
+
+graph TB
+    subgraph S1["Stage 1: ~50명"]
+        direction LR
+        S1N["Nginx
(Reverse Proxy)"] --> S1A["App Server x1"] + S1A --> S1D[" MySQL
Burstable B1ms"] + end + + subgraph S2["Stage 2: 50~200명"] + direction LR + S2N["Nginx
(Reverse Proxy)"] --> S2API["APP Server
x 1 ~ 2"] + S2N --> S2WK["Scheduler
Server"] + S2API --> S2P["MySQL BC
Primary
(D4ds_v4)"] + S2API --> S2R1["Read Replica
x1"] + S2WK --> S2P + S2WK --> S2R1 + end + + subgraph S3["Stage 3: 200~1,000명"] + direction LR + S3N["Nginx
(Reverse Proxy)"] --> S3API["APP Server
x N"] + S3N --> S3WK["Scheduler
Server"] + S3API --> S3P["MySQL BC
Primary
(D4ds_v4)"] + S3API --> S3R1["Read Replica
xN"] + S3API --> S3RD["Redis
Premium P1"] + S3WK --> S3P + S3WK --> S3R1 + end + + S1 ~~~ S2 ~~~ S3 + + style S1 fill:#0d3320,stroke:#34d399,stroke-width:2px,color:#e1e4ed + style S2 fill:#3b2506,stroke:#fb923c,stroke-width:2px,color:#e1e4ed + style S3 fill:#3b1010,stroke:#f87171,stroke-width:2px,color:#e1e4ed +
+
+
+ + +
+
+

2전체 아키텍처 다이어그램

+

Nginx + FastAPI App Server 구성과 외부 서비스 연동 구조

+
+ +
+
+
    +
  • 로드밸런서: Nginx (Reverse Proxy, L7 LB, SSL 종단)
  • +
  • App Server: FastAPI (Python 3.13) — Auth, Home, Lyric, Song, Video, Social, SNS, Archive, Admin, Background Worker
  • +
  • DB: Database for MySQL Flexible Server — Stage 2+ Read Replica
  • +
+
+
+
    +
  • 캐시: Cache for Redis (Stage 3 도입)
  • +
  • 콘텐츠 생성: 가사(ChatGPT) → 음악(Suno AI) → 영상(Creatomate) → SNS 업로드
  • +
  • 외부 연동: Kakao OAuth, Naver Map/Search API, Blob Storage
  • +
+
+
+ + +
+
+graph TB
+    Client["클라이언트
(Web / App)"] + LB["Nginx
(Reverse Proxy + SSL 종단)"] + + subgraph APP["App Server (FastAPI)"] + direction LR + Auth["Auth"] --- Home["Home"] --- Lyric["Lyric"] --- Song["Song"] --- Video["Video"] + Social["Social"] --- SNS["SNS"] --- Archive["Archive"] --- Admin["Admin"] --- BG["BG Worker"] + end + + subgraph DB[" MySQL Flexible Server"] + direction LR + Primary["Primary (R/W)"] + Replica["Read Replica"] + end + + subgraph AI["AI 콘텐츠 생성 파이프라인"] + direction LR + ChatGPT["ChatGPT
(가사 생성)"] + Suno["Suno AI
(음악 생성)"] + Creatomate["Creatomate
(영상 생성)"] + ChatGPT --> Suno --> Creatomate + end + + subgraph EXT["외부 서비스"] + direction LR + Blob[" Blob
Storage"] + Kakao["Kakao
OAuth"] + YT["YouTube /
Instagram"] + Naver["Naver Map /
Search API"] + end + + Redis[" Cache for Redis
(Stage 3 도입)"] + + Client -->|HTTPS| LB + LB --> APP + APP --> Primary + APP -->|"읽기 전용"| Replica + APP -.->|"Stage 3"| Redis + APP --> AI + APP --> Blob + APP --> Kakao + APP --> YT + APP --> Naver + + style Client fill:#1a3a1a,stroke:#34d399,stroke-width:2px,color:#e1e4ed + style LB fill:#1a3a1a,stroke:#34d399,stroke-width:2px,color:#e1e4ed + style APP fill:#1a2744,stroke:#6c8cff,stroke-width:2px,color:#e1e4ed + style DB fill:#2a1f00,stroke:#fb923c,stroke-width:2px,color:#e1e4ed + style AI fill:#2a0f2a,stroke:#a78bfa,stroke-width:2px,color:#e1e4ed + style EXT fill:#0d2a2a,stroke:#34d399,stroke-width:2px,color:#e1e4ed + style Redis fill:#3b1010,stroke:#f87171,stroke-width:1px,color:#e1e4ed +
+
+

전체 시스템 아키텍처 구성도

+ +
+ 콘텐츠 생성 흐름: 사용자 요청 → Naver 크롤링 → ChatGPT 가사 생성 → Suno AI 음악 생성 → Creatomate 영상 생성 → Blob 저장 → YouTube/Instagram 업로드 +
+
+ + +
+
+

3예상 리소스 및 비용

+

기반 단계별 월 예상 비용 (인프라 + 외부 API)

+
+ +
+
+
Stage 1 · 동시 ~50명
+
$170~390
+
약 22~51만원/월
+
+
+
Stage 2 · 동시 50~200명
+
$960~2,160
+
약 125~280만원/월
+
+
+
Stage 3 · 동시 200~1,000명
+
$3,850~8,500
+
약 500~1,100만원/월
+
+
+ +
+
+

항목별 비용 상세

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
항목Stage 1Stage 2Stage 3
App Server$50~70$200~400$600~1,000
Nginx-포함 / VM $15~30VM $30~60
MySQL PrimaryB1ms $15~25GP $130~160BC $350~450
MySQL Replica-GP x1 $130~160BC x2 $260~360
Redis--P1 $225
스토리지/네트워크$10~20$55~100$160~270
AI API (합계)$90~280$400~1,250$2,100~5,800
+
+ +
+

DB 용량 예측 (1년 후)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Stage 1 (500명)Stage 2 (5,000명)Stage 3 (50,000명)
DB 용량~1.2GB~12GB~120GB
Blob 스토리지~1.1TB~11TB~110TB
MySQL 추천32GB SSD128GB SSD512GB SSD
+ +
+ 비용 최적화 팁: + 3rd party 의존도 낮춰야함 +
+ Blob Lifecycle Policy (30일 미접근 → Cool 티어), +
+
+
+ + +
+
+pie title Stage 3 월 비용 구성 비중
+    "App Server (APP+Scheduler)" : 800
+    "Nginx" : 45
+    "MySQL Primary" : 400
+    "MySQL Replica x2" : 310
+    "Redis Premium" : 225
+    "스토리지/네트워크" : 215
+    "OpenAI API" : 550
+    "Suno AI" : 1400
+    "Creatomate" : 2000
+            
+
+

Stage 3 월간 비용 구성 비율 — AI API 비중이 전체의 약 66%

+
+
+ + + + + diff --git a/docs/architecture.pptx b/docs/architecture.pptx new file mode 100644 index 0000000000000000000000000000000000000000..561b79318182c00d04ccaf45cf849b13ee2275b3 GIT binary patch literal 97923 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 dt^W$q$Vov0Lk Date: Thu, 12 Feb 2026 17:19:16 +0900 Subject: [PATCH 2/5] finished test for refresh token --- app/user/api/routers/v1/auth.py | 15 +-- app/user/services/auth.py | 37 ++++++-- timezone-check.md | 163 -------------------------------- 3 files changed, 39 insertions(+), 176 deletions(-) delete mode 100644 timezone-check.md diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index 3981621..c8b4b6e 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -28,6 +28,7 @@ from app.user.schemas.user_schema import ( KakaoLoginResponse, LoginResponse, RefreshTokenRequest, + TokenResponse, UserResponse, ) from app.user.services import auth_service, kakao_client @@ -240,19 +241,19 @@ async def kakao_verify( @router.post( "/refresh", - response_model=AccessTokenResponse, - summary="토큰 갱신", - description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.", + response_model=TokenResponse, + summary="토큰 갱신 (Refresh Token Rotation)", + description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.", ) async def refresh_token( body: RefreshTokenRequest, session: AsyncSession = Depends(get_session), -) -> AccessTokenResponse: +) -> TokenResponse: """ - 액세스 토큰 갱신 + 토큰 갱신 (Refresh Token Rotation) - 유효한 리프레시 토큰을 제출하면 새 액세스 토큰을 발급합니다. - 리프레시 토큰은 변경되지 않습니다. + 유효한 리프레시 토큰을 제출하면 새 액세스 토큰과 새 리프레시 토큰을 발급합니다. + 사용된 기존 리프레시 토큰은 즉시 폐기(revoke)됩니다. """ return await auth_service.refresh_tokens( refresh_token=body.refresh_token, diff --git a/app/user/services/auth.py b/app/user/services/auth.py index 35269e7..43e7bec 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -84,6 +84,7 @@ from app.user.schemas.user_schema import ( AccessTokenResponse, KakaoUserInfo, LoginResponse, + TokenResponse, ) from app.user.services.jwt import ( create_access_token, @@ -188,22 +189,27 @@ class AuthService: self, refresh_token: str, session: AsyncSession, - ) -> AccessTokenResponse: + ) -> TokenResponse: """ - 리프레시 토큰으로 액세스 토큰 갱신 + 리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation) + + 기존 리프레시 토큰을 폐기하고, 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. + 사용자가 서비스를 지속 사용하는 한 세션이 자동 유지됩니다. Args: refresh_token: 리프레시 토큰 session: DB 세션 Returns: - AccessTokenResponse: 새 액세스 토큰 + TokenResponse: 새 액세스 토큰 + 새 리프레시 토큰 Raises: InvalidTokenError: 토큰이 유효하지 않은 경우 TokenExpiredError: 토큰이 만료된 경우 TokenRevokedError: 토큰이 폐기된 경우 """ + logger.info("[AUTH] 토큰 갱신 시작 (Refresh Token Rotation)") + # 1. 토큰 디코딩 및 검증 payload = decode_token(refresh_token) if payload is None: @@ -236,11 +242,30 @@ class AuthService: if not user.is_active: raise UserInactiveError() - # 5. 새 액세스 토큰 발급 - new_access_token = create_access_token(user.user_uuid) + # 5. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음) + db_token.is_revoked = True + db_token.revoked_at = now().replace(tzinfo=None) - return AccessTokenResponse( + # 6. 새 토큰 발급 + new_access_token = create_access_token(user.user_uuid) + new_refresh_token = create_refresh_token(user.user_uuid) + + # 7. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행) + await self._save_refresh_token( + user_id=user.id, + user_uuid=user.user_uuid, + token=new_refresh_token, + session=session, + ) + + # 8. 폐기 + 저장을 하나의 트랜잭션으로 커밋 + await session.commit() + + logger.info(f"[AUTH] 토큰 갱신 완료 - user_uuid: {user.user_uuid}") + + return TokenResponse( access_token=new_access_token, + refresh_token=new_refresh_token, token_type="Bearer", expires_in=get_access_token_expire_seconds(), ) diff --git a/timezone-check.md b/timezone-check.md deleted file mode 100644 index c9a2baa..0000000 --- a/timezone-check.md +++ /dev/null @@ -1,163 +0,0 @@ -# 타임존 검수 보고서 - -**검수일**: 2026-02-10 -**대상**: o2o-castad-backend 전체 프로젝트 -**기준**: 서울 타임존(Asia/Seoul, KST +09:00) - ---- - -## 1. 타임존 설정 현황 - -### 1.1 FastAPI 전역 타임존 (`config.py:15`) -```python -TIMEZONE = ZoneInfo(os.getenv("TIMEZONE", "Asia/Seoul")) -``` -- `ZoneInfo`를 사용한 aware datetime 기반 -- `.env`로 오버라이드 가능 (기본값: `Asia/Seoul`) - -### 1.2 타임존 유틸리티 (`app/utils/timezone.py`) -```python -def now() -> datetime: - return datetime.now(TIMEZONE) # aware datetime (tzinfo=Asia/Seoul) - -def today_str(fmt="%Y-%m-%d") -> str: - return datetime.now(TIMEZONE).strftime(fmt) -``` -- 모든 모듈에서 `from app.utils.timezone import now` 사용 권장 -- 반환값은 **aware datetime** (tzinfo 포함) - -### 1.3 데이터베이스 타임존 -- MySQL `server_default=func.now()` → DB 서버의 시스템 타임존 사용 -- DB 생성 시 서울 타임존으로 설정됨 → `func.now()`는 KST 반환 - ---- - -## 2. 검수 결과 요약 - -| 구분 | 상태 | 비고 | -|------|------|------| -| `datetime.now()` 직접 호출 | ✅ 정상 | 앱 코드에서 bare `datetime.now()` 사용 없음 | -| `datetime.utcnow()` 사용 | ✅ 정상 | 프로젝트 전체에서 사용하지 않음 | -| `app.utils.timezone.now()` 사용 | ✅ 정상 | 필요한 모든 곳에서 사용 중 | -| 모델 `server_default=func.now()` | ✅ 정상 | DB 서버 타임존(서울) 기준 | -| naive/aware datetime 혼합 비교 | ⚠️ 주의 | `now().replace(tzinfo=None)` 패턴으로 처리됨 | - ---- - -## 3. 모듈별 상세 검수 - -### 3.1 `app/user/services/jwt.py` — ✅ 정상 - -| 위치 | 코드 | 판정 | -|------|------|------| -| L27 | `now() + timedelta(minutes=...)` | ✅ 토큰 만료시간 계산에 서울 타임존 사용 | -| L52 | `now() + timedelta(days=...)` | ✅ 리프레시 토큰 만료시간 | -| L110 | `now().replace(tzinfo=None) + timedelta(days=...)` | ✅ DB 저장용 naive datetime 변환 | - -### 3.2 `app/user/services/auth.py` — ✅ 정상 - -| 위치 | 코드 | 판정 | -|------|------|------| -| L171 | `user.last_login_at = now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 | -| L226 | `db_token.expires_at < now().replace(tzinfo=None)` | ✅ naive datetime끼리 비교 | -| L486 | `revoked_at=now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 | -| L511 | `revoked_at=now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 | - -### 3.3 `app/user/api/routers/v1/auth.py` — ✅ 정상 - -| 위치 | 코드 | 판정 | -|------|------|------| -| L464 | `user.last_login_at = now().replace(tzinfo=None)` | ✅ 테스트 엔드포인트, DB 저장용 | - -### 3.4 `app/social/services.py` — ✅ 정상 - -| 위치 | 코드 | 판정 | -|------|------|------| -| L308 | `current_time = now().replace(tzinfo=None)` | ✅ DB datetime과 비교용 | -| L506 | `current_time = now().replace(tzinfo=None)` | ✅ 토큰 만료 확인 | -| L577 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 토큰 만료시간 DB 저장 | -| L639 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 신규 계정 토큰 만료시간 | -| L693 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 토큰 업데이트 시 만료시간 | -| L709 | `account.connected_at = now().replace(tzinfo=None)` | ✅ 재연결 시간 DB 저장 | - -### 3.5 `app/social/worker/upload_task.py` — ✅ 정상 - -| 위치 | 코드 | 판정 | -|------|------|------| -| L74 | `upload.uploaded_at = now().replace(tzinfo=None)` | ✅ 업로드 완료시간 DB 저장 | - -### 3.6 `app/utils/logger.py` — ✅ 정상 - -| 위치 | 코드 | 판정 | -|------|------|------| -| L27 | `from app.utils.timezone import today_str` | ✅ 로그 파일명에 서울 기준 날짜 사용 | -| L89 | `today = today_str()` | ✅ `{날짜}_app.log` 파일명 | - ---- - -## 4. 모델 `created_at` / `updated_at` 패턴 검수 - -모든 모델의 `created_at`, `updated_at` 컬럼은 `server_default=func.now()`를 사용합니다. - -| 모델 | 파일 | `created_at` | `updated_at` | 판정 | -|------|------|:---:|:---:|:---:| -| User | `app/user/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ | -| RefreshToken | `app/user/models.py` | `func.now()` | — | ✅ | -| SocialAccount | `app/user/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ | -| Project | `app/home/models.py` | `func.now()` | — | ✅ | -| Image | `app/home/models.py` | `func.now()` | — | ✅ | -| Lyric | `app/lyric/models.py` | `func.now()` | — | ✅ | -| Song | `app/song/models.py` | `func.now()` | — | ✅ | -| SongTimestamp | `app/song/models.py` | `func.now()` | — | ✅ | -| Video | `app/video/models.py` | `func.now()` | — | ✅ | -| SNSUploadTask | `app/sns/models.py` | `func.now()` | — | ✅ | -| SocialUpload | `app/social/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ | - -> `func.now()`는 MySQL 서버의 `NOW()` 함수를 호출하므로 DB 서버 타임존(서울)이 적용됩니다. - ---- - -## 5. `now().replace(tzinfo=None)` 패턴 분석 - -이 프로젝트에서는 **aware datetime → naive datetime** 변환 패턴이 일관되게 사용됩니다: - -```python -now().replace(tzinfo=None) # Asia/Seoul aware → naive (값은 KST 유지) -``` - -**이유**: MySQL의 `DateTime` 타입은 타임존 정보를 저장하지 않으므로(naive datetime), DB에 저장하거나 DB 값과 비교할 때 `tzinfo`를 제거해야 합니다. - -**검증**: 이 패턴은 `now()`가 이미 서울 타임존 기준이므로, `.replace(tzinfo=None)` 후에도 **값 자체는 KST 시간**을 유지합니다. DB의 `func.now()`도 KST이므로 비교 시 일관성이 보장됩니다. - -| 사용처 | 목적 | 일관성 | -|--------|------|:------:| -| `jwt.py:110` | refresh token 만료시간 DB 저장 | ✅ | -| `auth.py:171` | 마지막 로그인 시간 DB 저장 | ✅ | -| `auth.py:226` | refresh token 만료 여부 비교 | ✅ | -| `auth.py:486,511` | token 폐기 시간 DB 저장 | ✅ | -| `auth.py(router):464` | 테스트 엔드포인트 로그인 시간 | ✅ | -| `social/services.py:308,506` | 토큰 만료 비교 | ✅ | -| `social/services.py:577,639,693` | 토큰 만료시간 DB 저장 | ✅ | -| `social/services.py:709` | 재연결 시간 DB 저장 | ✅ | -| `social/worker/upload_task.py:74` | 업로드 완료시간 DB 저장 | ✅ | - ---- - -## 6. 최종 결론 - -### ✅ 전체 판정: 정상 (PASS) - -프로젝트 전반에 걸쳐 타임존 처리가 **일관되게** 구현되어 있습니다: - -1. **bare `datetime.now()` 미사용** — 앱 코드에서 타임존 없는 `datetime.now()` 직접 호출이 없음 -2. **`datetime.utcnow()` 미사용** — UTC 기반 시간 생성 없음 -3. **`app.utils.timezone.now()` 일관 사용** — 모든 서비스/라우터에서 유틸리티 함수 사용 -4. **DB 저장 시 naive 변환 일관** — `now().replace(tzinfo=None)` 패턴 통일 -5. **모델 기본값 `func.now()` 통일** — DB 서버 타임존(서울) 기준으로 자동 설정 -6. **비교 연산 안전** — DB의 naive datetime과 비교 시 항상 naive로 변환 후 비교 - -### 주의사항 (현재 문제 아님, 향후 참고용) - -1. **DB 서버 타임존 변경 주의**: `func.now()`는 DB 서버 타임존에 의존하므로, DB 서버의 타임존이 변경되면 `created_at`/`updated_at` 등의 자동 생성 시간이 영향을 받습니다. -2. **다중 타임존 확장 시**: 현재는 단일 타임존(서울)만 사용하므로 문제없지만, 다국적 서비스 확장 시 UTC 기반 저장 + 표시 시 변환 패턴으로 전환을 고려할 수 있습니다. -3. **`replace(tzinfo=None)` 패턴**: 값은 유지하면서 타임존 정보만 제거하므로 안전하지만, 코드 리뷰 시 의도를 명확히 하기 위해 주석을 유지하는 것이 좋습니다(현재 `social/services.py:307`에 주석 존재). From 7f0ae81351cc9260359d7a7be19b4a7bc6a9b9df Mon Sep 17 00:00:00 2001 From: Dohyun Lim Date: Thu, 12 Feb 2026 17:52:51 +0900 Subject: [PATCH 3/5] remove endpoint at the video of get_videos --- app/home/schemas/home_schema.py | 129 ---------------------- app/sns/schemas/sns_schema.py | 16 +-- app/song/schemas/song_schema.py | 111 +------------------ app/user/api/routers/v1/auth.py | 1 - app/user/schemas/__init__.py | 2 - app/user/schemas/user_schema.py | 18 --- app/user/services/auth.py | 1 - app/utils/prompts/schemas/youtube_desc.py | 16 --- app/video/api/routers/v1/video.py | 129 +--------------------- 9 files changed, 3 insertions(+), 420 deletions(-) delete mode 100644 app/utils/prompts/schemas/youtube_desc.py diff --git a/app/home/schemas/home_schema.py b/app/home/schemas/home_schema.py index 45b0126..e227da2 100644 --- a/app/home/schemas/home_schema.py +++ b/app/home/schemas/home_schema.py @@ -3,112 +3,6 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field from app.utils.prompts.schemas import MarketingPromptOutput -class AttributeInfo(BaseModel): - """음악 속성 정보""" - - genre: str = Field(..., description="음악 장르") - vocal: str = Field(..., description="보컬 스타일") - tempo: str = Field(..., description="템포") - mood: str = Field(..., description="분위기") - - -class GenerateRequestImg(BaseModel): - """이미지 URL 스키마""" - - url: str = Field(..., description="이미지 URL") - name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") - - -class GenerateRequestInfo(BaseModel): - """생성 요청 정보 스키마 (이미지 제외)""" - - customer_name: str = Field(..., description="고객명/가게명") - region: str = Field(..., description="지역명") - detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") - attribute: AttributeInfo = Field(..., description="음악 속성 정보") - language: str = Field( - default="Korean", - description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", - ) - - -class GenerateRequest(GenerateRequestInfo): - """기본 생성 요청 스키마 (이미지 없음, JSON body) - - 이미지 없이 프로젝트 정보만 전달합니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "attribute": { - "genre": "K-Pop", - "vocal": "Raspy", - "tempo": "110 BPM", - "mood": "happy", - }, - "language": "Korean", - } - } - ) - - -class GenerateUrlsRequest(GenerateRequestInfo): - """URL 기반 생성 요청 스키마 (JSON body) - - GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "attribute": { - "genre": "K-Pop", - "vocal": "Raspy", - "tempo": "110 BPM", - "mood": "happy", - }, - "language": "Korean", - "images": [ - {"url": "https://example.com/images/image_001.jpg"}, - {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, - ], - } - } - ) - - images: list[GenerateRequestImg] = Field( - ..., description="이미지 URL 목록", min_length=1 - ) - - -class GenerateUploadResponse(BaseModel): - """파일 업로드 기반 생성 응답 스키마""" - - task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") - status: Literal["processing", "completed", "failed"] = Field( - ..., description="작업 상태" - ) - message: str = Field(..., description="응답 메시지") - uploaded_count: int = Field(..., description="업로드된 이미지 개수") - - -class GenerateResponse(BaseModel): - """생성 응답 스키마""" - - task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") - status: Literal["processing", "completed", "failed"] = Field( - ..., description="작업 상태" - ) - message: str = Field(..., description="응답 메시지") - - class CrawlingRequest(BaseModel): """크롤링 요청 스키마""" @@ -371,29 +265,6 @@ class ImageUrlItem(BaseModel): name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") -class ImageUploadRequest(BaseModel): - """이미지 업로드 요청 스키마 (JSON body 부분) - - URL 이미지 목록을 전달합니다. - 바이너리 파일은 multipart/form-data로 별도 전달됩니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "images": [ - {"url": "https://example.com/images/image_001.jpg"}, - {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, - ] - } - } - ) - - images: Optional[list[ImageUrlItem]] = Field( - None, description="외부 이미지 URL 목록" - ) - - class ImageUploadResultItem(BaseModel): """업로드된 이미지 결과 아이템""" diff --git a/app/sns/schemas/sns_schema.py b/app/sns/schemas/sns_schema.py index 51fc960..ae15aee 100644 --- a/app/sns/schemas/sns_schema.py +++ b/app/sns/schemas/sns_schema.py @@ -4,7 +4,7 @@ SNS API Schemas Instagram 업로드 관련 Pydantic 스키마를 정의합니다. """ from datetime import datetime -from typing import Any, Optional +from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -98,20 +98,6 @@ class Media(BaseModel): children: Optional[list["Media"]] = None -class MediaList(BaseModel): - """미디어 목록 응답""" - - data: list[Media] = Field(default_factory=list) - paging: Optional[dict[str, Any]] = None - - @property - def next_cursor(self) -> Optional[str]: - """다음 페이지 커서""" - if self.paging and "cursors" in self.paging: - return self.paging["cursors"].get("after") - return None - - class MediaContainer(BaseModel): """미디어 컨테이너 상태""" diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py index 2656646..eb2d420 100644 --- a/app/song/schemas/song_schema.py +++ b/app/song/schemas/song_schema.py @@ -1,8 +1,5 @@ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List, Optional +from typing import Optional -from fastapi import Request from pydantic import BaseModel, Field @@ -107,21 +104,6 @@ class GenerateSongResponse(BaseModel): error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") -class PollingSongRequest(BaseModel): - """노래 생성 상태 조회 요청 스키마 (Legacy) - - Note: - 현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용. - - Example Request: - { - "task_id": "abc123..." - } - """ - - task_id: str = Field(..., description="Suno 작업 ID") - - class SongClipData(BaseModel): """생성된 노래 클립 정보""" @@ -234,94 +216,3 @@ class PollingSongResponse(BaseModel): song_result_url: Optional[str] = Field( None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)" ) - - -# ============================================================================= -# Dataclass Schemas (Legacy) -# ============================================================================= - - -@dataclass -class StoreData: - id: int - created_at: datetime - store_name: str - store_category: str | None = None - store_region: str | None = None - store_address: str | None = None - store_phone_number: str | None = None - store_info: str | None = None - - -@dataclass -class AttributeData: - id: int - attr_category: str - attr_value: str - created_at: datetime - - -@dataclass -class SongSampleData: - id: int - ai: str - ai_model: str - sample_song: str - season: str | None = None - num_of_people: int | None = None - people_category: str | None = None - genre: str | None = None - - -@dataclass -class PromptTemplateData: - id: int - prompt: str - description: str | None = None - - -@dataclass -class SongFormData: - store_name: str - store_id: str - prompts: str - attributes: Dict[str, str] = field(default_factory=dict) - attributes_str: str = "" - lyrics_ids: List[int] = field(default_factory=list) - llm_model: str = "gpt-5-mini" - - @classmethod - async def from_form(cls, request: Request): - """Request의 form 데이터로부터 dataclass 인스턴스 생성""" - form_data = await request.form() - - # 고정 필드명들 - fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} - - # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 - lyrics_ids = [] - attributes = {} - - for key, value in form_data.items(): - if key.startswith("lyrics-"): - lyrics_id = key.split("-")[1] - lyrics_ids.append(int(lyrics_id)) - elif key not in fixed_keys: - attributes[key] = value - - # attributes를 문자열로 변환 - attributes_str = ( - "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) - if attributes - else "" - ) - - return cls( - store_name=form_data.get("store_info_name", ""), - store_id=form_data.get("store_id", ""), - attributes=attributes, - attributes_str=attributes_str, - lyrics_ids=lyrics_ids, - llm_model=form_data.get("llm_model", "gpt-5-mini"), - prompts=form_data.get("prompts", ""), - ) diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index c8b4b6e..a553a7b 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -23,7 +23,6 @@ logger = logging.getLogger(__name__) from app.user.dependencies import get_current_user from app.user.models import RefreshToken, User from app.user.schemas.user_schema import ( - AccessTokenResponse, KakaoCodeRequest, KakaoLoginResponse, LoginResponse, diff --git a/app/user/schemas/__init__.py b/app/user/schemas/__init__.py index 6841f87..4709e8a 100644 --- a/app/user/schemas/__init__.py +++ b/app/user/schemas/__init__.py @@ -1,5 +1,4 @@ from app.user.schemas.user_schema import ( - AccessTokenResponse, KakaoCodeRequest, KakaoLoginResponse, KakaoTokenResponse, @@ -12,7 +11,6 @@ from app.user.schemas.user_schema import ( ) __all__ = [ - "AccessTokenResponse", "KakaoCodeRequest", "KakaoLoginResponse", "KakaoTokenResponse", diff --git a/app/user/schemas/user_schema.py b/app/user/schemas/user_schema.py index e0c6337..d10fd13 100644 --- a/app/user/schemas/user_schema.py +++ b/app/user/schemas/user_schema.py @@ -64,24 +64,6 @@ class TokenResponse(BaseModel): } -class AccessTokenResponse(BaseModel): - """액세스 토큰 갱신 응답""" - - access_token: str = Field(..., description="액세스 토큰") - token_type: str = Field(default="Bearer", description="토큰 타입") - expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)") - - model_config = { - "json_schema_extra": { - "example": { - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token", - "token_type": "Bearer", - "expires_in": 3600 - } - } - } - - class RefreshTokenRequest(BaseModel): """토큰 갱신 요청""" diff --git a/app/user/services/auth.py b/app/user/services/auth.py index 43e7bec..9f964e8 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -81,7 +81,6 @@ class AdminRequiredError(AuthException): from app.user.models import RefreshToken, User from app.utils.common import generate_uuid from app.user.schemas.user_schema import ( - AccessTokenResponse, KakaoUserInfo, LoginResponse, TokenResponse, diff --git a/app/utils/prompts/schemas/youtube_desc.py b/app/utils/prompts/schemas/youtube_desc.py deleted file mode 100644 index d81b97d..0000000 --- a/app/utils/prompts/schemas/youtube_desc.py +++ /dev/null @@ -1,16 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Optional - -# Input 정의 -class YTUploadPromptInput(BaseModel): - customer_name : str = Field(..., description = "마케팅 대상 사업체 이름") - detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세") - marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서") - language : str= Field(..., description = "영상 언어") - target_keywords: List[str] = Field(..., description="태그 키워드 리스트") - -# Output 정의 -class YTUploadPromptOutput(BaseModel): - title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화") - description: str = Field(..., description = "유튜브 영상 설명 - SEO/AEO 최적화") - diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index b203082..94e39ff 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -7,7 +7,6 @@ Video API Router - POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결) - GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회 - GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling) - - GET /video/list: 완료된 영상 목록 조회 (페이지네이션) 사용 예시: from app.video.api.routers.v1.video import router @@ -18,11 +17,10 @@ import json from typing import Literal from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session -from app.dependencies.pagination import PaginationParams, get_pagination_params from app.user.dependencies.auth import get_current_user from app.user.models import User from app.home.models import Image, Project @@ -30,13 +28,11 @@ from app.lyric.models import Lyric from app.song.models import Song, SongTimestamp from app.utils.creatomate import CreatomateService from app.utils.logger import get_logger -from app.utils.pagination import PaginatedResponse from app.video.models import Video from app.video.schemas.video_schema import ( DownloadVideoResponse, GenerateVideoResponse, PollingVideoResponse, - VideoListItem, VideoRenderData, ) from app.video.worker.video_task import download_and_upload_video_to_blob @@ -738,126 +734,3 @@ async def download_video( message="영상 다운로드 조회에 실패했습니다.", error_message=str(e), ) - - -@router.get( - "/list", - summary="생성된 영상 목록 조회", - description=""" -완료된 영상 목록을 페이지네이션하여 조회합니다. - -## 인증 -**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. - -## 쿼리 파라미터 -- **page**: 페이지 번호 (1부터 시작, 기본값: 1) -- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100) - -## 반환 정보 -- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at) -- **total**: 전체 데이터 수 -- **page**: 현재 페이지 -- **page_size**: 페이지당 데이터 수 -- **total_pages**: 전체 페이지 수 -- **has_next**: 다음 페이지 존재 여부 -- **has_prev**: 이전 페이지 존재 여부 - -## 사용 예시 (cURL) -```bash -curl -X GET "http://localhost:8000/video/list?page=1&page_size=10" \\ - -H "Authorization: Bearer {access_token}" -``` - -## 참고 -- status가 'completed'인 영상만 반환됩니다. -- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다. -- created_at 기준 내림차순 정렬됩니다. - """, - response_model=PaginatedResponse[VideoListItem], - responses={ - 200: {"description": "영상 목록 조회 성공"}, - 401: {"description": "인증 실패 (토큰 없음/만료)"}, - 500: {"description": "조회 실패"}, - }, -) -async def get_videos( - current_user: User = Depends(get_current_user), - session: AsyncSession = Depends(get_session), - pagination: PaginationParams = Depends(get_pagination_params), -) -> PaginatedResponse[VideoListItem]: - """완료된 영상 목록을 페이지네이션하여 반환합니다.""" - logger.info( - f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}" - ) - try: - offset = (pagination.page - 1) * pagination.page_size - - # 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만) - subquery = ( - select(func.max(Video.id).label("max_id")) - .where(Video.status == "completed") - .group_by(Video.task_id) - .subquery() - ) - - # 전체 개수 조회 (task_id별 최신 1개만) - count_query = select(func.count()).select_from(subquery) - total_result = await session.execute(count_query) - total = total_result.scalar() or 0 - - # 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순) - query = ( - select(Video) - .where(Video.id.in_(select(subquery.c.max_id))) - .order_by(Video.created_at.desc()) - .offset(offset) - .limit(pagination.page_size) - ) - result = await session.execute(query) - videos = result.scalars().all() - - # Project 정보 일괄 조회 (N+1 문제 해결) - project_ids = [v.project_id for v in videos if v.project_id] - projects_map: dict = {} - if project_ids: - projects_result = await session.execute( - select(Project).where(Project.id.in_(project_ids)) - ) - projects_map = {p.id: p for p in projects_result.scalars().all()} - - # VideoListItem으로 변환 - items = [] - for video in videos: - project = projects_map.get(video.project_id) - - item = VideoListItem( - video_id=video.id, - store_name=project.store_name if project else None, - region=project.region if project else None, - task_id=video.task_id, - result_movie_url=video.result_movie_url, - created_at=video.created_at, - ) - items.append(item) - - response = PaginatedResponse.create( - items=items, - total=total, - page=pagination.page, - page_size=pagination.page_size, - ) - - logger.info( - f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, " - f"page_size: {pagination.page_size}, items_count: {len(items)}" - ) - return response - - except Exception as e: - logger.error(f"[get_videos] EXCEPTION - error: {e}") - raise HTTPException( - status_code=500, - detail=f"영상 목록 조회에 실패했습니다: {str(e)}", - ) - - From 9d074632bc8d18fdb4059db70f0074038fb4de86 Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 13 Feb 2026 10:09:02 +0900 Subject: [PATCH 4/5] =?UTF-8?q?margeting=5Finteligence=20->=20marketing=5F?= =?UTF-8?q?intelligence=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/home/models.py | 2 +- app/lyric/api/routers/v1/lyric.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/home/models.py b/app/home/models.py index 9a991a3..ff55307 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -107,7 +107,7 @@ class Project(Base): comment="상세 지역 정보", ) - marketing_inteligence: Mapped[Optional[str]] = mapped_column( + marketing_intelligence: Mapped[Optional[str]] = mapped_column( Integer, nullable=True, comment="마케팅 인텔리전스 결과 정보 저장", diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index c508723..5c00b9b 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -319,7 +319,7 @@ async def generate_lyric( detail_region_info=request_body.detail_region_info, language=request_body.language, user_uuid=current_user.user_uuid, - marketing_inteligence = request_body.m_id + marketing_intelligence = request_body.m_id ) session.add(project) await session.commit() From 1398546dac6824d2b14fd07273c912e54c2087ab Mon Sep 17 00:00:00 2001 From: Dohyun Lim Date: Fri, 13 Feb 2026 17:41:27 +0900 Subject: [PATCH 5/5] add logs for token --- app/user/api/routers/v1/auth.py | 18 +- app/user/dependencies/auth.py | 37 ++- app/user/services/auth.py | 72 +++++- app/user/services/jwt.py | 37 ++- token_log_plan.md | 422 ++++++++++++++++++++++++++++++++ 5 files changed, 573 insertions(+), 13 deletions(-) create mode 100644 token_log_plan.md diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index a553a7b..002f1b6 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -254,10 +254,16 @@ async def refresh_token( 유효한 리프레시 토큰을 제출하면 새 액세스 토큰과 새 리프레시 토큰을 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기(revoke)됩니다. """ - return await auth_service.refresh_tokens( + logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}") + result = await auth_service.refresh_tokens( refresh_token=body.refresh_token, session=session, ) + logger.info( + f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, " + f"new_refresh: ...{result.refresh_token[-20:]}" + ) + return result @router.post( @@ -281,11 +287,16 @@ async def logout( 현재 사용 중인 리프레시 토큰을 폐기합니다. 해당 토큰으로는 더 이상 액세스 토큰을 갱신할 수 없습니다. """ + logger.info( + f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, " + f"user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}" + ) await auth_service.logout( user_id=current_user.id, refresh_token=body.refresh_token, session=session, ) + logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}") return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -309,10 +320,15 @@ async def logout_all( 사용자의 모든 리프레시 토큰을 폐기합니다. 모든 기기에서 재로그인이 필요합니다. """ + logger.info( + f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, " + f"user_uuid: {current_user.user_uuid}" + ) await auth_service.logout_all( user_id=current_user.id, session=session, ) + logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}") return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/user/dependencies/auth.py b/app/user/dependencies/auth.py index 5074a9d..8290f60 100644 --- a/app/user/dependencies/auth.py +++ b/app/user/dependencies/auth.py @@ -4,6 +4,7 @@ FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다. """ +import logging from typing import Optional from fastapi import Depends @@ -22,6 +23,8 @@ from app.user.services.auth import ( ) from app.user.services.jwt import decode_token +logger = logging.getLogger(__name__) + security = HTTPBearer(auto_error=False) @@ -47,18 +50,28 @@ async def get_current_user( UserInactiveError: 비활성화된 계정인 경우 """ if credentials is None: + logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError") raise MissingTokenError() - payload = decode_token(credentials.credentials) + token = credentials.credentials + logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}") + + payload = decode_token(token) if payload is None: + logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}") raise InvalidTokenError() # 토큰 타입 확인 if payload.get("type") != "access": + logger.warning( + f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, " + f"got: {payload.get('type')}, sub: {payload.get('sub')}" + ) raise InvalidTokenError("액세스 토큰이 아닙니다.") user_uuid = payload.get("sub") if user_uuid is None: + logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}") raise InvalidTokenError() # 사용자 조회 @@ -71,11 +84,18 @@ async def get_current_user( user = result.scalar_one_or_none() if user is None: + logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}") raise UserNotFoundError() if not user.is_active: + logger.warning( + f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}" + ) raise UserInactiveError() + logger.debug( + f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}" + ) return user @@ -96,17 +116,24 @@ async def get_current_user_optional( User | None: 로그인한 사용자 또는 None """ if credentials is None: + logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음") return None - payload = decode_token(credentials.credentials) + token = credentials.credentials + payload = decode_token(token) if payload is None: + logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}") return None if payload.get("type") != "access": + logger.debug( + f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})" + ) return None user_uuid = payload.get("sub") if user_uuid is None: + logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음") return None result = await session.execute( @@ -118,8 +145,14 @@ async def get_current_user_optional( user = result.scalar_one_or_none() if user is None or not user.is_active: + logger.debug( + f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}" + ) return None + logger.debug( + f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}" + ) return user diff --git a/app/user/services/auth.py b/app/user/services/auth.py index 9f964e8..31a071a 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -207,49 +207,91 @@ class AuthService: TokenExpiredError: 토큰이 만료된 경우 TokenRevokedError: 토큰이 폐기된 경우 """ - logger.info("[AUTH] 토큰 갱신 시작 (Refresh Token Rotation)") + logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}") # 1. 토큰 디코딩 및 검증 payload = decode_token(refresh_token) if payload is None: + logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}") raise InvalidTokenError() if payload.get("type") != "refresh": + logger.warning( + f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, " + f"sub: {payload.get('sub')}" + ) raise InvalidTokenError("리프레시 토큰이 아닙니다.") + logger.debug( + f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, " + f"exp: {payload.get('exp')}" + ) + # 2. DB에서 리프레시 토큰 조회 token_hash = get_token_hash(refresh_token) db_token = await self._get_refresh_token_by_hash(token_hash, session) if db_token is None: + logger.warning( + f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, " + f"token_hash: {token_hash[:16]}..." + ) raise InvalidTokenError() + logger.debug( + f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., " + f"user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, " + f"expires_at: {db_token.expires_at}" + ) + # 3. 토큰 상태 확인 if db_token.is_revoked: + logger.warning( + f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), " + f"token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, " + f"revoked_at: {db_token.revoked_at}" + ) raise TokenRevokedError() + # 4. 만료 확인 if db_token.expires_at < now().replace(tzinfo=None): + logger.info( + f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, " + f"user_uuid: {db_token.user_uuid}" + ) raise TokenExpiredError() - # 4. 사용자 확인 + # 5. 사용자 확인 user_uuid = payload.get("sub") user = await self._get_user_by_uuid(user_uuid, session) if user is None: + logger.warning( + f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}" + ) raise UserNotFoundError() if not user.is_active: + logger.warning( + f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, " + f"user_id: {user.id}" + ) raise UserInactiveError() - # 5. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음) + # 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음) db_token.is_revoked = True db_token.revoked_at = now().replace(tzinfo=None) + logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...") - # 6. 새 토큰 발급 + # 7. 새 토큰 발급 new_access_token = create_access_token(user.user_uuid) new_refresh_token = create_refresh_token(user.user_uuid) + logger.debug( + f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, " + f"new_refresh: ...{new_refresh_token[-20:]}" + ) - # 7. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행) + # 8. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행) await self._save_refresh_token( user_id=user.id, user_uuid=user.user_uuid, @@ -257,10 +299,14 @@ class AuthService: session=session, ) - # 8. 폐기 + 저장을 하나의 트랜잭션으로 커밋 + # 폐기 + 저장을 하나의 트랜잭션으로 커밋 await session.commit() - logger.info(f"[AUTH] 토큰 갱신 완료 - user_uuid: {user.user_uuid}") + logger.info( + f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, " + f"user_id: {user.id}, old_hash: {token_hash[:16]}..., " + f"new_refresh: ...{new_refresh_token[-20:]}" + ) return TokenResponse( access_token=new_access_token, @@ -284,7 +330,12 @@ class AuthService: session: DB 세션 """ token_hash = get_token_hash(refresh_token) + logger.info( + f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., " + f"token: ...{refresh_token[-20:]}" + ) await self._revoke_refresh_token_by_hash(token_hash, session) + logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}") async def logout_all( self, @@ -298,7 +349,9 @@ class AuthService: user_id: 사용자 ID session: DB 세션 """ + logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}") await self._revoke_all_user_tokens(user_id, session) + logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}") async def _get_or_create_user( self, @@ -428,6 +481,11 @@ class AuthService: ) session.add(refresh_token) await session.flush() + + logger.debug( + f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, " + f"token_hash: {token_hash[:16]}..., expires_at: {expires_at}" + ) return refresh_token async def _get_refresh_token_by_hash( diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py index ebcf2d6..39f0b5a 100644 --- a/app/user/services/jwt.py +++ b/app/user/services/jwt.py @@ -5,14 +5,18 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니 """ import hashlib +import logging from datetime import datetime, timedelta from typing import Optional from jose import JWTError, jwt +from jose.exceptions import ExpiredSignatureError, JWTClaimsError from app.utils.timezone import now from config import jwt_settings +logger = logging.getLogger(__name__) + def create_access_token(user_uuid: str) -> str: """ @@ -32,11 +36,16 @@ def create_access_token(user_uuid: str) -> str: "exp": expire, "type": "access", } - return jwt.encode( + token = jwt.encode( to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM, ) + logger.debug( + f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, " + f"expires: {expire}, token: ...{token[-20:]}" + ) + return token def create_refresh_token(user_uuid: str) -> str: @@ -57,11 +66,16 @@ def create_refresh_token(user_uuid: str) -> str: "exp": expire, "type": "refresh", } - return jwt.encode( + token = jwt.encode( to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM, ) + logger.debug( + f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, " + f"expires: {expire}, token: ...{token[-20:]}" + ) + return token def decode_token(token: str) -> Optional[dict]: @@ -80,8 +94,25 @@ def decode_token(token: str) -> Optional[dict]: jwt_settings.JWT_SECRET, algorithms=[jwt_settings.JWT_ALGORITHM], ) + logger.debug( + f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, " + f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, " + f"token: ...{token[-20:]}" + ) return payload - except JWTError: + except ExpiredSignatureError: + logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}") + return None + except JWTClaimsError as e: + logger.warning( + f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}" + ) + return None + except JWTError as e: + logger.warning( + f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, " + f"token: ...{token[-20:]}" + ) return None diff --git a/token_log_plan.md b/token_log_plan.md new file mode 100644 index 0000000..b5f0fec --- /dev/null +++ b/token_log_plan.md @@ -0,0 +1,422 @@ +# 서버 JWT 토큰 라이프사이클 로깅 강화 계획 + +## 1. 토큰 라이프사이클 개요 + +서버가 직접 발급/관리하는 JWT 토큰의 전체 흐름: + +``` +[발급] kakao_login() / generate_test_token() + ├── Access Token 생성 (sub=user_uuid, type=access, exp=60분) + ├── Refresh Token 생성 (sub=user_uuid, type=refresh, exp=7일) + └── Refresh Token DB 저장 (token_hash, user_id, expires_at) + │ + ▼ +[검증] get_current_user() — 매 요청마다 Access Token 검증 + ├── Bearer 헤더에서 토큰 추출 + ├── decode_token() → payload (sub, type, exp) + ├── type == "access" 확인 + └── user_uuid로 사용자 조회/활성 확인 + │ + ▼ +[갱신] refresh_tokens() — Access Token 만료 시 Refresh Token으로 갱신 + ├── 기존 Refresh Token 디코딩 → payload + ├── token_hash로 DB 조회 → is_revoked / expires_at 확인 + ├── 기존 Refresh Token 폐기 (is_revoked=True) + ├── 새 Access Token + 새 Refresh Token 발급 + └── 새 Refresh Token DB 저장 + │ + ▼ +[폐기] logout() / logout_all() + ├── 단일: token_hash로 해당 Refresh Token 폐기 + └── 전체: user_id로 모든 Refresh Token 폐기 +``` + +--- + +## 2. 현황 분석 — 로깅 공백 지점 + +### 발급 단계 +| 위치 | 함수 | 현재 로깅 | 부족한 정보 | +|------|------|----------|------------| +| `jwt.py` | `create_access_token()` | 없음 | 발급 대상(user_uuid), 만료시간 | +| `jwt.py` | `create_refresh_token()` | 없음 | 발급 대상(user_uuid), 만료시간 | +| `auth.py` (service) | `_save_refresh_token()` | 없음 | DB 저장 결과(token_hash, expires_at) | +| `auth.py` (service) | `kakao_login()` | `debug`로 토큰 앞 30자 출력 | 충분 (변경 불필요) | + +### 검증 단계 +| 위치 | 함수 | 현재 로깅 | 부족한 정보 | +|------|------|----------|------------| +| `jwt.py` | `decode_token()` | 없음 | 디코딩 성공 시 payload 내용, 실패 시 원인 | +| `auth.py` (dependency) | `get_current_user()` | 없음 | 검증 각 단계 통과/실패 사유, 토큰 내 정보 | +| `auth.py` (dependency) | `get_current_user_optional()` | 없음 | 위와 동일 | + +### 갱신 단계 +| 위치 | 함수 | 현재 로깅 | 부족한 정보 | +|------|------|----------|------------| +| `auth.py` (router) | `refresh_token()` | 없음 | 수신 토큰 정보, 갱신 결과 | +| `auth.py` (service) | `refresh_tokens()` | 진입/완료 `info` 1줄씩 | 각 단계 실패 사유, DB 토큰 상태, 신규 토큰 정보 | + +### 폐기 단계 +| 위치 | 함수 | 현재 로깅 | 부족한 정보 | +|------|------|----------|------------| +| `auth.py` (router) | `logout()`, `logout_all()` | 없음 | 요청 수신, 대상 사용자 | +| `auth.py` (service) | `logout()`, `logout_all()` | 없음 | 폐기 대상, 폐기 결과 | + +--- + +## 3. 수정 대상 파일 + +| # | 파일 | 수정 내용 | +|---|------|----------| +| 1 | `app/user/services/jwt.py` | 토큰 발급 로그 + `decode_token()` 실패 원인 분류 | +| 2 | `app/user/dependencies/auth.py` | Access Token 검증 과정 로깅 | +| 3 | `app/user/services/auth.py` | `refresh_tokens()`, `_save_refresh_token()`, `logout()`, `logout_all()` 로깅 | +| 4 | `app/user/api/routers/v1/auth.py` | `refresh_token()`, `logout()`, `logout_all()` 라우터 로깅 | + +--- + +## 4. 상세 구현 계획 + +### 4-1. `jwt.py` — 토큰 발급 로그 + 디코딩 실패 원인 분류 + +**import 추가:** +```python +import logging +from jose import JWTError, ExpiredSignatureError, JWTClaimsError, jwt + +logger = logging.getLogger(__name__) +``` + +**`create_access_token()` — 발급 로그 추가:** +```python +def create_access_token(user_uuid: str) -> str: + expire = now() + timedelta(minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode = {"sub": user_uuid, "exp": expire, "type": "access"} + token = jwt.encode(to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM) + logger.debug(f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, expires: {expire}, token: ...{token[-20:]}") + return token +``` + +**`create_refresh_token()` — 발급 로그 추가:** +```python +def create_refresh_token(user_uuid: str) -> str: + expire = now() + timedelta(days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS) + to_encode = {"sub": user_uuid, "exp": expire, "type": "refresh"} + token = jwt.encode(to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM) + logger.debug(f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, expires: {expire}, token: ...{token[-20:]}") + return token +``` + +**`decode_token()` — 성공/실패 분류 로그:** +```python +def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, jwt_settings.JWT_SECRET, algorithms=[jwt_settings.JWT_ALGORITHM]) + logger.debug( + f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, " + f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, " + f"token: ...{token[-20:]}" + ) + return payload + except ExpiredSignatureError: + logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}") + return None + except JWTClaimsError as e: + logger.warning(f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}") + return None + except JWTError as e: + logger.warning(f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, token: ...{token[-20:]}") + return None +``` + +### 4-2. `dependencies/auth.py` — Access Token 검증 로깅 + +**import 추가:** +```python +import logging +logger = logging.getLogger(__name__) +``` + +**`get_current_user()` — 검증 과정 로그:** +```python +async def get_current_user(...) -> User: + if credentials is None: + logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError") + raise MissingTokenError() + + token = credentials.credentials + logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}") + + payload = decode_token(token) + if payload is None: + logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}") + raise InvalidTokenError() + + if payload.get("type") != "access": + logger.warning(f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, got: {payload.get('type')}, sub: {payload.get('sub')}") + raise InvalidTokenError("액세스 토큰이 아닙니다.") + + user_uuid = payload.get("sub") + if user_uuid is None: + logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}") + raise InvalidTokenError() + + # 사용자 조회 + result = await session.execute(...) + user = result.scalar_one_or_none() + + if user is None: + logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}") + raise UserNotFoundError() + + if not user.is_active: + logger.warning(f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}") + raise UserInactiveError() + + logger.debug(f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}") + return user +``` + +**`get_current_user_optional()` — 동일 패턴, `debug` 레벨:** +```python +async def get_current_user_optional(...) -> Optional[User]: + if credentials is None: + logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음") + return None + + token = credentials.credentials + payload = decode_token(token) + if payload is None: + logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}") + return None + + if payload.get("type") != "access": + logger.debug(f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})") + return None + + user_uuid = payload.get("sub") + if user_uuid is None: + logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음") + return None + + result = await session.execute(...) + user = result.scalar_one_or_none() + + if user is None or not user.is_active: + logger.debug(f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}") + return None + + logger.debug(f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}") + return user +``` + +### 4-3. `services/auth.py` — Refresh Token 갱신/폐기 로깅 + +**`refresh_tokens()` — 전체 흐름 로그:** +```python +async def refresh_tokens(self, refresh_token: str, session: AsyncSession) -> TokenResponse: + logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}") + + # 1. 디코딩 + payload = decode_token(refresh_token) + if payload is None: + logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}") + raise InvalidTokenError() + + if payload.get("type") != "refresh": + logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, sub: {payload.get('sub')}") + raise InvalidTokenError("리프레시 토큰이 아닙니다.") + + logger.debug(f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, exp: {payload.get('exp')}") + + # 2. DB 조회 + token_hash = get_token_hash(refresh_token) + db_token = await self._get_refresh_token_by_hash(token_hash, session) + + if db_token is None: + logger.warning(f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, token_hash: {token_hash[:16]}...") + raise InvalidTokenError() + + logger.debug(f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, expires_at: {db_token.expires_at}") + + # 3. 폐기 여부 + if db_token.is_revoked: + logger.warning(f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, revoked_at: {db_token.revoked_at}") + raise TokenRevokedError() + + # 4. 만료 확인 + if db_token.expires_at < now().replace(tzinfo=None): + logger.info(f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, user_uuid: {db_token.user_uuid}") + raise TokenExpiredError() + + # 5. 사용자 확인 + user_uuid = payload.get("sub") + user = await self._get_user_by_uuid(user_uuid, session) + + if user is None: + logger.warning(f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}") + raise UserNotFoundError() + + if not user.is_active: + logger.warning(f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, user_id: {user.id}") + raise UserInactiveError() + + # 6. 기존 토큰 폐기 + db_token.is_revoked = True + db_token.revoked_at = now().replace(tzinfo=None) + logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...") + + # 7. 새 토큰 발급 + new_access_token = create_access_token(user.user_uuid) + new_refresh_token = create_refresh_token(user.user_uuid) + logger.debug(f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, new_refresh: ...{new_refresh_token[-20:]}") + + # 8. 새 Refresh Token DB 저장 + 커밋 + await self._save_refresh_token(user_id=user.id, user_uuid=user.user_uuid, token=new_refresh_token, session=session) + await session.commit() + + logger.info(f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, user_id: {user.id}, old_hash: {token_hash[:16]}..., new_refresh: ...{new_refresh_token[-20:]}") + return TokenResponse(...) +``` + +**`_save_refresh_token()` — DB 저장 로그:** +```python +async def _save_refresh_token(self, ...) -> RefreshToken: + token_hash = get_token_hash(token) + expires_at = get_refresh_token_expires_at() + + refresh_token = RefreshToken(...) + session.add(refresh_token) + await session.flush() + + logger.debug(f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, token_hash: {token_hash[:16]}..., expires_at: {expires_at}") + return refresh_token +``` + +**`logout()` — 단일 로그아웃 로그:** +```python +async def logout(self, user_id: int, refresh_token: str, session: AsyncSession) -> None: + token_hash = get_token_hash(refresh_token) + logger.info(f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., token: ...{refresh_token[-20:]}") + await self._revoke_refresh_token_by_hash(token_hash, session) + logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}") +``` + +**`logout_all()` — 전체 로그아웃 로그:** +```python +async def logout_all(self, user_id: int, session: AsyncSession) -> None: + logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}") + await self._revoke_all_user_tokens(user_id, session) + logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}") +``` + +### 4-4. `routers/v1/auth.py` — 라우터 진입/완료 로깅 + +```python +# POST /auth/refresh +async def refresh_token(body, session) -> TokenResponse: + logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}") + result = await auth_service.refresh_tokens(refresh_token=body.refresh_token, session=session) + logger.info(f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, new_refresh: ...{result.refresh_token[-20:]}") + return result + +# POST /auth/logout +async def logout(body, current_user, session) -> Response: + logger.info(f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}") + await auth_service.logout(user_id=current_user.id, refresh_token=body.refresh_token, session=session) + logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}") + return Response(status_code=status.HTTP_204_NO_CONTENT) + +# POST /auth/logout/all +async def logout_all(current_user, session) -> Response: + logger.info(f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, user_uuid: {current_user.user_uuid}") + await auth_service.logout_all(user_id=current_user.id, session=session) + logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}") + return Response(status_code=status.HTTP_204_NO_CONTENT) +``` + +--- + +## 5. 보안 원칙 + +| 원칙 | 적용 방법 | 이유 | +|------|----------|------| +| 토큰 전체 노출 금지 | 뒷 20자만: `...{token[-20:]}` | 토큰 탈취 시 세션 하이재킹 가능 | +| 해시값 부분 노출 | 앞 16자만: `{hash[:16]}...` | DB 레코드 식별에 충분 | +| user_uuid 전체 허용 | 전체 출력 | 내부 식별자, 토큰이 아님 | +| 페이로드 내용 출력 | `sub`, `type`, `exp` 출력 | 디버깅에 필수, 민감정보 아님 | +| DB 토큰 상태 출력 | `is_revoked`, `expires_at`, `revoked_at` | 토큰 라이프사이클 추적 | +| 로그 레벨 구분 | 하단 표 참조 | 운영 환경에서 불필요한 로그 억제 | + +### 로그 레벨 기준 + +| 레벨 | 사용 기준 | 예시 | +|------|----------|------| +| `debug` | 정상 처리 과정 상세 (운영환경에서 비활성) | 토큰 발급, 디코딩 성공, 검증 통과 | +| `info` | 주요 이벤트 (운영환경에서 활성) | 갱신 시작/완료, 로그아웃, 만료로 인한 실패 | +| `warning` | 비정상/의심 상황 | 디코딩 실패, 폐기된 토큰 사용, 사용자 미존재 | + +--- + +## 6. 구현 순서 + +| 순서 | 파일 | 이유 | +|------|------|------| +| 1 | `app/user/services/jwt.py` | 최하위 유틸리티. 토큰 발급/디코딩의 기본 로그 | +| 2 | `app/user/dependencies/auth.py` | 모든 인증 API의 공통 진입점 | +| 3 | `app/user/services/auth.py` | 갱신/폐기 비즈니스 로직 | +| 4 | `app/user/api/routers/v1/auth.py` | 라우터 진입/완료 + 응답 토큰 정보 | + +--- + +## 7. 기대 효과 — 시나리오별 로그 출력 예시 + +### 시나리오 1: 정상 토큰 갱신 +``` +[ROUTER] POST /auth/refresh - token: ...7d90-aac8-ecf1385c +[AUTH] 토큰 갱신 시작 (Rotation) - token: ...7d90-aac8-ecf1385c +[JWT] 토큰 디코딩 성공 - type: refresh, sub: 019c5452-b1cf-7d90-aac8-ecf1385c9dc4, exp: 1739450400 +[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: 019c5452-..., exp: 1739450400 +[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: a1b2c3d4e5f6g7h8..., is_revoked: False, expires_at: 2026-02-20 11:46:36 +[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: a1b2c3d4e5f6g7h8... +[JWT] Access Token 발급 - user_uuid: 019c5452-..., expires: 2026-02-13 12:46:36 +[JWT] Refresh Token 발급 - user_uuid: 019c5452-..., expires: 2026-02-20 11:46:36 +[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...xNewAccess12345, new_refresh: ...xNewRefresh6789 +[AUTH] Refresh Token DB 저장 - user_uuid: 019c5452-..., token_hash: f8e9d0c1b2a3..., expires_at: 2026-02-20 11:46:36 +[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: 019c5452-..., user_id: 42, old_hash: a1b2c3d4e5f6g7h8..., new_refresh: ...xNewRefresh6789 +[ROUTER] POST /auth/refresh 완료 - new_access: ...xNewAccess12345, new_refresh: ...xNewRefresh6789 +``` + +### 시나리오 2: 만료된 Refresh Token으로 갱신 시도 +``` +[ROUTER] POST /auth/refresh - token: ...expiredToken12345 +[AUTH] 토큰 갱신 시작 (Rotation) - token: ...expiredToken12345 +[JWT] 토큰 만료 - token: ...expiredToken12345 +[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...expiredToken12345 +→ 401 InvalidTokenError 응답 +``` + +### 시나리오 3: 이미 폐기된 Refresh Token 재사용 (Replay Attack) +``` +[ROUTER] POST /auth/refresh - token: ...revokedToken98765 +[AUTH] 토큰 갱신 시작 (Rotation) - token: ...revokedToken98765 +[JWT] 토큰 디코딩 성공 - type: refresh, sub: 019c5452-..., exp: 1739450400 +[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: c3d4e5f6..., is_revoked: True, expires_at: 2026-02-20 +[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - replay attack 의심, token_hash: c3d4e5f6..., user_uuid: 019c5452-..., revoked_at: 2026-02-13 10:30:00 +→ 401 TokenRevokedError 응답 +``` + +### 시나리오 4: Access Token 검증 (매 API 요청) +``` +[AUTH-DEP] Access Token 검증 시작 - token: ...validAccess12345 +[JWT] 토큰 디코딩 성공 - type: access, sub: 019c5452-..., exp: 1739450400 +[AUTH-DEP] Access Token 검증 성공 - user_uuid: 019c5452-..., user_id: 42 +``` + +### 시나리오 5: 로그아웃 +``` +[ROUTER] POST /auth/logout - user_id: 42, user_uuid: 019c5452-..., token: ...refreshToRevoke99 +[AUTH] 로그아웃 - user_id: 42, token_hash: d5e6f7g8..., token: ...refreshToRevoke99 +[AUTH] 로그아웃 완료 - user_id: 42 +[ROUTER] POST /auth/logout 완료 - user_id: 42 +```