9.8 KiB
9.8 KiB
탭 시스템 아키텍처 및 구현 계획
1. 개요
사이드바 메뉴 클릭 시 router.push() 페이지 이동 방식에서 탭 기반 멀티 화면 시스템으로 전환한다.
┌──────────────────────────┐
│ Tab Data Layer (중앙) │
API 응답 ────────→│ │
│ 탭별 상태 저장소 │
│ ├─ formData │
│ ├─ selectedRows │
│ ├─ scrollPosition │
│ ├─ modalState │
│ ├─ sortState │
│ └─ cacheState │
│ │
│ 공통 규칙 엔진 │
│ ├─ 날짜 포맷 규칙 │
│ ├─ 숫자/통화 포맷 규칙 │
│ ├─ 로케일 처리 규칙 │
│ ├─ 유효성 검증 규칙 │
│ └─ 데이터 타입 변환 규칙 │
│ │
│ F5 복원 / 캐시 관리 │
│ (sessionStorage 중앙관리) │
└────────────┬─────────────┘
│
가공 완료된 데이터
│
┌────────────────┼────────────────┐
│ │ │
화면 A (경량) 화면 B (경량) 화면 C (경량)
렌더링만 담당 렌더링만 담당 렌더링만 담당
2. 레이어 구조
| 레이어 | 책임 |
|---|---|
| Tab Data Layer | 탭별 상태 보관, 캐시, 복원, 데이터 가공 |
| 공통 규칙 엔진 | 날짜/숫자/로케일 포맷, 유효성 검증 |
| 화면 컴포넌트 | 가공된 데이터를 받아서 렌더링만 담당 |
3. 파일 구성
| 파일 | 역할 |
|---|---|
stores/tabStore.ts |
Zustand 기반 탭 상태 관리 |
components/layout/TabBar.tsx |
탭 바 UI (드래그, 우클릭, 오버플로우) |
components/layout/TabContent.tsx |
탭별 콘텐츠 렌더링 (컨테이너) |
components/layout/EmptyDashboard.tsx |
탭 없을 때 안내 화면 |
components/layout/AppLayout.tsx |
전체 레이아웃 (사이드바 + 탭 + 콘텐츠) |
lib/tabStateCache.ts |
탭별 상태 캐싱 엔진 |
lib/formatting/rules.ts |
포맷 규칙 정의 |
lib/formatting/index.ts |
formatDate, formatNumber, formatCurrency |
app/(main)/screens/[screenId]/page.tsx |
화면별 렌더링 |
4. 기술 스택
- Next.js 15, React 19, Zustand
- Tailwind CSS, shadcn/ui
5. Phase 1: 탭 껍데기
5-1. Zustand 탭 Store (stores/tabStore.ts)
- zustand 직접 의존성 추가
- Tab 인터페이스: id, type, title, screenId, menuObjid, adminUrl
- 탭 목록, 활성 탭 ID
- openTab, closeTab, switchTab, refreshTab
- closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs
- updateTabOrder (드래그 순서 변경)
- 중복 방지: 같은 탭이면 해당 탭으로 이동
- 닫기 후 왼쪽 탭으로 이동, 왼쪽 없으면 오른쪽
- sessionStorage 영속화 (persist middleware)
- 탭 ID 생성 규칙: V2 화면
tab-{screenId}-{menuObjid}, URL 탭tab-url-{menuObjid}
5-2. TabBar 컴포넌트 (components/layout/TabBar.tsx)
- 고정 너비 탭, 화면 너비에 맞게 동적 개수
- 활성 탭: 새로고침 버튼 + X 버튼
- 비활성 탭: X 버튼만
- 오버플로우 시 +N 드롭다운 (ResizeObserver 감시)
- 드래그 순서 변경 (mousedown/move/up, DOM transform 직접 조작)
- 사이드바 메뉴 드래그 드롭 수신 (
application/tab-menu커스텀 데이터, 마우스 위치에 삽입) - 우클릭 컨텍스트 메뉴 (새로고침/왼쪽닫기/오른쪽닫기/다른탭닫기/모든탭닫기)
- 휠 클릭: 탭 즉시 닫기
5-3. TabContent 컴포넌트 (components/layout/TabContent.tsx)
- display:none 방식 (비활성 탭 DOM 유지, 상태 보존)
- 지연 마운트 (한 번 활성화된 탭만 마운트)
- 안정적 순서 유지 (탭 순서 변경 시 리마운트 방지)
- 탭별 모달 격리 (DialogPortalContainerContext)
- tab.type === "screen" -> ScreenViewPageWrapper 임베딩
- tab.type === "admin" -> 동적 import로 관리자 페이지 렌더링
5-4. EmptyDashboard 컴포넌트 (components/layout/EmptyDashboard.tsx)
- 탭이 없을 때 "사이드바에서 메뉴를 선택하여 탭을 추가하세요" 표시
5-5. AppLayout 수정 (components/layout/AppLayout.tsx)
- handleMenuClick: router.push -> tabStore.openTab 호출
- 레이아웃: main 영역을 TabBar + TabContent로 교체
- children prop 제거 (탭이 콘텐츠 관리)
- 사이드바 메뉴 드래그 가능하게 (draggable)
5-6. 라우팅 연동
app/(main)/layout.tsx수정 - children 대신 탭 시스템- URL 직접 접근 시 탭으로 열기 (북마크/공유 링크 대응)
6. Phase 2: F5 최대 복원
6-1. 탭 상태 캐싱 엔진 (lib/tabStateCache.ts)
- 탭별 상태 저장/복원 (sessionStorage)
- 저장 대상: formData, selectedRows, sortState, scrollPosition, modalState, checkboxState
- debounce 적용 (상태 변경마다 저장하지 않음)
6-2. 복원 로직
- 활성 탭: fresh API 호출 (캐시 데이터 무시)
- 비활성 탭: 캐시에서 복원
- 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
6-3. 캐시 키 관리 (clearTabStateCache)
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거:
tab-cache-{screenId}-{menuObjid}page-scroll-{screenId}-{menuObjid}tsp-{screenId}-*,table-state-{screenId}-*split-sel-{screenId}-*,catval-sel-{screenId}-*bom-tree-{screenId}-*- URL 탭:
tsp-{urlHash}-*,admin-scroll-{url}
7. Phase 3: 포맷팅 중앙화
7-1. 포맷팅 규칙 엔진
// lib/formatting/rules.ts
interface FormatRules {
date: {
display: string; // "YYYY-MM-DD"
datetime: string; // "YYYY-MM-DD HH:mm:ss"
input: string; // "YYYY-MM-DD"
};
number: {
locale: string; // 사용자 로케일 기반
decimals: number; // 기본 소수점 자릿수
};
currency: {
code: string; // 회사 설정 기반
locale: string;
};
}
export function formatValue(value: any, dataType: string, rules: FormatRules): string;
export function formatDate(value: any, format?: string): string;
export function formatNumber(value: any, locale?: string): string;
export function formatCurrency(value: any, currencyCode?: string): string;
7-2. 하드코딩 교체 대상
- V2DateRenderer.tsx
- EditModal.tsx
- InteractiveDataTable.tsx
- FlowWidget.tsx
- AggregationWidgetComponent.tsx
- aggregation.ts (피벗)
- 기타 하드코딩 파일들
8. Phase 4: ScreenViewPage 경량화
- 탭 데이터 레이어에서 받은 데이터로 렌더링만 담당
- API 호출, 캐시, 복원 로직 제거 (탭 레이어가 담당)
- 관리자 페이지도 동일한 데이터 레이어 패턴 적용
구현 완료: 다중 스크롤 영역 F5 복원
개요
split panel 등 한 탭 안에 스크롤 영역이 여러 개인 화면에서, F5 새로고침 후에도 각 영역의 스크롤 위치가 복원된다.
탭 전환 시에는 display: none 방식으로 DOM이 유지되므로 브라우저가 스크롤을 자연 보존한다. 이 기능은 F5 새로고침 전용이다.
동작 방식
탭 내 모든 스크롤 가능한 요소를 DOM 경로("0/1/0/2" 형태)와 함께 저장한다.
scrollPositions: [
{ path: "0/1/0/2", top: 150, left: 0 }, // 예: 좌측 패널
{ path: "0/1/1/3/1", top: 420, left: 0 }, // 예: 우측 패널
]
- 실시간 추적: 스크롤 이벤트 발생 시 해당 요소의 경로와 위치를 Map에 기록
- 저장 시점: 탭 전환 시 +
beforeunload(F5/닫기) 시 sessionStorage에 저장 - 복원 시점: 탭 활성화 시 경로를 기반으로 각 요소를 찾아 개별 복원
관련 파일 및 주요 함수
| 파일 | 역할 |
|---|---|
lib/tabStateCache.ts |
스크롤 캡처/복원 핵심 로직 |
components/layout/TabContent.tsx |
스크롤 이벤트 감지, 저장/복원 호출 |
tabStateCache.ts 핵심 함수:
| 함수 | 설명 |
|---|---|
getElementPath(element, container) |
요소의 DOM 경로를 자식 인덱스 문자열로 생성 |
captureAllScrollPositions(container) |
TreeWalker로 컨테이너 하위 모든 스크롤 요소의 위치를 일괄 캡처 |
restoreAllScrollPositions(container, positions) |
경로 기반으로 각 요소를 찾아 스크롤 위치 복원 (콘텐츠 렌더링 대기 폴링 포함) |
TabContent.tsx 핵심 Ref:
| Ref | 설명 |
|---|---|
lastScrollMapRef |
Map<tabId, Map<path, {top, left}>> - 탭 내 요소별 최신 스크롤 위치 |
pathCacheRef |
WeakMap<HTMLElement, string> - 동일 요소의 경로 재계산 방지용 캐시 |
9. 참고 파일
| 파일 | 비고 |
|---|---|
frontend/components/layout/AppLayout.tsx |
사이드바 + 콘텐츠 레이아웃 |
frontend/app/(main)/screens/[screenId]/page.tsx |
화면 렌더링 (건드리지 않음) |
frontend/stores/modalDataStore.ts |
Zustand store 참고 패턴 |
frontend/lib/adminPageRegistry.tsx |
관리자 페이지 레지스트리 |