From 2ee400c9bb7330437b9b270c3f64a614242d5cd6 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 23 Mar 2026 14:36:18 +0900 Subject: [PATCH] =?UTF-8?q?[st]=20feat:=20tab=20store=EC=97=90=20persist?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/app/(with-base-layout)/layout.tsx | 251 ++++++++++-------- .../st/src/entities/auth/api/login-service.ts | 16 +- .../st/src/entities/auth/lib/token-manager.ts | 5 +- apps/st/src/store/tab-store.ts | 236 ++++++++++------ 4 files changed, 312 insertions(+), 196 deletions(-) diff --git a/apps/st/app/(with-base-layout)/layout.tsx b/apps/st/app/(with-base-layout)/layout.tsx index 98c126bf6..71343c00e 100644 --- a/apps/st/app/(with-base-layout)/layout.tsx +++ b/apps/st/app/(with-base-layout)/layout.tsx @@ -1,20 +1,24 @@ "use client"; -import { useEffect, useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { usePathname } from "next/navigation"; -import { useQueryClient, useQuery } from "@tanstack/react-query"; -import type { MenuItem } from "@/entities/menu"; -import { useMenuStore, useAlertStore } from "@/shared/lib"; -import { useTabStore, getTabStoreState } from "@/store/tab-store"; -import { useTabStore as gridTabStore } from "@rsone/components"; -import { getConfig } from "@common/config"; -import { getCookie } from "@/shared/lib"; -import { getMenuList } from "../../../../packages/common/components/src/api"; +import { + getTabStoreState as getGridTabStoreState, + useTabStore as gridTabStore, +} from "@rsone/components"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + import { ENDPOINT } from "@/shared/config"; - -import { BaseLayout } from "@common/components"; +import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib"; import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout"; +import type { MenuItem } from "@/entities/menu"; +import { BaseLayout } from "@common/components"; +import { getConfig } from "@common/config"; + +import { getMenuList } from "../../../../packages/common/components/src/api"; + +import { getTabStoreState, useTabStore } from "@/store/tab-store"; const appConfig = getConfig(); const userId = getCookie(appConfig.AUTH_USER_ID ?? ""); @@ -22,118 +26,147 @@ const appSiteId = getCookie("site-id-key") ?? ""; const isLoggedIn = !!userId; export default function WithBaseLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const pathname = usePathname(); - const queryClient = useQueryClient(); + const pathname = usePathname(); + const queryClient = useQueryClient(); - const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); - const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo); + const setCurrentMenuInfo = useMenuStore( + (state) => state.setCurrentMenuInfo, + ); + const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo); - const addTab = useTabStore((state) => state.addTab); - const setActiveTab = useTabStore((state) => state.setActiveTab); - const setMenuList = useTabStore((state) => state.setMenuList); - const activeTabId = useTabStore((state) => state.activeTabId); - const menuList = useTabStore((state) => state.menuList); - const tabs = useTabStore((state) => state.tabs); - const addTabForGrid = gridTabStore((state) => state.addTab); - const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab); - const setMenuListForGrid = gridTabStore((state) => state.setMenuList); - const alert = useAlertStore((state) => state.setMessage); + const addTab = useTabStore((state) => state.addTab); + const setActiveTab = useTabStore((state) => state.setActiveTab); + const setMenuList = useTabStore((state) => state.setMenuList); + const activeTabId = useTabStore((state) => state.activeTabId); + const menuList = useTabStore((state) => state.menuList); + const tabs = useTabStore((state) => state.tabs); + const addTabForGrid = gridTabStore((state) => state.addTab); + const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab); + const setMenuListForGrid = gridTabStore((state) => state.setMenuList); + const alert = useAlertStore((state) => state.setMessage); - const { data: menuListData } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES], - queryFn: () => getMenuList(appSiteId), - enabled: isLoggedIn, - }); + const { data: menuListData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES], + queryFn: () => getMenuList(appSiteId), + enabled: isLoggedIn, + }); - useEffect(() => { - if (menuListData) { - setMenuList(menuListData); - setMenuListForGrid(menuListData); - } - }, [menuListData, setMenuList, setMenuListForGrid]); - - useEffect(() => { - if (!pathname) return; - - // pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴 - const currentTabs = getTabStoreState().tabs; - const tab = currentTabs.find((t) => t.path === pathname); - if (tab) { - setActiveTab(tab.id); - setActiveTabForGrid(tab.id); - } - }, [pathname, setActiveTab, setActiveTabForGrid]); - - // 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거 - const currentPath = useMemo(() => { - if (tabs.length === 0) return ""; - - if (activeTabId && menuList) { - // menuList에서 활성 탭의 메뉴 찾기 - const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => { - for (const item of items) { - if (item.urlPath === activeTabId) { - return item; - } - if (item.children) { - const found = findMenuByEngNm(item.children); - if (found) return found; - } + useEffect(() => { + if (menuListData) { + setMenuList(menuListData); + setMenuListForGrid(menuListData); } - return null; - }; + }, [menuListData, setMenuList, setMenuListForGrid]); - const activeMenu = findMenuByEngNm(menuList); + // 새로고침 후 persist로 앱 탭만 복원된 경우 rsone 그리드 스토어에 탭을 맞춤 + useEffect(() => { + if (!menuList) return; + const appTabs = getTabStoreState().tabs; + if (appTabs.length === 0) return; + if (getGridTabStoreState().tabs.length > 0) return; - if (activeMenu?.urlPath) { - return activeMenu.urlPath; - } - } + for (const tab of appTabs) { + getGridTabStoreState().addTab(tab.menuItem); + } - // 탭에서 직접 찾기 - if (activeTabId) { - const currentTabs = getTabStoreState().tabs; - const activeTab = currentTabs.find((t) => t.id === activeTabId); - if (activeTab?.menuItem?.urlPath) { - return activeTab.menuItem.urlPath; - } - } + const activeId = getTabStoreState().activeTabId; + if (activeId) { + const activeTab = appTabs.find((t) => t.id === activeId); + const gridActiveId = activeTab?.menuItem.menuEngNm; + if (gridActiveId) { + getGridTabStoreState().setActiveTab(gridActiveId); + } + } + }, [menuList, tabs]); - // currentMenuInfo에서 가져오기 - if (currentMenuInfo?.urlPath) { - return currentMenuInfo.urlPath; - } + useEffect(() => { + if (!pathname) return; - // 기본값으로 pathname 사용 - return pathname ?? ""; - }, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]); + // pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴 + const currentTabs = getTabStoreState().tabs; + const tab = currentTabs.find((t) => t.path === pathname); + if (tab) { + setActiveTab(tab.id); + const gridActiveId = tab.menuItem.menuEngNm; + if (gridActiveId) { + setActiveTabForGrid(gridActiveId); + } + } + }, [pathname, setActiveTab, setActiveTabForGrid]); - const handleMenuClick = useCallback((item: MenuItem) => { - setCurrentMenuInfo(item); - const addTabResult: boolean = addTab(item); - - // packages/rsone의 스토어에도 동기화 - if (addTabResult) { - addTabForGrid(item); - } + // 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거 + const currentPath = useMemo(() => { + if (tabs.length === 0) return ""; - if (!addTabResult) { - alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다. + if (activeTabId && menuList) { + // menuList에서 활성 탭의 메뉴 찾기 + const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => { + for (const item of items) { + if (item.urlPath === activeTabId) { + return item; + } + if (item.children) { + const found = findMenuByEngNm(item.children); + if (found) return found; + } + } + return null; + }; + + const activeMenu = findMenuByEngNm(menuList); + + if (activeMenu?.urlPath) { + return activeMenu.urlPath; + } + } + + // 탭에서 직접 찾기 + if (activeTabId) { + const currentTabs = getTabStoreState().tabs; + const activeTab = currentTabs.find((t) => t.id === activeTabId); + if (activeTab?.menuItem?.urlPath) { + return activeTab.menuItem.urlPath; + } + } + + // currentMenuInfo에서 가져오기 + if (currentMenuInfo?.urlPath) { + return currentMenuInfo.urlPath; + } + + // 기본값으로 pathname 사용 + return pathname ?? ""; + }, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]); + + const handleMenuClick = useCallback( + (item: MenuItem) => { + setCurrentMenuInfo(item); + const addTabResult: boolean = addTab(item); + + // packages/rsone의 스토어에도 동기화 + if (addTabResult) { + addTabForGrid(item); + } + + if (!addTabResult) { + alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다. 기존 화면을 닫은 후 이용해주세요.`); - } - }, [setCurrentMenuInfo, addTab, addTabForGrid, alert]); + } + }, + [setCurrentMenuInfo, addTab, addTabForGrid, alert], + ); - return ( - - {children} - - ); + return ( + + {children} + + ); } diff --git a/apps/st/src/entities/auth/api/login-service.ts b/apps/st/src/entities/auth/api/login-service.ts index c1968fe36..7fd87cc27 100644 --- a/apps/st/src/entities/auth/api/login-service.ts +++ b/apps/st/src/entities/auth/api/login-service.ts @@ -1,13 +1,18 @@ import { AxiosError } from "axios"; + import { - ENDPOINT, - JWT_EXPIRATION_TIME, ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, + ENDPOINT, + JWT_EXPIRATION_TIME, } from "@/shared/config"; -import type { LoginService, ILogin } from "../model/login-interface"; +import { getCookie } from "@/shared/lib"; + import { tokenManager } from "../lib/token-manager"; +import type { ILogin,LoginService } from "../model/login-interface"; + +import { clearTabStorePersist } from "@/store/tab-store"; export class LoginApiService implements LoginService { private static instance: LoginApiService; @@ -39,6 +44,11 @@ export class LoginApiService implements LoginService { throw new Error("Missing required authentication tokens"); } + const prevTokenId = getCookie(AUTH_USER_ID); + if (prevTokenId !== undefined && prevTokenId !== tokenId) { + clearTabStorePersist(); + } + tokenManager.setToken(CLAIM_NAME, accessToken); tokenManager.setToken(AUTH_USER_ID, tokenId); tokenManager.setupRefreshTimer( diff --git a/apps/st/src/entities/auth/lib/token-manager.ts b/apps/st/src/entities/auth/lib/token-manager.ts index dd9aea352..60cd47de5 100644 --- a/apps/st/src/entities/auth/lib/token-manager.ts +++ b/apps/st/src/entities/auth/lib/token-manager.ts @@ -1,5 +1,7 @@ import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; -import { getCookie, setCookie, removeCookie } from "@/shared/lib"; +import { getCookie, removeCookie,setCookie } from "@/shared/lib"; + +import { clearTabStorePersist } from "@/store/tab-store"; export class TokenManager { private static instance: TokenManager; @@ -74,6 +76,7 @@ export class TokenManager { this.refreshTimer = null; } + clearTabStorePersist(); this.removeToken(CLAIM_NAME); this.removeToken(AUTH_USER_ID); } diff --git a/apps/st/src/store/tab-store.ts b/apps/st/src/store/tab-store.ts index ace855808..640cada97 100644 --- a/apps/st/src/store/tab-store.ts +++ b/apps/st/src/store/tab-store.ts @@ -1,8 +1,10 @@ import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + import type { MenuItem } from "@/entities/menu"; export interface Tab { - id: string; // 고유 ID (menuEngNm) + id: string; // 고유 ID (urlPath) label: string; // 탭에 표시될 이름 path: string; // 메뉴 경로 urlPath: string; // url 경로 @@ -22,96 +24,164 @@ interface TabStore { closeOtherTabs: (tabId: string) => void; } -const useTabStoreHook = create((set, get) => ({ - tabs: [], - activeTabId: null, - menuList: null, - addTab: (menuItem: MenuItem) => { - const { tabs, menuList } = get(); - const tabId = menuItem.urlPath || ''; +export const TAB_STORE_PERSIST_KEY = "st-tab-store"; +const TAB_STORE_VERSION = 1; +const MAX_TABS = 10; - // 이미 존재하는 탭인지 확인 - const existingTab = tabs.find((tab) => tab.id === tabId); +function tabForPersist(tab: Tab): Tab { + return { + ...tab, + menuItem: { ...tab.menuItem, children: null }, + }; +} - if (existingTab) { - // 이미 존재하면 활성화만 - set({ activeTabId: tabId }); - return true; - } +function sanitizePersistedTabsAndActive( + tabs: Tab[], + activeTabId: string | null, +): { tabs: Tab[]; activeTabId: string | null } { + let nextTabs = Array.isArray(tabs) ? tabs : []; + if (nextTabs.length > MAX_TABS) nextTabs = nextTabs.slice(0, MAX_TABS); - if (tabs.length < 10) { - const newTab: Tab = { - id: tabId, - label: menuItem.menuKornNm, - path: menuItem.menuPath, - urlPath: menuItem.urlPath, - parentEngNm: menuList?.find((m) => m.menuCd === menuItem.parentId)?.menuEngNm || '', - menuItem, - }; - - set({ - tabs: [...tabs, newTab], - activeTabId: tabId, - }); + if (nextTabs.length === 0) { + return { tabs: [], activeTabId: null }; + } - return true; - } else { - return false; - } + let nextActive = activeTabId; + if (nextActive && !nextTabs.some((t) => t.id === nextActive)) { + nextActive = nextTabs[0]?.id ?? null; + } + if (nextActive == null && nextTabs.length > 0) { + nextActive = nextTabs[0]?.id ?? null; + } - // 새 탭 추가 - }, - removeTab: (tabId: string): void => { - const { tabs, activeTabId } = get(); - const newTabs = tabs.filter((tab) => tab.id !== tabId); + return { tabs: nextTabs, activeTabId: nextActive }; +} - // 닫는 탭이 현재 활성 탭이면 다른 탭으로 전환 - let newActiveTabId = activeTabId; - if (activeTabId === tabId) { - const closedTabIndex = tabs.findIndex((tab) => tab.id === tabId); - if (newTabs.length > 0) { - // 닫힌 탭의 이전 탭을 활성화, 없으면 다음 탭 - if (closedTabIndex > 0) { - const prevTab = tabs[closedTabIndex - 1]; - newActiveTabId = prevTab ? prevTab.id : null; - } else { - const firstTab = newTabs[0]; - newActiveTabId = firstTab ? firstTab.id : null; - } - } else { - newActiveTabId = null; - } - } - - set({ - tabs: newTabs, - activeTabId: newActiveTabId, - }); - }, - setActiveTab: (tabId: string) => { - set({ activeTabId: tabId }); - }, - setMenuList: (menuList: MenuItem[] | null) => { - set({ menuList }); - }, - closeAllTabs: () => { - set({ +const useTabStoreHook = create()( + persist( + (set, get) => ({ tabs: [], activeTabId: null, - }); - }, - closeOtherTabs: (tabId: string) => { - const { tabs } = get(); - const tabToKeep = tabs.find((tab) => tab.id === tabId); + menuList: null, + addTab: (menuItem: MenuItem) => { + const { tabs, menuList } = get(); + const tabId = menuItem.urlPath || ""; - if (tabToKeep) { - set({ - tabs: [tabToKeep], - activeTabId: tabId, - }); - } - }, -})); + const existingTab = tabs.find((tab) => tab.id === tabId); + + if (existingTab) { + set({ activeTabId: tabId }); + return true; + } + + if (tabs.length < MAX_TABS) { + const newTab: Tab = { + id: tabId, + label: menuItem.menuKornNm, + path: menuItem.menuPath, + urlPath: menuItem.urlPath, + parentEngNm: + menuList?.find( + (m) => m.menuCd === menuItem.parentId, + )?.menuEngNm || "", + menuItem, + }; + + set({ + tabs: [...tabs, newTab], + activeTabId: tabId, + }); + + return true; + } + return false; + }, + removeTab: (tabId: string): void => { + const { tabs, activeTabId } = get(); + const newTabs = tabs.filter((tab) => tab.id !== tabId); + + let newActiveTabId = activeTabId; + if (activeTabId === tabId) { + const closedTabIndex = tabs.findIndex( + (tab) => tab.id === tabId, + ); + if (newTabs.length > 0) { + if (closedTabIndex > 0) { + const prevTab = tabs[closedTabIndex - 1]; + newActiveTabId = prevTab ? prevTab.id : null; + } else { + const firstTab = newTabs[0]; + newActiveTabId = firstTab ? firstTab.id : null; + } + } else { + newActiveTabId = null; + } + } + + set({ + tabs: newTabs, + activeTabId: newActiveTabId, + }); + }, + setActiveTab: (tabId: string) => { + set({ activeTabId: tabId }); + }, + setMenuList: (menuList: MenuItem[] | null) => { + set({ menuList }); + }, + closeAllTabs: () => { + set({ + tabs: [], + activeTabId: null, + }); + }, + closeOtherTabs: (tabId: string) => { + const { tabs } = get(); + const tabToKeep = tabs.find((tab) => tab.id === tabId); + + if (tabToKeep) { + set({ + tabs: [tabToKeep], + activeTabId: tabId, + }); + } + }, + }), + { + name: TAB_STORE_PERSIST_KEY, + version: TAB_STORE_VERSION, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + tabs: state.tabs.map(tabForPersist), + activeTabId: state.activeTabId, + }), + merge: (persistedState, currentState) => { + const p = persistedState as Partial< + Pick + > | null; + const rawTabs = p?.tabs; + const rawActive = p?.activeTabId; + const { tabs, activeTabId } = sanitizePersistedTabsAndActive( + (rawTabs as Tab[]) ?? [], + typeof rawActive === "string" || rawActive === null + ? rawActive + : null, + ); + return { + ...currentState, + tabs, + activeTabId, + }; + }, + }, + ), +); export const useTabStore = useTabStoreHook; -export const getTabStoreState = () => useTabStoreHook.getState(); \ No newline at end of file +export const getTabStoreState = () => useTabStoreHook.getState(); + +// 로그아웃 또는 사용자 전환 시 로컬 탭 스냅샷 제거 및 메모리 상태 초기화 +export function clearTabStorePersist(): void { + useTabStoreHook.getState().closeAllTabs(); + useTabStoreHook.persist.clearStorage(); +}