테넌트 DB 만 만져도 됨 — 메타 DB 무수정.
TEST02 의 USER_INFO 를 임시 RENAME 해서 SELECT 실패 유도 → fan-out
호출 → 즉시 롤백. 메타 DB·다른 테이블 일체 영향 없음.
결과:
- RENAME 후 /users → HTTP 200, header X-CrossTenant-Failed: TEST02
total=9 q=2 failed=1 by={'*':8, 'TEST01':1} (TEST02 0)
- 롤백 후 /users → total=10 failed=0 by 원복
검증된 항목:
- fail-open: 한 회사 실패해도 전체 응답 200
- 회사 격리: TEST01·메타 행 영향 없음
- companies_failed: 1 + failed_company_codes: ["TEST02"]
- 응답 헤더 X-CrossTenant-Failed: TEST02
문서 갱신:
- 설계 27.md §11.2: ⏳ → ✅ + 실행로그 §9.4 링크
- 실행 28.md §9.4 신설 — 시뮬레이션 SQL + 결과 + 검증 항목 5개
- 실행 28.md §9.5 — 남은 시나리오 (§11.4 락 비획득 / §11.5 캐시 N/A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
Cross-Tenant 어드민 합산 설계 (SUPER_ADMIN 전사 보기)
작성일: 2026-04-27 최종 갱신: 2026-04-29 — Phase A/B/C 구현·커밋 완료 표시, 검증 시나리오에 실제 결과 반영 작성자: hjjeong 관련: docs/MULTI_TENANCY_ARCHITECTURE.md, notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md, notes/gbpark/2026-04-24-company-mgmt-ui-schema.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()의 N+1 패턴을 일반화한CrossTenantAggregator를 새로 만든다. - 모든 응답 행에
company_code컬럼을 박아 화면에서 회사 필터/그룹이 가능하게 한다. - 회사 N≤20 까지는 직렬, 그 이후는
CompletableFuture병렬 + 5분 캐시.
1. 배경
1.1 이미 깔린 것 (재사용)
| 인프라 | 위치 |
|---|---|
| 서브도메인 → tenant DB 라우팅 | SubdomainResolverFilter |
| 요청 단위 DB 컨텍스트 | DbContextHolder (META / dbName / null) |
| 회사 → DB 룩업 + 캐시 | CompanyResolver |
Hikari 풀 lazy 생성 (회사 풀 minIdle=0) |
TenantDataSourceFactory |
| MyBatis 라우팅 DataSource | TenantRoutingDataSource |
| Cross-tenant 1세대 패턴 (회사관리 메인) | CompanyStatsService.enrichOne() |
| 컨텍스트 임시 전환 패턴 | CompanyLifecycleService — 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. 설계 원칙
- DB-per-tenant 불변. 메타 DB 로 데이터 회수 없음. 격리 원칙 유지.
- Fan-out 후 머지. SUPER_ADMIN 요청 1건 =
COMPANY_MNG.DB_STATUS='active'회사 N 개 각각에 동일 SELECT 1번씩 + 응답 머지. - 읽기 전용. Cross-tenant 엔드포인트는 SELECT 만 허용. 수정은 무조건 단일 회사 컨텍스트로 들어가서 한다 (3-A 회사 전환 모드는 본 문서 범위 외).
- company_code 컬럼 강제. 모든 응답 행에 어느 회사에서 왔는지 박는다. 화면이 그룹/필터 가능하게.
- PK 충돌은 화면이 책임. 회사 간 같은
OBJID가 있을 수 있다 → keying 은${company_code}::${pk}로 합성. - 권장 N 범위는 ~50. 그 이상은 병렬 + 캐시 + 페이지네이션 정책 별도 결정 (본 문서 9장).
- 감사 흔적. 본 엔드포인트 호출은
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 의사 코드
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 병렬 풀 정책
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 응답 예시
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면 400cross_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 예시
<!-- 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 에 추가:
<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 bumpCompanyProvisioningService.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 테이블 컬럼 추가
기존 어드민 테이블 컴포넌트들은 다음 두 가지만 변경:
- 첫 컬럼에 회사 (company_code → company_name 매핑 — 메타 DB
COMPANY_MNG에서 룩업) 노출. - 회사 필터 드롭다운 추가 (멀티셀렉트). 백엔드에
?company_codes=로 전달.
8.3 키 충돌
<DataGrid> 의 keyField 를 단일 컬럼에서 (row) => ${row.company_code}::${row[pk]}`` 로 변경. ResponsiveDataView 폴백 키 로직 (frontend/components/admin-test-result.md "공통 수정 1") 과 호환.
8.4 편집 진입
전사 보기에서 1행 클릭하면 그 회사 도메인으로 새 탭 오픈 + 해당 메뉴의 단일 회사 화면으로 이동.
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 §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 — 인프라 (최소) — ✅ 완료
CrossTenantContext(가드 클래스) — boolean 반환식으로 구현 (실행로그 §1)CrossTenantAggregator(직렬, cache off)mapper/provisioning.xml에listActiveCompanies추가— 의도적 미적용. 95개 컨트롤러 회귀 위험 → 컨트롤러 레벨SecurityConfig에/api/admin/cross-tenant/**SUPER_ADMIN 가드@RequestAttribute("role")검사로 대체 (실행로그 §1 표)CrossTenantController빈 컨트롤러 + 1개 엔드포인트 (/users)
Phase B — PoC: 사용자관리 — ✅ 부분 완료
mapper/admin-cross-tenant.xml+listUsersSELECT/api/admin/cross-tenant/users동작 확인 (TEST01 1개 회사 / 8명)- 프론트 사용자관리 페이지에 host 분기 (
isCrossTenantMode()헬퍼로 분기) - [~] 회사 컬럼 —
UserTable.tsx에 컬럼 정의 이미 있어 데이터만 박히면 자동 표시 / 회사 필터 드롭다운은 미구현
Phase C — 14개 메뉴 확산 — 🟡 일부 완료 (3/14, 4 보류, 7 미진행)
- 권한그룹관리 (
/cross-tenant/roles, TEST01 1건) - 배치관리 (
/cross-tenant/batches, TEST01 10건) - 다국어 키관리 (
/cross-tenant/lang-keys, TEST01 646건 → cap 200) - 메뉴관리 — 보류 (트리 구조 UX 결정 필요)
- 공통코드관리 — 보류 (카테고리 트리 의존)
- 외부 커넥션 관리 — 보류 (메타 JOIN + JSONB cast 다수)
- 화면관리 / POP화면관리 / 대시보드관리 — 보류 (행 수 규모 + 페이지네이션 정책)
- 권한관리 / 테이블 타입관리 / 메일관리 — 미진행
- 키 충돌 정책 (
company_code::pk) 일괄 적용 — 미진행 - 행 클릭 → 회사 도메인 새 탭 오픈 패턴 — 미진행
페이지네이션 cap (설계서 §4.2 / §9.3 보강) — ✅ 완료
- 회사당 디폴트 cap 200, override 1~2000
truncated플래그 +truncated_company_codes응답- 응답 헤더
X-CrossTenant-Truncated - 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.md9장 "관련 마이그레이션" 표에 082~084 추가 + 본 문서 11장 참조 링크docs/MULTI_TENANCY_ARCHITECTURE.md새 섹션 "12. 어드민 cross-tenant 합산" 신설하여 본 문서 요약 + 링크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 참조 |
| 11.2 부분 실패 | ✅ 완료 (2026-04-29) — TEST02 USER_INFO RENAME 으로 SELECT 실패 유도, fail-open + X-CrossTenant-Failed 헤더 + companies_failed: 1 모두 확인. 실행로그 §9.4 참조 |
| 11.3 권한 | ✅ super_admin_required (토큰 없이 호출 → 403) — 스모크 (실행로그 §1) + fan-out 검증 (실행로그 §9.3) 모두 통과 |
| 11.4 락 비획득 | ⏳ 미검증 |
| 11.5 캐시 무효화 | ⏳ N/A — 캐시 미구현 (Phase D) |
11.1 행복 경로 (원안)
- 회사 3개 (
qnc,kookje,topsil) 활성. 각각 사용자 5명씩. - SUPER_ADMIN 으로
https://admin.invyone.com/admin/userMng/userMngList진입. - 응답
rows.length === 15, 각 row 에company_code채워짐. - 회사 필터 "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 부분 실패
topsil회사 DB pg_terminate 로 다운.- cross-tenant 호출.
rows에 qnc/kookje 만,companies_failed: 1, 응답 헤더X-CrossTenant-Failed: COMPANY_TOPSIL.- 화면이 "1개 회사 조회 실패" 토스트 노출.
11.3 권한
- 일반
COMPANY_ADMIN으로 cross-tenant 엔드포인트 호출 → 403. - SUPER_ADMIN 인데 회사 도메인(
qnc.invyone.com) 으로 호출 → 400cross_tenant_requires_meta_context.
실 검증 (2026-04-28): 토큰 없이
/api/admin/cross-tenant/_active-companies호출 → 403super_admin_required✓. SUPER_ADMIN 토큰 호출 → 200 ✓.
11.4 락 비획득
- 한 회사 풀이 풀 maxPool=5 모두 점유 중.
- cross-tenant 호출 시
connection-timeout=30000걸림 → 실패 격리로 그 회사만 빠짐.
11.5 캐시 무효화
- cross-tenant 호출 → cache hit 만들기.
- 그 사이 SUPER_ADMIN 이 회사 신규 프로비저닝 (FINALIZE 완료).
- 다음 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 갱신)
본 설계 문서는 의도/원칙의 진실의 원천이고, 현재 구현·검증 진행 상황은 실행 로그 §5~§6 에서 확인.
Phase A/B/C 의 핵심 (직렬 fan-out + Aggregator + 4개 엔드포인트 + 페이지네이션 cap) 은 hjjeong 브랜치 e16fb169 에 커밋·푸시 완료.
즉시 가능한 다음 작업 (우선순위 순)
- TEST02 가 활성 상태이므로 진짜 fan-out (
companies_queried: 2) 호출 검증 —/api/admin/cross-tenant/users등 4개 엔드포인트에 SUPER_ADMIN 토큰으로 호출, 행에 두 회사 섞이는지 + 실패 격리 확인. 본 설계 §11.1·§11.2 의 첫 실증. - 화면 측
truncated === true안내 박스 (4개 페이지) — 실행로그 §3.5 의 권장 사용 그대로. - 보류 4개 메뉴 (메뉴/공통코드/외부커넥션/화면계열) UX 결정 후 mapper 작성.
- Phase D (캐시·병렬) — 회사 N≥20 환경 만든 뒤 측정 후 결정.
새 코드 작성 시 주의
- 새 코드는 raw JDBC 가 아니라 MyBatis
sqlSession을 그대로 쓴다 (TenantRoutingDataSource가 있기 때문). 이미 만들어진 CompanyStatsService.enrichOne() 의 raw JDBC 패턴은 1세대라 그대로 둔 것. - Phase A → B → C 순서 강제 원칙 유지. Aggregator 가 흔들리면 4개 엔드포인트가 모두 흔들림 — 새 메뉴 mapper 추가 시
runFanOut(...)헬퍼 (실행로그 §3) 에 얹어서 일원화. - 컨트롤러 가드는
SecurityConfig가 아닌 컨트롤러 레벨@RequestAttribute("role")검사 (실행로그 §1 의 의도된 우회).