diff --git a/src/app/[locale]/(protected)/error.tsx b/src/app/[locale]/(protected)/error.tsx
index 1cda06c7..8534082c 100644
--- a/src/app/[locale]/(protected)/error.tsx
+++ b/src/app/[locale]/(protected)/error.tsx
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
* Protected Group Error Boundary
*
* 특징:
- * - DashboardLayout 자동 적용 (사이드바, 헤더 포함)
+ * - AuthenticatedLayout 자동 적용 (사이드바, 헤더 포함)
* - 보호된 경로 내 에러만 포착
* - 인증된 사용자를 위한 친근한 에러 UI
*/
diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx
index 4adad018..d1829cdc 100644
--- a/src/app/[locale]/(protected)/layout.tsx
+++ b/src/app/[locale]/(protected)/layout.tsx
@@ -1,7 +1,7 @@
"use client";
import { useAuthGuard } from '@/hooks/useAuthGuard';
-import DashboardLayout from '@/layouts/DashboardLayout';
+import AuthenticatedLayout from '@/layouts/AuthenticatedLayout';
import { RootProvider } from '@/contexts/RootProvider';
/**
@@ -32,7 +32,7 @@ export default function ProtectedLayout({
// 🎨 모든 하위 페이지에 공통 레이아웃 및 Context 적용
return (
- {children}
+ {children}
);
}
\ No newline at end of file
diff --git a/src/app/[locale]/(protected)/loading.tsx b/src/app/[locale]/(protected)/loading.tsx
index 6266aa94..7e10b4a3 100644
--- a/src/app/[locale]/(protected)/loading.tsx
+++ b/src/app/[locale]/(protected)/loading.tsx
@@ -4,10 +4,10 @@ import { PageLoadingSpinner } from '@/components/ui/loading-spinner';
* Protected Group Loading UI
*
* 특징:
- * - DashboardLayout 내에서 표시됨 (사이드바, 헤더 유지)
+ * - AuthenticatedLayout 내에서 표시됨 (사이드바, 헤더 유지)
* - React Suspense 자동 적용
* - 페이지 전환 시 즉각적인 피드백
- * - 대시보드 스타일로 통일
+ * - 공통 레이아웃 스타일로 통일
*/
export default function ProtectedLoading() {
return (
diff --git a/src/app/[locale]/(protected)/not-found.tsx b/src/app/[locale]/(protected)/not-found.tsx
index 0a1c0432..6e209c17 100644
--- a/src/app/[locale]/(protected)/not-found.tsx
+++ b/src/app/[locale]/(protected)/not-found.tsx
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
* Protected Group 404 Not Found Page
*
* 특징:
- * - DashboardLayout 자동 적용 (사이드바, 헤더 포함)
+ * - AuthenticatedLayout 자동 적용 (사이드바, 헤더 포함)
* - 인증된 사용자만 볼 수 있음
* - 보호된 경로 내에서 404 발생 시 표시
*/
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx
index ada33373..e24722a9 100644
--- a/src/contexts/AuthContext.tsx
+++ b/src/contexts/AuthContext.tsx
@@ -1,6 +1,7 @@
'use client';
import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
+import { performFullLogout } from '@/lib/auth/logout';
// ===== 타입 정의 =====
@@ -54,7 +55,7 @@ interface AuthContextType {
updateUser: (userId: string, updates: Partial) => void;
deleteUser: (userId: string) => void;
getUserByUserId: (userId: string) => User | undefined;
- logout: () => void; // ✅ 추가: 로그아웃
+ logout: () => Promise; // ✅ 추가: 로그아웃 (완전한 캐시 정리)
clearTenantCache: (tenantId: number) => void; // ✅ 추가: 테넌트 캐시 삭제
resetAllData: () => void;
}
@@ -224,14 +225,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
};
- // ✅ 추가: 로그아웃 함수
- const logout = () => {
- if (currentUser?.tenant?.id) {
- clearTenantCache(currentUser.tenant.id);
- }
+ // ✅ 추가: 로그아웃 함수 (완전한 캐시 정리)
+ const logout = async () => {
+ console.log('[Auth] Starting logout...');
+
+ // 1. React 상태 초기화 (UI 즉시 반영)
setCurrentUser(null);
- localStorage.removeItem('mes-currentUser');
- console.log('[Auth] Logged out and cleared tenant cache');
+
+ // 2. 완전한 로그아웃 수행 (Zustand, sessionStorage, localStorage, 서버 API)
+ await performFullLogout({
+ skipServerLogout: false, // 서버 API 호출 (HttpOnly 쿠키 삭제)
+ redirectTo: null, // 리다이렉트는 호출하는 곳에서 처리
+ });
+
+ console.log('[Auth] Logout completed');
};
// Context value
diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/AuthenticatedLayout.tsx
similarity index 97%
rename from src/layouts/DashboardLayout.tsx
rename to src/layouts/AuthenticatedLayout.tsx
index e8f4023f..43531334 100644
--- a/src/layouts/DashboardLayout.tsx
+++ b/src/layouts/AuthenticatedLayout.tsx
@@ -35,15 +35,17 @@ import {
} from '@/components/ui/dropdown-menu';
import Sidebar from '@/components/layout/Sidebar';
import { useTheme } from '@/contexts/ThemeContext';
+import { useAuth } from '@/contexts/AuthContext';
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
-interface DashboardLayoutProps {
+interface AuthenticatedLayoutProps {
children: React.ReactNode;
}
-export default function DashboardLayout({ children }: DashboardLayoutProps) {
+export default function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();
const { theme, setTheme } = useTheme();
+ const { logout } = useAuth();
const router = useRouter();
const pathname = usePathname(); // 현재 경로 추적
@@ -174,20 +176,18 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
const handleLogout = async () => {
try {
- // HttpOnly Cookie 방식: Next.js API Route로 프록시
- await fetch('/api/auth/logout', {
- method: 'POST',
- });
-
- // localStorage 정리
- localStorage.removeItem('user');
+ // AuthContext의 logout() 호출 (완전한 캐시 정리 수행)
+ // - Zustand 스토어 초기화
+ // - sessionStorage 캐시 삭제 (page_config_*, mes-*)
+ // - localStorage 사용자 데이터 삭제
+ // - 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
+ await logout();
// 로그인 페이지로 리다이렉트
router.push('/login');
} catch (error) {
console.error('로그아웃 처리 중 오류:', error);
// 에러가 나도 로그인 페이지로 이동
- localStorage.removeItem('user');
router.push('/login');
}
};
diff --git a/src/lib/auth/logout.ts b/src/lib/auth/logout.ts
new file mode 100644
index 00000000..4a7bece4
--- /dev/null
+++ b/src/lib/auth/logout.ts
@@ -0,0 +1,225 @@
+/**
+ * 완전한 로그아웃 처리
+ *
+ * 로그아웃 시 수행하는 작업:
+ * 1. Zustand 스토어 초기화 (메모리 캐시)
+ * 2. sessionStorage 캐시 삭제 (page_config_*, mes-*)
+ * 3. localStorage 사용자 데이터 삭제 (mes-currentUser, mes-users)
+ * 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
+ *
+ * @see claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md
+ */
+
+import { useMasterDataStore } from '@/stores/masterDataStore';
+import { useItemStore } from '@/stores/itemStore';
+
+// ===== 캐시 삭제 대상 Prefix =====
+
+const SESSION_STORAGE_PREFIXES = [
+ 'page_config_', // masterDataStore 페이지 구성 캐시
+ 'mes-', // TenantAwareCache 테넌트 캐시
+];
+
+const LOCAL_STORAGE_KEYS = [
+ 'mes-currentUser', // 현재 사용자 정보
+ 'mes-users', // 사용자 목록
+];
+
+const LOCAL_STORAGE_PREFIXES = [
+ 'mes-', // 테넌트 캐시 (mes-{tenantId}-*)
+];
+
+// ===== 캐시 정리 함수 =====
+
+/**
+ * sessionStorage에서 우리 앱 캐시만 삭제
+ * - page_config_* (페이지 구성)
+ * - mes-* (테넌트 캐시)
+ */
+export function clearSessionStorageCache(): void {
+ if (typeof window === 'undefined') return;
+
+ const keysToRemove: string[] = [];
+
+ Object.keys(sessionStorage).forEach((key) => {
+ if (SESSION_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))) {
+ keysToRemove.push(key);
+ }
+ });
+
+ keysToRemove.forEach((key) => {
+ sessionStorage.removeItem(key);
+ console.log(`[Logout] Cleared sessionStorage: ${key}`);
+ });
+
+ if (keysToRemove.length > 0) {
+ console.log(`[Logout] Cleared ${keysToRemove.length} sessionStorage items`);
+ }
+}
+
+/**
+ * localStorage에서 사용자 데이터 및 테넌트 캐시 삭제
+ */
+export function clearLocalStorageCache(): void {
+ if (typeof window === 'undefined') return;
+
+ // 특정 키 삭제
+ LOCAL_STORAGE_KEYS.forEach((key) => {
+ if (localStorage.getItem(key)) {
+ localStorage.removeItem(key);
+ console.log(`[Logout] Cleared localStorage: ${key}`);
+ }
+ });
+
+ // Prefix 기반 삭제
+ const keysToRemove: string[] = [];
+
+ Object.keys(localStorage).forEach((key) => {
+ if (LOCAL_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))) {
+ keysToRemove.push(key);
+ }
+ });
+
+ keysToRemove.forEach((key) => {
+ localStorage.removeItem(key);
+ console.log(`[Logout] Cleared localStorage: ${key}`);
+ });
+
+ if (keysToRemove.length > 0) {
+ console.log(`[Logout] Cleared ${keysToRemove.length} localStorage items`);
+ }
+}
+
+/**
+ * Zustand 스토어 초기화
+ */
+export function resetZustandStores(): void {
+ try {
+ // masterDataStore 초기화
+ const masterDataStore = useMasterDataStore.getState();
+ masterDataStore.reset();
+ console.log('[Logout] Reset masterDataStore');
+
+ // itemStore 초기화
+ const itemStore = useItemStore.getState();
+ itemStore.reset();
+ console.log('[Logout] Reset itemStore');
+ } catch (error) {
+ console.error('[Logout] Failed to reset Zustand stores:', error);
+ }
+}
+
+/**
+ * 서버 로그아웃 API 호출
+ * - HttpOnly 쿠키 삭제 (access_token, refresh_token)
+ */
+export async function callLogoutAPI(): Promise {
+ try {
+ const response = await fetch('/api/auth/logout', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ console.warn('[Logout] Server logout failed:', response.status);
+ return false;
+ }
+
+ console.log('[Logout] Server logout successful');
+ return true;
+ } catch (error) {
+ console.error('[Logout] Server logout error:', error);
+ return false;
+ }
+}
+
+// ===== 통합 로그아웃 함수 =====
+
+/**
+ * 완전한 로그아웃 수행
+ *
+ * 실행 순서:
+ * 1. Zustand 스토어 초기화 (즉시 UI 반영)
+ * 2. sessionStorage 캐시 삭제
+ * 3. localStorage 사용자 데이터 삭제
+ * 4. 서버 로그아웃 API 호출
+ *
+ * @param options.skipServerLogout - 서버 로그아웃 생략 여부 (기본: false)
+ * @param options.redirectTo - 로그아웃 후 리다이렉트 경로 (기본: null)
+ * @returns 로그아웃 성공 여부
+ */
+export async function performFullLogout(options?: {
+ skipServerLogout?: boolean;
+ redirectTo?: string | null;
+}): Promise {
+ const { skipServerLogout = false, redirectTo = null } = options || {};
+
+ console.log('[Logout] Starting full logout...');
+
+ try {
+ // 1. Zustand 스토어 초기화 (즉시 UI 반영)
+ resetZustandStores();
+
+ // 2. sessionStorage 캐시 삭제
+ clearSessionStorageCache();
+
+ // 3. localStorage 사용자 데이터 삭제
+ clearLocalStorageCache();
+
+ // 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
+ if (!skipServerLogout) {
+ await callLogoutAPI();
+ }
+
+ console.log('[Logout] Full logout completed successfully');
+
+ // 5. 리다이렉트 (선택적)
+ if (redirectTo && typeof window !== 'undefined') {
+ window.location.href = redirectTo;
+ }
+
+ return true;
+ } catch (error) {
+ console.error('[Logout] Full logout failed:', error);
+ return false;
+ }
+}
+
+// ===== 유틸리티 함수 =====
+
+/**
+ * 현재 캐시 상태 확인 (디버깅용)
+ */
+export function debugCacheStatus(): void {
+ if (typeof window === 'undefined') {
+ console.log('[Debug] Server environment - no cache');
+ return;
+ }
+
+ console.group('[Debug] Cache Status');
+
+ // sessionStorage
+ const sessionKeys = Object.keys(sessionStorage).filter((key) =>
+ SESSION_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))
+ );
+ console.log('sessionStorage:', sessionKeys);
+
+ // localStorage
+ const localKeys = Object.keys(localStorage).filter(
+ (key) =>
+ LOCAL_STORAGE_KEYS.includes(key) ||
+ LOCAL_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))
+ );
+ console.log('localStorage:', localKeys);
+
+ // Zustand
+ const masterDataState = useMasterDataStore.getState();
+ console.log('masterDataStore.pageConfigs:', Object.keys(masterDataState.pageConfigs));
+
+ const itemState = useItemStore.getState();
+ console.log('itemStore.items:', itemState.items.length);
+
+ console.groupEnd();
+}
\ No newline at end of file
diff --git a/src/stores/masterDataStore.ts b/src/stores/masterDataStore.ts
index b0fce1cc..2573243d 100644
--- a/src/stores/masterDataStore.ts
+++ b/src/stores/masterDataStore.ts
@@ -19,6 +19,9 @@ import { fetchPageConfigByType, invalidatePageConfigCache } from '@/lib/api/mast
interface MasterDataStore {
// === State ===
+ // 현재 테넌트 ID (캐시 격리용)
+ currentTenantId: number | null;
+
// 페이지 구성 캐시 (페이지 타입별)
pageConfigs: Record;
@@ -36,8 +39,12 @@ interface MasterDataStore {
// === Actions ===
+ // 테넌트 ID 설정 (로그인 시 호출)
+ setCurrentTenantId: (tenantId: number | null) => void;
+
// 페이지 구성 가져오기 (하이브리드 로딩)
- fetchPageConfig: (pageType: PageType) => Promise;
+ // tenantId 파라미터는 선택적 (없으면 currentTenantId 사용)
+ fetchPageConfig: (pageType: PageType, tenantId?: number) => Promise;
// 페이지 구성 캐시 무효화
invalidateConfig: (pageType: PageType) => void;
@@ -365,12 +372,19 @@ export const useMasterDataStore = create()(
// ===== 초기화 =====
- reset: () =>
- set(
- initialState,
- false,
- 'reset'
- ),
+ reset: () => {
+ // 1. 메모리 캐시 초기화
+ set(initialState, false, 'reset');
+
+ // 2. sessionStorage 캐시도 정리
+ if (typeof window !== 'undefined') {
+ const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing'];
+ pageTypes.forEach((pageType) => {
+ removeConfigFromSessionStorage(pageType);
+ });
+ console.log('[masterDataStore] Reset: cleared memory and sessionStorage cache');
+ }
+ },
}),
{
name: 'MasterDataStore',