20 KiB
멀티 테넌시(Multi-Tenancy) 구현 현황 분석 보고서
작성일: 2025-01-27
시스템: ERP-node (Node.js + Next.js)
📋 목차
개요
시스템 구조
- 멀티 테넌시 방식: 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(배송 현황)
해결 방안
- 단기: 위 테이블들을
COMPANY_FILTERED_TABLES에 추가 - 장기: 테이블별
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)
현재 구현 상태
- ✅ 백엔드:
getUserListAPI에서 최고 관리자 필터링 적용됨 - ⚠️ 프론트엔드:
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/searchGET /api/admin/users/by-departmentGET /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 수정 완료) |
✅ 결론
현재 상태
시스템은 멀티 테넌시가 견고하게 구현되어 있으며, 대부분의 핵심 기능에서 회사별 데이터 격리가 적용되고 있습니다.
주요 강점
- ✅ 인증 시스템: JWT 토큰에 companyCode 포함, 모든 요청에서 사용 가능
- ✅ 플로우 관리: 최근 업데이트로 완벽히 필터링 적용
- ✅ 외부 연결: DB/REST API 연결 모두 회사별 격리
- ✅ 사용자 관리: 최고 관리자 숨김 처리로 보안 강화
- ✅ 메뉴 관리: 최고 관리자/회사 관리자 권한 구분 명확
개선 권장 사항
- ⚠️
COMPANY_FILTERED_TABLES에 누락된 테이블 추가 - ⚠️ 프론트엔드 API 클라이언트 일관성 확보 (진행 중)
- ⚠️ 동적 테이블 생성 시 company_code 자동 추가 확인
- ✅ 기존 구현 유지 및 신규 기능에 동일 패턴 적용
최종 평가
현재 시스템은 멀티 테넌시 환경에서 안전하게 운영 가능한 수준이며, 소규모 개선 사항만 적용하면 완벽한 데이터 격리를 달성할 수 있습니다.
📝 작성자
- 작성: AI Assistant (Claude Sonnet 4.5)
- 검토 필요: 백엔드 개발자, 시스템 아키텍트
- 다음 리뷰 일정: 신규 기능 추가 시 또는 월 1회