From 9798cda41e9c0fa1e76a1375ef4285f698c63dd1 Mon Sep 17 00:00:00 2001 From: joungmin Date: Wed, 1 Apr 2026 04:42:23 +0000 Subject: [PATCH] Add Axios interceptor for automatic token refresh with mutex pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api.ts: 401 응답 시 자동으로 refresh → retry, 동시 요청은 큐에 대기 (race condition 방지) - auth-context.tsx: interceptor에 콜백 연결 (토큰 갱신/로그아웃) - use-api.ts: 401 retry 로직 제거 (interceptor가 처리) - build.sh: NEXT_PUBLIC 환경변수 검증 단계 추가 - CLAUDE.md: 프론트엔드 빌드 절차 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 + sundol-frontend/build.sh | 41 +++++++++++ sundol-frontend/src/lib/api.ts | 88 +++++++++++++++++++++++- sundol-frontend/src/lib/auth-context.tsx | 22 +++++- sundol-frontend/src/lib/use-api.ts | 30 ++------ 5 files changed, 154 insertions(+), 29 deletions(-) create mode 100755 sundol-frontend/build.sh diff --git a/CLAUDE.md b/CLAUDE.md index 2b88955..2816884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,8 @@ - 빌드: `cd backend && export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home && set -a && source ../.env && set +a && mvn package -q -DskipTests` - 컴파일만: `cd backend && export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home && set -a && source ../.env && set +a && mvn compile` +- 프론트엔드 빌드/배포: `cd sundol-frontend && bash build.sh` (환경변수 검증 포함) +- 프론트엔드 빌드 전 `.env`에 `NEXT_PUBLIC_GOOGLE_CLIENT_ID`, `NEXT_PUBLIC_API_URL` 필수. 없으면 Google OAuth 로그인 깨짐. - 배포 시 반드시 `git push origin main` 포함 # DB 접속 (Oracle Autonomous DB - SQLcl) diff --git a/sundol-frontend/build.sh b/sundol-frontend/build.sh new file mode 100755 index 0000000..3f5aeaa --- /dev/null +++ b/sundol-frontend/build.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# .env 로드 +ENV_FILE="$SCRIPT_DIR/../.env" +if [ -f "$ENV_FILE" ]; then + set -a && source "$ENV_FILE" && set +a +fi + +# 필수 환경변수 검증 +echo "=== [0/3] 환경변수 검증 ===" +REQUIRED_VARS=("NEXT_PUBLIC_GOOGLE_CLIENT_ID" "NEXT_PUBLIC_API_URL") +for var in "${REQUIRED_VARS[@]}"; do + if [ -z "${!var}" ]; then + echo "ERROR: $var 가 .env에 설정되어 있지 않습니다. 빌드를 중단합니다." + exit 1 + fi + echo " $var = ${!var:0:20}..." +done + +echo "=== [1/3] Next.js 빌드 ===" +npx next build + +echo "=== [2/3] 심볼릭 링크 생성 ===" +STATIC_SRC="$SCRIPT_DIR/.next/static" +STATIC_DST="$SCRIPT_DIR/.next/standalone/.next/static" + +if [ -L "$STATIC_DST" ] || [ -e "$STATIC_DST" ]; then + rm -rf "$STATIC_DST" +fi + +ln -s "$STATIC_SRC" "$STATIC_DST" +echo "링크 생성 완료: $STATIC_DST -> $STATIC_SRC" + +echo "=== [3/3] PM2 재시작 ===" +pm2 restart sundol-frontend + +echo "=== 빌드 완료 ===" diff --git a/sundol-frontend/src/lib/api.ts b/sundol-frontend/src/lib/api.ts index 4d8c56f..80d4c10 100644 --- a/sundol-frontend/src/lib/api.ts +++ b/sundol-frontend/src/lib/api.ts @@ -1,10 +1,96 @@ -import axios from "axios"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; export const api = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080", withCredentials: true, }); +// --- 공통 토큰 refresh 로직 (mutex 패턴) --- + +let isRefreshing = false; +let pendingQueue: { + resolve: (token: string) => void; + reject: (error: unknown) => void; +}[] = []; + +// auth-context에서 주입하는 콜백 +let onTokenRefreshed: ((token: string) => void) | null = null; +let onRefreshFailed: (() => void) | null = null; + +export function setAuthCallbacks( + onRefreshed: (token: string) => void, + onFailed: () => void +) { + onTokenRefreshed = onRefreshed; + onRefreshFailed = onFailed; +} + +function processQueue(token: string | null, error: unknown) { + pendingQueue.forEach(({ resolve, reject }) => { + if (token) { + resolve(token); + } else { + reject(error); + } + }); + pendingQueue = []; +} + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + // 401이 아니거나, refresh 요청 자체가 실패한 경우, 이미 retry한 경우 → 그냥 throw + if ( + error.response?.status !== 401 || + originalRequest.url?.includes("/api/auth/") || + originalRequest._retry + ) { + return Promise.reject(error); + } + + // 이미 refresh 진행 중이면 큐에 대기 + if (isRefreshing) { + return new Promise((resolve, reject) => { + pendingQueue.push({ + resolve: (token: string) => { + originalRequest._retry = true; + originalRequest.headers["Authorization"] = `Bearer ${token}`; + resolve(api.request(originalRequest)); + }, + reject, + }); + }); + } + + // refresh 시작 + isRefreshing = true; + originalRequest._retry = true; + + try { + const res = await api.post("/api/auth/refresh"); + const newToken = res.data.accessToken; + + api.defaults.headers.common["Authorization"] = `Bearer ${newToken}`; + onTokenRefreshed?.(newToken); + + // 대기 중인 요청들 처리 + processQueue(newToken, null); + + // 원래 요청 retry + originalRequest.headers["Authorization"] = `Bearer ${newToken}`; + return api.request(originalRequest); + } catch (refreshError) { + processQueue(null, refreshError); + onRefreshFailed?.(); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } +); + // Types export interface LoginResponse { accessToken: string; diff --git a/sundol-frontend/src/lib/auth-context.tsx b/sundol-frontend/src/lib/auth-context.tsx index dbb8bff..d92f71c 100644 --- a/sundol-frontend/src/lib/auth-context.tsx +++ b/sundol-frontend/src/lib/auth-context.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; -import { api, LoginResponse } from "./api"; +import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from "react"; +import { api, LoginResponse, setAuthCallbacks } from "./api"; interface AuthContextType { isAuthenticated: boolean; @@ -24,13 +24,24 @@ const AuthContext = createContext({ export function AuthProvider({ children }: { children: React.ReactNode }) { const [accessToken, setAccessToken] = useState(null); const [isLoading, setIsLoading] = useState(true); + const logoutRef = useRef<() => void>(() => {}); + + // interceptor 콜백 등록 + useEffect(() => { + setAuthCallbacks( + (token: string) => setAccessToken(token), + () => logoutRef.current() + ); + }, []); useEffect(() => { // Try to restore session from refresh token cookie const tryRefresh = async () => { try { const res = await api.post("/api/auth/refresh"); - setAccessToken(res.data.accessToken); + const token = res.data.accessToken; + setAccessToken(token); + api.defaults.headers.common["Authorization"] = `Bearer ${token}`; } catch { // No valid session } finally { @@ -62,6 +73,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { window.location.href = "/login"; }, []); + // ref로 최신 logout 유지 (interceptor에서 사용) + useEffect(() => { + logoutRef.current = logout; + }, [logout]); + return ( (config: AxiosRequestConfig): Promise => { - try { - const response = await api.request(config); - return response.data; - } catch (error: unknown) { - const axiosError = error as AxiosError; - if (axiosError.response?.status === 401) { - try { - const refreshRes = await api.post("/api/auth/refresh"); - setAccessToken(refreshRes.data.accessToken); - api.defaults.headers.common["Authorization"] = `Bearer ${refreshRes.data.accessToken}`; - const retryResponse = await api.request(config); - return retryResponse.data; - } catch { - logout(); - throw error; - } - } - throw error; - } + const response = await api.request(config); + return response.data; }, - [setAccessToken, logout] + [] ); return { request };