---
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 데이터에 접근할 수 없는지 확인했는가?
- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인했는가?

---

**⚠️ 위 체크리스트 중 하나라도 "아니오"가 있다면, 코드를 다시 검토하세요!**

**🚨 멀티테넌시 위반은 치명적인 보안 취약점입니다!**
