Files
invyone/notes/hjjeong/2026-04-27-cross-tenant-admin-aggregation.md
hjjeong 280e25a4df notes(hjjeong): cross-tenant §11.2 부분 실패 시뮬레이션 검증 (2026-04-29)
테넌트 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>
2026-04-29 11:42:12 +09:00

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()
컨텍스트 임시 전환 패턴 CompanyLifecycleServicesetMeta() / 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 의사 코드

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 직접 호출이 가능한 이유 = TenantRoutingDataSourceDbContextHolder 값으로 매번 라우팅 결정하기 때문. 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 면 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 예시

<!-- 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 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 "공통 수정 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.xmllistActiveCompanies 추가
  • SecurityConfig/api/admin/cross-tenant/** SUPER_ADMIN 가드의도적 미적용. 95개 컨트롤러 회귀 위험 → 컨트롤러 레벨 @RequestAttribute("role") 검사로 대체 (실행로그 §1 표)
  • CrossTenantController 빈 컨트롤러 + 1개 엔드포인트 (/users)

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

  • mapper/admin-cross-tenant.xml + listUsers SELECT
  • /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.md 9장 "관련 마이그레이션" 표에 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 행복 경로 (원안)

  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_QNCrows.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 갱신)

본 설계 문서는 의도/원칙의 진실의 원천이고, 현재 구현·검증 진행 상황은 실행 로그 §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() 의 raw JDBC 패턴은 1세대라 그대로 둔 것.
  • Phase A → B → C 순서 강제 원칙 유지. Aggregator 가 흔들리면 4개 엔드포인트가 모두 흔들림 — 새 메뉴 mapper 추가 시 runFanOut(...) 헬퍼 (실행로그 §3) 에 얹어서 일원화.
  • 컨트롤러 가드는 SecurityConfig 가 아닌 컨트롤러 레벨 @RequestAttribute("role") 검사 (실행로그 §1 의 의도된 우회).