ERP-node/.cursor/rules/multi-tenancy-guide.mdc

845 lines
24 KiB
Plaintext
Raw Permalink Normal View History

2025-11-06 18:35:05 +09:00
---
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 데이터에 접근할 수 없는지 확인했는가?
- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인했는가?
---
**⚠️ 위 체크리스트 중 하나라도 "아니오"가 있다면, 코드를 다시 검토하세요!**
**🚨 멀티테넌시 위반은 치명적인 보안 취약점입니다!**