# 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 머지하여 반환 ▼ [Cache 5분 (선택, by mapperId+params hash)] │ ▼ [Map 응답 (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> fanOut(String mapperId, Map params) { requireSuperAdmin(); requireMetaContext(); // 호출 시점에 DbContextHolder 가 META 여야 함 List> companies = sqlSession.selectList( "provisioning.listActiveCompanies"); // {company_code, db_name} if (parallel) { List>>> futures = companies.stream() .map(c -> CompletableFuture.supplyAsync(() -> queryOne(c, mapperId, params), pool)) .toList(); return futures.stream().flatMap(f -> f.join().stream()).toList(); } else { List> out = new ArrayList<>(); for (Map c : companies) out.addAll(queryOne(c, mapperId, params)); return out; } } private List> queryOne(Map c, String mapperId, Map params) { String prev = DbContextHolder.get(); try { DbContextHolder.set((String) c.get("db_name")); List> rows = sqlSession.selectList(mapperId, params); for (Map 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 ``` ### 6.4 메타 DB SELECT (활성 회사 목록) `mapper/provisioning.xml` 에 추가: ```xml ``` --- ## 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 키 충돌 `` 의 `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 의 의도된 우회).