# SAM ERP 멀티테넌트 모듈 분리 아키텍처 > 작성일: 2026-03-18 > 상태: 프론트엔드 Phase 0~3 완료 / 백엔드 작업 필요 --- ## 0. 왜 산업군별 모듈 분리가 필요한가 (협의 필요) ### 현재 상황: 하나의 ERP, 다른 업종의 고객사 SAM ERP는 **하나의 코드베이스**로 여러 회사(테넌트)에 서비스를 제공합니다. 그런데 고객사마다 업종이 다릅니다: ``` 경동 → 셔터 제조업 (MES) → 생산관리, 품질관리, 차량관리가 필요 주일 → 건설업 → 시공관리, 차량관리가 필요 A사 → 유통/서비스업 → 공통 ERP(회계/인사/영업)만 필요 ``` 현재는 **모든 테넌트에게 모든 메뉴가 보입니다.** 경동 직원에게 시공관리 메뉴가 보이고, 주일 직원에게 생산관리 메뉴가 보입니다. → 사용하지 않는 메뉴가 노출되어 혼란을 주고, 대시보드에도 불필요한 섹션이 나타남. ### 제안: 업종(industry) 기반 모듈 ON/OFF ``` ┌─────────────────────────────────────────────────┐ │ SAM ERP (공통) │ │ 회계 · 인사 · 영업 · 결재 · 게시판 · 설정 │ ├──────────┬──────────┬───────────┬───────────────┤ │ 생산관리 │ 품질관리 │ 시공관리 │ 차량관리 │ │ (MES) │ │ (건설) │ (선택) │ ├──────────┴──────────┼───────────┤ │ │ 셔터 MES 업종 │ 건설 업종 │ │ │ (경동) │ (주일) │ │ └─────────────────────┴───────────┴───────────────┘ ``` - **공통 모듈**: 모든 테넌트가 사용 (회계, 인사, 영업 등) - **업종 모듈**: 테넌트의 업종에 따라 자동으로 켜짐/꺼짐 - **선택 모듈**: 업종과 관계없이 개별 선택 가능 (차량관리 등) ### 협의가 필요한 부분 이 구조로 가려면 다음 사항의 합의가 필요합니다: #### 1) 업종 분류 체계 현재 프론트엔드에 하드코딩된 매핑: | 업종 코드 | 의미 | 활성 모듈 | |-----------|------|-----------| | `shutter_mes` | 셔터 제조 (MES) | 생산관리 + 품질관리 + 차량관리 | | `construction` | 건설업 | 시공관리 + 차량관리 | **Q. 이 분류가 맞는지? 추가할 업종이 있는지?** 예: 일반 제조업, 도소매업, 서비스업 등 #### 2) 모듈 경계 현재 정의된 모듈 단위: | 모듈 | 포함 기능 | 비고 | |------|----------|------| | 공통 ERP | 대시보드, 회계, 인사, 영업, 결재, 게시판, 설정 등 | 항상 ON | | 생산관리 | 생산지시, 작업지시, 작업일보 | 경동 전용 | | 품질관리 | 설비점검, 수리요청, 검사 | 경동 전용 | | 시공관리 | 프로젝트, 계약, 기성, 시공일보 | 주일 전용 | | 차량관리 | 차량등록, 운행일지, 지게차 | 선택적 | **Q. 모듈 단위 범위가 적절한지? 분리/통합이 필요한 모듈이 있는지?** #### 3) 활성화 방식 | 방식 | 장점 | 단점 | |------|------|------| | **A. 업종 자동** (현재) | 간단, 실수 방지 | 유연성 낮음 | | **B. 모듈 개별 선택** (향후) | 유연함 | 관리 복잡 | | **C. 업종 기본값 + 개별 재정의** | 균형 | 구현 복잡도 중간 | **Q. 어떤 방식을 채택할 것인지?** 현재 프론트엔드는 A 방식으로 구현, B/C로 확장 가능하도록 설계됨. #### 4) 적용 시점과 범위 - 백엔드에서 `tenant.options.industry` 값만 세팅하면 즉시 동작 - 값을 안 넣으면 기존과 100% 동일 (부작용 제로) - **Q. 언제부터, 어떤 테넌트부터 적용할 것인지?** --- ## 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 = { 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
생산관리 모듈이 비활성화되어 있습니다.
; } // 생산관리 기능 렌더링... } ``` ### 크로스 모듈 임포트 규칙 ``` ✅ 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 | 물리적 분리 (경계 마커, 검증, 가드, 문서) | `4b8ca09e` | ✅ 완료 | --- ## 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 스키마로 정의 → 코드 변경 없이 테넌트별 화면 커스터마이징