diff --git a/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md b/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md index bdb8e3a3..862c9106 100644 --- a/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md +++ b/claudedocs/architecture/[PLAN-2026-03-11] dynamic-multi-tenant-page-system.md @@ -179,6 +179,61 @@ After: 백엔드 어드민 UI → 직접 DB 저장 → API로 config 전달 > ⚠️ **백엔드 논의 필요**: JSON 구조의 세부 스펙 확정 +#### 2-2. 백엔드 저장 방식: JSONB (확정) + +> ✅ **확정**: 페이지 config는 PostgreSQL **JSONB** 타입으로 저장 + +| 항목 | JSON | JSONB (채택) | +|------|------|:---:| +| 저장 형태 | 텍스트 그대로 | 바이너리 (파싱된 형태) | +| 읽기 속도 | 매번 파싱 필요 | 이미 파싱됨 → **빠름** | +| 인덱싱 | ❌ 불가 | ✅ **GIN 인덱스 가능** | +| 내부 검색 | ❌ 전체 꺼내서 비교 | ✅ **특정 키/값으로 쿼리** | +| 부분 수정 | ❌ 전체 교체 | ✅ **특정 키만 업데이트** | + +**JSONB가 필요한 이유 — 우리 시스템과의 연관**: + +```sql +-- 1. 테넌트별 특정 타입 페이지만 조회 (인덱싱) +SELECT * FROM page_configs +WHERE tenant_id = 282 +AND config->>'pageType' = 'list'; + +-- 2. 특정 필드 타입을 쓰는 페이지 검색 (내부 검색) +SELECT * FROM page_configs +WHERE config @> '{"layout":{"sections":[{"fields":[{"type":"reference"}]}]}}'; + +-- 3. 기준관리에서 섹션 하나만 수정 (부분 수정) +UPDATE page_configs +SET config = jsonb_set(config, '{layout,sections,0,title}', '"수정된 섹션명"'); +``` + +**JSONB 채택이 config 구조 설계에 미치는 영향**: + +| 영향 | 설명 | +|------|------| +| **구조 단순화** | 하나의 큰 JSONB에 전체 config를 담아도 부분 쿼리/수정 가능 → 테이블 분리 최소화 | +| **테넌트 분기** | JSONB 인덱스로 테넌트+pageType 조합 쿼리가 빠름 → 별도 테이블 불필요 | +| **기준관리 UI** | 섹션 하나만 수정해도 전체 config를 다시 저장할 필요 없음 → UX 향상 | +| **프론트 영향** | **없음** — 프론트는 동일한 JSON을 받아서 렌더링, 저장 방식 무관 | + +``` +DB 테이블 구조 (제안): + +page_configs +├── id (PK) +├── tenant_id (FK, 인덱스) +├── slug (UNIQUE per tenant, 인덱스) +├── config (JSONB) ← 페이지 config 전체 +├── created_at +└── updated_at + +GIN 인덱스: config에 대해 생성 → 내부 검색 고속화 +복합 인덱스: (tenant_id, slug) → 테넌트별 페이지 조회 최적화 +``` + +> ⚠️ **백엔드 논의 필요**: JSONB 기반 테이블 설계 세부 확정 (위 제안 구조 검토) + --- ### 규칙 3: 정적 페이지 vs 동적 페이지 분류 @@ -431,6 +486,8 @@ src/app/[locale]/(protected)/ ### 규칙 11: API 엔드포인트 동적 매핑 +#### 11-1. API 호출 유형 + | API 유형 | config 키 | 용도 | |---------|----------|------| | `list` | `api.list` | 목록 조회 (GET) | @@ -441,19 +498,119 @@ src/app/[locale]/(protected)/ | `export` | `api.export` | 엑셀 다운로드 (GET) | | `custom` | `api.custom[actionName]` | 커스텀 액션 | -``` -동적 페이지의 데이터 흐름: +#### 11-2. 백엔드 API 제공 방식 (3가지 방향) -config.api.list = "/api/v1/orders" - ↓ -DynamicListPage → Server Action (buildApiUrl 사용) - ↓ -API 프록시 → 백엔드 → 응답 - ↓ -테이블 렌더링 (config.columns 기준) +동적 페이지는 **데이터를 어디서 가져올지도 동적**이어야 합니다. +백엔드가 API를 어떤 방식으로 제공하느냐에 따라 3가지 방향이 있습니다. + +| 방향 | 설명 | 장점 | 단점 | +|------|------|------|------| +| **A. 개별 API** | 페이지마다 전용 API 존재, config에 경로 명시 | 기존 API 재사용, 복잡한 로직 처리 가능 | 새 페이지마다 백엔드 개발 필요 | +| **B. 범용 Entity API** | 하나의 엔드포인트가 entityType으로 분기 | 새 페이지 추가 시 백엔드 코드 변경 없음 | 복잡한 비즈니스 로직 처리 어려움 | +| **C. 하이브리드 (권장)** | 단순 CRUD는 범용 API, 복잡한 로직은 전용 API | 양쪽 장점 모두 취함 | 두 방식 공존에 따른 관리 비용 | + +**방향 A: 개별 API (config에 경로 포함)** +```jsonc +{ + "pageType": "list", + "slug": "sales/order-management", + "api": { + "list": "/api/v1/orders", + "detail": "/api/v1/orders/:id", + "create": "/api/v1/orders", + "delete": "/api/v1/orders/:id" + } +} +``` +→ 기존에 이미 만들어둔 API를 그대로 config에 연결 +→ 견적 계산, 세금 처리 등 **비즈니스 로직이 있는 페이지에 적합** + +**방향 B: 범용 Entity API** +```jsonc +{ + "pageType": "list", + "slug": "master/equipment", + "entityType": "equipment" +} +``` +``` +// 범용 API 1개로 모든 entity 처리 +GET /api/v1/entities/{entityType} +GET /api/v1/entities/{entityType}/{id} +POST /api/v1/entities/{entityType} +PUT /api/v1/entities/{entityType}/{id} +DELETE /api/v1/entities/{entityType}/{id} +``` +→ 백엔드에서 entityType에 따라 테이블/모델 동적 매핑 +→ 단순 CRUD(거래처, 설비, 자재 등) **마스터 데이터 페이지에 적합** + +**방향 C: 하이브리드 (권장)** +``` +┌──────────────────────────────────────────────────┐ +│ 단순 CRUD 페이지 (거래처, 설비, 자재 등) │ +│ → 방향 B: 범용 entity API │ +│ → config에 entityType만 지정 │ +│ → 새 페이지 추가 시 백엔드 코드 변경 없음 │ +│ → 동적 시스템의 최대 효과 │ +└──────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────┐ +│ 비즈니스 로직 페이지 (견적, 생산, 세금계산서) │ +│ → 방향 A: 전용 API 경로를 config에 명시 │ +│ → 계산/검증/워크플로우 등 복잡한 로직 처리 │ +│ → 기존 API 재사용으로 마이그레이션 용이 │ +└──────────────────────────────────────────────────┘ ``` -> ⚠️ **백엔드 논의 필요**: API 응답 구조 통일 (list는 `{ data: [], meta: { total, page } }` 등) +#### 11-3. 프론트 처리 방식 (어느 방향이든 동일) + +``` +프론트는 API 제공 방식에 무관하게 동일한 패턴으로 처리: + +1. config에서 API 경로 결정 + ├─ api.list 있으면 → 그 경로 사용 (방향 A) + └─ entityType 있으면 → `/api/v1/entities/${entityType}` 생성 (방향 B) + ↓ +2. buildApiUrl(경로, params) ← 기존 유틸 재사용 + ↓ +3. Server Action에서 API 프록시 호출 + ↓ +4. 응답을 config.columns 기준으로 렌더링 +``` + +```typescript +// 프론트 API 경로 결정 유틸 (예시) +function resolveApiUrl(config: PageConfig, action: 'list' | 'detail' | 'create' | 'update' | 'delete') { + // 방향 A: 전용 API 경로가 있으면 사용 + if (config.api?.[action]) { + return config.api[action]; + } + // 방향 B: entityType으로 범용 API 생성 + if (config.entityType) { + const base = `/api/v1/entities/${config.entityType}`; + if (action === 'list' || action === 'create') return base; + return `${base}/:id`; + } + throw new Error(`No API config for action: ${action}`); +} +``` + +#### 11-4. API 응답 구조 통일 + +어느 방향이든 **응답 구조는 통일**되어야 프론트가 범용 처리 가능: + +| API 유형 | 응답 구조 | +|---------|----------| +| list | `{ data: [...], meta: { total, current_page, per_page, last_page } }` | +| detail | `{ data: { ... } }` | +| create | `{ data: { id, ... }, message: "..." }` | +| update | `{ data: { id, ... }, message: "..." }` | +| delete | `{ message: "..." }` | + +> ⚠️ **백엔드 논의 필요**: +> - 범용 entity API 도입 여부 및 범위 +> - 기존 API 중 응답 구조가 통일되지 않은 것 정리 +> - 전용 API와 범용 API의 분류 기준 합의 --- @@ -497,27 +654,54 @@ DynamicItemForm의 DisplayCondition → visibility 타입으로 범용화 DynamicItemForm의 ComputedField → computed 타입으로 범용화 ``` -> ⚠️ **백엔드 논의 필요**: 복잡한 계산식(견적 할인율 등)은 백엔드 API 위임 vs 프론트 formula +> ✅ **확정**: 복잡한 계산식(견적 할인율 등)은 **백엔드에서 전부 처리**하여 결과만 전달 --- ### 규칙 14: 권한 통합 -| 권한 레벨 | 적용 대상 | 동작 | -|----------|----------|------| -| 페이지 | 메뉴/라우트 | 메뉴에 없으면 접근 불가 (기존 방식 유지) | -| 액션 | 버튼/기능 | 삭제, 엑셀 다운로드 등 특정 액션 | -| 필드 | 개별 입력 항목 | 보기/수정 권한 분리 | +#### 14-1. 현재 권한 시스템 검증 결과 + +✅ **현재 권한 시스템으로 동적 페이지도 컨트롤 가능** (검증 완료) + +현재 권한 시스템이 **메뉴 ID 기반 + URL 패턴 매칭**으로 동작하므로, 페이지가 정적이든 동적이든 해당 URL이 menu 테이블에 등록되어 있으면 권한 관리 페이지에서 동일하게 컨트롤됩니다. ``` -권한 적용 흐름: +현재 (정적 페이지): + 백엔드 menu 테이블에 URL 등록 → 권한 매트릭스 체크박스 on/off + → PermissionGate가 URL 매칭 → 접근 허용/차단 -1. 페이지 접근: 메뉴 API에 없으면 → 접근 차단 (기존) -2. 액션 권한: config.permissions.actionLevel → 버튼 표시/숨김 -3. 필드 권한: config.permissions.fieldLevel → 읽기전용/숨김 처리 +동적 페이지도 동일: + 백엔드 menu 테이블에 동적 페이지 URL(slug) 등록 + → 권한 매트릭스에서 동일하게 체크박스 on/off + → PermissionGate가 URL 매칭 → 동일하게 동작 ``` -> ⚠️ **백엔드 논의 필요**: 권한 정보를 페이지 config에 포함 vs 별도 권한 API +#### 14-2. 권한 레벨별 동적 페이지 호환성 + +| 권한 레벨 | 현재 지원 | 동적 페이지 호환 | 사용 컴포넌트 | +|----------|:---:|:---:|------| +| 페이지 접근 (view) | ✅ | ✅ | `PermissionGate` (URL 매칭) | +| 생성 (create) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | +| 수정 (update) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | +| 삭제 (delete) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | +| 승인 (approve) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | +| 내보내기 (export) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | +| 관리 (manage) | ✅ | ✅ | `usePermission()` / `PermissionGuard` | +| **필드 단위 권한** | ❌ | ❌ | 현재 미지원 → **v2 고려사항** | + +#### 14-3. 권한 적용 흐름 + +``` +권한 적용 흐름 (정적/동적 공통): + +1. 페이지 접근: PermissionGate → URL longest prefix 매칭 → view 권한 확인 +2. 액션 권한: usePermission() → canCreate/canDelete 등 → 버튼 표시/숨김 +3. 필드 권한: 현재 미지원 (v2에서 config.permissions.fieldLevel 추가 시 구현) +``` + +> ⚠️ **백엔드 논의 필요**: 동적 페이지 URL(slug)을 menu 테이블에 자동 등록하는 방안 +> (기준관리에서 페이지 생성 시 → menu 테이블에도 자동 연동?) --- @@ -544,25 +728,23 @@ DynamicItemForm의 ComputedField → computed 타입으로 범용화 --- -### 규칙 16: 비즈니스 로직 처리 (하이브리드) +### 규칙 16: 비즈니스 로직 처리 + +> ✅ **확정**: 복잡한 계산 수식은 **백엔드에서 전부 처리**하여 결과만 전달 | 로직 복잡도 | 처리 방식 | 예시 | |------------|----------|------| | 단순 계산 | config formula (프론트) | 수량 × 단가 = 금액 | -| 중간 로직 | 등록된 로직 블록 (프론트) | 세금 계산, 할인율 적용 | -| 복잡한 로직 | 백엔드 API 위임 | 견적 복합 계산, 재고 검증 | +| 복잡한 계산 | **백엔드 API** | 견적 할인, 세금, 재고 검증 등 | ```jsonc -// config에서 로직 블록 지정 +// config에서 로직 지정 { "businessLogic": { - // 단순: 프론트 formula + // 단순: 프론트 formula (기존 ComputedField 재사용) "amount": { "type": "formula", "expression": "quantity * unitPrice" }, - // 중간: 프론트 등록 블록 - "tax": { "type": "block", "blockId": "taxCalculation" }, - - // 복잡: 백엔드 위임 + // 복잡: 백엔드 위임 (확정) "totalDiscount": { "type": "api", "endpoint": "/api/v1/quotes/:id/calculate-discount", @@ -573,17 +755,7 @@ DynamicItemForm의 ComputedField → computed 타입으로 범용화 } ``` -**프론트 로직 블록 레지스트리**: - -``` -src/components/dynamic/logic-blocks/ - ├── taxCalculation.ts ← 세금 계산 - ├── currencyConversion.ts ← 환율 변환 - ├── stockValidation.ts ← 재고 검증 (API 호출) - └── registry.ts ← 블록 등록/조회 -``` - -> 새 블록이 필요할 때만 개발자 개입 → 등록 후 config에서 선택 사용 +프론트는 단순 사칙연산(ComputedField)만 담당하고, 그 외 모든 비즈니스 로직은 백엔드 API로 위임합니다. --- @@ -635,37 +807,88 @@ src/components/dynamic/logic-blocks/ --- -## 5. 백엔드 논의 체크리스트 +## 5. 논의 현황 정리 -백엔드와 확정해야 할 사항: +### 확정 사항 -- [ ] **page-config API 스펙**: `GET /api/v1/page-configs/{slug}` 응답 구조 -- [ ] **기준관리 어드민 UI**: mng에서 레이아웃/섹션/필드 등록 화면 설계 -- [ ] **JSON config 구조**: 위 제안 구조 검토 및 수정 -- [ ] **API 응답 통일**: list/detail/create/update/delete 응답 포맷 표준화 -- [ ] **권한 통합 방식**: config에 포함 vs 별도 API -- [ ] **분기 우선순위**: 테넌트 → 부서 → 역할 → 사용자 오버라이드 정책 -- [ ] **복잡한 비즈니스 로직**: 프론트 formula vs 백엔드 API 위임 기준 -- [ ] **캐시 무효화**: 기준관리 변경 시 프론트 캐시 갱신 방법 (polling vs push) -- [ ] **프리페치 범위**: 로그인 시 전체 config vs 페이지 접근 시 개별 로드 -- [ ] **검증 규칙 표현**: JSON validation 스펙 확정 -- [ ] **의존성 표현**: visibility/computed/cascade 등 JSON 스펙 확정 -- [ ] **마이그레이션 순서**: 어떤 페이지부터 동적 전환할지 +| 항목 | 확정 내용 | 비고 | +|------|----------|------| +| API 제공 방식 | 하이브리드 (C) — 단순 CRUD는 범용, 복잡 로직은 전용 | 범용 API 세분화 가능성 있음 | +| 복잡한 계산 수식 | 백엔드에서 전부 처리, 결과만 전달 | 프론트는 단순 사칙연산만 | +| 권한 관리 호환성 | 현재 권한 시스템으로 동적 페이지 컨트롤 가능 | 메뉴 ID + URL 패턴 매칭 방식 | +| 기존 동적 필드 재사용 | DynamicFieldRenderer 14종 등 90%+ 재사용 가능 | 기준관리 UI가 mng로 이동해도 렌더링 컴포넌트 유지 | +| DB 저장 방식 | PostgreSQL **JSONB** 사용 | 인덱싱/부분수정/내부검색 가능, 프론트 영향 없음 | + +### 협의 필요 사항 + +| 항목 | 현재 상태 | 논의 포인트 | +|------|----------|------------| +| JSON config 세부 구조 | 제안 구조 작성됨 (규칙 2 참조) | 회의에서 세부 항목 결정 후 확정 | +| 정적/동적 페이지 분류 | 초안 목록 작성됨 (규칙 3 참조) | 어떤 페이지를 정적으로 남길지 최종 확정 | +| 테넌트 하위 분기 정책 | 개념 정리됨 (규칙 7 참조) | 테넌트→부서→역할 오버라이드 정책, config를 최종 결과물로 줄지 프론트가 조합할지 | +| 동적 라우팅 전략 | catch-all 방식 제안 (규칙 10 참조) | 기존 정적 페이지와의 공존/전환 전략 | +| 범용 entity API 범위 | 하이브리드 방향 합의 | 페이지 렌더링 분기에 따라 범용 API 세분화 가능 | +| page-config API 스펙 | 미정 | `GET /api/v1/page-configs/{slug}` 응답 구조 | +| 기준관리 어드민 UI | 미정 | mng에서 레이아웃/섹션/필드 등록 화면 설계 | +| API 응답 통일 | 미정 | list/detail/create/update/delete 응답 포맷 표준화 | +| 캐시 무효화 | 미정 | 기준관리 변경 시 프론트 캐시 갱신 방법 (polling vs push) | +| 프리페치 범위 | 미정 | 로그인 시 전체 config vs 페이지 접근 시 개별 로드 | +| 검증/의존성 JSON 스펙 | 제안 구조 작성됨 (규칙 12, 13 참조) | 세부 스펙 확정 | +| 마이그레이션 순서 | Phase 0~4 제안 (규칙 17 참조) | 어떤 페이지부터 동적 전환할지 | +| 동적 페이지 → menu 자동 등록 | 미정 | 기준관리에서 페이지 생성 시 menu 테이블 자동 연동 방안 | +| 필드 단위 권한 | 현재 미지원 | v2 고려사항 (필요 시 추가 개발) | --- -## 6. 관련 문서 +## 6. 기존 자산 재사용 현황 + +### 즉시 재사용 가능 (코드 변경 없음) + +| 자산 | 현재 용도 | 동적 시스템 역할 | 재사용도 | +|------|----------|----------------|:---:| +| DynamicFieldRenderer (14종) | 품목 폼 필드 | 모든 동적 폼 필드 | 100% | +| DynamicTableSection | 품목 BOM 테이블 | 모든 동적 테이블 | 99% | +| DisplayCondition (9개 연산자) | 품목 조건부 표시 | 범용 visibility 규칙 | 100% | +| ComputedField | 품목 자동 계산 | 범용 단순 계산 | 100% | +| Reference Sources 프리셋 | 거래처/품목 등 조회 | 새 source 추가만으로 확장 | 100% | +| TenantAwareCache | 캐시 격리 | pageConfigStore 캐시 | 100% | +| menuRefresh (해시 비교) | 메뉴 갱신 | config 변경 감지 | 100% | +| buildApiUrl | URL 빌더 | 동적 API 호출 | 100% | +| PermissionGate / usePermission | 정적 페이지 권한 | 동적 페이지 권한 (동일) | 100% | + +### 범용화 필요 (약간의 리팩토링) + +| 자산 | 변경 사항 | +|------|----------| +| useDynamicFormState | API URL을 파라미터로 받도록 | +| useFormStructure | 품목 전용 API → 범용 API 경로 | +| types.ts | `ItemFieldResponse` → `DynamicFieldResponse` 리네이밍 | + +### 신규 개발 필요 + +| 자산 | 역할 | +|------|------| +| DynamicListPage | 동적 리스트 페이지 렌더러 (UniversalListPage 기반) | +| DynamicDetailPage | 동적 상세 페이지 렌더러 (IntegratedDetailTemplate 기반) | +| DynamicDashboardPage | 동적 대시보드 렌더러 | +| pageConfigStore | 페이지 config Zustand 스토어 | +| catch-all route | `[...slug]/page.tsx` 동적 라우터 | +| resolveApiUrl | API 경로 결정 유틸 (개별/범용 분기) | + +--- + +## 7. 관련 문서 | 문서 | 위치 | 내용 | |------|------|------| -| 동적 렌더링 플랫폼 비전 | `architecture/[VISION-2026-02-19]` | 전체 비전 및 자산 현황 | -| 멀티테넌시 최적화 로드맵 | `architecture/[PLAN-2026-02-06]` | 테넌트 격리/최적화 8 Phase | -| 동적 필드 타입 설계 | `architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드 | -| 동적 필드 구현 현황 | `architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 | -| 백엔드 API 스펙 | `item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 요청서 | +| 동적 렌더링 플랫폼 비전 | `claudedocs/architecture/[VISION-2026-02-19]` | 전체 비전 및 자산 현황 | +| 멀티테넌시 최적화 로드맵 | `claudedocs/architecture/[PLAN-2026-02-06]` | 테넌트 격리/최적화 8 Phase | +| 동적 필드 타입 설계 | `claudedocs/architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드 | +| 동적 필드 구현 현황 | `claudedocs/architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 | +| 백엔드 API 스펙 | `claudedocs/item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 요청서 | --- -**문서 버전**: 1.0 (초안) +**문서 버전**: 1.2 **마지막 업데이트**: 2026-03-11 -**다음 단계**: 백엔드와 논의 → 체크리스트 항목 확정 → v2.0 작성 +**다음 단계**: 백엔드 회의 → 협의 필요 항목 확정 → v2.0 작성 → `sam-docs/frontend/v2/`에 최종본 등록 diff --git a/next.config.ts b/next.config.ts index 76b70de7..d86b9b1c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트 turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility + allowedDevOrigins: ['192.168.0.*'], // 로컬 네트워크 기기 접속 허용 serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외 images: { remotePatterns: [ diff --git a/package.json b/package.json index cc3323bd..26d82403 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "node scripts/validate-next-cache.mjs && NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' next dev", "build": "next build", "build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &", "start": "next start -H 0.0.0.0", diff --git a/scripts/patch-json-parse.cjs b/scripts/patch-json-parse.cjs new file mode 100644 index 00000000..700cde2f --- /dev/null +++ b/scripts/patch-json-parse.cjs @@ -0,0 +1,46 @@ +/** + * JSON.parse 글로벌 패치 - macOS 26 파일시스템 손상 대응 + * + * macOS 26에서 atomic write(tmp + rename)가 실패하면 + * .next/prerender-manifest.json 등의 파일에 데이터가 중복 기록됨. + * 이로 인해 "Unexpected non-whitespace character after JSON at position N" 발생. + * + * 이 패치는 JSON.parse 실패 시 유효한 JSON 부분만 추출하여 자동 복구. + * NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' 로 로드. + */ +'use strict'; + +const originalParse = JSON.parse; + +JSON.parse = function patchedJsonParse(text, reviver) { + try { + return originalParse.call(this, text, reviver); + } catch (e) { + if (e instanceof SyntaxError && typeof text === 'string') { + // "Unexpected non-whitespace character after JSON at position N" + // → position N까지가 유효한 JSON + const match = e.message.match(/after JSON at position\s+(\d+)/); + if (match) { + const pos = parseInt(match[1], 10); + if (pos > 0) { + try { + const result = originalParse.call(this, text.substring(0, pos), reviver); + // 한 번만 경고 (같은 position이면 반복 출력 방지) + if (!patchedJsonParse._warned) patchedJsonParse._warned = new Set(); + const key = pos + ':' + text.length; + if (!patchedJsonParse._warned.has(key)) { + patchedJsonParse._warned.add(key); + console.warn( + `[patch-json-parse] macOS 파일 손상 자동 복구 (position ${pos}, total ${text.length} bytes)` + ); + } + return result; + } catch { + // truncation으로도 실패하면 원래 에러 throw + } + } + } + } + throw e; + } +}; diff --git a/scripts/validate-next-cache.mjs b/scripts/validate-next-cache.mjs new file mode 100644 index 00000000..cdb93902 --- /dev/null +++ b/scripts/validate-next-cache.mjs @@ -0,0 +1,49 @@ +/** + * .next 빌드 캐시 무결성 검증 + * + * macOS 26 파일시스템 이슈로 .next/ 내 JSON 파일이 손상될 수 있음. + * (atomic write 실패 → 데이터 중복 기록) + * dev 서버 시작 전 자동 검증하여 손상 시 .next 삭제. + */ +import { readFileSync, rmSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; + +const NEXT_DIR = '.next'; + +if (!existsSync(NEXT_DIR)) { + process.exit(0); +} + +const jsonFiles = []; +try { + // .next/ 루트의 JSON 파일들 + for (const f of readdirSync(NEXT_DIR)) { + if (f.endsWith('.json')) jsonFiles.push(join(NEXT_DIR, f)); + } + // .next/server/ 의 JSON 파일들 + const serverDir = join(NEXT_DIR, 'server'); + if (existsSync(serverDir)) { + for (const f of readdirSync(serverDir)) { + if (f.endsWith('.json')) jsonFiles.push(join(serverDir, f)); + } + } +} catch { + // 디렉토리 읽기 실패 시 무시 +} + +let corrupted = false; +for (const file of jsonFiles) { + try { + const content = readFileSync(file, 'utf8'); + JSON.parse(content); + } catch (e) { + console.warn(`⚠️ 손상된 캐시 발견: ${file}`); + console.warn(` ${e.message}`); + corrupted = true; + } +} + +if (corrupted) { + console.warn('🗑️ .next 캐시를 삭제하고 재빌드합니다...'); + rmSync(NEXT_DIR, { recursive: true, force: true }); +} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index c03dd477..4b390d2f 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { authenticatedFetch } from '@/lib/api/authenticated-fetch'; +import { stripJsonTrailingData } from '@/lib/api/safe-json-parse'; /** * 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern) @@ -190,7 +191,7 @@ async function proxyRequest( }, }); } else { - const responseData = await backendResponse.text(); + let responseData = await backendResponse.text(); // 백엔드가 HTML 에러 페이지를 반환한 경우 (404/500 등) // HTML을 그대로 전달하면 클라이언트 response.json()에서 SyntaxError 발생 @@ -208,6 +209,10 @@ async function proxyRequest( { status } ); } else { + // PHP trailing output 제거 (JSON 뒤에 warning/error 텍스트가 붙는 경우) + if (responseContentType.includes('application/json') || responseData.trimStart().startsWith('{') || responseData.trimStart().startsWith('[')) { + responseData = stripJsonTrailingData(responseData); + } clientResponse = new NextResponse(responseData, { status: backendResponse.status, headers: { diff --git a/src/components/approval/DocumentCreate/actions.ts b/src/components/approval/DocumentCreate/actions.ts index 75e5ba23..d340fa86 100644 --- a/src/components/approval/DocumentCreate/actions.ts +++ b/src/components/approval/DocumentCreate/actions.ts @@ -171,7 +171,7 @@ export async function uploadFiles(files: File[]): Promise<{ uploadedFiles.push({ id: result.data.id, name: result.data.display_name || file.name, - url: `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${result.data.id}/download`, + url: `/api/proxy/files/${result.data.id}/download`, size: result.data.file_size, mime_type: result.data.mime_type, }); @@ -589,7 +589,7 @@ function transformApiToFormData(apiData: { // URL이 없거나 상대 경로인 경우 다운로드 URL 생성 url: f.url?.startsWith('http') ? f.url - : `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${f.id}/download`, + : `/api/proxy/files/${f.id}/download`, size: f.size, mime_type: f.mime_type, })); diff --git a/src/components/common/NoticePopupModal/NoticePopupModal.tsx b/src/components/common/NoticePopupModal/NoticePopupModal.tsx index 33b47c2f..fbd1230c 100644 --- a/src/components/common/NoticePopupModal/NoticePopupModal.tsx +++ b/src/components/common/NoticePopupModal/NoticePopupModal.tsx @@ -112,8 +112,8 @@ export function NoticePopupModal({ popup, open, onOpenChange }: NoticePopupModal {/* 제목 */}
내용