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:
@@ -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)
|
||||
|
||||
41
sundol-frontend/build.sh
Executable file
41
sundol-frontend/build.sh
Executable 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 "=== 빌드 완료 ==="
|
||||
@@ -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;
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
[setAccessToken, logout]
|
||||
[]
|
||||
);
|
||||
|
||||
return { request };
|
||||
|
||||
Reference in New Issue
Block a user