# Cross-Tenant 어드민 합산 설계 (SUPER_ADMIN 전사 보기)

작성일: 2026-04-27
최종 갱신: 2026-04-29 — Phase A/B/C 구현·커밋 완료 표시, 검증 시나리오에 실제 결과 반영
작성자: hjjeong
관련: [docs/MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md), [notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md](../gbpark/2026-04-24-company-db-provisioning-execution-plan.md), [notes/gbpark/2026-04-24-company-mgmt-ui-schema.md](../gbpark/2026-04-24-company-mgmt-ui-schema.md)
실행 로그: [2026-04-28-cross-tenant-execution-log.md](2026-04-28-cross-tenant-execution-log.md)

> **상태 (2026-04-29):** Phase A/B/C 구현·검증·커밋 완료 (`hjjeong` 브랜치 `e16fb169`).
> Phase D/E 미진행. 자세한 진행 현황·미진행 항목·커밋 분배 의도는 실행 로그 §5~§6 참조.

---

## TL;DR

- DB-per-tenant 구조는 **그대로** 둔다. 메타 DB에 데이터를 다시 모으지 않는다.
- SUPER_ADMIN 이 `admin.invyone.com` 에서 어드민 14개 메뉴를 열면, 백엔드가 활성 회사 N 개에 **fan-out** 으로 동일 SELECT 를 돌려 합산 결과를 내려준다.
- 기존 [`CompanyStatsService.enrichOne()`](../../backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java) 의 N+1 패턴을 일반화한 `CrossTenantAggregator` 를 새로 만든다.
- 모든 응답 행에 `company_code` 컬럼을 박아 화면에서 회사 필터/그룹이 가능하게 한다.
- 회사 N≤20 까지는 직렬, 그 이후는 `CompletableFuture` 병렬 + 5분 캐시.

---

## 1. 배경

### 1.1 이미 깔린 것 (재사용)

| 인프라 | 위치 |
|---|---|
| 서브도메인 → tenant DB 라우팅 | [SubdomainResolverFilter](../../backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java) |
| 요청 단위 DB 컨텍스트 | [DbContextHolder](../../backend-spring/src/main/java/com/erp/tenant/DbContextHolder.java) (`META` / dbName / null) |
| 회사 → DB 룩업 + 캐시 | [CompanyResolver](../../backend-spring/src/main/java/com/erp/tenant/CompanyResolver.java) |
| Hikari 풀 lazy 생성 (회사 풀 `minIdle=0`) | [TenantDataSourceFactory](../../backend-spring/src/main/java/com/erp/tenant/TenantDataSourceFactory.java) |
| MyBatis 라우팅 DataSource | [TenantRoutingDataSource](../../backend-spring/src/main/java/com/erp/tenant/TenantRoutingDataSource.java) |
| Cross-tenant 1세대 패턴 (회사관리 메인) | [CompanyStatsService.enrichOne()](../../backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java) |
| 컨텍스트 임시 전환 패턴 | [CompanyLifecycleService](../../backend-spring/src/main/java/com/erp/provisioning/CompanyLifecycleService.java) — `setMeta()` / `set(dbName)` + finally `restoreCtx()` |
| 감사 로그 테이블 | `COMPANY_AUDIT_LOG` (메타 DB, 083 마이그레이션) |

### 1.2 설계 안 된 것 (이 문서가 메우는 갭)

`MULTI_TENANCY_ARCHITECTURE.md` 가 설명하는 라우팅·프로비저닝은 "회사 도메인으로 들어가서 그 회사 데이터만 본다" 시나리오에 최적화돼 있음. **`admin.invyone.com` 으로 들어와 14개 어드민 메뉴를 열 때 무엇을 보여줄지** 는 다음 메뉴들 외에 명문화 안 됨:

- 회사관리 (`CompanyStatsService` 로 회사별 1줄씩 줄나열) — 이미 됨
- 라이프사이클 (`CompanyLifecycleService`) — 단일 회사 대상이라 cross-tenant 아님

`provisioning-execution-plan.md` 4장에는 "SUPER_ADMIN 회사 DB 임시 전환 = Phase 5 이후 검토. 당장 불필요" 라고 적혀 있음. 본 문서는 이 항목의 후속 결정.

---

## 2. 설계 원칙

1. **DB-per-tenant 불변.** 메타 DB 로 데이터 회수 없음. 격리 원칙 유지.
2. **Fan-out 후 머지.** SUPER_ADMIN 요청 1건 = `COMPANY_MNG.DB_STATUS='active'` 회사 N 개 각각에 동일 SELECT 1번씩 + 응답 머지.
3. **읽기 전용.** Cross-tenant 엔드포인트는 SELECT 만 허용. 수정은 무조건 단일 회사 컨텍스트로 들어가서 한다 (3-A 회사 전환 모드는 본 문서 범위 외).
4. **company_code 컬럼 강제.** 모든 응답 행에 어느 회사에서 왔는지 박는다. 화면이 그룹/필터 가능하게.
5. **PK 충돌은 화면이 책임.** 회사 간 같은 `OBJID` 가 있을 수 있다 → keying 은 `${company_code}::${pk}` 로 합성.
6. **권장 N 범위는 ~50.** 그 이상은 병렬 + 캐시 + 페이지네이션 정책 별도 결정 (본 문서 9장).
7. **감사 흔적.** 본 엔드포인트 호출은 `COMPANY_AUDIT_LOG` 에 남기지 않음 (읽기 전용·고빈도). 단, 회사 컨텍스트 임시 전환 액션(추후 모드 A) 은 남겨야 함.

---

## 3. 아키텍처

```
[admin.invyone.com] (메타 DB 컨텍스트)
       │
       ▼
[SUPER_ADMIN check]   ← 권한 가드
       │
       ▼
[CrossTenantController]
   GET /api/admin/cross-tenant/users?...
       │
       ▼
[CrossTenantAggregator.fanOut(mapperId, params)]
       │  1. 메타 DB: SELECT DB_NAME, COMPANY_CODE FROM COMPANY_MNG WHERE DB_STATUS='active'
       │  2. 회사 N 개 각각:
       │       prev = DbContextHolder.get()
       │       try { DbContextHolder.set(dbName);
       │             rows = sqlSession.selectList(mapperId, params);
       │             rows.forEach(r -> r.put("company_code", code)); }
       │       finally { restoreCtx(prev); }
       │  3. List<Map> 머지하여 반환
       ▼
[Cache 5분 (선택, by mapperId+params hash)]
       │
       ▼
[Map<String,Object> 응답 (company_code 포함)]
```

### 3.1 핵심 컴포넌트

| 컴포넌트 | 역할 | 위치 |
|---|---|---|
| `CrossTenantContext` | "지금이 SUPER_ADMIN 의 cross-tenant 호출인가?" 마커 — JWT role + Host (admin.invyone.com) 양쪽 검증 | `com.erp.tenant.CrossTenantContext` (신규) |
| `CrossTenantAggregator` | 회사 N 개 fan-out + 머지. 직렬/병렬 모드 토글 | `com.erp.tenant.CrossTenantAggregator` (신규) |
| `CrossTenantController` | 엔드포인트 묶음. 메뉴별 fan-out mapper id 매핑 | `com.erp.controller.admin.CrossTenantController` (신규) |
| `mapper/admin-cross-tenant.xml` | 회사 DB 안에서 돌릴 정규화된 SELECT 모음 (수정 SQL 없음) | `mapper/admin-cross-tenant.xml` (신규) |
| `CrossTenantCache` | mapperId + params hash → 5분 LRU. 개발 모드 비활성 옵션 | `com.erp.tenant.CrossTenantCache` (신규, 선택) |

### 3.2 Aggregator 의사 코드

```java
public List<Map<String,Object>> fanOut(String mapperId, Map<String,Object> params) {
    requireSuperAdmin();
    requireMetaContext();   // 호출 시점에 DbContextHolder 가 META 여야 함

    List<Map<String,Object>> companies = sqlSession.selectList(
        "provisioning.listActiveCompanies");          // {company_code, db_name}

    if (parallel) {
        List<CompletableFuture<List<Map<String,Object>>>> futures = companies.stream()
            .map(c -> CompletableFuture.supplyAsync(() -> queryOne(c, mapperId, params), pool))
            .toList();
        return futures.stream().flatMap(f -> f.join().stream()).toList();
    } else {
        List<Map<String,Object>> out = new ArrayList<>();
        for (Map<String,Object> c : companies) out.addAll(queryOne(c, mapperId, params));
        return out;
    }
}

private List<Map<String,Object>> queryOne(Map<String,Object> c, String mapperId, Map<String,Object> params) {
    String prev = DbContextHolder.get();
    try {
        DbContextHolder.set((String) c.get("db_name"));
        List<Map<String,Object>> rows = sqlSession.selectList(mapperId, params);
        for (Map<String,Object> r : rows) r.put("company_code", c.get("company_code"));
        return rows;
    } catch (Exception e) {
        log.warn("[CrossTenant] {} failed for {}: {}", mapperId, c.get("db_name"), e.getMessage());
        return List.of();    // 한 회사 실패해도 전체는 진행
    } finally {
        if (prev == null) DbContextHolder.clear(); else DbContextHolder.set(prev);
    }
}
```

> **중요**: `sqlSession.selectList` 직접 호출이 가능한 이유 = `TenantRoutingDataSource` 가 `DbContextHolder` 값으로 매번 라우팅 결정하기 때문. raw JDBC 안 써도 됨. `CompanyStatsService` 는 1세대라 raw JDBC 였지만 이번엔 MyBatis 그대로 재사용.

### 3.3 병렬 풀 정책

```java
ExecutorService crossTenantPool = Executors.newFixedThreadPool(
    Math.min(activeCompanyCount, 8));
```

- 회사 50 개 X tenant 풀 maxPool=5 → 동시 동작 커넥션 ≤ 8 로 제한.
- pool 자체는 빈 상태. 회사 풀(`minIdle=0`) 이 lazy 생성되며 호출 끝나면 60초 idle 후 정리됨.

---

## 4. API 설계

### 4.1 네임스페이스

`/api/admin/cross-tenant/{domain}` — `domain` = `users`, `menus`, `roles`, `common-codes`, `i18n`, `batches`, `audit` 등.

### 4.2 공통 규칙

| 항목 | 규칙 |
|---|---|
| 인증 | JWT 필요. role = `SUPER_ADMIN` 만 통과 |
| 호스트 | Host 가 `admin.invyone.com` 또는 메타 컨텍스트인 경우만 허용 |
| 응답 형식 | `{ rows: [...], total: N, companies_queried: M, companies_failed: K }` |
| 행 식별자 | 모든 행에 `company_code` 추가. 화면 keying 시 `${company_code}::${pk}` 권장 |
| 페이지네이션 | 1차는 회사 단위로 limit 후 백엔드 머지 (offset 보장 X). 정확한 글로벌 페이지네이션 필요해지면 별도 설계 |
| 필터 | `?company_codes=A,B,C` 옵션 — fan-out 대상을 부분집합으로 제한 |
| 캐시 | `?nocache=1` 로 우회 가능 |

### 4.3 14개 어드민 메뉴 매핑 (1차 후보)

| 메뉴 | 엔드포인트 | fan-out mapper | 회사간 충돌 키 |
|---|---|---|---|
| 사용자관리 | `/cross-tenant/users` | `admin-cross-tenant.listUsers` | `USER_ID` |
| 메뉴관리 | `/cross-tenant/menus` | `admin-cross-tenant.listMenus` | `OBJID` |
| 권한관리 | `/cross-tenant/user-auth` | `admin-cross-tenant.listUserAuth` | `(user_id, menu_objid)` |
| 권한 그룹관리 | `/cross-tenant/roles` | `admin-cross-tenant.listRoles` | `OBJID` |
| 다국어관리 | `/cross-tenant/i18n` | `admin-cross-tenant.listLangKeys` | `KEY_ID` |
| 테이블 타입관리 | `/cross-tenant/table-types` | `admin-cross-tenant.listTableTypes` | `TABLE_NAME` |
| 공통코드관리 | `/cross-tenant/common-codes` | `admin-cross-tenant.listCommonCodes` | `(group_code, code)` |
| 화면관리 | `/cross-tenant/screens` | `admin-cross-tenant.listScreens` | `SCREEN_ID` |
| POP화면관리 | `/cross-tenant/pop-screens` | `admin-cross-tenant.listPopScreens` | `SCREEN_ID` |
| 대시보드관리 | `/cross-tenant/dashboards` | `admin-cross-tenant.listDashboards` | `DASHBOARD_ID` (메뉴통합 후 `OBJID`) |
| 배치관리 | `/cross-tenant/batches` | `admin-cross-tenant.listBatches` | `BATCH_ID` |
| 외부 커넥션 관리 | `/cross-tenant/connections` | `admin-cross-tenant.listConnections` | `CONNECTION_ID` |
| 회사관리 | (기존 `companies-stats` 유지) | — | `COMPANY_CODE` (메타 DB) |
| 메일관리 | (현재 API 미구현 — 본 설계 범위 외) | — | — |

### 4.4 응답 예시

```json
GET /api/admin/cross-tenant/users?status=active&limit=50
{
  "rows": [
    { "company_code": "COMPANY_27", "user_id": "mhkim",  "user_name": "...", "status": "Y", "last_login_date": "..." },
    { "company_code": "COMPANY_27", "user_id": "khlee",  "...": "..." },
    { "company_code": "COMPANY_10", "user_id": "wace",   "...": "..." }
  ],
  "total": 148,
  "companies_queried": 6,
  "companies_failed": 0,
  "from_cache": false
}
```

---

## 5. 라우팅·권한 가드

### 5.1 호스트 가드

`SubdomainResolverFilter` 는 이미 `admin.invyone.com` 을 reserved 처리해서 `DbContextHolder.setMeta()` 박음. 본 엔드포인트는 그 가정 위에서 동작.

추가로 `CrossTenantContext.requireMetaContext()` 가 진입 시 한 번 더 검증:
- `DbContextHolder.isMeta()` 가 `false` 면 400 `cross_tenant_requires_meta_context`.
- 회사 도메인(`qnc.invyone.com`) 으로 들어와서 cross-tenant 엔드포인트를 때리면 거부.

### 5.2 권한 가드

JWT claims:
- `role == SUPER_ADMIN` 만 통과.
- 일반 회사 관리자(`COMPANY_ADMIN`) 는 자기 회사 도메인의 기존 어드민 14개 메뉴(단일 회사 컨텍스트) 만 사용. cross-tenant 엔드포인트는 403.

`SecurityConfig` 에 `/api/admin/cross-tenant/**` 매처 추가하여 `hasRole("SUPER_ADMIN")` 강제.

### 5.3 실패 격리

회사 한 곳이 죽으면(예: tenant DB 다운, 풀 타임아웃, SQL 에러) 그 회사만 결과에서 빠지고 나머지는 진행.
- `companies_failed` 카운터 + 어떤 회사가 실패했는지 응답 헤더 `X-CrossTenant-Failed: COMPANY_8,COMPANY_9` 로 노출.
- 한 회사라도 실패면 `partial: true` 플래그로도 표시.

---

## 6. SQL 작성 규칙

### 6.1 mapper 파일 위치

`mapper/admin-cross-tenant.xml` — 모든 cross-tenant 전용 SELECT 한 곳에 모음.

### 6.2 작성 원칙

- **`company_code` 박지 말 것**. fan-out 시 Aggregator 가 응답에 박아준다. SQL 에서 박으면 회사 DB 안에 저장된 (참고용) `COMPANY_CODE` 값을 가져와서 메타 DB 라우팅 정보와 어긋날 수 있음.
- **WHERE 에 회사 코드 필터 안 박음.** 회사 DB 안의 데이터는 정의상 그 회사 데이터.
- **JOIN 은 회사 DB 내부 테이블끼리만.** 메타 DB 조인 금지.
- **결과 정규화.** 컬럼명을 lower_snake_case 로 통일 (기존 백엔드 규약 그대로).

### 6.3 예시

```xml
<!-- mapper/admin-cross-tenant.xml -->
<select id="listUsers" resultType="map">
  SELECT u.USER_ID         AS user_id,
         u.USER_NAME       AS user_name,
         u.EMAIL           AS email,
         u.STATUS          AS status,
         u.LAST_LOGIN_DATE AS last_login_date,
         u.WRITER          AS writer,
         u.CREATED_DATE    AS created_date
    FROM USER_INFO u
   <where>
     <if test="status != null">u.STATUS = #{status}</if>
     <if test="keyword != null">
       AND (u.USER_ID ILIKE '%' || #{keyword} || '%'
            OR u.USER_NAME ILIKE '%' || #{keyword} || '%')
     </if>
   </where>
   ORDER BY u.CREATED_DATE DESC
   <if test="limit != null">LIMIT #{limit}</if>
</select>
```

### 6.4 메타 DB SELECT (활성 회사 목록)

`mapper/provisioning.xml` 에 추가:

```xml
<select id="listActiveCompanies" resultType="map">
  SELECT COMPANY_CODE AS company_code,
         DB_NAME      AS db_name
    FROM COMPANY_MNG
   WHERE DB_STATUS = 'active'
     AND DB_NAME IS NOT NULL
   ORDER BY COMPANY_CODE
</select>
```

---

## 7. 캐시

### 7.1 정책

- 키: `mapperId + params canonical hash + companies snapshot version`
- TTL: 5분
- 무효화 트리거:
  - `CompanyLifecycleService.deactivate/reactivate/delete` → companies snapshot version bump
  - `CompanyProvisioningService.finalize()` (FINALIZE 단계) → companies snapshot version bump
- 메모리 한계: LRU 200 entry
- 개발 모드 (`spring.profiles.active=dev`) 는 cache off.

### 7.2 적용 시점

- N ≤ 20: cache 없이 직렬로 충분 (체감 < 500ms 예상).
- N > 20 또는 화면 1초 이상 체감: cache on + 병렬 on.

→ 1차 구현은 **cache off + 직렬**. 측정해보고 필요한 만큼만 켠다.

---

## 8. 화면(프론트) 가이드

### 8.1 라우팅·진입

- `admin.invyone.com` → 어드민 14개 메뉴는 cross-tenant 응답 그대로 렌더.
- 회사 도메인(`qnc.invyone.com`) → 같은 화면이 단일 회사 모드(기존 동작) 로 동작.
- `useAuth().role === 'SUPER_ADMIN'` && `host === 'admin.invyone.com'` 일 때 화면 상단에 **"전사 보기" 배너** 노출 (이건 별도 디자인 작업).

### 8.2 테이블 컬럼 추가

기존 어드민 테이블 컴포넌트들은 다음 두 가지만 변경:

1. 첫 컬럼에 **회사** (company_code → company_name 매핑 — 메타 DB `COMPANY_MNG` 에서 룩업) 노출.
2. 회사 필터 드롭다운 추가 (멀티셀렉트). 백엔드에 `?company_codes=` 로 전달.

### 8.3 키 충돌

`<DataGrid>` 의 `keyField` 를 단일 컬럼에서 `(row) => `${row.company_code}::${row[pk]}`` 로 변경. `ResponsiveDataView` 폴백 키 로직 ([frontend/components/admin-test-result.md](../../frontend/admin-test-result.md) "공통 수정 1") 과 호환.

### 8.4 편집 진입

전사 보기에서 1행 클릭하면 **그 회사 도메인으로 새 탭 오픈** + 해당 메뉴의 단일 회사 화면으로 이동.

```ts
const editUrl = `https://${row.subdomain}.invyone.com/admin/userMng/userMngList?focus=${row.user_id}`;
```

전사 보기는 SELECT 만, 수정은 회사 도메인 컨텍스트로 위임 — 본 설계 원칙 3 그대로.

---

## 9. 성능·확장

### 9.1 비용 모델

| N | 직렬 (raw 추정) | 병렬 (8 thread) |
|---|---|---|
| 6 (현재) | ~150ms | ~50ms |
| 20 | ~500ms | ~120ms |
| 50 | ~1.2s | ~250ms |
| 100 | ~2.5s | ~500ms |
| 500 | 비현실적 | 1차 cache hit 필수 |

> 가정: 회사당 SELECT 평균 25ms (count, 단순 list). 실측 후 갱신.

### 9.2 풀 영향

- 회사 풀 `minIdle=0` 정책 ([MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md) §10) 유지.
- Cross-tenant 호출이 회사 풀을 잠깐 깨우긴 하지만 60초 idle 후 정리되므로 상시 점유 없음.
- max_connections 영향: 동시 cross-tenant 호출 1건 = 활성 회사 수 만큼 커넥션 1초간 점유. 호출 빈도 낮으므로 N≤50 까지 무영향.

### 9.3 페이지네이션

1차 구현: 회사별 limit 후 머지. 글로벌 정확한 offset 페이지네이션 X.
- 화면이 페이지네이션 대신 **회사 필터 + 무한 스크롤** 위주로 가는 게 현 데이터 양에 더 맞음.
- 정확한 글로벌 페이지네이션이 필요해지면 → 메타 DB 에 인덱스용 mirror 테이블(예: `USER_INFO_INDEX`) 도입 검토. 본 문서 범위 외.

---

## 10. 단계별 실행 체크리스트 (2026-04-29 갱신)

### Phase A — 인프라 (최소) — ✅ 완료

- [x] `CrossTenantContext` (가드 클래스) — boolean 반환식으로 구현 (실행로그 §1)
- [x] `CrossTenantAggregator` (직렬, cache off)
- [x] `mapper/provisioning.xml` 에 `listActiveCompanies` 추가
- [ ] ~~`SecurityConfig` 에 `/api/admin/cross-tenant/**` SUPER_ADMIN 가드~~ — **의도적 미적용**. 95개 컨트롤러 회귀 위험 → 컨트롤러 레벨 `@RequestAttribute("role")` 검사로 대체 (실행로그 §1 표)
- [x] `CrossTenantController` 빈 컨트롤러 + 1개 엔드포인트 (`/users`)

### Phase B — PoC: 사용자관리 — ✅ 부분 완료

- [x] `mapper/admin-cross-tenant.xml` + `listUsers` SELECT
- [x] `/api/admin/cross-tenant/users` 동작 확인 (TEST01 1개 회사 / 8명)
- [x] 프론트 사용자관리 페이지에 host 분기 (`isCrossTenantMode()` 헬퍼로 분기)
- [~] 회사 컬럼 — `UserTable.tsx` 에 컬럼 정의 이미 있어 데이터만 박히면 자동 표시 / 회사 필터 드롭다운은 미구현

### Phase C — 14개 메뉴 확산 — 🟡 일부 완료 (3/14, 4 보류, 7 미진행)

- [x] 권한그룹관리 (`/cross-tenant/roles`, TEST01 1건)
- [x] 배치관리 (`/cross-tenant/batches`, TEST01 10건)
- [x] 다국어 키관리 (`/cross-tenant/lang-keys`, TEST01 646건 → cap 200)
- [ ] 메뉴관리 — **보류** (트리 구조 UX 결정 필요)
- [ ] 공통코드관리 — **보류** (카테고리 트리 의존)
- [ ] 외부 커넥션 관리 — **보류** (메타 JOIN + JSONB cast 다수)
- [ ] 화면관리 / POP화면관리 / 대시보드관리 — **보류** (행 수 규모 + 페이지네이션 정책)
- [ ] 권한관리 / 테이블 타입관리 / 메일관리 — 미진행
- [ ] 키 충돌 정책 (`company_code::pk`) 일괄 적용 — 미진행
- [ ] 행 클릭 → 회사 도메인 새 탭 오픈 패턴 — 미진행

### 페이지네이션 cap (설계서 §4.2 / §9.3 보강) — ✅ 완료

- [x] 회사당 디폴트 cap 200, override 1~2000
- [x] `truncated` 플래그 + `truncated_company_codes` 응답
- [x] 응답 헤더 `X-CrossTenant-Truncated`
- [x] 4개 cross-tenant 엔드포인트 (`/users`, `/roles`, `/batches`, `/lang-keys`) 모두 일원화

### Phase D — 측정 후 옵셔널 — ⏳ 미진행

- [ ] `CrossTenantCache` (5분 LRU)
- [ ] 병렬 fan-out (`CompletableFuture` + 8 thread pool)
- [ ] `companies_failed` 메트릭 노출 (Prometheus)
- [ ] N≥50 회사 환경 부하 테스트 (`autocannon` 으로 cross-tenant 엔드포인트 1초 100rps)

### Phase E — 문서 — 🟡 일부

- [ ] `docs/MULTI_TENANCY_ARCHITECTURE.md` 9장 "관련 마이그레이션" 표에 082~084 추가 + 본 문서 11장 참조 링크
- [ ] `docs/MULTI_TENANCY_ARCHITECTURE.md` 새 섹션 "12. 어드민 cross-tenant 합산" 신설하여 본 문서 요약 + 링크
- [x] `docs/MULTI_TENANCY_ARCHITECTURE.md` §4.2 (실행 모드 A/B) + §6 (1-b 분기) — dev `*.localhost` 분 갱신 (`383b837a` 에 포함)

---

## 11. 검증 시나리오 (2026-04-29 갱신 — 실 진행 상황 병기)

| 시나리오 | 상태 |
|---|---|
| 11.1 행복 경로 | ✅ 2회사 (TEST01 + TEST02) 머지 실증 완료 (2026-04-29). [실행로그 §9](2026-04-28-cross-tenant-execution-log.md) 참조 |
| 11.2 부분 실패 | ✅ 완료 (2026-04-29) — TEST02 USER_INFO RENAME 으로 SELECT 실패 유도, fail-open + `X-CrossTenant-Failed` 헤더 + `companies_failed: 1` 모두 확인. [실행로그 §9.4](2026-04-28-cross-tenant-execution-log.md) 참조 |
| 11.3 권한 | ✅ `super_admin_required` (토큰 없이 호출 → 403) — 스모크 (실행로그 §1) + fan-out 검증 (실행로그 §9.3) 모두 통과 |
| 11.4 락 비획득 | ⏳ 미검증 |
| 11.5 캐시 무효화 | ⏳ N/A — 캐시 미구현 (Phase D) |

### 11.1 행복 경로 (원안)

1. 회사 3개 (`qnc`, `kookje`, `topsil`) 활성. 각각 사용자 5명씩.
2. SUPER_ADMIN 으로 `https://admin.invyone.com/admin/userMng/userMngList` 진입.
3. 응답 `rows.length === 15`, 각 row 에 `company_code` 채워짐.
4. 회사 필터 "qnc" 선택 → `?company_codes=COMPANY_QNC` → `rows.length === 5`.

> **실 검증 (2026-04-28):** TEST01 1개 회사 / 8명 — 행마다 `company_code: "TEST01"`, `companies_queried: 1, companies_failed: 0` 확인.
> **다음:** TEST02 도 활성이므로 `companies_queried: 2`, 행에 두 회사 섞임 검증을 기다리는 상태 (즉시 호출 가능, 실행로그 §6 #3).

### 11.2 부분 실패

1. `topsil` 회사 DB pg_terminate 로 다운.
2. cross-tenant 호출.
3. `rows` 에 qnc/kookje 만, `companies_failed: 1`, 응답 헤더 `X-CrossTenant-Failed: COMPANY_TOPSIL`.
4. 화면이 "1개 회사 조회 실패" 토스트 노출.

### 11.3 권한

1. 일반 `COMPANY_ADMIN` 으로 cross-tenant 엔드포인트 호출 → 403.
2. SUPER_ADMIN 인데 회사 도메인(`qnc.invyone.com`) 으로 호출 → 400 `cross_tenant_requires_meta_context`.

> **실 검증 (2026-04-28):** 토큰 없이 `/api/admin/cross-tenant/_active-companies` 호출 → 403 `super_admin_required` ✓. SUPER_ADMIN 토큰 호출 → 200 ✓.

### 11.4 락 비획득

1. 한 회사 풀이 풀 maxPool=5 모두 점유 중.
2. cross-tenant 호출 시 `connection-timeout=30000` 걸림 → 실패 격리로 그 회사만 빠짐.

### 11.5 캐시 무효화

1. cross-tenant 호출 → cache hit 만들기.
2. 그 사이 SUPER_ADMIN 이 회사 신규 프로비저닝 (FINALIZE 완료).
3. 다음 cross-tenant 호출 시 새 회사가 즉시 반영되는지 확인 (companies snapshot version bump 동작).

> **N/A:** 캐시 자체가 Phase D 로 미루어진 상태 (1차 구현은 cache off + 직렬, 본 문서 §7.2). 캐시 도입 시 본 시나리오 활성.

---

## 12. 미정·후속

| 항목 | 결정 시점 |
|---|---|
| 글로벌 페이지네이션 (정확한 offset) | 회사당 데이터 1만건 넘어가는 도메인 생기면 |
| 메타 DB index mirror (전사 검색 가속) | 위 항목과 같은 시점 |
| Cross-tenant write (예: 전 회사 일괄 메뉴 추가) | 별도 RFC. 본 문서 범위 외. 트랜잭션 정합성·롤백 정책 별도 결정 필요 |
| `SUPER_ADMIN 회사 컨텍스트 임시 전환` (모드 A — 회사 드롭다운으로 그 회사 데이터 편집) | 본 문서 5.1 가드 위에 별도 라우팅·감사 정책 필요. 후속 |
| 회사간 같은 USER_ID 가 같은 사람인가 (계정 통합) | 멀티테넌시 정책 자체 재검토. 현재 정책: 다름 |
| Mail 도메인 cross-tenant 보기 | Mail API 본 백엔드 구현 후 |

---

## 13. 위반 금지 (요약)

- ❌ Cross-tenant 엔드포인트에서 UPDATE/DELETE/INSERT 호출 (수정은 회사 도메인 컨텍스트로 위임)
- ❌ Aggregator 안에서 메타 DB 와 tenant DB 를 한 mapperId 안에서 JOIN
- ❌ tenant DB 안에 저장된 `COMPANY_CODE` 값을 메타 DB 라우팅 정보보다 우선시
- ❌ 한 회사 실패 시 전체 응답 500 으로 떨어뜨리기 (실패 격리 원칙 위반)
- ❌ JWT role 체크 누락한 채 `/api/admin/cross-tenant/**` 노출
- ❌ DbContextHolder 변경 후 finally 에서 restore 누락 (요청 누수 위험)
- ❌ 회사 풀 `minIdle` 을 cross-tenant 핫경로 때문에 0 보다 키우기 (전사 풀 폭증)

---

## 14. 다음 세션 진입 시 (2026-04-29 갱신)

본 설계 문서는 의도/원칙의 진실의 원천이고, **현재 구현·검증 진행 상황은 [실행 로그](2026-04-28-cross-tenant-execution-log.md) §5~§6 에서 확인.**

Phase A/B/C 의 핵심 (직렬 fan-out + Aggregator + 4개 엔드포인트 + 페이지네이션 cap) 은 `hjjeong` 브랜치 `e16fb169` 에 커밋·푸시 완료.

### 즉시 가능한 다음 작업 (우선순위 순)

1. **TEST02 가 활성** 상태이므로 진짜 fan-out (`companies_queried: 2`) 호출 검증 — `/api/admin/cross-tenant/users` 등 4개 엔드포인트에 SUPER_ADMIN 토큰으로 호출, 행에 두 회사 섞이는지 + 실패 격리 확인. 본 설계 §11.1·§11.2 의 첫 실증.
2. 화면 측 `truncated === true` 안내 박스 (4개 페이지) — 실행로그 §3.5 의 권장 사용 그대로.
3. 보류 4개 메뉴 (메뉴/공통코드/외부커넥션/화면계열) UX 결정 후 mapper 작성.
4. Phase D (캐시·병렬) — 회사 N≥20 환경 만든 뒤 측정 후 결정.

### 새 코드 작성 시 주의

- 새 코드는 raw JDBC 가 아니라 MyBatis `sqlSession` 을 그대로 쓴다 (`TenantRoutingDataSource` 가 있기 때문). 이미 만들어진 [CompanyStatsService.enrichOne()](../../backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java) 의 raw JDBC 패턴은 1세대라 그대로 둔 것.
- Phase A → B → C 순서 강제 원칙 유지. Aggregator 가 흔들리면 4개 엔드포인트가 모두 흔들림 — 새 메뉴 mapper 추가 시 `runFanOut(...)` 헬퍼 (실행로그 §3) 에 얹어서 일원화.
- 컨트롤러 가드는 `SecurityConfig` 가 아닌 컨트롤러 레벨 `@RequestAttribute("role")` 검사 (실행로그 §1 의 의도된 우회).
