diff --git a/apps/admin/src/app/auth/login/page.tsx b/apps/admin/src/app/auth/login/page.tsx
index 3488dc0e..8130aacc 100644
--- a/apps/admin/src/app/auth/login/page.tsx
+++ b/apps/admin/src/app/auth/login/page.tsx
@@ -11,6 +11,12 @@ export default function AuthLoginPage() {
let isMounted = true;
const redirectIfAuthenticated = async () => {
+ const shouldSkipSessionCheck = new URLSearchParams(window.location.search).get("loggedOut") === "1";
+ if (shouldSkipSessionCheck) {
+ setCanRenderLogin(true);
+ return;
+ }
+
try {
const token = await ensureSessionToken();
if (!isMounted) return;
diff --git a/apps/admin/src/components/layout/AdminLayout.tsx b/apps/admin/src/components/layout/AdminLayout.tsx
index fa09dadf..1bf3a8c3 100644
--- a/apps/admin/src/components/layout/AdminLayout.tsx
+++ b/apps/admin/src/components/layout/AdminLayout.tsx
@@ -1,5 +1,9 @@
+"use client";
+
import { LogOut } from "lucide-react";
+import { useState } from "react";
import { toast } from "sonner";
+import { adminSignOutApi } from "@/lib/api/auth";
import { clearSession } from "@/lib/auth/session";
import { type ActiveAdminMenu, AdminSidebar } from "./AdminSidebar";
@@ -11,10 +15,26 @@ interface AdminLayoutProps {
}
export function AdminLayout({ children, activeMenu, title, description }: AdminLayoutProps) {
- const handleLogout = () => {
- clearSession();
- toast.success("로그아웃되었습니다.");
- window.location.assign("/auth/login");
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
+
+ const handleLogout = async () => {
+ if (isLoggingOut) {
+ return;
+ }
+
+ setIsLoggingOut(true);
+
+ try {
+ await adminSignOutApi();
+ toast.success("로그아웃되었습니다.");
+ } catch {
+ toast.error("서버 로그아웃 요청에 실패했습니다.", {
+ description: "로컬 세션을 정리하고 로그인 화면으로 이동합니다.",
+ });
+ } finally {
+ clearSession();
+ window.location.assign("/auth/login?loggedOut=1");
+ }
};
return (
@@ -35,10 +55,11 @@ export function AdminLayout({ children, activeMenu, title, description }: AdminL
diff --git a/apps/admin/src/lib/api/auth.ts b/apps/admin/src/lib/api/auth.ts
index 6f05e8a5..bd3a6903 100644
--- a/apps/admin/src/lib/api/auth.ts
+++ b/apps/admin/src/lib/api/auth.ts
@@ -1,4 +1,5 @@
import axios, { type AxiosResponse } from "axios";
+import { loadAccessToken } from "@/lib/utils/localStorage";
import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth";
const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();
@@ -17,3 +18,11 @@ export const adminSignInApi = (email: string, password: string): Promise> =>
authAxiosInstance.post("/auth/reissue");
+
+export const adminSignOutApi = (): Promise> => {
+ const accessToken = loadAccessToken();
+
+ return authAxiosInstance.post("/auth/sign-out", undefined, {
+ headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
+ });
+};
diff --git a/apps/admin/src/lib/auth/session.ts b/apps/admin/src/lib/auth/session.ts
index 528ee996..336d226f 100644
--- a/apps/admin/src/lib/auth/session.ts
+++ b/apps/admin/src/lib/auth/session.ts
@@ -3,6 +3,7 @@ import { isTokenExpired } from "@/lib/utils/jwtUtils";
import { loadAccessToken, removeAccessToken, saveAccessToken } from "@/lib/utils/localStorage";
let reissuePromise: Promise | null = null;
+let sessionVersion = 0;
const getValidAccessToken = (): string | null => {
const accessToken = loadAccessToken();
@@ -19,6 +20,8 @@ const getValidAccessToken = (): string | null => {
};
export const clearSession = () => {
+ sessionVersion += 1;
+ reissuePromise = null;
removeAccessToken();
};
@@ -27,11 +30,17 @@ export const reissueAccessTokenIfPossible = async (): Promise =>
return reissuePromise;
}
+ const reissueSessionVersion = sessionVersion;
+
reissuePromise = (async () => {
try {
const response = await reissueAccessTokenApi();
const nextAccessToken = response.data.accessToken;
+ if (reissueSessionVersion !== sessionVersion) {
+ return null;
+ }
+
if (!nextAccessToken) {
clearSession();
return null;