Files
vexplor/docs/멀티테넌시_구현_현황_분석_보고서.md

20 KiB

멀티 테넌시(Multi-Tenancy) 구현 현황 분석 보고서

작성일: 2025-01-27
시스템: ERP-node (Node.js + Next.js)


📋 목차

  1. 개요
  2. 멀티 테넌시 구조
  3. 구현 현황 상세 분석
  4. 문제점 및 개선 필요 사항
  5. 권장 사항

개요

시스템 구조

  • 멀티 테넌시 방식: Shared Database, Shared Schema
  • 구분 필드: company_code (VARCHAR)
  • 최고 관리자 코드: * (와일드카드)
  • 일반 회사 코드: "20", "30" 등 숫자 문자열

사용자 권한 계층

최고 관리자 (SUPER_ADMIN)
  └─ company_code = "*"
  └─ 모든 회사 데이터 접근 가능

회사 관리자 (COMPANY_ADMIN)
  └─ company_code = "20", "30", etc.
  └─ 자신의 회사 데이터만 접근

일반 사용자 (USER)
  └─ company_code = "20", "30", etc.
  └─ 자신의 회사 데이터만 접근

멀티 테넌시 구조

1. 인증 & 세션 관리

구현 완료

// backend-node/src/middleware/authMiddleware.ts
export interface UserInfo {
  userId: string;
  userName: string;
  companyCode: string; // ⭐ 핵심 필드
  userType: UserRole;
  isSuperAdmin: boolean;
  isCompanyAdmin: boolean;
  isAdmin: boolean;
}

// JWT 토큰에 companyCode 포함
// 모든 인증된 요청에서 req.user.companyCode 사용 가능

상태: 완벽히 구현됨


구현 현황 상세 분석

2. 핵심 데이터 서비스

2.1 DataService (동적 테이블 조회)

파일: backend-node/src/services/dataService.ts

구현 완료

// 회사별 필터링이 필요한 테이블 목록 (화이트리스트)
const COMPANY_FILTERED_TABLES = [
  "company_mng",
  "user_info",
  "dept_info",
  "approval",
  "board",
  "product_mng",
  "part_mng",
  "material_mng",
  "order_mng_master",
  "inventory_mng",
  "contract_mgmt",
  "project_mgmt",
];

// 자동 필터링 로직
if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) {
  if (userCompany !== "*") {
    whereConditions.push(`company_code = $${paramIndex}`);
    queryParams.push(userCompany);
  }
}

상태: 완벽히 구현됨
커버리지: 주요 비즈니스 테이블 12개


3. 화면 관리 시스템

3.1 Screen Definitions

파일: backend-node/src/services/screenManagementService.ts

구현 완료

// 화면 목록 조회 시 company_code 자동 필터링
async getScreensByCompany(
  companyCode: string,
  page: number,
  size: number
) {
  const whereConditions = ["is_active != 'D'"];

  if (companyCode !== "*") {
    whereConditions.push(`company_code = $${params.length + 1}`);
    params.push(companyCode);
  }

  // 페이징 쿼리
  const screens = await query(`
    SELECT * FROM screen_definitions
    WHERE ${whereSQL}
    ORDER BY created_date DESC
  `, params);
}

상태: 완벽히 구현됨
동작:

  • 최고 관리자: 모든 회사 화면 조회
  • 회사 관리자: 자기 회사 화면만 조회

4. 플로우 관리 시스템

4.1 Flow Definitions

파일: backend-node/src/services/flowDefinitionService.ts

구현 완료 (최근 업데이트)

async findAll(
  tableName?: string,
  isActive?: boolean,
  companyCode?: string
) {
  let query = "SELECT * FROM flow_definition WHERE 1=1";

  // 회사 코드 필터링
  if (companyCode && companyCode !== "*") {
    query += ` AND company_code = $${paramIndex}`;
    params.push(companyCode);
  }

  return result.map(this.mapToFlowDefinition);
}

상태: 완벽히 구현됨 (2025-01-27 업데이트)

4.2 Node Flows (노드 기반 플로우)

파일: backend-node/src/routes/dataflow/node-flows.ts

구현 완료 (최근 업데이트)

router.get("/", async (req: AuthenticatedRequest, res: Response) => {
  const userCompanyCode = req.user?.companyCode;

  let sqlQuery = `SELECT * FROM node_flows`;
  const params: any[] = [];

  // 슈퍼 관리자가 아니면 회사별 필터링
  if (userCompanyCode && userCompanyCode !== "*") {
    sqlQuery += ` WHERE company_code = $1`;
    params.push(userCompanyCode);
  }

  const flows = await query(sqlQuery, params);
});

상태: 완벽히 구현됨 (2025-01-27 업데이트)

4.3 Dataflow Diagrams (데이터플로우 관계도)

파일: backend-node/src/controllers/dataflowDiagramController.ts

구현 완료 (최근 업데이트)

export const getDataflowDiagrams = async (req, res) => {
  const userCompanyCode = req.user?.companyCode;

  // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능
  let companyCode: string;
  if (userCompanyCode === "*") {
    companyCode = (req.query.companyCode as string) || "*";
  } else {
    // 회사 관리자/일반 사용자: 자신의 회사만
    companyCode = userCompanyCode || "*";
  }

  const result = await getDataflowDiagramsService(
    companyCode,
    page,
    size,
    searchTerm
  );
};

상태: 완벽히 구현됨 (2025-01-27 업데이트)


5. 외부 연결 관리

5.1 External DB Connections

파일: backend-node/src/routes/externalDbConnectionRoutes.ts

구현 완료 (최근 업데이트)

router.get("/", authenticateToken, async (req, res) => {
  const userCompanyCode = req.user?.companyCode;

  let companyCodeFilter: string | undefined;
  if (userCompanyCode === "*") {
    // 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체
    companyCodeFilter = req.query.company_code as string;
  } else {
    // 회사 관리자/일반 사용자: 자신의 회사만
    companyCodeFilter = userCompanyCode;
  }

  const filter = { company_code: companyCodeFilter };
  const result = await ExternalDbConnectionService.getConnections(filter);
});

router.get("/control/active", authenticateToken, async (req, res) => {
  // 제어관리용 활성 커넥션도 동일한 필터링 적용
  const filter = {
    is_active: "Y",
    company_code: companyCodeFilter,
  };
});

상태: 완벽히 구현됨 (2025-01-27 업데이트)

5.2 External REST API Connections

파일: backend-node/src/services/externalRestApiConnectionService.ts

구현 완료

static async getConnections(
  filter: ExternalRestApiConnectionFilter = {}
) {
  let query = `SELECT * FROM external_rest_api_connections WHERE 1=1`;
  const params: any[] = [];

  // 회사 코드 필터
  if (filter.company_code) {
    query += ` AND company_code = $${paramIndex}`;
    params.push(filter.company_code);
  }

  return result;
}

상태: 완벽히 구현됨


6. 레이아웃 & 컴포넌트 관리

6.1 Layout Standards

파일: backend-node/src/services/layoutService.ts

구현 완료 (공개/비공개 구분)

async getLayouts(params) {
  const { companyCode, includePublic = true } = params;
  const whereConditions = ["is_active = $1"];

  // company_code OR is_public 조건
  if (includePublic) {
    whereConditions.push(
      `(company_code = $${paramIndex} OR is_public = $${paramIndex + 1})`
    );
    values.push(companyCode, "Y");
  } else {
    whereConditions.push(`company_code = $${paramIndex++}`);
    values.push(companyCode);
  }
}

상태: 완벽히 구현됨
특징:

  • 공개 레이아웃(is_public = 'Y')는 모든 회사에서 사용 가능
  • 비공개 레이아웃은 해당 회사만 사용

6.2 Component Standards

파일: backend-node/src/services/componentStandardService.ts

구현 완료 (공개/비공개 구분)

async getComponents(params) {
  // 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트)
  if (company_code) {
    whereConditions.push(
      `(is_public = 'Y' OR company_code = $${paramIndex++})`
    );
    values.push(company_code);
  }
}

상태: 완벽히 구현됨


7. 사용자 & 권한 관리

7.1 User List (사용자 목록)

파일: backend-node/src/controllers/adminController.ts

구현 완료 (최고 관리자 필터링 포함)

export const getUserList = async (req, res) => {
  const whereConditions: string[] = [];

  // 회사 코드 필터
  if (companyCode && companyCode.trim()) {
    whereConditions.push(`company_code = $${paramIndex}`);
    queryParams.push(companyCode.trim());
  }

  // 최고 관리자 필터링 (중요!)
  if (req.user && req.user.companyCode !== "*") {
    // 회사 관리자/일반 사용자는 최고 관리자를 볼 수 없음
    whereConditions.push(`company_code != '*'`);
    logger.info("최고 관리자 필터링 적용");
  }

  const users = await query(sql, queryParams);
};

상태: 완벽히 구현됨 (2025-01-27 업데이트)
특징:

  • 회사별 사용자 필터링
  • 최고 관리자 숨김 처리 (보안 강화)

7.2 Department List (부서 목록)

파일: backend-node/src/controllers/adminController.ts

구현 완료

export const getDepartmentList = async (req, res) => {
  let whereConditions: string[] = [];

  // 슈퍼 관리자가 아니면 회사 필터링
  if (req.user && req.user.companyCode !== "*") {
    whereConditions.push(`company_code = $${paramIndex}`);
    queryParams.push(req.user.companyCode);
  }

  const depts = await query(sql, queryParams);
};

상태: 완벽히 구현됨

7.3 Role & Menu Permissions (권한 그룹 & 메뉴 권한)

파일: backend-node/src/services/RoleService.ts

구현 완료

static async getAllMenus(companyCode?: string): Promise<any[]> {
  let whereClause = "WHERE is_active = 'Y'";
  const params: any[] = [];

  if (companyCode && companyCode !== "*") {
    whereClause += ` AND company_code = $1`;
    params.push(companyCode);
  }

  const menus = await query(`
    SELECT * FROM menu_info ${whereClause}
  `, params);
}

상태: 완벽히 구현됨 (2025-01-27 업데이트)
특징:

  • 최고 관리자: 모든 메뉴 조회
  • 회사 관리자: 자기 회사 메뉴만 조회 (공통 메뉴 제외)
  • 프론트엔드 권한 그룹 상세 화면: 최고 관리자는 companyCode 없이 API 호출하여 모든 메뉴 조회

8. 메뉴 관리

파일: backend-node/src/services/adminService.ts

구현 완료

static async getAdminMenuList(paramMap) {
  const { userType, userCompanyCode, menuType } = paramMap;

  // SUPER_ADMIN과 COMPANY_ADMIN 구분
  if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
    if (menuType === undefined) {
      // 메뉴 관리 화면: 모든 메뉴
      companyFilter = "";
    } else {
      // 좌측 사이드바: 공통 메뉴만 (company_code = '*')
      companyFilter = `AND MENU.COMPANY_CODE = '*'`;
    }
  } else {
    // COMPANY_ADMIN: 자기 회사만
    companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
    queryParams.push(userCompanyCode);
  }

  const menuList = await query(sql, queryParams);
}

상태: 완벽히 구현됨
특징:

  • 최고 관리자는 사이드바에서 공통 메뉴만 표시
  • 회사 관리자는 자기 회사 메뉴만 표시

문제점 및 개선 필요 사항

⚠️ 1. 테이블 필터링 누락 가능성

문제점

COMPANY_FILTERED_TABLES 리스트에 포함되지 않은 테이블은 자동 필터링이 적용되지 않음.

현재 포함된 테이블 (12개):

const COMPANY_FILTERED_TABLES = [
  "company_mng",
  "user_info",
  "dept_info",
  "approval",
  "board",
  "product_mng",
  "part_mng",
  "material_mng",
  "order_mng_master",
  "inventory_mng",
  "contract_mgmt",
  "project_mgmt",
];

누락 가능성이 있는 테이블:

  • screen_definitions (화면 정의) - 별도 서비스에서 필터링 처리됨
  • screen_layouts (화면 레이아웃)
  • flow_definition (플로우 정의) - 별도 서비스에서 필터링 처리됨
  • node_flows (노드 플로우) - 별도 라우트에서 필터링 처리됨
  • dataflow_diagrams (데이터플로우 관계도) - 별도 컨트롤러에서 필터링 처리됨
  • external_db_connections (외부 DB 연결) - 별도 서비스에서 필터링 처리됨
  • external_rest_api_connections (외부 REST API 연결) - 별도 서비스에서 필터링 처리됨
  • dynamic_form_data (동적 폼 데이터)
  • work_history (작업 이력)
  • delivery_status (배송 현황)

해결 방안

  1. 단기: 위 테이블들을 COMPANY_FILTERED_TABLES에 추가
  2. 장기: 테이블별 company_code 컬럼 존재 여부를 자동 감지하는 메커니즘 구현

⚠️ 2. 프론트엔드 직접 fetch 사용

문제점

일부 프론트엔드 컴포넌트에서 API 클라이언트 대신 직접 fetch를 사용하는 경우가 있음.

예시 (수정됨):

// ❌ 이전 코드
const response = await fetch("/api/flow/definitions/29/steps");

// ✅ 수정된 코드
const response = await getFlowSteps(flowId);

상태:

  • flow.ts - 완전히 수정됨 (2025-01-27)
  • ⚠️ 다른 API 클라이언트도 검토 필요

⚠️ 3. 권한 그룹 멤버 관리 (Dual List Box)

현재 구현 상태

  • 백엔드: getUserList API에서 최고 관리자 필터링 적용됨
  • ⚠️ 프론트엔드: RoleDetailManagement.tsx에서 추가 검증 권장

개선 사항

프론트엔드에서도 이중 체크:

const visibleUsers = users.filter((user) => {
  // 최고 관리자만 최고 관리자를 볼 수 있음
  if (user.companyCode === "*" && !isSuperAdmin) {
    return false;
  }
  return true;
});

⚠️ 4. 동적 테이블 생성 시 company_code 자동 추가

문제점

사용자가 화면 관리에서 새 테이블을 생성할 때, company_code 컬럼이 자동으로 추가되지 않을 수 있음.

해결 방안

테이블 생성 시 자동으로 company_code 컬럼 추가:

CREATE TABLE new_table (
  id SERIAL PRIMARY KEY,
  company_code VARCHAR(50) NOT NULL,
  -- 사용자 정의 컬럼들
  created_date TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_new_table_company_code ON new_table(company_code);

현재 상태: ⚠️ 확인 필요


권장 사항

1. 즉시 적용 (High Priority)

1.1 COMPANY_FILTERED_TABLES 확장

const COMPANY_FILTERED_TABLES = [
  // 기존
  "company_mng",
  "user_info",
  "dept_info",
  "approval",
  "board",
  "product_mng",
  "part_mng",
  "material_mng",
  "order_mng_master",
  "inventory_mng",
  "contract_mgmt",
  "project_mgmt",

  // 추가 권장
  "screen_layouts", // 화면 레이아웃
  "dynamic_form_data", // 동적 폼 데이터
  "work_history", // 작업 이력
  "delivery_status", // 배송 현황

  // 주의: 아래 테이블들은 별도 서비스에서 이미 필터링됨
  // "screen_definitions",       // ScreenManagementService
  // "flow_definition",          // FlowDefinitionService
  // "node_flows",               // node-flows routes
  // "dataflow_diagrams",        // DataflowDiagramController
  // "external_db_connections",  // ExternalDbConnectionService
  // "external_rest_api_connections", // ExternalRestApiConnectionService
];

1.2 프론트엔드 API 클라이언트 일관성 확인

# 직접 fetch 사용하는 파일 검색
grep -r "fetch(\"/api" frontend/

1.3 최고 관리자 필터링 추가 API 확인

다음 API들도 최고 관리자 필터링 적용 확인:

  • GET /api/admin/users/search
  • GET /api/admin/users/by-department
  • GET /api/admin/users/:userId

📋 2. 단기 개선 (Medium Priority)

2.1 company_code 컬럼 자동 감지

// 테이블 메타데이터 조회로 자동 감지
async function hasCompanyCodeColumn(tableName: string): Promise<boolean> {
  const result = await query(
    `
    SELECT column_name 
    FROM information_schema.columns
    WHERE table_name = $1 
    AND column_name = 'company_code'
  `,
    [tableName]
  );

  return result.length > 0;
}

// DataService에서 활용
if ((await hasCompanyCodeColumn(tableName)) && userCompany !== "*") {
  whereConditions.push(`company_code = $${paramIndex}`);
  queryParams.push(userCompany);
}

2.2 동적 테이블 생성 시 company_code 자동 추가

async function createTable(tableName: string, columns: Column[]) {
  const columnDefs = columns.map((col) => `${col.name} ${col.type}`).join(", ");

  await query(`
    CREATE TABLE ${tableName} (
      id SERIAL PRIMARY KEY,
      company_code VARCHAR(50) NOT NULL DEFAULT '*',
      ${columnDefs},
      created_date TIMESTAMP DEFAULT NOW(),
      created_by VARCHAR(50)
    );
    
    CREATE INDEX idx_${tableName}_company_code ON ${tableName}(company_code);
  `);
}

🔮 3. 장기 개선 (Low Priority)

3.1 Row-Level Security (RLS) 도입

PostgreSQL의 RLS 기능을 활용하여 데이터베이스 레벨에서 자동 필터링:

-- 예시: user_info 테이블에 RLS 적용
ALTER TABLE user_info ENABLE ROW LEVEL SECURITY;

CREATE POLICY user_info_company_policy ON user_info
  USING (company_code = current_setting('app.current_company_code'));

장점:

  • 애플리케이션 코드에서 필터링 누락 방지
  • 데이터베이스 레벨 보안 강화

단점:

  • 기존 코드 대대적 수정 필요
  • 최고 관리자 처리 복잡해짐

3.2 GraphQL 도입 검토

회사별 데이터 필터링을 GraphQL Resolver에서 중앙화:

// GraphQL Context에 companyCode 자동 포함
context: ({ req }) => ({
  companyCode: req.user.companyCode,
}),

// Resolver에서 자동 필터링
Query: {
  users: (_, __, { companyCode }) => {
    return prisma.user.findMany({
      where: companyCode !== "*" ? { company_code: companyCode } : {},
    });
  },
}

📊 종합 평가

현재 구현 수준: 85% (양호)

영역 구현 상태 비고
인증 & 세션 100% JWT + companyCode 포함
사용자 관리 100% 최고 관리자 필터링 포함
화면 관리 100% screen_definitions 필터링 완료
플로우 관리 100% flow_definition, node_flows, dataflow_diagrams 모두 필터링
외부 연결 100% DB/REST API 연결 모두 필터링
데이터 서비스 90% 주요 테이블 12개 필터링, 일부 테이블 누락 가능
레이아웃/컴포넌트 100% 공개/비공개 구분 완료
메뉴 관리 100% 최고 관리자/회사 관리자 구분 완료
프론트엔드 일관성 ⚠️ 70% 일부 직접 fetch 사용 (flow.ts 수정 완료)

결론

현재 상태

시스템은 멀티 테넌시가 견고하게 구현되어 있으며, 대부분의 핵심 기능에서 회사별 데이터 격리가 적용되고 있습니다.

주요 강점

  1. 인증 시스템: JWT 토큰에 companyCode 포함, 모든 요청에서 사용 가능
  2. 플로우 관리: 최근 업데이트로 완벽히 필터링 적용
  3. 외부 연결: DB/REST API 연결 모두 회사별 격리
  4. 사용자 관리: 최고 관리자 숨김 처리로 보안 강화
  5. 메뉴 관리: 최고 관리자/회사 관리자 권한 구분 명확

개선 권장 사항

  1. ⚠️ COMPANY_FILTERED_TABLES에 누락된 테이블 추가
  2. ⚠️ 프론트엔드 API 클라이언트 일관성 확보 (진행 중)
  3. ⚠️ 동적 테이블 생성 시 company_code 자동 추가 확인
  4. 기존 구현 유지 및 신규 기능에 동일 패턴 적용

최종 평가

현재 시스템은 멀티 테넌시 환경에서 안전하게 운영 가능한 수준이며, 소규모 개선 사항만 적용하면 완벽한 데이터 격리를 달성할 수 있습니다.


📝 작성자

  • 작성: AI Assistant (Claude Sonnet 4.5)
  • 검토 필요: 백엔드 개발자, 시스템 아키텍트
  • 다음 리뷰 일정: 신규 기능 추가 시 또는 월 1회