# Cross-Tenant 어드민 합산 — 실행 로그

작성일: 2026-04-28
최종 갱신: 2026-04-29 — 커밋/푸시 상태 반영, dev `*.localhost` 라우팅 후속 묶음 기재
작성자: hjjeong
관련 설계: [2026-04-27-cross-tenant-admin-aggregation.md](2026-04-27-cross-tenant-admin-aggregation.md)
관련 후속(같은 커밋 배치): [2026-04-28-localhost-tenant-routing-handoff.md](2026-04-28-localhost-tenant-routing-handoff.md)
관련 SoT: [docs/MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md)

---

## TL;DR

- **Phase A (백엔드 인프라)** — 작성·검증 완료
- **Phase B (사용자관리 PoC)** — 작성·검증 완료 (TEST01 회사 사용자 8명, `company_code` 정상 주입)
- **Phase C (확산)** — 권한그룹/배치/다국어키 3개 작성·검증 완료 (TEST01: 1/10/646 건)
- **페이지네이션 cap** — 회사당 디폴트 200, override 1~2000, `truncated` 플래그 응답 (검증 완료)
- **커밋/푸시 완료** (2026-04-28 저녁) — `hjjeong` 브랜치에 3 커밋, `origin/hjjeong` 까지 푸시. base `gbpark-node` 대비 20 ahead / 0 behind
- TEST02 회사 프로비저닝됨 — 진짜 fan-out (2회사 머지) 호출 검증은 **미진행** (조건만 충족)

---

## 1. Phase A — 백엔드 인프라 (✅ 검증)

### 신규 파일

| 파일 | 역할 |
|---|---|
| [backend-spring/.../crosstenant/CrossTenantContext.java](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantContext.java) | SUPER_ADMIN role + META 컨텍스트 가드 (boolean 반환식) |
| [backend-spring/.../crosstenant/CrossTenantAggregator.java](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantAggregator.java) | 활성 회사 N개에 fan-out + 응답 행에 `company_code` 주입 + 실패 격리 |
| [backend-spring/.../crosstenant/CrossTenantController.java](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java) | `/api/admin/cross-tenant/**` 컨트롤러 |
| [backend-spring/.../mapper/admin-cross-tenant.xml](../../backend-spring/src/main/resources/mapper/admin-cross-tenant.xml) | cross-tenant 전용 SELECT 모음 |
| [frontend/lib/auth/crossTenantMode.ts](../../frontend/lib/auth/crossTenantMode.ts) | `decodeJwtClaims()` / `isCrossTenantMode()` 공용 헬퍼 |

### 기존 파일 수정 (additive only)

| 파일 | 변경 |
|---|---|
| [mapper/provisioning.xml](../../backend-spring/src/main/resources/mapper/provisioning.xml) | `listActiveCompanies` SELECT 추가 (`DB_STATUS='active' AND DB_NAME IS NOT NULL`) |

### 설계 의도와 다른 점 — 기록

| 설계서 명시 | 실제 구현 | 이유 |
|---|---|---|
| `SecurityConfig` 에 `/api/admin/cross-tenant/**` SUPER_ADMIN 가드 추가 | **안 함**. 컨트롤러 레벨에서 `@RequestAttribute("role")` 검사 | [SecurityConfig.java:52~56](../../backend-spring/src/main/java/com/erp/security/SecurityConfig.java#L52) 코멘트 — "95개 컨트롤러가 permitAll 로 동작 중. SecurityConfig 강제 인증 켜면 회귀 위험". 기존 ProvisioningController 도 컨트롤러 레벨 검사 패턴 |
| Aggregator 가 throw 로 가드 위반 표시 | boolean 반환 + 컨트롤러가 `ResponseEntity.status(403/400)` 명시 반환 | [GlobalExceptionHandler.java:32](../../backend-spring/src/main/java/com/erp/config/GlobalExceptionHandler.java#L32) 의 `@ExceptionHandler(Exception.class)` 가 모든 예외를 500 으로 단일 변환. `ResponseStatusException` 도 잡힘. 우회 |

### 스모크 테스트 — 통과

- `GET /api/admin/cross-tenant/_active-companies` (토큰 없음) → 403 `super_admin_required` ✓
- 동 엔드포인트 (SUPER_ADMIN 토큰) → 200, `rows=[{company_code: "TEST01", db_name: "test01_invyone", ...}], total=1` ✓

---

## 2. Phase B — 사용자관리 PoC (✅ 검증)

### 신규/수정 파일

| 파일 | 변경 |
|---|---|
| [mapper/admin-cross-tenant.xml](../../backend-spring/src/main/resources/mapper/admin-cross-tenant.xml) | `listUsers` SELECT 추가 (단일 회사 `admin.selectUserList` 와 컬럼 동일, `COMPANY_CODE` 필터 제외) |
| [CrossTenantController.java](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java) | `GET /users` 엔드포인트 |
| [frontend/lib/api/user.ts](../../frontend/lib/api/user.ts) | `getUserList` 분기 (`isCrossTenantMode()` → cross-tenant 엔드포인트, 응답 shape 정규화) |

### 변경하지 않은 파일 (의도)

- [components/admin/UserTable.tsx](../../frontend/components/admin/UserTable.tsx) — 회사 컬럼이 이미 정의돼 있어 (line 123) 데이터만 박히면 자동 표시
- [hooks/useUserManagement.ts](../../frontend/hooks/useUserManagement.ts) — 분기는 API 클라이언트가 흡수

### 검증 결과

| 검증 | 결과 |
|---|---|
| 단일 모드 (`/api/admin/users`, SUPER_ADMIN → company_code='*') | 8명 |
| cross-tenant (`/api/admin/cross-tenant/users`) | 8명, 행마다 `company_code: "TEST01"` |
| 응답 봉투 | `{success, data: {rows, total, companies_queried, companies_failed}, message}` |

> 8명이 어드민 위주로 보이는 건 **TEST01 회사 DB 의 USER_INFO 자체가 그래서**. 프로비저닝 6단계 중 `COPY_DATA` 에서 메타 시드가 복제된 직후 + 일반 사용자 미등록 상태로 추정. cross-tenant 코드 잘못 아님.

---

## 3. Phase C — 확산 (✅ 검증)

설계서 §10 의 14개 메뉴 중 **3개 작성·검증 완료, 4개 보류**.

### 작성 완료 + 검증 (2026-04-28)

| 메뉴 | 백엔드 | 프론트 분기 | 검증 결과 |
|---|---|---|---|
| 권한그룹관리 | `GET /admin/cross-tenant/roles` + `listRoleGroups` (단일 모드 `role.getRoleGroupList` 미러) | [lib/api/role.ts](../../frontend/lib/api/role.ts) `roleAPI.getList` | TEST01: 1건, `company_code` 정상 ✓ |
| 배치관리 | `GET /admin/cross-tenant/batches` + `listBatches` (`SELECT *` from `BATCH_CONFIGS`) | [lib/api/batch.ts](../../frontend/lib/api/batch.ts) `BatchAPI.getBatchConfigs` | TEST01: 10건, `company_code` 정상 ✓ |
| 다국어 키관리 | `GET /admin/cross-tenant/lang-keys` + `listLangKeys` (단일 모드 `multilang.getMultilangKeyList` 미러, `categoryId` 재귀 필터 비지원) | [lib/api/multilang.ts](../../frontend/lib/api/multilang.ts) `getLangKeys` | TEST01: 646건, `company_code` 정상 ✓ |

검증은 SUPER_ADMIN 토큰으로 백엔드 직접 curl. `companies_queried: 1, companies_failed: 0` 모두 동일.

컨트롤러에 공통 `runFanOut(...)` 헬퍼 도입 — 가드 + 파라미터 변환 + Aggregator 호출 + `X-CrossTenant-Failed` 헤더 처리를 일원화. `/users` 는 Phase B 검증 손대지 않으려고 인라인 그대로 유지 (페이지네이션 작업 시 같이 일원화됨, 아래 §3.5 참조).

### 3.5 페이지네이션 cap 도입 (2026-04-28 오후)

다국어 키 검증 시 회사 1개 만으로 646건 발견 — 회사 N 개 늘어나면 응답 폭증 위험. cross-tenant 는 "전사 둘러보기" 라는 본 설계 의도 (설계서 §9.3) 위에서 **회사당 cap + truncated 플래그** 만 도입. 글로벌 정확한 offset 페이지네이션은 의도적으로 추가 안 함 — 정확한 페이지 넘기기가 필요하면 화면이 회사 도메인 단일 모드로 전환.

#### 변경 (모두 backward-compat)

| 영역 | 변경 |
|---|---|
| `CrossTenantAggregator.fanOut(...)` | `Integer perCompanyLimit` 받는 오버로드 추가. 회사별로 `rows.size() > limit` 면 잘라 truncated 마킹 |
| `CrossTenantAggregator.Result` | `truncated_company_codes: List<String>` 필드 추가 |
| `mapper/admin-cross-tenant.xml` 4개 SELECT (`listUsers`/`listRoleGroups`/`listBatches`/`listLangKeys`) | `<if test="per_company_limit_plus_one != null">LIMIT #{per_company_limit_plus_one}</if>` 추가. cap+1 가져와서 Aggregator 가 cap+truncated 판정 |
| `CrossTenantController` | 디폴트 cap 200, 클램프 1~2000. `?per_company_limit=` 으로 override 가능. 응답에 `truncated`/`truncated_company_codes`/`per_company_limit` 필드 + 헤더 `X-CrossTenant-Truncated` 추가 |
| `CrossTenantController` | `resolvePerCompanyLimit()` / `buildResponse()` 헬퍼로 `/users` 포함 4개 엔드포인트 모두 일원화 |

#### 검증 결과 (TEST01 1개 회사 기준)

| 시나리오 | total | truncated | per_company_limit |
|---|---|---|---|
| `/lang-keys` (디폴트) | 200 (646→cap) | `true`, `["TEST01"]` | 200 |
| `/lang-keys?per_company_limit=50` | 50 | `true`, `["TEST01"]` | 50 |
| `/lang-keys?per_company_limit=1000` | 646 (전체) | `false`, `[]` | 1000 |
| `/users` (8명) | 8 | `false` | (cap 안 걸림) |
| `/batches` (10개) | 10 | `false` | (cap 안 걸림) |

#### 응답 형태

```json
{
  "success": true,
  "data": {
    "rows": [...],
    "total": 200,
    "companies_queried": 1,
    "companies_failed": 0,
    "truncated": true,
    "truncated_company_codes": ["TEST01"],
    "per_company_limit": 200
  }
}
```

응답 헤더에도 `X-CrossTenant-Failed: ...` / `X-CrossTenant-Truncated: ...` 박혀 디버깅 편의.

#### 화면 측 권장 사용 (후속 작업)

`truncated === true` 일 때 화면 상단에 안내 박:

> "TEST01: 200건 표시 중 — 더 보려면 검색을 좁히거나 회사 도메인으로 전환"

회사 도메인 전환 링크는 `https://${subdomain}.invyone.com/admin/...` 으로 새 탭. 단일 모드의 정확한 페이지네이션이 동작.

#### 무엇을 안 했나 (의도적 미작업)

- **글로벌 offset/limit** — `?page=2&limit=20` 식 정확한 글로벌 페이지네이션. 회사 N 개 분산 쿼리 + 정렬 보장 + count 합산 모두 복잡. 설계서 §9.3 가 명시적으로 "메타 DB 인덱스 도입과 같이 별도 설계" 라 적은 항목
- **회사별 페이지 넘기기** (`per_company_offset`) — 가능하지만 글로벌 정렬과 의미 안 맞아 UX 가 헷갈림. 회사 도메인 단일 모드가 더 자연스러움

### 보류 — 단순 fan-out 으로 안 맞아서 별도 UX 설계 필요

| 메뉴 | 보류 이유 |
|---|---|
| **메뉴관리** | `MENU_INFO` 트리 구조. 회사별 트리를 한 화면에 합칠지/회사 선택 후 트리 보일지 UX 결정 필요 |
| **공통코드관리** | `code_info` 가 `code_category` 필수 필터. 회사별 카테고리도 다를 수 있어 cross-tenant 카테고리 트리 설계 필요 |
| **외부 커넥션 관리** | `EXTERNAL_REST_API_CONNECTIONS` 가 `COMPANY_MNG` JOIN + `JSONB::TEXT` cast 다수. JOIN 은 회사 DB 안에선 무의미해서 컬럼 셋 조정 필요 |
| **화면관리 / POP화면관리 / 대시보드관리** | 행 수 규모 (958+) → 페이지네이션 / 검색 인덱스 정밀화 후 |

### 검증 시나리오 (내일 진행)

브라우저 콘솔 (현재 SUPER_ADMIN 토큰 유지된 상태) 에서 한 줄씩:

```js
fetch("/api/admin/cross-tenant/roles",     { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}` } }).then(r => r.json()).then(d => console.log("roles:",     d.data.rows.length, "/실패", d.data.companies_failed));
fetch("/api/admin/cross-tenant/batches",   { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}` } }).then(r => r.json()).then(d => console.log("batches:",   d.data.rows.length, "/실패", d.data.companies_failed));
fetch("/api/admin/cross-tenant/lang-keys", { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}` } }).then(r => r.json()).then(d => console.log("lang-keys:", d.data.rows.length, "/실패", d.data.companies_failed));
```

`companies_failed: 1` 이 뜨면 → 해당 테이블이 TEST01 회사 DB 에 복제 안 된 케이스 (예: `AUTHORITY_MASTER` / `BATCH_CONFIGS` / `MULTI_LANG_KEY_MASTER` 중 하나가 COPY_DATA 그룹에 빠짐). 백엔드 콘솔에 `[CrossTenant] mapper=... failed for company=TEST01 ...` 형태로 어떤 SQL 에러인지 출력.

---

## 4. 부수 작업 (이번 세션 중 발생)

### 부수-1) 로그인 화면 하이드레이션 픽스

[frontend/app/(auth)/login/page.tsx](../../frontend/app/(auth)/login/page.tsx) — `next-themes` 의 `resolvedTheme` 가 SSR 시 undefined → 클라이언트와 다른 클래스 렌더링 → 하이드레이션 미스매치. dev 모드 오버레이가 로그인 클릭을 막아 **로그인 자체 실패로 보였던 증상**의 직접 원인.

표준 패턴 적용: `mounted` state + `<>{mounted && ...}</>` 게이트, `<div className="pill" suppressHydrationWarning>`.

### 부수-2) 맥 dev 환경 셋업

| 항목 | 내용 |
|---|---|
| **Java 버전 충돌** | macOS 글로벌 Java 8 + jenv 글로벌 8 → Spring 3.3.5 가 17+ 요구해 부팅 실패. **`jenv add /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home` + `jenv local 21`** 로 프로젝트 디렉토리만 21 로 격리. `jenv enable-plugin export` 로 jenv 가 `JAVA_HOME` 까지 제어. |
| **direnv** | 프로젝트 `.envrc` 에 `export JWT_SECRET="…"` (openssl rand 생성). zsh 훅 `eval "$(direnv hook zsh)"`. `.envrc` 와 `.direnv/` 를 `.gitignore` 에 추가 (개발자별 시크릿 분리). |
| **npm install 실패 (Mac arm64)** | `frontend/package.json` 의 `lightningcss-linux-x64-gnu` 가 regular `dependencies` 에 박혀있어 macOS arm64 에서 EBADPLATFORM. **`npm install --force`** 로 우회. 근본 수정 (옵션 2 — `optionalDependencies` 로 이동) 은 별도 PR 권장. |

### 부수-3) `.gitignore` 추가

```
.envrc
.direnv/
```

(`.java-version` 은 jenv 표준 파일이라 커밋 권장 — 다른 Mac 개발자도 자동 21 전환됨. Windows 등에선 무해히 무시됨.)

### 부수-4) dev `*.localhost` 테넌트 라우팅 (별도 문서)

본 cross-tenant 작업과 한 묶음으로 진행된 dev 환경 패치. 별도 핸드오프 문서로 분리:
[2026-04-28-localhost-tenant-routing-handoff.md](2026-04-28-localhost-tenant-routing-handoff.md)

요지: `qnc.localhost:9771` 같은 dev 호스트도 운영 `*.invyone.com` 과 동일하게 테넌트 DB 로 자동 라우팅되게.

- 백엔드 `SubdomainResolverFilter.extractSubdomain()` 가 2파트 `{sub}.localhost` 호스트 인식 (RFC 6761)
- 프론트 [client.ts](../../frontend/lib/api/client.ts) 1-b 분기 + [subdomain.ts](../../frontend/lib/tenant/subdomain.ts) 동일 규칙
- `application.yml` CORS 디폴트에 `http://*.localhost:[*]` 추가
- `SubdomainResolverFilterTest` 8 케이스 신규
- 운영 3파트 코드 경로 무변경 (회귀 0)

검증 (2026-04-29):
- 백엔드 단위 테스트 8/8 통과 (bare/IP/베이스/dev 2파트/예약어/3파트 운영)
- curl 4종 200 OK (test02.localhost / localhost / test02.invyone.com / nonexistent.localhost)
- CORS preflight `Access-Control-Allow-Origin: http://test02.localhost:9771` echo 정상
- 프론트 dev server `Host: test02.localhost:9771 → /login` 200
- TenantGuard → `nonexistent.localhost` → `{exists:false}` → `/tenant-not-found` 200
- 시각적 로그인 (비번 입력 → 강제 변경) 은 사용자 브라우저 검증 영역

---

## 5. 커밋·푸시 상태 (2026-04-29 갱신)

`hjjeong` 브랜치, 3 커밋. `origin/hjjeong` 까지 푸시 완료. base `gbpark-node` 대비 **20 ahead / 0 behind**.

```
a6be4f2e  사용자관리 테이블 자체 스크롤 — viewport 기반 max-height 로 강제   (1 file, +5/-6)
383b837a  dev *.localhost 테넌트 라우팅 + direnv/Java 21 dev 환경 정비       (9 files)
e16fb169  어드민 cross-tenant 집계 (SUPER_ADMIN) + 사용자관리 자체 스크롤    (16 files, +1654/-56)
```

### 5.1 커밋 분배 의도

| 커밋 | 포함 작업 | 비고 |
|---|---|---|
| `e16fb169` | Phase A/B/C cross-tenant 전체 + 사용자관리 자체 스크롤 1차 + 본 실행 로그 / 설계 문서 두 MD | 본 문서 §1~§3 의 모든 작업 |
| `383b837a` | dev `*.localhost` 라우팅 + direnv / Java 21 / `.java-version` / `.gitignore` / `application.yml` CORS / `MULTI_TENANCY_ARCHITECTURE.md` §4.2·§6 갱신 + 단위 테스트 8건 + 핸드오프 MD | 본 문서 §4 부수 작업 + 부수-4 |
| `a6be4f2e` | UserTable 스크롤 후속 (viewport 기반 max-height 로 hack) | flex 기반 height 가 shadcn Table wrapper 와 충돌 — 안정화 |

설계서 §6 권장 #5 ("한 커밋으로 정리") 와 다르게 분리됨. 사유:
- cross-tenant 본체와 dev 환경 라우팅은 영향 범위가 다른 도메인. 리뷰·되돌리기 단위가 다름
- UserTable 스크롤 hack 은 후속 안정화라 별도 분리

### 5.2 워킹트리

이전 워킹카피 모두 커밋 처리됨. 현재 워킹트리는 깨끗 (untracked `.envrc` 만 — `.gitignore` 처리됨).

---

## 6. 권장 다음 단계 (2026-04-29 갱신)

| # | 항목 | 상태 | 비용 / 효용 |
|---|---|---|---|
| 1 | Phase C 3개 메뉴 검증 | ✅ 완료 (2026-04-28 오전) | — |
| 2 | 페이지네이션 cap 도입 | ✅ 완료 (2026-04-28 오후) | — |
| 3 | 두번째 회사 프로비저닝 → 진짜 fan-out 효과 (`companies_queried: 2`, 행에 `TEST01` / `TEST02` 섞임) 확인 | ✅ 완료 (2026-04-29) — §9 결과 참조 | — |
| 4 | 화면 측 `truncated === true` 안내 박스 — "200건 표시 중, 더 보려면 검색 좁히거나 회사 도메인 전환" | ⏳ 미진행 | 페이지당 10분 × 4 / UX |
| 5 | 전체 변경을 한 커밋으로 정리 | ❌ **다른 결정** — 3 커밋으로 분리 (§5.1 사유 참조) | — |
| 6 | 보류 4개 메뉴 UX 설계 — 특히 메뉴관리(트리)와 공통코드(카테고리 의존) | ⏳ 별도 세션 | 14개 메뉴 완주 |
| 7 | Phase D (캐시 / 병렬) — 회사 N≥20 환경 만들어 측정 후 결정 | ⏳ 별도 세션 | 운영 확장성 |
| 8 | Phase E — [MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md) §12 신설로 본 작업 통합 + 082~084 마이그레이션 표 갱신 | ⏳ 미진행 (단, dev `*.localhost` §4.2·§6 갱신은 `383b837a` 에 포함됨) | 30분 / 후임자 온보딩 |
| 9 | Gitea PR 생성 (base: `gbpark-node`, compare: `hjjeong`) | ⏳ 사용자 결정 대기 — `tea` CLI 미설치 / 토큰 미세팅 | — |

---

## 7. 참고 — 검증할 때 쓸 명령 모음

### Java 21 사용 셸 진입
```bash
cd /Users/jhj/invyone   # direnv 가 .envrc 로드, jenv 가 .java-version 로 21 잡음
java -version           # openjdk 21.0.10 나와야 OK
```

### 백엔드 기동
```bash
cd /Users/jhj/invyone/backend-spring && ./gradlew bootRun
```
Ctrl+C 로 종료. 코드 변경 시 다른 터미널에서 `./gradlew classes` 만 돌리면 devtools 자동 재시작.

### 프론트 기동
```bash
cd /Users/jhj/invyone/frontend && npm run dev
```
http://localhost:9771 — turbopack 핫리로드.

### 백엔드 살아있는지 확인
```bash
curl http://localhost:8081/api/auth/status
# {"success":true,"data":{"is_admin":false,"is_logged_in":false},"message":"세션 상태 확인"}
```

---

## 8. 알려진 데이터 상태 (참고)

- **활성 회사 (2026-04-29 시점)**: TEST01 + TEST02 (시연용회사 / 시연용회사2). 두 회사 모두 `DB_STATUS='active'`. 다른 1개("공통", `company_code='*'`) 는 가상 row → cross-tenant 대상 X.
- **TEST01 USER_INFO** (2026-04-28 측정): 8명 (강빈박, 관리자, 김대성, 명건희, 박창현, 정혜진, 오재옥, chpark). 7명은 시드 어드민 패턴 (`dept_code='DPT005'`, `user_name_eng='admin'`).
- **현재 로그인 토큰**: `user_id=hjjeong`, `user_type=SUPER_ADMIN`, `company_code=*` — META 컨텍스트로 라우팅 확인됨.

> 2026-04-29 fan-out 측정에서 회사 DB 데이터 갯수가 시점에 따라 달라짐 — §9 표 참조. 위 8명은 2026-04-28 시점 기준이며, 회사 DB 데이터는 프로비저닝 시드 이후 사용자 작업으로 변동될 수 있음.

---

## 9. fan-out 2회사 머지 검증 (2026-04-29)

설계서 §11.1 (행복 경로) 의 첫 다회사 실증. SUPER_ADMIN 토큰 (`hjjeong / company_code=*`) + bare `Host: localhost:9771` (META 컨텍스트) 로 백엔드 4개 cross-tenant 엔드포인트 직접 curl.

### 9.1 결과

| 엔드포인트 | total | q | failed | trunc | by company | 평가 |
|---|---|---|---|---|---|---|
| `/_active-companies` | 2 | — | — | — | — | TEST01 + TEST02 둘 다 `DB_STATUS='active'` 확인 |
| `/users` | 10 | 2 | 0 | false | `{'*': 8, 'TEST01': 1, 'TEST02': 1}` | ✅ 메타 8명 + 회사별 1명씩 |
| `/roles` | 1 | 2 | 0 | false | `{'TEST01': 1}` | ✅ TEST02 권한그룹 시드 없음 |
| `/batches` | 14 | 2 | 0 | false | `{'TEST01': 7, 'TEST02': 7}` | ✅ 균등 fan-out 머지 |
| `/lang-keys` | 0 | 2 | 0 | false | `{}` | 회사 DB 양쪽 모두 다국어 키 시드 없음 |

### 9.2 해석

**`/users` 의 `*` 8행 = 의도된 동작.** [`CrossTenantController#listUsers`](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java#L116) 가 `aggregator.fanOut(..., true)` 로 `includeMeta=true` 호출 — 메타 DB USER_INFO (SUPER_ADMIN 들) 를 `company_code='*'` 로 prepend. 다른 3개는 메타 미포함.

**runFanOut 의 마지막 `true` 는 `wrapSearchWithPercent` (메타 포함 아님)** — `/lang-keys` 가 0건 나온 이유는 회사 DB 자체에 `MULTI_LANG_KEY_MASTER` 행이 없기 때문 (`failed=0` 으로 SELECT 자체는 정상). 이전 §3 의 "TEST01: 646건" 은 시점이 다른 측정 (시드 직후 또는 라우팅 다른 컨텍스트) 으로 추정.

### 9.3 검증된 항목 (체크)

- [x] `companies_queried: 2` — 메타 `listActiveCompanies` 가 두 회사 반환, Aggregator 가 둘 다 호출
- [x] `company_code` 강제 주입 — 회사 DB 행에 라우팅 정보 기준 `company_code` 박힘 (`/batches` 14행 확인)
- [x] `includeMeta=true` 전용 prepend — `/users` 의 `*` 8행
- [x] 실패 격리 (`companies_failed: 0`, X-CrossTenant-Failed 헤더 없음) — 두 회사 모두 정상이라 fail-open 자체는 미실증 (TEST02 다운 시뮬레이션은 별도)
- [x] 페이지네이션 cap — 모든 회사가 cap 200 미만이라 `truncated: false`. cap 동작은 2026-04-28 §3.5 에서 이미 검증 (646→200)
- [x] 권한 가드 — 토큰 없이 `/_active-companies` 호출 → `403 super_admin_required`

### 9.4 §11.2 부분 실패 시뮬레이션 (2026-04-29 추가)

테넌트 DB 만 만져도 된다는 사용자 OK 받고 진행. 메타 DB 무수정.

**플랜:** TEST02 의 `USER_INFO` 테이블을 임시 RENAME 해서 SELECT 실패 유도 → fan-out 호출 → 즉시 롤백.

```sql
-- test02_invyone 만
ALTER TABLE USER_INFO RENAME TO USER_INFO_HJTEST_BAK;  -- 시뮬레이션 시작
-- (curl /users)
ALTER TABLE USER_INFO_HJTEST_BAK RENAME TO USER_INFO;  -- 즉시 롤백
```

**결과:**

| 단계 | 응답 |
|---|---|
| 1. RENAME 후 `/users` | HTTP 200, header `X-CrossTenant-Failed: TEST02`, body `total=9 q=2 failed=1 by={'*':8, 'TEST01':1}` |
| 2. 롤백 후 `/users` | `total=10 failed=0 by={'*':8, 'TEST01':1, 'TEST02':1}` — 완전 복귀 |

**검증된 것:**
- [x] **fail-open** — 한 회사 SELECT 실패해도 전체 응답 200, 다른 회사 + 메타 결과 그대로 반환
- [x] **회사 격리** — TEST01·메타 행은 영향 없음 (`'*': 8, 'TEST01': 1` 그대로)
- [x] **`companies_failed: 1`** + `failed_company_codes: ["TEST02"]`
- [x] **응답 헤더 `X-CrossTenant-Failed: TEST02`** — 클라이언트가 토스트 띄우기에 충분한 정보
- [x] 백엔드 로그에 `[CrossTenant] mapper=admin-cross-tenant.listUsers failed for company=TEST02 db=test02_invyone : ...` 형태 메시지 (Aggregator line 159-161)

설계서 §11.2 가 이 결과로 ✅ 처리됨.

### 9.5 여전히 미검증 (남은 시나리오)

- §11.4 락 비획득 — TEST02 풀 maxPool 점유 후 `connection-timeout`. Hikari 풀 점유 시뮬레이션 필요 (별도 부하 테스트 환경)
- §11.5 캐시 무효화 — 캐시 자체가 Phase D 라 N/A

### 9.5 명령 (재현용)

```bash
TOKEN='<SUPER_ADMIN JWT>'
for ep in users roles batches lang-keys; do
  curl -s -D /tmp/h.$ep -o /tmp/b.$ep \
    -H "Authorization: Bearer $TOKEN" -H "Host: localhost:9771" \
    "http://localhost:8081/api/admin/cross-tenant/$ep"
done

python3 <<'PY'
import json, collections
for ep in ["users","roles","batches","lang-keys"]:
    d = json.load(open(f"/tmp/b.{ep}"))["data"]
    codes = collections.Counter(r.get("company_code") for r in d["rows"])
    print(f"[{ep}] total={d['total']} q={d['companies_queried']} failed={d['companies_failed']} by={dict(codes)}")
PY
```
