--- priority: critical applies_to: all check_frequency: always enforcement: mandatory --- # 멀티테넌시(Multi-Tenancy) 필수 구현 가이드 **🚨 최우선 보안 규칙: 이 문서의 모든 규칙은 예외 없이 반드시 준수해야 합니다.** **⚠️ AI 에이전트는 모든 코드 작성/수정 후 반드시 이 체크리스트를 확인해야 합니다.** ## 핵심 원칙 **모든 비즈니스 데이터는 회사별(company_code)로 완벽하게 격리되어야 합니다.** 이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다. 다른 회사의 데이터에 접근하는 것은 **치명적인 보안 취약점**입니다. --- ## 1. 데이터베이스 스키마 요구사항 ### 1.1 company_code 컬럼 필수 **모든 비즈니스 테이블은 `company_code` 컬럼을 반드시 포함해야 합니다.** ```sql CREATE TABLE example_table ( id SERIAL PRIMARY KEY, company_code VARCHAR(20) NOT NULL, -- ✅ 필수! name VARCHAR(100), created_at TIMESTAMPTZ DEFAULT NOW(), -- 외래키 제약조건 (필수) CONSTRAINT fk_company FOREIGN KEY (company_code) REFERENCES company_mng(company_code) ON DELETE CASCADE ON UPDATE CASCADE ); -- 성능을 위한 인덱스 (필수) CREATE INDEX idx_example_company_code ON example_table(company_code); -- 복합 유니크 제약조건 (중복 방지) CREATE UNIQUE INDEX idx_example_unique ON example_table(name, company_code); -- 회사별로 고유해야 하는 경우 ``` ### 1.2 예외 테이블 (company_code 불필요) **⚠️ 유일한 예외: `company_mng` 테이블만 `company_code`가 없습니다.** 이 테이블은 회사 정보를 저장하는 마스터 테이블이므로 예외입니다. **모든 다른 테이블은 예외 없이 `company_code`가 필수입니다:** - ✅ `user_info` → `company_code` 필수 (사용자는 특정 회사 소속) - ✅ `menu_info` → `company_code` 필수 (회사별 메뉴 설정 가능) - ✅ `system_config` → `company_code` 필수 (회사별 시스템 설정) - ✅ `audit_log` → `company_code` 필수 (회사별 감사 로그) - ✅ 모든 비즈니스 테이블 → `company_code` 필수 **새로운 테이블 생성 시 체크리스트:** - [ ] `company_mng` 테이블인가? → `company_code` 불필요 (유일한 예외) - [ ] 그 외 모든 테이블 → `company_code` 필수 (예외 없음) - [ ] `company_code` 없이 테이블을 만들려고 하는가? → 다시 생각하세요! --- ## 2. 백엔드 API 구현 필수 사항 ### 2.1 모든 데이터 조회 시 필터링 **절대 원칙: 모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다.** #### ✅ 올바른 방법 ```typescript async function getDataList(req: Request, res: Response) { const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드 let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 회사 데이터 조회 가능 query = ` SELECT * FROM example_table ORDER BY created_at DESC `; params = []; logger.info("최고 관리자 전체 데이터 조회"); } else { // 일반 회사: 자신의 회사 데이터만 조회 query = ` SELECT * FROM example_table WHERE company_code = $1 ORDER BY created_at DESC `; params = [companyCode]; logger.info("회사별 데이터 조회", { companyCode }); } const result = await pool.query(query, params); return res.json({ success: true, data: result.rows }); } ``` #### ❌ 잘못된 방법 - 절대 사용 금지 ```typescript // 🚨 치명적 보안 취약점: company_code 필터링 없음 async function getDataList(req: Request, res: Response) { const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출! const result = await pool.query(query); return res.json({ success: true, data: result.rows }); } ``` ### 2.2 데이터 생성 (INSERT) **모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다.** #### ✅ 올바른 방법 ```typescript async function createData(req: Request, res: Response) { const companyCode = req.user!.companyCode; // 서버에서 확정 const { name, description } = req.body; const query = ` INSERT INTO example_table (company_code, name, description) VALUES ($1, $2, $3) RETURNING * `; const result = await pool.query(query, [companyCode, name, description]); logger.info("데이터 생성", { companyCode, id: result.rows[0].id, }); return res.json({ success: true, data: result.rows[0] }); } ``` #### ❌ 클라이언트 입력 사용 금지 ```typescript // 🚨 보안 취약점: 클라이언트가 임의의 회사 코드 지정 가능 async function createData(req: Request, res: Response) { const { companyCode, name } = req.body; // 사용자가 다른 회사 코드 전달 가능! const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`; await pool.query(query, [companyCode, name]); } ``` ### 2.3 데이터 수정 (UPDATE) **WHERE 절에 company_code를 반드시 포함해야 합니다.** #### ✅ 올바른 방법 ```typescript async function updateData(req: Request, res: Response) { const companyCode = req.user!.companyCode; const { id } = req.params; const { name, description } = req.body; let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 데이터 수정 가능 query = ` UPDATE example_table SET name = $1, description = $2, updated_at = NOW() WHERE id = $3 RETURNING * `; params = [name, description, id]; } else { // 일반 회사: 자신의 데이터만 수정 가능 query = ` UPDATE example_table SET name = $1, description = $2, updated_at = NOW() WHERE id = $3 AND company_code = $4 RETURNING * `; params = [name, description, id, companyCode]; } const result = await pool.query(query, params); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "데이터를 찾을 수 없거나 권한이 없습니다", }); } logger.info("데이터 수정", { companyCode, id }); return res.json({ success: true, data: result.rows[0] }); } ``` #### ❌ 잘못된 방법 ```typescript // 🚨 보안 취약점: 다른 회사의 같은 ID 데이터도 수정됨 const query = ` UPDATE example_table SET name = $1, description = $2 WHERE id = $3 `; ``` ### 2.4 데이터 삭제 (DELETE) **WHERE 절에 company_code를 반드시 포함해야 합니다.** #### ✅ 올바른 방법 ```typescript async function deleteData(req: Request, res: Response) { const companyCode = req.user!.companyCode; const { id } = req.params; let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 데이터 삭제 가능 query = `DELETE FROM example_table WHERE id = $1 RETURNING id`; params = [id]; } else { // 일반 회사: 자신의 데이터만 삭제 가능 query = ` DELETE FROM example_table WHERE id = $1 AND company_code = $2 RETURNING id `; params = [id, companyCode]; } const result = await pool.query(query, params); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "데이터를 찾을 수 없거나 권한이 없습니다", }); } logger.info("데이터 삭제", { companyCode, id }); return res.json({ success: true }); } ``` --- ## 3. company_code = "\*" 의 의미 ### 3.1 최고 관리자 전용 데이터 **중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다. - ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터 - ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터 ### 3.2 데이터 격리 원칙 **회사별 데이터 접근 규칙:** | 사용자 유형 | company_code | 접근 가능한 데이터 | | ----------- | ------------ | ---------------------------------------------- | | 회사 A | `COMPANY_A` | `company_code = 'COMPANY_A'` 데이터만 | | 회사 B | `COMPANY_B` | `company_code = 'COMPANY_B'` 데이터만 | | 최고 관리자 | `*` | 모든 회사 데이터 + `company_code = '*'` 데이터 | **핵심**: - 일반 회사는 `company_code = "*"` 데이터를 **절대 볼 수 없음** - 일반 회사는 다른 회사의 데이터를 **절대 볼 수 없음** - 최고 관리자만 모든 데이터에 접근 가능 --- ## 4. 복잡한 쿼리에서의 멀티테넌시 ### 4.1 JOIN 쿼리 **모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다.** #### ✅ 올바른 방법 ```typescript const query = ` SELECT a.*, b.name as category_name, c.name as user_name FROM example_table a LEFT JOIN category_table b ON a.category_id = b.id AND a.company_code = b.company_code -- ✅ JOIN 조건에도 company_code 필수 LEFT JOIN user_info c ON a.user_id = c.user_id AND a.company_code = c.company_code WHERE a.company_code = $1 `; ``` #### ❌ 잘못된 방법 ```typescript // 🚨 보안 취약점: JOIN에서 다른 회사 데이터와 섞임 const query = ` SELECT a.*, b.name as category_name FROM example_table a LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음! WHERE a.company_code = $1 `; ``` ### 4.2 서브쿼리 **모든 서브쿼리에도 company_code 필터링을 적용해야 합니다.** #### ✅ 올바른 방법 ```typescript const query = ` SELECT * FROM example_table WHERE category_id IN ( SELECT id FROM category_table WHERE active = true AND company_code = $1 -- ✅ ) AND company_code = $1 `; ``` #### ❌ 잘못된 방법 ```typescript // 🚨 보안 취약점: 서브쿼리에서 company_code 누락 const query = ` SELECT * FROM example_table WHERE category_id IN ( SELECT id FROM category_table WHERE active = true -- company_code 없음! ) AND company_code = $1 `; ``` ### 4.3 집계 함수 (COUNT, SUM 등) **집계 함수도 company_code로 필터링해야 합니다.** #### ✅ 올바른 방법 ```typescript const query = ` SELECT COUNT(*) as total FROM example_table WHERE company_code = $1 `; ``` #### ❌ 잘못된 방법 ```typescript // 🚨 보안 취약점: 모든 회사의 총합 반환 const query = `SELECT COUNT(*) as total FROM example_table`; ``` ### 4.4 EXISTS 서브쿼리 ```typescript // ✅ 올바른 방법 const query = ` SELECT * FROM example_table a WHERE EXISTS ( SELECT 1 FROM related_table b WHERE b.example_id = a.id AND b.company_code = a.company_code -- ✅ 필수 ) AND a.company_code = $1 `; ``` --- ## 5. 자동 필터 시스템 (autoFilter) ### 5.1 백엔드 구현 (이미 완료) 백엔드에는 `autoFilter` 기능이 구현되어 있습니다: ```typescript // tableManagementController.ts let enhancedSearch = { ...search }; if (autoFilter?.enabled && req.user) { const filterColumn = autoFilter.filterColumn || "company_code"; const userField = autoFilter.userField || "companyCode"; const userValue = (req.user as any)[userField]; if (userValue) { enhancedSearch[filterColumn] = userValue; logger.info("🔍 현재 사용자 필터 적용:", { filterColumn, userValue, tableName, }); } } ``` ### 5.2 프론트엔드 사용 (필수) **모든 테이블 데이터 API 호출 시 `autoFilter`를 반드시 전달해야 합니다.** #### ✅ 올바른 방법 ```typescript // frontend/lib/api/screen.ts const requestBody = { ...params, autoFilter: { enabled: true, filterColumn: "company_code", userField: "companyCode", }, }; const response = await apiClient.post( `/table-management/tables/${tableName}/data`, requestBody ); ``` #### Entity 조인 API ```typescript // frontend/lib/api/entityJoin.ts const autoFilter = { enabled: true, filterColumn: "company_code", userField: "companyCode", }; const response = await apiClient.get( `/table-management/tables/${tableName}/data-with-joins`, { params: { ...params, autoFilter: JSON.stringify(autoFilter), }, } ); ``` --- ## 6. 서비스 계층 패턴 ### 6.1 표준 서비스 함수 패턴 **서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다.** ```typescript class ExampleService { async findAll(companyCode: string, filters?: any) { let query: string; let params: any[]; if (companyCode === "*") { query = `SELECT * FROM example_table`; params = []; } else { query = `SELECT * FROM example_table WHERE company_code = $1`; params = [companyCode]; } return await pool.query(query, params); } async findById(companyCode: string, id: number) { let query: string; let params: any[]; if (companyCode === "*") { query = `SELECT * FROM example_table WHERE id = $1`; params = [id]; } else { query = `SELECT * FROM example_table WHERE id = $1 AND company_code = $2`; params = [id, companyCode]; } const result = await pool.query(query, params); return result.rows[0]; } async create(companyCode: string, data: any) { const query = ` INSERT INTO example_table (company_code, name, description) VALUES ($1, $2, $3) RETURNING * `; const result = await pool.query(query, [ companyCode, data.name, data.description, ]); return result.rows[0]; } } // 컨트롤러에서 사용 const exampleService = new ExampleService(); async function getDataList(req: Request, res: Response) { const companyCode = req.user!.companyCode; const data = await exampleService.findAll(companyCode, req.query); return res.json({ success: true, data }); } ``` --- ## 7. 마이그레이션 체크리스트 ### 7.1 새로운 테이블 생성 시 - [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가 - [ ] `company_mng` 테이블에 대한 외래키 제약조건 추가 - [ ] `company_code`에 인덱스 생성 - [ ] 복합 유니크 제약조건에 `company_code` 포함 - [ ] 샘플 데이터에 올바른 `company_code` 값 포함 ### 7.2 기존 테이블 마이그레이션 시 ```sql -- 1. company_code 컬럼 추가 ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20); -- 2. 기존 데이터를 모든 회사별로 복제 INSERT INTO example_table (company_code, name, description, created_at) SELECT ci.company_code, et.name, et.description, et.created_at FROM (SELECT * FROM example_table WHERE company_code IS NULL) et CROSS JOIN company_mng ci WHERE NOT EXISTS ( SELECT 1 FROM example_table et2 WHERE et2.name = et.name AND et2.company_code = ci.company_code ); -- 3. NULL 데이터 삭제 DELETE FROM example_table WHERE company_code IS NULL; -- 4. NOT NULL 제약조건 ALTER TABLE example_table ALTER COLUMN company_code SET NOT NULL; -- 5. 인덱스 및 외래키 CREATE INDEX idx_example_company ON example_table(company_code); ALTER TABLE example_table ADD CONSTRAINT fk_example_company FOREIGN KEY (company_code) REFERENCES company_mng(company_code) ON DELETE CASCADE ON UPDATE CASCADE; ``` --- ## 8. 테스트 체크리스트 ### 8.1 필수 테스트 시나리오 **모든 새로운 API는 다음 테스트를 통과해야 합니다:** - [ ] **회사 A 테스트**: 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인 - [ ] **회사 B 테스트**: 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인 - [ ] **격리 테스트**: 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인 - [ ] **최고 관리자 테스트**: 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인 - [ ] **수정 권한 테스트**: 회사 A가 회사 B의 데이터를 수정할 수 없는지 확인 - [ ] **삭제 권한 테스트**: 회사 A가 회사 B의 데이터를 삭제할 수 없는지 확인 ### 8.2 SQL 인젝션 테스트 ```typescript // company_code를 URL 파라미터로 전달하려는 시도 차단 // ❌ 이런 요청을 받아서는 안 됨 GET /api/data?company_code=COMPANY_B // ✅ company_code는 항상 req.user에서 가져와야 함 const companyCode = req.user!.companyCode; ``` --- ## 9. 감사 로그 (Audit Log) ### 9.1 모든 중요 작업에 로깅 ```typescript logger.info("데이터 생성", { companyCode: req.user!.companyCode, userId: req.user!.userId, tableName: "example_table", action: "INSERT", recordId: result.rows[0].id, }); logger.warn("권한 없는 접근 시도", { companyCode: req.user!.companyCode, userId: req.user!.userId, attemptedRecordId: req.params.id, message: "다른 회사의 데이터 접근 시도", }); ``` ### 9.2 감사 로그 테이블 구조 ```sql CREATE TABLE audit_log ( id SERIAL PRIMARY KEY, company_code VARCHAR(20) NOT NULL, user_id VARCHAR(100) NOT NULL, action VARCHAR(50) NOT NULL, table_name VARCHAR(100), record_id VARCHAR(100), old_value JSONB, new_value JSONB, ip_address INET, user_agent TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_audit_company ON audit_log(company_code); CREATE INDEX idx_audit_action ON audit_log(action, created_at); ``` --- ## 10. 보안 체크리스트 (코드 리뷰 시 필수) ### 10.1 백엔드 API 체크리스트 - [ ] 모든 SELECT 쿼리에 `WHERE company_code = $1` 포함 (최고 관리자 예외) - [ ] 모든 INSERT 쿼리에 `company_code` 컬럼 포함 - [ ] 모든 UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 포함 - [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 포함 - [ ] 서브쿼리에 `company_code` 필터링 포함 - [ ] 집계 함수에 `company_code` 필터링 포함 - [ ] `req.user.companyCode` 사용 (클라이언트 입력 사용 금지) - [ ] 최고 관리자(`company_code = "*"`) 예외 처리 - [ ] 로그에 `companyCode` 정보 포함 - [ ] 권한 없음 시 404 또는 403 반환 ### 10.2 프론트엔드 체크리스트 - [ ] 모든 테이블 데이터 API 호출 시 `autoFilter` 전달 - [ ] `company_code`를 직접 전달하지 않음 (백엔드에서 자동 처리) - [ ] 에러 발생 시 적절한 메시지 표시 ### 10.3 데이터베이스 체크리스트 - [ ] 모든 비즈니스 테이블에 `company_code` 컬럼 존재 - [ ] `company_code`에 NOT NULL 제약조건 적용 - [ ] `company_code`에 인덱스 생성 - [ ] 외래키 제약조건으로 `company_mng` 참조 - [ ] 복합 유니크 제약조건에 `company_code` 포함 --- ## 11. 일반적인 실수와 해결방법 ### 실수 1: 서브쿼리에서 company_code 누락 ```typescript // ❌ 잘못된 방법 const query = ` SELECT * FROM example_table WHERE category_id IN ( SELECT id FROM category_table WHERE active = true ) AND company_code = $1 `; // ✅ 올바른 방법 const query = ` SELECT * FROM example_table WHERE category_id IN ( SELECT id FROM category_table WHERE active = true AND company_code = $1 ) AND company_code = $1 `; ``` ### 실수 2: COUNT/SUM 집계 함수 ```typescript // ❌ 잘못된 방법 - 모든 회사의 총합 const query = `SELECT COUNT(*) as total FROM example_table`; // ✅ 올바른 방법 const query = ` SELECT COUNT(*) as total FROM example_table WHERE company_code = $1 `; ``` ### 실수 3: autoFilter 누락 ```typescript // ❌ 잘못된 방법 const response = await apiClient.post( `/table-management/tables/${tableName}/data`, { page, size, search, } ); // ✅ 올바른 방법 const response = await apiClient.post( `/table-management/tables/${tableName}/data`, { page, size, search, autoFilter: { enabled: true, filterColumn: "company_code", userField: "companyCode", }, } ); ``` --- ## 12. 참고 자료 ### 완료된 구현 예시 - **테이블 데이터 API**: `backend-node/src/controllers/tableManagementController.ts` (getTableData) - **Entity 조인 API**: `backend-node/src/controllers/entityJoinController.ts` (getTableDataWithJoins) - **카테고리 값 API**: `backend-node/src/services/tableCategoryValueService.ts` (getCategoryValues) - **프론트엔드 API**: `frontend/lib/api/screen.ts` (getTableData) - **프론트엔드 Entity 조인**: `frontend/lib/api/entityJoin.ts` (getTableDataWithJoins) ### 마이그레이션 스크립트 - `db/migrations/044_simple_version.sql` - table_type_columns에 company_code 추가 - `db/migrations/045_add_company_code_to_category_values.sql` - 카테고리 값 테이블 마이그레이션 --- ## 요약: 절대 잊지 말아야 할 핵심 규칙 ### 데이터베이스 1. **모든 테이블에 `company_code` 필수** (`company_mng` 제외) 2. **인덱스와 외래키 필수** 3. **복합 유니크 제약조건에 `company_code` 포함** ### 백엔드 API 1. **모든 SELECT 쿼리**: `WHERE company_code = $1` (최고 관리자 제외) 2. **모든 INSERT 쿼리**: `company_code` 컬럼 포함 3. **모든 UPDATE/DELETE 쿼리**: WHERE 절에 `company_code` 조건 포함 4. **JOIN/서브쿼리/집계**: 모두 `company_code` 필터링 필수 ### 프론트엔드 1. **모든 테이블 데이터 API 호출**: `autoFilter` 전달 필수 2. **`company_code`를 직접 전달 금지**: 백엔드에서 자동 처리 --- **🚨 멀티테넌시는 보안의 핵심입니다. 예외 없이 모든 규칙을 준수하세요!** **⚠️ company_code = "\*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!** **✅ 모든 테이블에 company_code 필수! (company_mng 제외)** --- ## 🤖 AI 에이전트 필수 체크리스트 **모든 코드 작성/수정 완료 후 반드시 다음을 확인하세요:** ### 데이터베이스 마이그레이션을 작성했다면: - [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가했는가? - [ ] `company_code`에 인덱스를 생성했는가? - [ ] `company_mng` 테이블에 대한 외래키를 추가했는가? - [ ] 복합 유니크 제약조건에 `company_code`를 포함했는가? - [ ] 기존 데이터를 모든 회사별로 복제했는가? ### 백엔드 API를 작성/수정했다면: - [ ] SELECT 쿼리에 `WHERE company_code = $1` 조건이 있는가? (최고 관리자 제외) - [ ] INSERT 쿼리에 `company_code` 컬럼이 포함되어 있는가? - [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건이 있는가? - [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건이 있는가? - [ ] 서브쿼리에 `company_code` 필터링이 있는가? - [ ] 집계 함수(COUNT, SUM 등)에 `company_code` 필터링이 있는가? - [ ] `req.user.companyCode`를 사용하고 있는가? (클라이언트 입력 사용 금지) - [ ] 최고 관리자(`company_code = "*"`) 예외 처리를 했는가? - [ ] 로그에 `companyCode` 정보를 포함했는가? - [ ] 권한 없음 시 적절한 HTTP 상태 코드(404/403)를 반환하는가? ### 프론트엔드 API 호출을 작성/수정했다면: - [ ] `autoFilter` 옵션을 전달하고 있는가? - [ ] `autoFilter.enabled = true`로 설정했는가? - [ ] `autoFilter.filterColumn = "company_code"`로 설정했는가? - [ ] `autoFilter.userField = "companyCode"`로 설정했는가? - [ ] `company_code`를 직접 전달하지 않았는가? (백엔드 자동 처리) ### 테스트를 수행했다면: - [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인했는가? - [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인했는가? - [ ] 회사 A가 회사 B 데이터에 접근할 수 없는지 확인했는가? - [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인했는가? --- **⚠️ 위 체크리스트 중 하나라도 "아니오"가 있다면, 코드를 다시 검토하세요!** **🚨 멀티테넌시 위반은 치명적인 보안 취약점입니다!**