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 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 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` 포함
|
- 배포 시 반드시 `git push origin main` 포함
|
||||||
|
|
||||||
# DB 접속 (Oracle Autonomous DB - SQLcl)
|
# 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({
|
export const api = axios.create({
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
|
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
|
||||||
withCredentials: true,
|
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
|
// Types
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
|
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { api, LoginResponse } from "./api";
|
import { api, LoginResponse, setAuthCallbacks } from "./api";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -24,13 +24,24 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const logoutRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// interceptor 콜백 등록
|
||||||
|
useEffect(() => {
|
||||||
|
setAuthCallbacks(
|
||||||
|
(token: string) => setAccessToken(token),
|
||||||
|
() => logoutRef.current()
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Try to restore session from refresh token cookie
|
// Try to restore session from refresh token cookie
|
||||||
const tryRefresh = async () => {
|
const tryRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.post<LoginResponse>("/api/auth/refresh");
|
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 {
|
} catch {
|
||||||
// No valid session
|
// No valid session
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,6 +73,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ref로 최신 logout 유지 (interceptor에서 사용)
|
||||||
|
useEffect(() => {
|
||||||
|
logoutRef.current = logout;
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@@ -1,36 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { api, LoginResponse } from "./api";
|
import { api } from "./api";
|
||||||
import { useAuth } from "./auth-context";
|
import { AxiosRequestConfig } from "axios";
|
||||||
import { AxiosRequestConfig, AxiosError } from "axios";
|
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
const { setAccessToken, logout } = useAuth();
|
|
||||||
|
|
||||||
const request = useCallback(
|
const request = useCallback(
|
||||||
async <T>(config: AxiosRequestConfig): Promise<T> => {
|
async <T>(config: AxiosRequestConfig): Promise<T> => {
|
||||||
try {
|
|
||||||
const response = await api.request<T>(config);
|
const response = await api.request<T>(config);
|
||||||
return response.data;
|
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 };
|
return { request };
|
||||||
|
|||||||
Reference in New Issue
Block a user