Add Axios interceptor for automatic token refresh with mutex pattern

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:42:23 +00:00
parent bb5a601433
commit 9798cda41e
5 changed files with 154 additions and 29 deletions

41
sundol-frontend/build.sh Executable file
View File

@@ -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 "=== 빌드 완료 ==="

View File

@@ -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<LoginResponse>("/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;

View File

@@ -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<AuthContextType>({
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessToken] = useState<string | null>(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<LoginResponse>("/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 (
<AuthContext.Provider
value={{

View File

@@ -1,36 +1,16 @@
"use client";
import { useCallback } from "react";
import { api, LoginResponse } from "./api";
import { useAuth } from "./auth-context";
import { AxiosRequestConfig, AxiosError } from "axios";
import { api } from "./api";
import { AxiosRequestConfig } from "axios";
export function useApi() {
const { setAccessToken, logout } = useAuth();
const request = useCallback(
async <T>(config: AxiosRequestConfig): Promise<T> => {
try {
const response = await api.request<T>(config);
return response.data;
} catch (error: unknown) {
const axiosError = error as AxiosError;
if (axiosError.response?.status === 401) {
try {
const refreshRes = await api.post<LoginResponse>("/api/auth/refresh");
setAccessToken(refreshRes.data.accessToken);
api.defaults.headers.common["Authorization"] = `Bearer ${refreshRes.data.accessToken}`;
const retryResponse = await api.request<T>(config);
return retryResponse.data;
} catch {
logout();
throw error;
}
}
throw error;
}
const response = await api.request<T>(config);
return response.data;
},
[setAccessToken, logout]
[]
);
return { request };