From 69832b4c5860144d8b1823512dc63b9f10650c5f Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Mon, 29 Dec 2025 14:54:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=ED=8F=B4=EB=A7=81?= =?UTF-8?q?=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메뉴 폴링 API 및 훅 추가 (useMenuPolling, menuRefresh) - AuthenticatedLayout 메뉴 새로고침 연동 - 품질검사 체크리스트 문서 추가 - Vercel 배포 가이드 추가 - 동적 메뉴 리프레시 계획 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claudedocs/.DS_Store | Bin 6148 -> 6148 bytes claudedocs/_index.md | 4 +- .../[PLAN-2025-12-29] dynamic-menu-refresh.md | 308 ++++++++++++++++++ ...025-12-29] quality-inspection-checklist.md | 84 +++++ .../[GUIDE-2025-12-29] vercel-deployment.md | 204 ++++++++++++ src/app/api/menus/route.ts | 92 ++++++ src/hooks/useMenuPolling.ts | 169 ++++++++++ src/layouts/AuthenticatedLayout.tsx | 11 + src/lib/utils/menuRefresh.ts | 189 +++++++++++ 9 files changed, 1060 insertions(+), 1 deletion(-) create mode 100644 claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md create mode 100644 claudedocs/dev/[IMPL-2025-12-29] quality-inspection-checklist.md create mode 100644 claudedocs/guides/[GUIDE-2025-12-29] vercel-deployment.md create mode 100644 src/app/api/menus/route.ts create mode 100644 src/hooks/useMenuPolling.ts create mode 100644 src/lib/utils/menuRefresh.ts diff --git a/claudedocs/.DS_Store b/claudedocs/.DS_Store index 11ca4f54acbe119a1b521908aee49b863788a2cf..c8745c09e01c91b65259ce8568de5ff32d6c045a 100644 GIT binary patch delta 158 zcmZoMXfc=|&e%S&P>hv>fq{WzVxfpE6OaJ{AexbZL4bilmm!xSk)fEOgdufep}G}F zf}0@|#05$qNir0r6es5-<>%)B^)avlbtE$602LL(gf|vCu}?M-VcE>h!NbAWzOnE- Y^JIPzMM02>3P79y#9*-5QRFZ)03>T3>Hq)$ delta 93 zcmZoMXfc=|&Zs)EPb=@aY}J=PEvk;4&%m)X6zFm g5a3``-FWdk^JIPzMM03#1Rw?h5W8WsqsU=q0FLJqPXGV_ diff --git a/claudedocs/_index.md b/claudedocs/_index.md index ccf1cca8..77f102a9 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -152,7 +152,8 @@ claudedocs/ | 파일 | 설명 | |------|------| -| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 🔴 **NEW** - 공통 컴포넌트 추출 계획서 (Phase 1-4, 체크리스트 포함, ~1,900줄 절감) | +| `[GUIDE-2025-12-29] vercel-deployment.md` | 🔴 **NEW** - Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) | +| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 공통 컴포넌트 추출 계획서 (Phase 1-4, 체크리스트 포함, ~1,900줄 절감) | | `[ANALYSIS-2025-12-23] common-component-extraction-candidates.md` | 📋 공통 컴포넌트 추출 후보 분석 (다이얼로그 102개 중복, ~2,370줄 절감 예상) | | `[PLAN-2025-12-19] project-health-improvement.md` | ✅ **Phase 1 완료** - 프로젝트 헬스 개선 계획서 (타입에러 0개, API키 보안, SSR 수정) | | `[PLAN-2025-12-19] page-layout-standardization.md` | 🔴 **NEW** - 페이지 레이아웃 표준화 계획 | @@ -172,6 +173,7 @@ claudedocs/ | 파일 | 설명 | |------|------| +| `[PLAN-2025-12-29] dynamic-menu-refresh.md` | 🔴 **NEW** - 동적 메뉴 갱신 시스템 (1단계: 폴링, 2단계: SSE) | | `multi-tenancy-implementation.md` | 멀티테넌시 구현 | | `multi-tenancy-test-guide.md` | 멀티테넌시 테스트 | | `architecture-integration-risks.md` | 통합 리스크 | diff --git a/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md b/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md new file mode 100644 index 00000000..effee8dc --- /dev/null +++ b/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md @@ -0,0 +1,308 @@ +# 동적 메뉴 갱신 시스템 + +## 개요 + +관리자가 게시판/메뉴를 추가하면 사용자가 **재로그인 없이** 즉시 메뉴를 갱신받을 수 있는 시스템 구현. + +## 현재 문제점 + +``` +현재 흐름: + 로그인 → API 응답에서 메뉴 수신 → localStorage.user.menu 저장 → 세션 종료까지 고정 + +문제: + - 관리자가 게시판 추가해도 사용자는 재로그인 전까지 새 메뉴 안 보임 + - 메뉴 전용 갱신 API 없음 + - 실시간 알림 메커니즘 없음 +``` + +## 데이터 흐름 (현재) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 로그인 시 │ +├─────────────────────────────────────────────────────────────┤ +│ POST /api/v1/login │ +│ ↓ │ +│ 응답: { user, tenant, roles, menus } │ +│ ↓ │ +│ transformApiMenusToMenuItems(menus) │ +│ ↓ │ +│ localStorage.setItem('user', { ...userData, menu }) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 페이지 로드 시 │ +├─────────────────────────────────────────────────────────────┤ +│ AuthenticatedLayout.tsx │ +│ ↓ │ +│ localStorage.getItem('user') → userData.menu │ +│ ↓ │ +│ deserializeMenuItems(userData.menu) │ +│ ↓ │ +│ menuStore.setMenuItems(deserializedMenus) │ +│ ↓ │ +│ Sidebar 컴포넌트 렌더링 │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 관련 파일 + +| 파일 | 역할 | +|------|------| +| `src/store/menuStore.ts` | Zustand 메뉴 상태 관리 | +| `src/lib/utils/menuTransform.ts` | API 메뉴 → UI 메뉴 변환 | +| `src/layouts/AuthenticatedLayout.tsx` | 메뉴 로드 및 스토어 설정 | +| `src/components/layout/Sidebar.tsx` | 메뉴 렌더링 | +| `src/contexts/AuthContext.tsx` | 사용자 인증 컨텍스트 | + +--- + +## 구현 계획 + +### 1단계: 폴링 방식 (현재 구현 목표) + +**방식**: 30초마다 메뉴 API 호출하여 변경사항 확인 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 폴링 방식 흐름 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ [30초마다] │ +│ ↓ │ +│ GET /api/menus (메뉴 전용 API 필요) │ +│ ↓ │ +│ 현재 메뉴와 비교 (해시 또는 버전 비교) │ +│ ↓ │ +│ 변경 있으면 → refreshMenus() 호출 │ +│ ↓ │ +│ localStorage.user.menu 업데이트 │ +│ menuStore.setMenuItems() 호출 │ +│ ↓ │ +│ UI 즉시 반영 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**장점**: +- 구현 단순 +- 백엔드 수정 최소화 (메뉴 조회 API만 추가) +- 기존 인프라 그대로 사용 + +**단점**: +- 최대 30초 지연 +- 불필요한 API 호출 발생 + +#### 프론트엔드 구현 사항 + +1. **메뉴 갱신 유틸리티 함수** (`src/lib/utils/menuRefresh.ts`) +2. **폴링 훅** (`src/hooks/useMenuPolling.ts`) +3. **AuthenticatedLayout에 훅 적용** + +#### 백엔드 요청 사항 + +| 항목 | 설명 | +|------|------| +| **엔드포인트** | `GET /api/v1/menus` | +| **인증** | Bearer 토큰 필요 | +| **응답** | 현재 사용자의 메뉴 목록 (로그인 응답의 menus와 동일 구조) | +| **선택사항** | `menu_version` 또는 `menu_hash` 필드 추가 (변경 감지 최적화용) | + +--- + +### 2단계: SSE 고도화 (향후 계획) + +**방식**: 서버에서 메뉴 변경 시 SSE로 클라이언트에 푸시 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 백엔드 (Laravel) │ +├─────────────────────────────────────────────────────────────┤ +│ 1. 관리자가 메뉴 추가 → DB 저장 │ +│ 2. MenuUpdatedEvent 발생 │ +│ 3. 해당 테넌트의 SSE 채널로 푸시 │ +└─────────────────────────────────────────────────────────────┘ + ↓ SSE +┌─────────────────────────────────────────────────────────────┐ +│ 프론트엔드 (Next.js) │ +├─────────────────────────────────────────────────────────────┤ +│ 1. EventSource로 SSE 연결 유지 │ +│ 2. 'menu-updated' 이벤트 수신 │ +│ 3. refreshMenus() 호출 → UI 즉시 갱신 │ +└─────────────────────────────────────────────────────────────┘ +``` + +**장점**: +- 실시간 갱신 (지연 없음) +- 효율적 (변경 시에만 통신) + +**단점**: +- 백엔드 SSE 인프라 구축 필요 +- 동시 접속자 관리 필요 +- 멀티테넌트 채널 분리 필요 + +#### 백엔드 요구사항 (SSE) + +| 항목 | 설명 | +|------|------| +| **SSE 엔드포인트** | `GET /api/v1/sse/menu-updates` | +| **인증** | Bearer 토큰 또는 쿼리 파라미터 | +| **이벤트 타입** | `menu-updated` | +| **채널 분리** | 테넌트별로 분리 필요 | +| **구현 옵션** | Laravel Broadcasting + Redis, 직접 구현 등 | + +--- + +## 구현 체크리스트 + +### 1단계: 폴링 방식 + +#### 프론트엔드 ✅ 구현 완료 (2025-12-29) +- [x] `src/lib/utils/menuRefresh.ts` 생성 + - [x] `refreshMenus()` 함수 구현 + - [x] `forceRefreshMenus()` 강제 갱신 함수 + - [x] localStorage + Zustand 동시 업데이트 + - [x] 해시 기반 변경 감지 +- [x] `src/hooks/useMenuPolling.ts` 생성 + - [x] 30초 간격 폴링 로직 + - [x] 탭 가시성 변경 시 자동 중지/재개 + - [x] pause/resume 기능 + - [x] 컴포넌트 언마운트 시 정리 +- [x] `src/app/api/menus/route.ts` 생성 (Next.js 프록시) + - [x] 백엔드 메뉴 API 프록시 + - [x] HttpOnly 쿠키 토큰 처리 + - [x] `{ data: [...] }` 응답 구조 처리 +- [x] `AuthenticatedLayout.tsx`에 훅 적용 +- [ ] 테스트: 관리자 메뉴 추가 → 30초 내 사용자 메뉴 갱신 확인 + +#### 백엔드 (이미 존재!) +- [x] `GET /api/v1/menus` API 존재 확인 ✅ +- [x] `MenuController::index` → `MenuService::index` (사용자 권한 기반 필터링) +- [x] 응답 구조: `{ data: [...] }` (ApiResponse::handle 표준) + +### 2단계: SSE 고도화 (향후) + +- [ ] 백엔드 SSE 인프라 구축 +- [ ] 프론트엔드 EventSource 훅 구현 +- [ ] 폴링 → SSE 전환 +- [ ] 폴백: SSE 연결 실패 시 폴링으로 대체 + +--- + +## 코드 스니펫 + +### refreshMenus 함수 + +```typescript +// src/lib/utils/menuRefresh.ts +import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform'; +import { useMenuStore } from '@/store/menuStore'; + +export async function refreshMenus(): Promise { + try { + const response = await fetch('/api/menus'); + if (!response.ok) return false; + + const { menus } = await response.json(); + const transformedMenus = transformApiMenusToMenuItems(menus); + + // 1. localStorage 업데이트 (새로고침 대응) + const userData = JSON.parse(localStorage.getItem('user') || '{}'); + userData.menu = transformedMenus; + localStorage.setItem('user', JSON.stringify(userData)); + + // 2. Zustand 스토어 업데이트 (UI 즉시 반영) + const { setMenuItems } = useMenuStore.getState(); + setMenuItems(deserializeMenuItems(transformedMenus)); + + console.log('[Menu] 메뉴 갱신 완료'); + return true; + } catch (error) { + console.error('[Menu] 메뉴 갱신 실패:', error); + return false; + } +} +``` + +### useMenuPolling 훅 + +```typescript +// src/hooks/useMenuPolling.ts +import { useEffect, useRef } from 'react'; +import { refreshMenus } from '@/lib/utils/menuRefresh'; + +const POLLING_INTERVAL = 30000; // 30초 + +export function useMenuPolling(enabled: boolean = true) { + const intervalRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + + // 초기 실행은 하지 않음 (로그인 시 이미 받아옴) + intervalRef.current = setInterval(() => { + refreshMenus(); + }, POLLING_INTERVAL); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [enabled]); +} +``` + +### Next.js API 프록시 + +```typescript +// src/app/api/menus/route.ts +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const token = request.cookies.get('access_token')?.value; + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/menus`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', + }, + }); + + const data = await response.json(); + return NextResponse.json(data); +} +``` + +--- + +## 참고 사항 + +### 메뉴 데이터 저장 위치 + +| 저장소 | 키 | 용도 | +|--------|-----|------| +| localStorage | `user.menu` | 새로고침 시 복구용 | +| Zustand | `menuStore.menuItems` | UI 렌더링용 | + +### 갱신 시 동기화 필수 + +```typescript +// 반드시 둘 다 업데이트! +localStorage.user.menu = newMenus; // 새로고침 대응 +menuStore.setMenuItems(newMenus); // UI 즉시 반영 +``` + +--- + +## 작성 정보 + +- **작성일**: 2025-12-29 +- **상태**: ✅ 1단계 구현 완료 (테스트 대기) +- **담당**: 프론트엔드 팀 +- **백엔드**: `GET /api/v1/menus` API 이미 존재 ✅ \ No newline at end of file diff --git a/claudedocs/dev/[IMPL-2025-12-29] quality-inspection-checklist.md b/claudedocs/dev/[IMPL-2025-12-29] quality-inspection-checklist.md new file mode 100644 index 00000000..c4dc0eb0 --- /dev/null +++ b/claudedocs/dev/[IMPL-2025-12-29] quality-inspection-checklist.md @@ -0,0 +1,84 @@ +# 품질인정심사 시스템 구현 체크리스트 + +> **경로**: `src/app/[locale]/(protected)/dev/quality-inspection/` +> **작업일**: 2025-12-29 +> **담당**: 버디 +> **상태**: ✅ 완료 + +--- + +## Phase 1: 상태 관리 구현 ✅ + +- [x] 1.1 page.tsx에 필터 상태 추가 (년도, 분기, 검색어) +- [x] 1.2 selectedReport 상태 추가 +- [x] 1.3 selectedRoute 상태 추가 +- [x] 1.4 필터링 로직 구현 (useMemo) + +## Phase 2: 컴포넌트 Props 연동 ✅ + +- [x] 2.1 ReportList.tsx - onSelect 콜백 추가 +- [x] 2.2 RouteList.tsx - reports 데이터 + onSelect 콜백 추가 +- [x] 2.3 DocumentList.tsx - route 데이터 연동 +- [x] 2.4 Filters.tsx - 상태 콜백 연동 + +## Phase 3: Mock 데이터 통합 ✅ + +- [x] 3.1 types.ts에 통합 데이터 구조 정의 +- [x] 3.2 mockData.ts 생성 (계층 구조 데이터) +- [x] 3.3 Report → Route → Document 연결 구조 + +## Phase 4: 문서 모달 연동 ✅ + +### 기존 문서 컴포넌트 (재사용) + +| 문서 종류 | 기존 컴포넌트 | 상태 | +|----------|--------------|------| +| 수주서 | `orders/documents/OrderDocumentModal.tsx` | ✅ 있음 | +| 작업일지 | `production/WorkerScreen/WorkLogModal.tsx` | ✅ 있음 | +| 납품확인서 | `outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx` | ✅ 있음 | +| 출고증 | `outbound/ShipmentManagement/documents/ShippingSlip.tsx` | ✅ 있음 | + +### 신규 문서 (양식 필요) + +| 문서 종류 | 상태 | 비고 | +|----------|------|------| +| 수입검사 성적서 | ❌ 양식 필요 | 디자인 파일 대기 | +| 중간검사 성적서 | ❌ 양식 필요 | 디자인 파일 대기 | +| 제품검사 성적서 | ❌ 양식 필요 | 디자인 파일 대기 | +| 품질관리서 | ❌ 양식 필요 | 디자인 파일 대기 | + +### 모달 연동 작업 + +- [x] 4.1 InspectionModal에서 문서 타입별 분기 처리 +- [x] 4.2 기존 문서 컴포넌트 Placeholder 표시 (연동 예정 안내) +- [x] 4.3 신규 문서는 Placeholder 표시 (양식 대기) + +## Phase 5: UI 개선 ✅ + +- [x] 5.1 PageLayout 적용 → N/A (전체 높이 대시보드 레이아웃으로 별도 처리) +- [x] 5.2 Filters.tsx 미사용 import 정리 → 미사용 import 없음 확인 +- [x] 5.3 반응형 레이아웃 검증 → grid-cols-12 + lg: 반응형 적용됨 + +--- + +## 진행 현황 + +| Phase | 상태 | 완료일 | +|-------|------|--------| +| Phase 1 | ✅ 완료 | 2025-12-29 | +| Phase 2 | ✅ 완료 | 2025-12-29 | +| Phase 3 | ✅ 완료 | 2025-12-29 | +| Phase 4 | ✅ 완료 | 2025-12-29 | +| Phase 5 | ✅ 완료 | 2025-12-29 | + +--- + +## 참고 파일 + +``` +src/components/orders/documents/OrderDocumentModal.tsx +src/components/production/WorkerScreen/WorkLogModal.tsx +src/components/outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx +src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx +src/components/process-management/ProcessWorkLogPreviewModal.tsx +``` diff --git a/claudedocs/guides/[GUIDE-2025-12-29] vercel-deployment.md b/claudedocs/guides/[GUIDE-2025-12-29] vercel-deployment.md new file mode 100644 index 00000000..82d4cc87 --- /dev/null +++ b/claudedocs/guides/[GUIDE-2025-12-29] vercel-deployment.md @@ -0,0 +1,204 @@ +# Vercel 배포 가이드 + +> 작성일: 2025-12-29 +> 상태: 🔄 진행 중 +> 담당: + +--- + +## 📋 배포 전 체크리스트 + +### 프로젝트 상태 +- [x] 빌드 테스트 성공 +- [x] Node.js v20.x 호환 확인 +- [x] Next.js 15 + next-intl 설정 완료 +- [x] 다국어 지원 (ko/en/ja) + +### 배포 준비 +- [ ] Vercel 계정 준비 +- [ ] Git 레포지토리 연동 +- [ ] 환경 변수 설정 +- [ ] CORS 설정 요청 (백엔드) +- [ ] 배포 완료 +- [ ] 테스트 완료 + +--- + +## 1단계: Vercel 프로젝트 생성 + +### 1.1 Vercel 접속 +1. [vercel.com](https://vercel.com) 접속 +2. GitHub/GitLab 계정으로 로그인 + +### 1.2 프로젝트 생성 +1. Dashboard → **Add New** → **Project** +2. Git 레포지토리 선택: `sam-react-prod` +3. Framework Preset: **Next.js** (자동 감지) +4. Root Directory: `.` (기본값) + +--- + +## 2단계: 환경 변수 설정 + +### 필수 환경 변수 + +| 변수명 | 값 | 환경 | 설명 | +|--------|-----|------|------| +| `NEXT_PUBLIC_API_URL` | `https://api.5130.co.kr` | All | 백엔드 API URL | +| `NEXT_PUBLIC_FRONTEND_URL` | `(배포 후 URL)` | Production | 프론트엔드 URL | +| `NEXT_PUBLIC_AUTH_MODE` | `sanctum` | All | 인증 모드 | +| `API_KEY` | `(실제 키)` | All | 서버사이드 API 키 | + +### 설정 방법 +1. Vercel Dashboard → Settings → Environment Variables +2. 각 변수 추가 +3. Environment: Production / Preview / Development 선택 + +### 환경 변수 값 메모 +``` +NEXT_PUBLIC_API_URL = +NEXT_PUBLIC_FRONTEND_URL = +NEXT_PUBLIC_AUTH_MODE = +API_KEY = +``` + +--- + +## 3단계: 백엔드 CORS 설정 + +### 요청 내용 +Vercel 배포 후 도메인을 백엔드팀에 전달하여 CORS 허용 요청 + +``` +허용 요청 도메인: +- https://프로젝트명.vercel.app +- https://커스텀도메인.com (있는 경우) +``` + +### 백엔드 요청 메모 +``` +요청일: +요청 도메인: +처리 상태: [ ] 대기 / [ ] 완료 +``` + +--- + +## 4단계: 배포 실행 + +### 4.1 첫 배포 +1. 환경 변수 설정 완료 확인 +2. **Deploy** 버튼 클릭 +3. 빌드 로그 모니터링 + +### 4.2 배포 성공 확인 +- [ ] 빌드 성공 +- [ ] 배포 URL 생성 +- [ ] 페이지 로딩 확인 + +### 배포 정보 +``` +배포 URL: +배포 시간: +빌드 시간: +``` + +--- + +## 5단계: 배포 후 테스트 + +### 5.1 기본 테스트 +- [ ] 메인 페이지 로딩 +- [ ] 로그인 페이지 접근 +- [ ] 다국어 전환 (ko/en/ja) + +### 5.2 인증 테스트 +- [ ] 로그인 시도 +- [ ] 토큰 발급 확인 +- [ ] 로그아웃 + +### 5.3 API 연동 테스트 +- [ ] API 호출 정상 +- [ ] CORS 에러 없음 +- [ ] 데이터 로딩 확인 + +### 5.4 주요 페이지 테스트 +- [ ] 대시보드 +- [ ] 품목기준관리 +- [ ] 설정 페이지 + +### 테스트 결과 메모 +``` +테스트일: +발견된 이슈: +- + +해결 필요 사항: +- +``` + +--- + +## 6단계: 커스텀 도메인 (선택) + +### 6.1 도메인 연결 +1. Vercel Dashboard → Settings → Domains +2. 도메인 추가: `your-domain.com` +3. DNS 설정 안내 확인 + +### 6.2 DNS 설정 +``` +Type: CNAME +Name: @ 또는 www +Value: cname.vercel-dns.com +``` + +### 도메인 정보 +``` +도메인: +SSL 상태: [ ] 대기 / [ ] 활성화 +``` + +--- + +## 트러블슈팅 + +### 빌드 실패 시 +```bash +# 로컬에서 빌드 테스트 +npm run build +``` + +### CORS 에러 시 +- 백엔드 CORS 설정 확인 +- `NEXT_PUBLIC_FRONTEND_URL` 값 확인 + +### 환경 변수 미적용 시 +- Vercel Dashboard에서 값 확인 +- 재배포 필요 (환경 변수 변경 후) + +### API 연결 실패 시 +- `NEXT_PUBLIC_API_URL` 확인 +- `API_KEY` 값 확인 +- 네트워크 탭에서 요청/응답 확인 + +--- + +## 참고 자료 + +- [Vercel Next.js 배포 가이드](https://vercel.com/docs/frameworks/nextjs) +- [Next.js 환경 변수](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables) +- [Vercel 커스텀 도메인](https://vercel.com/docs/projects/domains) + +--- + +## 작업 로그 + +### 2025-12-29 +- [ ] 가이드 문서 생성 +- [ ] 배포 시작 + +### 추가 메모 +``` +(여기에 진행하면서 메모 추가) +``` \ No newline at end of file diff --git a/src/app/api/menus/route.ts b/src/app/api/menus/route.ts new file mode 100644 index 00000000..7ebbcbf7 --- /dev/null +++ b/src/app/api/menus/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * 🔵 Next.js 내부 API - 메뉴 조회 프록시 (PHP 백엔드로 전달) + * + * ⚡ 설계 목적: + * - 동적 메뉴 갱신: 재로그인 없이 메뉴 목록 갱신 + * - 보안: HttpOnly 쿠키에서 토큰을 읽어 백엔드로 전달 + * + * 🔄 동작 흐름: + * 1. 클라이언트 → Next.js /api/menus + * 2. Next.js: HttpOnly 쿠키에서 access_token 읽기 + * 3. Next.js → PHP /api/v1/menus (메뉴 조회 요청) + * 4. Next.js → 클라이언트 (메뉴 목록 응답) + * + * 📌 백엔드 API 요청 사항: + * - 엔드포인트: GET /api/v1/menus + * - 인증: Bearer 토큰 필요 + * - 응답: { menus: [...] } (로그인 응답의 menus와 동일 구조) + * + * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md + */ +export async function GET(request: NextRequest) { + try { + // HttpOnly 쿠키에서 access_token 읽기 + const accessToken = request.cookies.get('access_token')?.value; + + if (!accessToken) { + return NextResponse.json( + { error: 'Unauthorized', message: '인증 토큰이 없습니다' }, + { status: 401 } + ); + } + + // PHP 백엔드 메뉴 API 호출 + const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/menus`; + + const response = await fetch(backendUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + 'X-API-KEY': process.env.API_KEY || '', + }, + }); + + if (!response.ok) { + // 백엔드 에러 응답 전달 + const errorData = await response.json().catch(() => ({})); + console.error('[Menu API] Backend error:', response.status, errorData); + + return NextResponse.json( + { + error: 'Backend Error', + message: errorData.message || '메뉴 조회에 실패했습니다', + status: response.status + }, + { status: response.status } + ); + } + + const data = await response.json(); + + // 백엔드 응답 구조: { data: [...] } (ApiResponse::handle 표준) + // 또는 로그인 응답과 동일한 { menus: [...] } 형태일 수 있음 + const menus = data.data || data.menus || (Array.isArray(data) ? data : null); + + // 메뉴 데이터 검증 + if (!menus || !Array.isArray(menus)) { + console.error('[Menu API] Invalid response format:', data); + return NextResponse.json( + { error: 'Invalid Response', message: '메뉴 데이터 형식이 올바르지 않습니다' }, + { status: 500 } + ); + } + + // 응답 구조 통일: { menus: [...] } + return NextResponse.json({ menus }, { status: 200 }); + + } catch (error) { + console.error('[Menu API] Proxy error:', error); + return NextResponse.json( + { + error: 'Internal Server Error', + message: error instanceof Error ? error.message : '서버 오류가 발생했습니다' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/hooks/useMenuPolling.ts b/src/hooks/useMenuPolling.ts new file mode 100644 index 00000000..24ae3540 --- /dev/null +++ b/src/hooks/useMenuPolling.ts @@ -0,0 +1,169 @@ +/** + * 메뉴 폴링 훅 + * + * 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다. + * + * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { refreshMenus } from '@/lib/utils/menuRefresh'; + +// 기본 폴링 간격: 30초 +const DEFAULT_POLLING_INTERVAL = 30 * 1000; + +// 최소 폴링 간격: 10초 (서버 부하 방지) +const MIN_POLLING_INTERVAL = 10 * 1000; + +// 최대 폴링 간격: 5분 +const MAX_POLLING_INTERVAL = 5 * 60 * 1000; + +interface UseMenuPollingOptions { + /** 폴링 활성화 여부 (기본: true) */ + enabled?: boolean; + /** 폴링 간격 (ms, 기본: 30초) */ + interval?: number; + /** 메뉴 갱신 시 콜백 */ + onMenuUpdated?: () => void; + /** 에러 발생 시 콜백 */ + onError?: (error: string) => void; +} + +interface UseMenuPollingReturn { + /** 수동으로 메뉴 갱신 실행 */ + refresh: () => Promise; + /** 폴링 일시 중지 */ + pause: () => void; + /** 폴링 재개 */ + resume: () => void; + /** 현재 폴링 상태 */ + isPaused: boolean; +} + +/** + * 메뉴 폴링 훅 + * + * @example + * ```tsx + * // 기본 사용 + * useMenuPolling(); + * + * // 옵션과 함께 사용 + * const { refresh, pause, resume } = useMenuPolling({ + * interval: 60000, // 1분마다 + * onMenuUpdated: () => console.log('메뉴 업데이트됨!'), + * }); + * + * // 수동 갱신 + * await refresh(); + * ``` + */ +export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPollingReturn { + const { + enabled = true, + interval = DEFAULT_POLLING_INTERVAL, + onMenuUpdated, + onError, + } = options; + + // 폴링 간격 유효성 검사 + const safeInterval = Math.max(MIN_POLLING_INTERVAL, Math.min(MAX_POLLING_INTERVAL, interval)); + + const intervalRef = useRef(null); + const isPausedRef = useRef(false); + + // 메뉴 갱신 실행 + const executeRefresh = useCallback(async () => { + if (isPausedRef.current) return; + + const result = await refreshMenus(); + + if (result.success && result.updated) { + onMenuUpdated?.(); + } + + if (!result.success && result.error) { + onError?.(result.error); + } + }, [onMenuUpdated, onError]); + + // 수동 갱신 함수 + const refresh = useCallback(async () => { + await executeRefresh(); + }, [executeRefresh]); + + // 폴링 일시 중지 + const pause = useCallback(() => { + isPausedRef.current = true; + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + // 폴링 재개 + const resume = useCallback(() => { + isPausedRef.current = false; + if (enabled && !intervalRef.current) { + intervalRef.current = setInterval(executeRefresh, safeInterval); + } + }, [enabled, safeInterval, executeRefresh]); + + // 폴링 설정 + useEffect(() => { + if (!enabled) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + + // 페이지 로드 시 즉시 실행하지 않음 (로그인 시 이미 받아옴) + // 폴링 시작 + intervalRef.current = setInterval(executeRefresh, safeInterval); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [enabled, safeInterval, executeRefresh]); + + // 탭 가시성 변경 시 처리 + useEffect(() => { + if (!enabled) return; + + const handleVisibilityChange = () => { + if (document.hidden) { + // 탭이 숨겨지면 폴링 중지 (리소스 절약) + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } else { + // 탭이 다시 보이면 즉시 갱신 후 폴링 재개 + if (!isPausedRef.current) { + executeRefresh(); + intervalRef.current = setInterval(executeRefresh, safeInterval); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled, safeInterval, executeRefresh]); + + return { + refresh, + pause, + resume, + isPaused: isPausedRef.current, + }; +} + +export default useMenuPolling; \ No newline at end of file diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 82848935..6705c992 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -37,6 +37,7 @@ import Sidebar from '@/components/layout/Sidebar'; import { useTheme } from '@/contexts/ThemeContext'; import { useAuth } from '@/contexts/AuthContext'; import { deserializeMenuItems } from '@/lib/utils/menuTransform'; +import { useMenuPolling } from '@/hooks/useMenuPolling'; interface AuthenticatedLayoutProps { children: React.ReactNode; @@ -60,6 +61,16 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro const [userName, setUserName] = useState("사용자"); const [userPosition, setUserPosition] = useState("직책"); + // 메뉴 폴링 (30초마다 메뉴 변경 확인) + // 백엔드 GET /api/v1/menus API 준비되면 자동 동작 + useMenuPolling({ + enabled: true, + interval: 30000, // 30초 + onMenuUpdated: () => { + console.log('[Menu] 메뉴가 업데이트되었습니다'); + }, + }); + // 모바일 감지 useEffect(() => { const checkScreenSize = () => { diff --git a/src/lib/utils/menuRefresh.ts b/src/lib/utils/menuRefresh.ts new file mode 100644 index 00000000..b5cb7e17 --- /dev/null +++ b/src/lib/utils/menuRefresh.ts @@ -0,0 +1,189 @@ +/** + * 메뉴 동적 갱신 유틸리티 + * + * 관리자가 게시판/메뉴 추가 시 재로그인 없이 메뉴를 갱신합니다. + * + * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md + */ + +import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform'; +import { useMenuStore } from '@/store/menuStore'; +import type { SerializableMenuItem } from '@/store/menuStore'; + +/** + * 메뉴 해시 생성 (변경 감지용) + * 메뉴 ID들을 정렬하여 해시 생성 + */ +function generateMenuHash(menus: SerializableMenuItem[]): string { + const collectIds = (items: SerializableMenuItem[]): string[] => { + return items.flatMap(item => [ + item.id, + ...(item.children ? collectIds(item.children) : []) + ]); + }; + + return collectIds(menus).sort().join(','); +} + +/** + * 현재 저장된 메뉴 해시 가져오기 + */ +export function getCurrentMenuHash(): string { + if (typeof window === 'undefined') return ''; + + try { + const userData = localStorage.getItem('user'); + if (!userData) return ''; + + const parsed = JSON.parse(userData); + if (!parsed.menu || !Array.isArray(parsed.menu)) return ''; + + return generateMenuHash(parsed.menu); + } catch { + return ''; + } +} + +/** + * 메뉴 갱신 결과 타입 + */ +interface RefreshMenuResult { + success: boolean; + updated: boolean; // 실제로 메뉴가 변경되었는지 + error?: string; +} + +/** + * 메뉴 갱신 함수 + * + * 1. API에서 새 메뉴 받아오기 + * 2. 기존 메뉴와 비교 (해시) + * 3. 변경 시 localStorage + Zustand 업데이트 + * + * @returns 갱신 결과 + */ +export async function refreshMenus(): Promise { + try { + // 1. 현재 메뉴 해시 저장 + const currentHash = getCurrentMenuHash(); + + // 2. API에서 새 메뉴 받아오기 + const response = await fetch('/api/menus', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + // 401 등 인증 오류는 조용히 실패 (로그아웃 상태일 수 있음) + if (response.status === 401) { + return { success: false, updated: false }; + } + return { + success: false, + updated: false, + error: `API 오류: ${response.status}` + }; + } + + const data = await response.json(); + + if (!data.menus || !Array.isArray(data.menus)) { + return { + success: false, + updated: false, + error: '메뉴 데이터 형식 오류' + }; + } + + // 3. 메뉴 변환 + const transformedMenus = transformApiMenusToMenuItems(data.menus); + const newHash = generateMenuHash(transformedMenus); + + // 4. 변경 없으면 업데이트 스킵 + if (currentHash === newHash) { + return { success: true, updated: false }; + } + + // 5. localStorage 업데이트 (새로고침 대응) + const userData = localStorage.getItem('user'); + if (userData) { + const parsed = JSON.parse(userData); + parsed.menu = transformedMenus; + localStorage.setItem('user', JSON.stringify(parsed)); + } + + // 6. Zustand 스토어 업데이트 (UI 즉시 반영) + const { setMenuItems } = useMenuStore.getState(); + setMenuItems(deserializeMenuItems(transformedMenus)); + + console.log('[Menu] 메뉴 갱신 완료 - 변경 감지됨'); + return { success: true, updated: true }; + + } catch (error) { + console.error('[Menu] 메뉴 갱신 실패:', error); + return { + success: false, + updated: false, + error: error instanceof Error ? error.message : '알 수 없는 오류' + }; + } +} + +/** + * 메뉴 강제 갱신 (비교 없이 무조건 갱신) + */ +export async function forceRefreshMenus(): Promise { + try { + const response = await fetch('/api/menus', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + return { + success: false, + updated: false, + error: `API 오류: ${response.status}` + }; + } + + const data = await response.json(); + + if (!data.menus || !Array.isArray(data.menus)) { + return { + success: false, + updated: false, + error: '메뉴 데이터 형식 오류' + }; + } + + const transformedMenus = transformApiMenusToMenuItems(data.menus); + + // localStorage 업데이트 + const userData = localStorage.getItem('user'); + if (userData) { + const parsed = JSON.parse(userData); + parsed.menu = transformedMenus; + localStorage.setItem('user', JSON.stringify(parsed)); + } + + // Zustand 스토어 업데이트 + const { setMenuItems } = useMenuStore.getState(); + setMenuItems(deserializeMenuItems(transformedMenus)); + + console.log('[Menu] 메뉴 강제 갱신 완료'); + return { success: true, updated: true }; + + } catch (error) { + console.error('[Menu] 메뉴 강제 갱신 실패:', error); + return { + success: false, + updated: false, + error: error instanceof Error ? error.message : '알 수 없는 오류' + }; + } +} \ No newline at end of file