feat: [module] Phase 3 — 물리적 분리 (경계 마커, 검증 스크립트, 라우트 가드, 문서)

- MODULE.md 경계 마커 4개 (production, quality, construction, vehicle-management)
- verify-module-separation.sh: Common→Tenant 금지 임포트 검증 스크립트
- 영업 생산지시 3개 페이지에 useModules 가드 추가
- MODULE_SEPARATION_OK 주석 마커 (공유 래퍼 허용)
- tsconfig @modules/* path alias 추가
- CLAUDE.md 모듈 분리 아키텍처 섹션 추가
- 모듈 분리 가이드 문서 (claudedocs/architecture/)
This commit is contained in:
유병철
2026-03-18 15:40:53 +09:00
parent 60c4bc111c
commit 4b8ca09ea5
13 changed files with 557 additions and 2 deletions

View File

@@ -593,6 +593,73 @@ import { FormField } from '@/components/molecules/FormField';
---
## Module Separation Architecture
**Priority**: 🔴
### 개요
멀티테넌트 모듈 분리 아키텍처. 테넌트별로 필요한 모듈만 활성화하여 불필요한 기능 숨김.
`tenant.options.industry` 미설정 시 **모든 모듈 활성화** (기존 동작 100% 유지).
### 핵심 패턴: moduleAware 안전 장치
```typescript
const { isEnabled, tenantIndustry } = useModules();
const moduleAware = !!tenantIndustry; // industry 미설정 → false → 전부 허용
if (!moduleAware) return allData; // 기존과 동일
return filteredData; // 모듈 기반 필터링
```
### 모듈 구조
```
src/modules/
├── types.ts # ModuleId, TenantIndustry, MODULE_REGISTRY
├── config.ts # INDUSTRY_MODULES (업종별 활성 모듈 매핑)
├── ModuleGuard.tsx # 라우트 기반 접근 제어 (layout.tsx에서 사용)
└── ModuleProvider.tsx # React Context (tenant API → enabledModules 계산)
src/hooks/useModules.ts # { isEnabled, tenantIndustry, enabledModules, ... }
```
### 모듈 ID 목록
| ModuleId | 설명 | 테넌트 |
|----------|------|--------|
| `production` | 생산관리 | 경동 |
| `quality` | 품질관리 | 경동 |
| `construction` | 시공관리 | 주일 |
| `vehicle-management` | 차량관리 | 선택적 |
### 라우트 가드 vs 명시적 가드
- **라우트 가드 (ModuleGuard)**: `/production/*`, `/quality/*` 등 전용 라우트
- **명시적 가드 (useModules)**: 공통 라우트 내 모듈 의존 페이지 (예: `/sales/*/production-orders`)
```typescript
// 공통 라우트 내 모듈 의존 페이지 — 명시적 가드 필수
if (tenantIndustry && !isEnabled('production')) {
return <div>생산관리 모듈이 활성화되어 있지 않습니다.</div>;
}
```
### 크로스 모듈 임포트 규칙
- **Common → Tenant 직접 import 금지** (검증 스크립트: `scripts/verify-module-separation.sh`)
- **허용 예외**: `// MODULE_SEPARATION_OK` 주석 + `src/lib/api/` 공유 래퍼
- **Tenant → Common import**: 자유
- **Tenant → Tenant import**: 금지 (dynamic import만 허용)
### MODULE.md 경계 마커
각 테넌트 모듈 디렉토리에 `MODULE.md` 파일로 모듈 경계 문서화:
- `src/components/production/MODULE.md`
- `src/components/quality/MODULE.md`
- `src/components/business/construction/MODULE.md`
- `src/components/vehicle-management/MODULE.md`
### Path Aliases
```json
// tsconfig.json
"@modules/*": ["./src/modules/*"]
```
---
## User Environment
**Priority**: 🟢

View File

@@ -0,0 +1,234 @@
# SAM ERP 멀티테넌트 모듈 분리 아키텍처
> 작성일: 2026-03-18
> 상태: 프론트엔드 Phase 0~3 완료 / 백엔드 작업 필요
---
## 1. 개요
### 목표
하나의 SAM ERP 코드베이스에서 **테넌트(회사)별로 필요한 모듈만 활성화**하여,
불필요한 메뉴·페이지·대시보드 섹션을 숨기는 구조.
### 현재 테넌트별 모듈 구성
| 업종 코드 | 테넌트 예시 | 활성 모듈 |
|-----------|------------|-----------|
| `shutter_mes` | 경동 | 생산관리, 품질관리, 차량관리 |
| `construction` | 주일 | 시공관리, 차량관리 |
| (미설정) | 기타 모든 테넌트 | **전체 모듈 활성화 (기존과 동일)** |
### 안전 원칙
```
tenant.options.industry가 설정되지 않은 테넌트 → 모든 기능 그대로 사용 가능
= 기존 동작 100% 유지, 부작용 제로
```
---
## 2. 프론트엔드 구조 (완료)
### 파일 구조
```
src/modules/
├── types.ts # ModuleId 타입 정의
├── tenant-config.ts # 업종→모듈 매핑 (resolveEnabledModules)
└── index.ts # 모듈 레지스트리 (라우트 매핑, 대시보드 섹션)
src/hooks/
└── useModules.ts # React 훅: isEnabled(), isRouteAllowed(), tenantIndustry
```
### 모듈 ID 목록
| ModuleId | 이름 | 소유 라우트 | 대시보드 섹션 |
|----------|------|------------|--------------|
| `common` | 공통 ERP | /dashboard, /accounting, /sales, /hr, /approval, /settings 등 | 전부 |
| `production` | 생산관리 | /production | dailyProduction, unshipped |
| `quality` | 품질관리 | /quality | - |
| `construction` | 시공관리 | /construction | construction |
| `vehicle-management` | 차량관리 | /vehicle-management, /vehicle | - |
### 프론트엔드 동작 흐름
```
1. 로그인 → authStore에 tenant 정보 저장
2. useModules() 훅이 tenant.options.industry 읽음
3. industry 값으로 INDUSTRY_MODULE_MAP 조회 → 활성 모듈 목록 결정
4. 각 컴포넌트에서 isEnabled('production') 등으로 분기
```
### 적용된 영역
#### A. CEO 대시보드
- **섹션 필터링**: 비활성 모듈의 대시보드 섹션 자동 제외
- **API 호출 차단**: 비활성 모듈의 API는 호출하지 않음 (null endpoint)
- **설정 팝업**: 비활성 모듈 섹션은 설정에서도 안 보임
- **캘린더**: 비활성 모듈의 일정 유형 필터 숨김
- **요약 네비**: 비활성 섹션 자동 제외
#### B. 라우트 접근 제어
- `/production/*`, `/quality/*`, `/construction/*` 등 전용 라우트는 모듈 비활성 시 접근 차단
- `/sales/*/production-orders` 같은 공통 라우트 내 모듈 의존 페이지는 명시적 가드 적용
#### C. 사이드바 메뉴
- 비활성 모듈의 메뉴 항목 숨김 (isRouteAllowed 기반)
---
## 3. 백엔드 필요 작업
### 3.1 tenants 테이블 options 필드에 industry 추가
**우선순위: 🔴 필수**
현재 프론트엔드는 `tenant.options.industry` 값을 읽어서 모듈을 결정합니다.
이 값이 백엔드에서 내려와야 실제로 동작합니다.
```php
// tenants 테이블의 options JSON 컬럼에 industry 추가
// 예시 데이터:
{
"industry": "shutter_mes" // 경동: 셔터 MES
}
{
"industry": "construction" // 주일: 건설
}
// 다른 테넌트: industry 키 없음 → 프론트에서 전체 모듈 활성화
```
**작업 내용:**
1. `tenants` 테이블의 `options` JSON 컬럼에 `industry` 키 추가 (마이그레이션 불필요, JSON이므로)
2. 경동 테넌트: `options->industry = 'shutter_mes'`
3. 주일 테넌트: `options->industry = 'construction'`
4. 테넌트 정보 API 응답에 `options.industry` 포함 확인
**확인 포인트:**
- 프론트엔드에서 `authStore.currentUser.tenant.options.industry`로 접근
- 현재 로그인 API(`/api/v1/auth/me` 또는 유사)의 응답에서 tenant.options가 포함되는지 확인
- 포함 안 되면 응답에 추가 필요
### 3.2 (선택) 테넌트 관리 화면에서 industry 설정 UI
**우선순위: 🟡 선택**
관리자가 테넌트별 업종을 설정할 수 있는 UI. 급하지 않음 — DB 직접 수정으로 충분.
### 3.3 (Phase 2 예정) 명시적 모듈 목록 API
**우선순위: 🟢 향후**
현재는 `industry` → 프론트엔드 하드코딩 매핑으로 모듈 결정.
향후 백엔드에서 직접 모듈 목록을 내려주면 더 유연해짐.
```php
// tenant.options 예시 (Phase 2)
{
"industry": "shutter_mes",
"modules": ["production", "quality", "vehicle-management"] // 명시적 목록
}
```
프론트엔드는 이미 이 구조를 지원하도록 준비되어 있음:
```typescript
// src/modules/tenant-config.ts
export function resolveEnabledModules(options) {
// Phase 2: 백엔드가 명시적 모듈 목록 제공 → 우선 사용
if (explicitModules && explicitModules.length > 0) {
return explicitModules;
}
// Phase 1: industry 기반 기본값 (현재)
if (industry) {
return INDUSTRY_MODULE_MAP[industry] ?? [];
}
return [];
}
```
---
## 4. 업종별 모듈 매핑 (프론트엔드 하드코딩)
```typescript
// src/modules/tenant-config.ts
const INDUSTRY_MODULE_MAP: Record<string, ModuleId[]> = {
shutter_mes: ['production', 'quality', 'vehicle-management'],
construction: ['construction', 'vehicle-management'],
};
```
새로운 업종 추가 시:
1. 여기에 매핑 추가
2. 필요하면 `ModuleId` 타입에 새 모듈 ID 추가
3. `MODULE_REGISTRY` (src/modules/index.ts)에 라우트/대시보드 섹션 등록
---
## 5. 핵심 코드 패턴
### 기본 사용법
```typescript
import { useModules } from '@/hooks/useModules';
function MyComponent() {
const { isEnabled, tenantIndustry } = useModules();
// 안전 장치: industry 미설정이면 모든 기능 활성
const moduleAware = !!tenantIndustry;
if (moduleAware && !isEnabled('production')) {
return <div>생산관리 모듈이 비활성화되어 있습니다.</div>;
}
// 생산관리 기능 렌더링...
}
```
### 크로스 모듈 임포트 규칙
```
✅ Common → Common (자유)
✅ Tenant → Common (자유)
✅ Common → Tenant (래퍼 경유) (src/lib/api/에서 MODULE_SEPARATION_OK 주석과 함께)
❌ Common → Tenant (직접) (scripts/verify-module-separation.sh가 검출)
❌ Tenant → Tenant (금지, dynamic import만 허용)
```
---
## 6. 구현 이력
| Phase | 내용 | 커밋 | 상태 |
|-------|------|------|------|
| Phase 0 | 크로스 모듈 의존성 해소 | `a99c3b39` | ✅ 완료 |
| Phase 1 | 모듈 레지스트리 + 라우트 가드 | `0a65609e` | ✅ 완료 |
| Phase 2 | CEO 대시보드 모듈 디커플링 | `46501214` | ✅ 완료 |
| Phase 3 | 물리적 분리 (경계 마커, 검증, 가드, 문서) | (미커밋) | ✅ 완료 |
---
## 7. 테스트 시나리오
### 테스트 방법
백엔드에서 `tenant.options.industry`를 설정한 후:
| 시나리오 | 예상 결과 |
|----------|----------|
| industry 미설정 테넌트 로그인 | 기존과 완전 동일 (모든 메뉴/기능 표시) |
| `shutter_mes` 테넌트 로그인 | 시공관리 메뉴 숨김, 대시보드 시공 섹션 안 보임 |
| `construction` 테넌트 로그인 | 생산/품질 메뉴 숨김, 대시보드 생산 섹션 안 보임 |
| `shutter_mes`에서 `/construction` 직접 접근 | 접근 차단 메시지 표시 |
| `construction`에서 `/production` 직접 접근 | 접근 차단 메시지 표시 |
### 롤백 방법
문제 발생 시 DB에서 `tenant.options.industry` 값만 제거하면 즉시 원복.
프론트엔드 코드 변경 불필요.
---
## 8. 향후 로드맵
```
현재 (Phase 1) 향후 (Phase 2) 최종 (Phase 3)
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ industry 하드코딩 │ → │ 백엔드 modules 목록 │ → │ JSON 스키마 기반 │
│ 매핑으로 모듈 결정 │ │ API에서 직접 수신 │ │ 동적 페이지 조립 │
└─────────────────┘ └──────────────────────┘ └─────────────────────┘
```
- **Phase 2**: `tenant.options.modules = ["production", "quality"]` 형태로 백엔드에서 명시적 모듈 목록 전달 → 업종 매핑 테이블 불필요
- **Phase 3**: 각 모듈의 페이지 구성을 JSON 스키마로 정의 → 코드 변경 없이 테넌트별 화면 커스터마이징

View File

@@ -0,0 +1,97 @@
#!/bin/bash
# ===================================================================
# Module Separation Verification Script
#
# 공통 ERP → 테넌트 모듈 간 금지된 정적 import를 검사합니다.
# Phase 0에서 해소한 의존성이 다시 발생하지 않도록 CI에서 실행 가능.
#
# 사용법: bash scripts/verify-module-separation.sh
# 종료코드: 0 = 통과, 1 = 위반 발견
# ===================================================================
set -euo pipefail
echo "================================================="
echo " Module Separation Verification"
echo "================================================="
echo ""
# 테넌트 전용 경로 패턴 (from 'xxx' 또는 from "xxx" 형태로 검색)
TENANT_PATHS=(
"@/components/production/"
"@/components/quality/"
"@/components/business/construction/"
"@/components/vehicle-management/"
)
# 공통 ERP 소스 디렉토리 (테넌트 페이지 제외)
COMMON_DIRS=(
"src/components/approval"
"src/components/accounting"
"src/components/auth"
"src/components/atoms"
"src/components/board"
"src/components/business/CEODashboard"
"src/components/business/DashboardSwitcher.tsx"
"src/components/clients"
"src/components/common"
"src/components/customer-center"
"src/components/document-system"
"src/components/hr"
"src/components/items"
"src/components/layout"
"src/components/material"
"src/components/molecules"
"src/components/organisms"
"src/components/orders"
"src/components/outbound"
"src/components/pricing"
"src/components/providers"
"src/components/reports"
"src/components/settings"
"src/components/stocks"
"src/components/templates"
"src/components/ui"
"src/lib"
"src/hooks"
"src/stores"
"src/contexts"
)
VIOLATIONS=0
for dir in "${COMMON_DIRS[@]}"; do
# 디렉토리/파일이 없으면 스킵
[ -e "$dir" ] || continue
for tenant_path in "${TENANT_PATHS[@]}"; do
# 정적 import 검색 (dynamic import는 허용)
found=$(grep -rn "from ['\"]${tenant_path}" "$dir" \
--include="*.ts" --include="*.tsx" 2>/dev/null \
| grep -v "dynamic(" \
| grep -v "// MODULE_SEPARATION_OK" \
|| true)
if [ -n "$found" ]; then
echo "VIOLATION: $dir$tenant_path"
echo "$found"
echo ""
VIOLATIONS=$((VIOLATIONS + 1))
fi
done
done
echo "================================================="
if [ $VIOLATIONS -eq 0 ]; then
echo " PASSED: No forbidden imports found."
exit 0
else
echo " FAILED: Found $VIOLATIONS forbidden import(s)."
echo ""
echo " 해결 방법:"
echo " - dynamic import (next/dynamic)로 교체"
echo " - @/lib/api/ 또는 @/interfaces/로 타입 이동"
echo " - @/components/document-system/으로 공유 모달 이동"
echo " - 불가피한 경우 // MODULE_SEPARATION_OK 주석 추가"
exit 1
fi

View File

@@ -16,6 +16,7 @@
import { useState, useEffect, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import { useModules } from "@/hooks/useModules";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -345,6 +346,24 @@ export default function ProductionOrderCreatePage() {
const router = useRouter();
const params = useParams();
const orderId = params.id as string;
const { isEnabled, tenantIndustry } = useModules();
// 생산 모듈 비활성 시 접근 차단 (tenantIndustry 미설정 시 전부 허용)
if (tenantIndustry && !isEnabled('production')) {
return (
<PageLayout>
<div className="flex flex-col items-center justify-center min-h-[40vh]">
<p className="text-muted-foreground"> .</p>
<button
className="mt-4 text-sm text-blue-600 hover:underline"
onClick={() => router.push('/sales/order-management-sales')}
>
</button>
</div>
</PageLayout>
);
}
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

View File

@@ -11,6 +11,7 @@
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useModules } from "@/hooks/useModules";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -194,6 +195,24 @@ export default function ProductionOrderDetailPage() {
const router = useRouter();
const params = useParams();
const orderId = params.id as string;
const { isEnabled, tenantIndustry } = useModules();
// 생산 모듈 비활성 시 접근 차단 (tenantIndustry 미설정 시 전부 허용)
if (tenantIndustry && !isEnabled('production')) {
return (
<PageLayout>
<div className="flex flex-col items-center justify-center min-h-[40vh]">
<p className="text-muted-foreground"> .</p>
<button
className="mt-4 text-sm text-blue-600 hover:underline"
onClick={() => router.push('/sales/order-management-sales')}
>
</button>
</div>
</PageLayout>
);
}
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
const [loading, setLoading] = useState(true);

View File

@@ -12,6 +12,7 @@
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useModules } from "@/hooks/useModules";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -174,6 +175,23 @@ const TABLE_COLUMNS: TableColumn[] = [
export default function ProductionOrdersListPage() {
const router = useRouter();
const { isEnabled, tenantIndustry } = useModules();
// 생산 모듈 비활성 시 접근 차단 (tenantIndustry 미설정 시 전부 허용)
if (tenantIndustry && !isEnabled('production')) {
return (
<div className="flex flex-col items-center justify-center min-h-[40vh]">
<p className="text-muted-foreground"> .</p>
<button
className="mt-4 text-sm text-blue-600 hover:underline"
onClick={() => router.push('/sales/order-management-sales')}
>
</button>
</div>
);
}
const [stats, setStats] = useState<ProductionOrderStats>({
total: 0,
waiting: 0,

View File

@@ -0,0 +1,32 @@
# Construction Module (건설관리)
**Module ID**: `construction`
**Tenant**: Juil (주일건설)
**Route Prefixes**: `/construction`
**Component Count**: 161 files
## Dependencies on Common ERP
- `@/lib/api/*` — Server actions, API client
- `@/components/ui/*` — UI primitives (shadcn/ui)
- `@/components/templates/*` — IntegratedListTemplateV2 등
- `@/components/organisms/*` — PageLayout, PageHeader
- `@/hooks/*` — usePermission, useModules 등
- `@/stores/authStore` — Tenant 정보
- `@/components/common/*` — 공통 컴포넌트
## Exports to Common ERP
**NONE** — 건설 모듈은 독립적으로 작동.
## Related Dashboard Sections
- `construction` (시공 현황)
## Subdirectories
- `bidding/` — 입찰 관리
- `contract/` — 계약 관리
- `estimates/` — 견적 관리
- `progress-billing/` — 기성 관리
- `site-management/` — 현장 관리
- `labor-management/` — 노무 관리
- `item-management/` — 자재 관리
- `partners/` — 협력업체 관리
- 기타 20개 하위 도메인

View File

@@ -0,0 +1,25 @@
# Production Module (생산관리)
**Module ID**: `production`
**Tenant**: Kyungdong (경동 셔터 MES)
**Route Prefixes**: `/production`
**Component Count**: 56 files
## Dependencies on Common ERP
- `@/lib/api/*` — Server actions, API client
- `@/components/ui/*` — UI primitives (shadcn/ui)
- `@/components/templates/*` — IntegratedListTemplateV2 등
- `@/components/organisms/*` — PageLayout, PageHeader
- `@/hooks/*` — usePermission, useModules 등
- `@/stores/authStore` — Tenant 정보
- `@/stores/menuStore` — 사이드바 상태
## Exports to Common ERP
**NONE** — Phase 0에서 모든 교차 참조 해소 완료.
- 타입: `@/lib/api/production-orders/types.ts` (re-export)
- 서버 액션: `@/lib/api/production-orders/actions.ts` (async wrapper)
- 모달: `@/components/document-system/modals/` (dynamic import wrapper)
## Related Dashboard Sections
- `production` (생산 현황)
- `shipment` (출고 현황)

View File

@@ -0,0 +1,21 @@
# Quality Module (품질관리)
**Module ID**: `quality`
**Tenant**: Kyungdong (경동 셔터 MES)
**Route Prefixes**: `/quality`
**Component Count**: 35 files
## Dependencies on Common ERP
- `@/lib/api/*` — Server actions, API client
- `@/components/ui/*` — UI primitives (shadcn/ui)
- `@/components/templates/*` — IntegratedListTemplateV2 등
- `@/components/organisms/*` — PageLayout, PageHeader
- `@/hooks/*` — usePermission, useModules 등
- `@/stores/authStore` — Tenant 정보
## Exports to Common ERP
**NONE** — Phase 0에서 교차 참조 해소 완료.
- 모달: `@/components/document-system/modals/` (WorkLogModal — dynamic import)
## Related Dashboard Sections
없음 (품질 대시보드 섹션은 아직 미구현)

View File

@@ -0,0 +1,20 @@
# Vehicle Management Module (차량관리)
**Module ID**: `vehicle-management`
**Tenant**: Optional (경동 + 주일 공통 선택)
**Route Prefixes**: `/vehicle-management`, `/vehicle`
**Component Count**: 13 files
## Dependencies on Common ERP
- `@/lib/api/*` — Server actions, API client
- `@/components/ui/*` — UI primitives (shadcn/ui)
- `@/components/templates/*` — IntegratedListTemplateV2 등
- `@/components/organisms/*` — PageLayout, PageHeader
- `@/hooks/*` — usePermission, useModules 등
- `@/stores/authStore` — Tenant 정보
## Exports to Common ERP
**NONE**
## Related Dashboard Sections
없음

View File

@@ -11,7 +11,7 @@ import {
getProductionOrders as _getProductionOrders,
getProductionOrderStats as _getProductionOrderStats,
getProductionOrderDetail as _getProductionOrderDetail,
} from '@/components/production/ProductionOrders/actions';
} from '@/components/production/ProductionOrders/actions'; // MODULE_SEPARATION_OK — 공유 액션 래퍼 (Phase 0)
import type { ProductionOrderListParams } from './types';
export async function getProductionOrders(params: ProductionOrderListParams) {

View File

@@ -19,4 +19,4 @@ export type {
ApiProductionWorkOrder,
ApiBomProcessGroup,
ApiBomItem,
} from '@/components/production/ProductionOrders/types';
} from '@/components/production/ProductionOrders/types'; // MODULE_SEPARATION_OK — 공유 인터페이스 (Phase 0)

View File

@@ -25,6 +25,9 @@
"paths": {
"@/*": [
"./src/*"
],
"@modules/*": [
"./src/modules/*"
]
}
},