diff --git a/src/api/http.ts b/src/api/http.ts
index fe0257d..f6e9b78 100644
--- a/src/api/http.ts
+++ b/src/api/http.ts
@@ -9,7 +9,7 @@ export const http = axios.create({
headers: {
'Content-Type': 'application/json',
},
- withCredentials: true, // 쿠키 전송 허용 (필요 시)
+ withCredentials: true, // 쿠키 전송 허용
});
// 1. 요청 인터셉터: 헤더에 AccessToken 주입
@@ -40,25 +40,44 @@ const processQueue = (error: any, token: string | null = null) => {
failedQueue = [];
};
-// 2. 응답 인터셉터: 401 발생 시 토큰 갱신 (RTR 적용)
+// 2. 응답 인터셉터: 401 발생 시 토큰 갱신 (RTR 적용 + 멀티탭 동기화 안전장치)
http.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
- const status = error.response?.status;
+
+ // 응답 자체가 없는 네트워크 에러 등은 바로 reject
+ if (!error.response) return Promise.reject(error);
+
+ const status = error.response.status;
// 401(Unauthorized) 에러이고, 아직 재시도하지 않은 요청인 경우
if ((status === 401 || status === 403) && !originalRequest._retry) {
- // 이미 갱신 중이라면 큐에 넣고 대기
+
+ // [안전장치 1] 이미 갱신된 토큰이 있는지 스토어 메모리 확인 (동시성 요청 제어)
+ const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login, logout } = useAuthStore.getState();
+ const requestAccessToken = originalRequest.headers['Authorization']?.split(' ')[1];
+
+ // 요청 보낼 때 쓴 토큰과 현재 스토어 토큰이 다르다면? => 이미 누군가 갱신함!
+ if (currentAccessToken && requestAccessToken && currentAccessToken !== requestAccessToken) {
+ originalRequest.headers['Authorization'] = `Bearer ${currentAccessToken}`;
+ return http(originalRequest);
+ }
+
+ // 이미 갱신 프로세스가 진행 중이라면 큐에 넣고 대기
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
- if (originalRequest.headers) {
- originalRequest.headers['Authorization'] = `Bearer ${token}`;
- }
- return http(originalRequest);
+ const newConfig = {
+ ...originalRequest,
+ headers: {
+ ...originalRequest.headers,
+ 'Authorization': `Bearer ${token}`
+ }
+ };
+ return http(newConfig);
})
.catch((err) => Promise.reject(err));
}
@@ -67,20 +86,49 @@ http.interceptors.response.use(
isRefreshing = true;
try {
- const { accessToken, refreshToken, login, logout } = useAuthStore.getState();
+ // [안전장치 2] 로컬 스토리지 직접 확인 (멀티 탭 이슈 해결의 핵심!)
+ // 다른 탭에서 리프레시를 했다면 localStorage에는 최신 값이 있지만,
+ // 현재 탭의 메모리(Zustand)에는 옛날 값이 있을 수 있음.
+ // 옛날 RefreshToken을 보내면 백엔드가 "토큰 탈취"로 간주하고 로그아웃 시키므로 이를 방지해야 함.
+ let actualRefreshToken = currentRefreshToken;
+ let actualAccessToken = currentAccessToken;
- // AccessToken이나 RefreshToken이 없으면 갱신 불가능 -> 로그아웃
- if (!accessToken || !refreshToken) {
+ if (typeof window !== 'undefined') {
+ const storageData = localStorage.getItem('auth-storage'); // Zustand persist 키
+ if (storageData) {
+ const parsed = JSON.parse(storageData);
+ const storedRefreshToken = parsed.state?.refreshToken;
+ const storedAccessToken = parsed.state?.accessToken;
+
+ // 로컬 스토리지의 토큰이 현재 메모리보다 최신(다름)이라면?
+ if (storedRefreshToken && storedRefreshToken !== currentRefreshToken) {
+ // 갱신 요청 보내지 말고 스토어를 최신화하고 재시도
+ login(storedAccessToken, storedRefreshToken);
+ processQueue(null, storedAccessToken);
+
+ const newConfig = {
+ ...originalRequest,
+ headers: { ...originalRequest.headers, 'Authorization': `Bearer ${storedAccessToken}` }
+ };
+ return http(newConfig);
+ }
+ // 최신 토큰 정보를 변수에 업데이트 (갱신 요청에 사용)
+ if (storedRefreshToken) actualRefreshToken = storedRefreshToken;
+ if (storedAccessToken) actualAccessToken = storedAccessToken;
+ }
+ }
+
+ // 토큰이 아예 없으면 갱신 시도 불가 -> 로그아웃
+ if (!actualAccessToken || !actualRefreshToken) {
throw new Error('Tokens are missing for reissue');
}
// 🛠️ 토큰 갱신 요청 (Backend: POST /api/auth/reissue)
- // 백엔드 ReissueRequest 구조: { accessToken, refreshToken }
const { data } = await axios.post(
`${BASE_URL}/api/auth/reissue`,
{
- accessToken,
- refreshToken
+ accessToken: actualAccessToken,
+ refreshToken: actualRefreshToken
},
{
headers: { 'Content-Type': 'application/json' },
@@ -88,32 +136,41 @@ http.interceptors.response.use(
}
);
- // RTR(Refresh Token Rotation) 적용:
- // 백엔드에서 AccessToken 뿐만 아니라 새로운 RefreshToken도 줍니다.
+ // RTR(Refresh Token Rotation): 새 토큰 받기
const newAccessToken = data.data.accessToken;
const newRefreshToken = data.data.refreshToken;
- // Zustand 스토어에 새 토큰 쌍 업데이트
+ // 스토어 업데이트
login(newAccessToken, newRefreshToken);
// 큐에 대기 중이던 요청들 처리
processQueue(null, newAccessToken);
// 실패했던 원래 요청 재시도
- if (originalRequest.headers) {
- originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
- }
- return http(originalRequest);
+ const newConfig = {
+ ...originalRequest,
+ headers: {
+ ...originalRequest.headers,
+ 'Authorization': `Bearer ${newAccessToken}`
+ }
+ };
+ return http(newConfig);
} catch (refreshError) {
// 갱신 실패 (RefreshToken 만료/위변조 등) -> 로그아웃 처리
processQueue(refreshError, null);
useAuthStore.getState().logout();
- // 브라우저 환경인 경우 로그인 페이지로 리다이렉트
+ // 🚨 작성 중인 데이터 보호를 위해 강제 리다이렉트는 최소화
+ // 401/403 등 명확한 인증 실패일 때만 로그인 페이지로 이동
+ // (단, 페이지 새로고침은 하지 않음 -> SPA 라우팅이 더 안전하지만 여기선 href 사용 시 주의)
if (typeof window !== 'undefined') {
- // alert('세션이 만료되었습니다. 다시 로그인해주세요.');
- window.location.href = '/login';
+ // 글쓰기 페이지 등에서 갑자기 튕기는 것보다, 저장이 안 된다는 걸 알리는게 나음
+ console.error('Session expired. Please login again.');
+ // window.location.href = '/login'; // 👈 이 코드가 강제 새로고침을 유발하여 데이터를 날립니다.
+ // 대신 메인화면으로 튕기거나 모달을 띄우는게 좋지만, 일단 주석 처리하거나 신중히 사용하세요.
+ // 정말 만료되어 갱신 불가할 때만 이동:
+ alert('세션이 만료되었습니다. 새 창에서 로그인해주세요.');
}
return Promise.reject(refreshError);
} finally {
diff --git a/src/app/write/page.tsx b/src/app/write/page.tsx
index c589cf3..0e14b8e 100644
--- a/src/app/write/page.tsx
+++ b/src/app/write/page.tsx
@@ -202,7 +202,7 @@ function WritePageContent() {
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-colors disabled:bg-gray-400 shadow-md hover:shadow-lg transform active:scale-95 duration-200"
>
{mutation.isPending ? : }
- {isEditMode ? '수정하기' : '발행하기'}
+ {isEditMode ? '수정하기' : '작성하기'}