Files
sam-react-prod/claudedocs/architecture/module-separation-guide.md
유병철 341f2b3e3f fix: [build] TS 에러 6건 수정 (수주 status 타입, 출하 cancelled 누락)
- page.tsx: draft→order_registered, in_progress→production_ordered (OrderStatus 타입 일치)
- actions.ts: ApiOrderStats에 in_production, produced optional 필드 추가
- ShipmentDetail.tsx: STATUS_TRANSITIONS에 cancelled 추가
- ShipmentList.tsx: colorMap에 cancelled 추가
2026-03-18 15:50:55 +09:00

12 KiB

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 값을 읽어서 모듈을 결정합니다. 이 값이 백엔드에서 내려와야 실제로 동작합니다.

// 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 → 프론트엔드 하드코딩 매핑으로 모듈 결정. 향후 백엔드에서 직접 모듈 목록을 내려주면 더 유연해짐.

// tenant.options 예시 (Phase 2)
{
    "industry": "shutter_mes",
    "modules": ["production", "quality", "vehicle-management"]  // 명시적 목록
}

프론트엔드는 이미 이 구조를 지원하도록 준비되어 있음:

// 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. 업종별 모듈 매핑 (프론트엔드 하드코딩)

// 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. 핵심 코드 패턴

기본 사용법

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 물리적 분리 (경계 마커, 검증, 가드, 문서) 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 스키마로 정의 → 코드 변경 없이 테넌트별 화면 커스터마이징