어드민 cross-tenant 집계 (SUPER_ADMIN) + 사용자관리 자체 스크롤

SUPER_ADMIN 토큰(company_code=*)이면 등록 회사들 DB 를 순회해 결과를
집계해 돌려주는 CrossTenantAggregator/Controller 추가. 사용자/권한그룹/
배치/다국어 키 4개 도메인의 list API 가 cross-tenant 모드 지원.

UserTable + ResponsiveDataView 에 compact/scrollContainer prop 추가.
페이지 헤더/툴바/페이지네이션은 고정, 테이블만 자체 스크롤.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-04-28 16:56:49 +09:00
parent 8b8186d1c0
commit e16fb16987
16 changed files with 1654 additions and 56 deletions
@@ -0,0 +1,228 @@
package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDataSourceFactory;
import com.erp.tenant.TenantDbSettings;
import com.erp.tenant.TenantRoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* SUPER_ADMIN 의 어드민 14개 메뉴 전사 합산용 fan-out 집계기.
*
* 호출자(컨트롤러)는 {@link #fanOut(String, Map)} 에 cross-tenant 전용 mapper id 와
* 파라미터를 넘긴다. Aggregator 가 메타 DB 에서 활성 회사 목록을 가져온 뒤,
* 회사마다 {@link DbContextHolder} 를 잠깐 그 회사 DB 로 바꿔 같은 SELECT 를 돌리고,
* 모든 응답 행에 {@code company_code} 를 박아 머지한다.
*
* 핵심 원칙 (설계서 §3, §5.3):
* - 한 회사 실패해도 전체는 진행 (fail-open). 실패 카운트만 누적.
* - 모든 행에 {@code company_code} 추가 (응답 측에서 회사 필터/그룹 가능하도록).
* - SELECT 만. UPDATE/DELETE 는 회사 도메인 컨텍스트로 위임.
* - 풀 정책 무변경 — 회사 풀 {@code minIdle=0} 그대로 유지.
*
* 1차 구현은 직렬·캐시 OFF (설계서 §7.2 — N≤20 까진 충분).
*
* @see com.erp.crosstenant.CrossTenantContext
* @see com.erp.tenant.TenantRoutingDataSource
* @see com.erp.provisioning.CompanyStatsService // raw JDBC 1세대 패턴 (참고용)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CrossTenantAggregator {
private final SqlSession sqlSession;
private final TenantRoutingDataSource routingDataSource;
private final TenantDbSettings tenantDbSettings;
/** Aggregator 결과 봉투. 컨트롤러는 이걸 그대로 ApiResponse.data 로 감싸 반환. */
public static class Result {
public final List<Map<String, Object>> rows;
public final int companies_queried;
public final int companies_failed;
public final List<String> failed_company_codes;
public final List<String> truncated_company_codes;
public Result(List<Map<String, Object>> rows,
int companies_queried,
int companies_failed,
List<String> failed_company_codes,
List<String> truncated_company_codes) {
this.rows = rows;
this.companies_queried = companies_queried;
this.companies_failed = companies_failed;
this.failed_company_codes = failed_company_codes;
this.truncated_company_codes = truncated_company_codes;
}
public List<Map<String, Object>> getRows() { return rows; }
public int getCompanies_queried() { return companies_queried; }
public int getCompanies_failed() { return companies_failed; }
public List<String> getFailed_company_codes() { return failed_company_codes; }
public List<String> getTruncated_company_codes() { return truncated_company_codes; }
}
/**
* 활성 회사 N 개에 fan-out (cap 없음, 메타 포함 X).
*/
public Result fanOut(String mapperId, Map<String, Object> params) {
return fanOut(mapperId, params, null, false);
}
/**
* 활성 회사 N 개에 fan-out + 회사당 cap (메타 포함 X).
*/
public Result fanOut(String mapperId, Map<String, Object> params, Integer perCompanyLimit) {
return fanOut(mapperId, params, perCompanyLimit, false);
}
/**
* 활성 회사 N 개에 fan-out + 회사당 cap + 옵션으로 메타 DB 도 같이 조회.
* 호출자는 미리 {@link CrossTenantContext#requireSuperAdmin}, {@link CrossTenantContext#requireMetaContext}
* 통과를 확인해야 한다 (Aggregator 자체는 권한 가드 안 함 — 단일 책임).
*
* cap 동작:
* - mapper 가 {@code LIMIT #{per_company_limit_plus_one}} 으로 cap+1 만큼 가져옴.
* - Aggregator 가 회사별로 rows.size() > cap 인지 검사 → 초과면 잘라 cap 까지만 반환 + truncated 마킹.
*
* 메타 포함 (includeMeta=true):
* - 회사 fan-out 전에 메타 DB 에서 같은 mapper 한 번 더 실행.
* - 메타 행에는 {@code company_code='*'} 박힘 (시스템/공통 사용자 표시 — 기존 회사관리 화면 컨벤션과 동일).
* - 사용자관리처럼 "SUPER_ADMIN 도 관리 대상"인 도메인에서만 사용. roles/batches 등은 메타에 의미있는 데이터 없으니 false.
*
* @param mapperId cross-tenant 전용 mapper id
* @param params SQL 바인딩 파라미터
* @param perCompanyLimit 회사당 cap. null 이면 cap 없음
* @param includeMeta true 면 메타 DB 도 한 번 조회해서 결과 앞에 prepend (company_code='*')
* @return fan-out 결과
*/
public Result fanOut(String mapperId, Map<String, Object> params, Integer perCompanyLimit, boolean includeMeta) {
// 호출 직전 컨텍스트는 META 여야 한다 (CrossTenantContext.requireMetaContext 통과 후라고 가정).
List<Map<String, Object>> companies = sqlSession.selectList("provisioning.listActiveCompanies");
List<Map<String, Object>> mergedRows = new ArrayList<>();
List<String> failed = new ArrayList<>();
List<String> truncated = new ArrayList<>();
int queried = 0;
// includeMeta=true 면 메타 DB 결과를 먼저 추가 (company_code='*')
// — 회사 fan-out 들어가기 전에 META 컨텍스트 그대로에서 실행.
if (includeMeta) {
try {
List<Map<String, Object>> metaRows = params == null
? sqlSession.selectList(mapperId)
: sqlSession.selectList(mapperId, params);
if (perCompanyLimit != null && metaRows.size() > perCompanyLimit) {
metaRows = new ArrayList<>(metaRows.subList(0, perCompanyLimit));
truncated.add("*");
}
for (Map<String, Object> r : metaRows) {
r.put("company_code", "*");
mergedRows.add(r);
}
} catch (Exception e) {
log.warn("[CrossTenant] meta query mapper={} failed: {}", mapperId, e.getMessage());
failed.add("*");
}
}
for (Map<String, Object> c : companies) {
String companyCode = (String) c.get("company_code");
String dbName = (String) c.get("db_name");
queried++;
try {
List<Map<String, Object>> rows = queryOne(dbName, mapperId, params);
// cap 적용 — mapper 가 cap+1 만큼 가져왔으면 cap 으로 잘라 truncated 마킹
if (perCompanyLimit != null && rows.size() > perCompanyLimit) {
rows = new ArrayList<>(rows.subList(0, perCompanyLimit));
truncated.add(companyCode);
}
for (Map<String, Object> r : rows) {
// company_code 강제 주입 — 응답 행이 어느 회사 것인지 명시.
// mapper 내부에서 박은 값이 있더라도 메타 DB 라우팅 정보로 덮어씀.
r.put("company_code", companyCode);
mergedRows.add(r);
}
} catch (Exception e) {
log.warn("[CrossTenant] mapper={} failed for company={} db={} : {}",
mapperId, companyCode, dbName, e.getMessage());
failed.add(companyCode);
}
}
return new Result(mergedRows, queried, failed.size(), failed, truncated);
}
/**
* 한 회사 DB 로 컨텍스트 잠깐 바꿔 SELECT 1번.
* finally 에서 반드시 prev 복원 — 누수되면 후속 요청이 엉뚱한 회사 DB 로 라우팅됨.
*
* ★ 핵심: TenantRoutingDataSource 의 routing map 에 회사 DB 풀이 등록돼있지 않으면
* META 로 fallback 됨. SubdomainResolverFilter 는 회사 도메인 진입 시에만 풀을 등록하므로,
* SUPER_ADMIN 이 admin 도메인에서 호출하는 cross-tenant 경로에선 풀이 안 깔려 있다.
* 따라서 매 호출마다 ensureTenantPool 로 lazy 생성 (이미 있으면 no-op).
*/
private List<Map<String, Object>> queryOne(String dbName, String mapperId, Map<String, Object> params) {
ensureTenantPool(dbName);
String prev = DbContextHolder.get();
try {
DbContextHolder.set(dbName);
return params == null
? sqlSession.selectList(mapperId)
: sqlSession.selectList(mapperId, params);
} finally {
if (prev == null) {
DbContextHolder.clear();
} else {
DbContextHolder.set(prev);
}
}
}
/**
* 회사 DB 풀이 없으면 최초 1회 생성. minIdle=0 정책은 Factory 가 책임.
* SubdomainResolverFilter.ensureTenantPool 과 동일한 패턴.
*/
private void ensureTenantPool(String dbName) {
if (routingDataSource.hasTenant(dbName)) return;
synchronized (routingDataSource) {
if (routingDataSource.hasTenant(dbName)) return;
HikariDataSource ds = TenantDataSourceFactory.createTenant(
tenantDbSettings.buildJdbcUrl(dbName),
tenantDbSettings.username(),
tenantDbSettings.password(),
dbName);
routingDataSource.addTenant(dbName, ds);
}
}
/**
* Phase A 스모크 테스트 전용 — Aggregator 우회하고 메타 DB 활성 회사 목록만 반환.
* 컨트롤러가 가드 통과 검증 + mapper 등록 확인용으로 호출.
*/
public List<Map<String, Object>> listActiveCompaniesForSmokeTest() {
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.listActiveCompanies");
// 응답 키 순서 안정화 (Map 직렬화 시 순서 보장 위해 LinkedHashMap 으로 복사)
List<Map<String, Object>> out = new ArrayList<>(rows.size());
for (Map<String, Object> r : rows) {
Map<String, Object> ordered = new LinkedHashMap<>();
ordered.put("company_code", r.get("company_code"));
ordered.put("company_name", r.get("company_name"));
ordered.put("db_name", r.get("db_name"));
out.add(ordered);
}
return out;
}
}
@@ -0,0 +1,45 @@
package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import jakarta.servlet.http.HttpServletRequest;
/**
* Cross-tenant 어드민 엔드포인트 진입 가드.
*
* 정적 헬퍼 두 개. 컨트롤러는 이 둘을 호출해 boolean 으로 검사 후
* 명시적으로 {@link org.springframework.http.ResponseEntity} 반환한다.
* 예외 throw 방식을 안 쓰는 이유 — {@link com.erp.config.GlobalExceptionHandler} 의
* catch-all 핸들러가 모든 예외를 500 으로 변환하므로, 가드 결과를 정확한 status code 로
* 내려주려면 컨트롤러가 직접 결정해야 함.
*
* SecurityConfig 단계에서 매처로 가두지 않는 이유는 기존 95개 컨트롤러가
* permitAll 로 동작 중이기 때문 ({@code SecurityConfig} L52~56 코멘트 참조).
*
* @see com.erp.crosstenant.CrossTenantAggregator
* @see com.erp.tenant.DbContextHolder
*/
public final class CrossTenantContext {
public static final String ROLE_SUPER_ADMIN = "SUPER_ADMIN";
private CrossTenantContext() {}
/**
* JWT 가 세팅한 role attribute 가 SUPER_ADMIN 인지.
* JwtAuthenticationFilter 가 토큰 검증 후 {@code request.setAttribute("role", userType)} 박음.
* 토큰 없거나 role 미스매치면 false.
*/
public static boolean isSuperAdmin(HttpServletRequest request) {
Object role = request.getAttribute("role");
return ROLE_SUPER_ADMIN.equals(role);
}
/**
* 현재 요청이 META DB 컨텍스트인지.
* SubdomainResolverFilter 가 admin.invyone.com / 메인 도메인일 때 setMeta() 박음.
* 회사 도메인 (qnc.invyone.com 등) 에서 호출되면 false.
*/
public static boolean isMetaContext() {
return DbContextHolder.isMeta();
}
}
@@ -0,0 +1,242 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* SUPER_ADMIN 의 cross-tenant 어드민 합산 엔드포인트.
*
* 모든 엔드포인트는 진입 시 두 가드를 통과해야 한다:
* 1. JWT role == "SUPER_ADMIN" → 미통과 시 403
* 2. 현재 컨텍스트 == META DB → 미통과 시 400
* 통과 후 {@link CrossTenantAggregator#fanOut} 으로 회사 N 개에 fan-out.
*
* Phase A (2026-04-27): 인프라만. 스모크 테스트용 {@code /_active-companies} 엔드포인트 1개.
* Phase B 부터 {@code /users}, {@code /menus} ... 14개 메뉴 fan-out 엔드포인트 추가 예정.
*
* 라우팅 규약: admin.invyone.com 또는 메인 도메인(메타 컨텍스트) 에서만 호출 가능.
*/
@RestController
@RequestMapping("/api/admin/cross-tenant")
@RequiredArgsConstructor
@Slf4j
public class CrossTenantController {
/**
* 회사당 cap 디폴트. cross-tenant 는 "전사 둘러보기" 용이라 정확한 페이지네이션 불필요 —
* 200건 넘으면 검색으로 좁히거나 회사 도메인 단일 모드로 전환하는 게 본 설계의 의도.
* 호출자가 ?per_company_limit= 으로 override 가능.
*/
private static final int DEFAULT_PER_COMPANY_LIMIT = 200;
private static final int MAX_PER_COMPANY_LIMIT = 2000; // 안전 가드
private final CrossTenantAggregator aggregator;
/**
* Phase A 스모크 테스트 엔드포인트.
*
* 가드 두 개 통과 후 메타 DB 의 {@code COMPANY_MNG} 에서 활성 회사 목록 반환.
* Aggregator 의 fan-out 자체는 호출하지 않음 — Phase B 에서 첫 mapper (listUsers) 추가 시 활성화.
*
* 검증 항목:
* - 컨트롤러 라우팅 정상
* - SUPER_ADMIN 가드 (다른 role 이면 403)
* - META 컨텍스트 가드 (회사 도메인이면 400)
* - {@code provisioning.listActiveCompanies} mapper 등록 확인
*/
@GetMapping("/_active-companies")
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
List<Map<String, Object>> rows = aggregator.listActiveCompaniesForSmokeTest();
Map<String, Object> data = new LinkedHashMap<>();
data.put("rows", rows);
data.put("total", rows.size());
return ResponseEntity.ok(ApiResponse.success(data, "Phase A smoke test ok"));
}
/**
* GET /api/admin/cross-tenant/users
*
* 활성 회사 N 개에 fan-out 으로 사용자 목록 합산.
* 응답 행마다 {@code company_code} 박혀있어 화면 측에서 회사 컬럼/필터 가능.
*
* 지원 파라미터: search, status, dept_code (단일 회사 화면과 동일).
* 페이지네이션은 1차 구현에선 비지원 — 회사당 전체 반환 후 클라이언트에서 페이지네이션 (설계서 §9.3).
*
* 회사 한 곳이 실패해도 나머지는 반환 (실패 격리). 응답에 {@code companies_failed} +
* 헤더 {@code X-CrossTenant-Failed} 로 어떤 회사가 빠졌는지 명시.
*/
@GetMapping("/users")
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
// search 파라미터 ILIKE 패턴화 — admin.selectUserList 와 동일한 변환 규칙 (양쪽 % 감쌈)
Map<String, Object> params = new HashMap<>(queryParams);
Object rawSearch = params.get("search");
if (rawSearch != null && !String.valueOf(rawSearch).isBlank()) {
params.put("search", "%" + rawSearch + "%");
}
int perCompanyLimit = resolvePerCompanyLimit(params);
params.put("per_company_limit_plus_one", perCompanyLimit + 1);
// ★ /users 는 includeMeta=true — 메타 DB 의 SUPER_ADMIN 들도 함께 반환 (company_code='*').
// SUPER_ADMIN 자기 자신들도 어디선가 관리되어야 한다는 요구. roles/batches/lang-keys 는 메타에 의미있는 데이터 없으니 false.
CrossTenantAggregator.Result result = aggregator.fanOut(
"admin-cross-tenant.listUsers", params, perCompanyLimit, true);
return buildResponse(result, perCompanyLimit);
}
/**
* GET /api/admin/cross-tenant/roles
*
* 활성 회사 N 개에 fan-out 으로 권한 그룹 목록 합산.
* 단일 회사 GET /api/roles 와 동일한 컬럼 + 행마다 company_code 추가.
*
* 지원 파라미터: search (AUTH_NAME ILIKE 검색).
*/
@GetMapping("/roles")
public ResponseEntity<ApiResponse<Map<String, Object>>> listRoles(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
return runFanOut(request, queryParams, "admin-cross-tenant.listRoleGroups", true);
}
/**
* GET /api/admin/cross-tenant/batches
*
* 활성 회사 N 개에 fan-out 으로 배치 목록 합산. 행마다 company_code 박힘.
* 지원 파라미터: search, is_active.
*/
@GetMapping("/batches")
public ResponseEntity<ApiResponse<Map<String, Object>>> listBatches(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
// 배치는 search 를 SQL 안에서 '%' || #{search} || '%' 로 감싸므로 컨트롤러 변환 불필요
return runFanOut(request, queryParams, "admin-cross-tenant.listBatches", false);
}
/**
* GET /api/admin/cross-tenant/lang-keys
*
* 활성 회사 N 개에 fan-out 으로 다국어 키 목록 합산. 행마다 company_code 박힘.
* 지원 파라미터: search, menu_code.
*/
@GetMapping("/lang-keys")
public ResponseEntity<ApiResponse<Map<String, Object>>> listLangKeys(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
return runFanOut(request, queryParams, "admin-cross-tenant.listLangKeys", true);
}
/**
* 공통 fan-out 실행 헬퍼.
*
* @param wrapSearchWithPercent true 면 search 파라미터를 컨트롤러에서 % 로 감쌈
* (mapper 가 그대로 ILIKE 에 넣는 방식 — listUsers, listRoleGroups, listLangKeys).
* false 면 mapper 안에서 '%' || ? || '%' 로 직접 감싸는 케이스 (listBatches).
*/
private ResponseEntity<ApiResponse<Map<String, Object>>> runFanOut(
HttpServletRequest request,
Map<String, Object> queryParams,
String mapperId,
boolean wrapSearchWithPercent) {
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
}
if (!CrossTenantContext.isMetaContext()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI()));
}
Map<String, Object> params = new HashMap<>(queryParams);
if (wrapSearchWithPercent) {
Object rawSearch = params.get("search");
if (rawSearch != null && !String.valueOf(rawSearch).isBlank()) {
params.put("search", "%" + rawSearch + "%");
}
}
int perCompanyLimit = resolvePerCompanyLimit(params);
params.put("per_company_limit_plus_one", perCompanyLimit + 1);
CrossTenantAggregator.Result result = aggregator.fanOut(mapperId, params, perCompanyLimit);
return buildResponse(result, perCompanyLimit);
}
/**
* 회사당 cap 결정 — 쿼리 파라미터 {@code per_company_limit} 가 있으면 사용 (1~MAX 사이 클램프),
* 없으면 디폴트 200.
*/
private int resolvePerCompanyLimit(Map<String, Object> params) {
Object raw = params.get("per_company_limit");
if (raw == null) return DEFAULT_PER_COMPANY_LIMIT;
try {
int v = Integer.parseInt(String.valueOf(raw));
if (v < 1) return DEFAULT_PER_COMPANY_LIMIT;
if (v > MAX_PER_COMPANY_LIMIT) return MAX_PER_COMPANY_LIMIT;
return v;
} catch (NumberFormatException e) {
return DEFAULT_PER_COMPANY_LIMIT;
}
}
/**
* Aggregator 결과 → 응답 봉투. truncated 정보 포함.
* 응답 헤더에도 X-CrossTenant-Failed / X-CrossTenant-Truncated 박아 디버깅 편의.
*/
private ResponseEntity<ApiResponse<Map<String, Object>>> buildResponse(
CrossTenantAggregator.Result result, int perCompanyLimit) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("rows", result.rows);
data.put("total", result.rows.size());
data.put("companies_queried", result.companies_queried);
data.put("companies_failed", result.companies_failed);
data.put("truncated", !result.truncated_company_codes.isEmpty());
data.put("truncated_company_codes", result.truncated_company_codes);
data.put("per_company_limit", perCompanyLimit);
ResponseEntity.BodyBuilder builder = ResponseEntity.ok();
if (!result.failed_company_codes.isEmpty()) {
builder.header("X-CrossTenant-Failed", String.join(",", result.failed_company_codes));
}
if (!result.truncated_company_codes.isEmpty()) {
builder.header("X-CrossTenant-Truncated", String.join(",", result.truncated_company_codes));
}
return builder.body(ApiResponse.success(data));
}
}
@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
Cross-tenant 어드민 합산 전용 mapper.
핵심 규칙 (notes/gbpark/2026-04-27-cross-tenant-admin-aggregation.md §6.2):
- SELECT 만. UPDATE/DELETE/INSERT 금지 (수정은 회사 도메인 컨텍스트로 위임).
- WHERE 에 COMPANY_CODE 필터 박지 말 것 — fan-out 시 각 회사 DB 안에서만 실행되므로
그 회사 데이터로 정의상 한정됨.
- SELECT 절에도 COMPANY_CODE 박지 말 것 — Aggregator 가 메타 DB 라우팅 정보 기준으로
응답 행에 박아준다 (회사 DB 안에 저장된 stale COMPANY_CODE 값 우선시 금지).
- JOIN 은 회사 DB 내부 테이블끼리만. 메타 DB 조인 금지.
namespace 단일: "admin-cross-tenant"
-->
<mapper namespace="admin-cross-tenant">
<!--
사용자 목록 — 단일 회사 admin.selectUserList 와 컬럼 동일 (COMPANY_CODE 제외).
company_code 는 Aggregator 가 응답에 박는다.
cross-tenant 1차 구현은 페이지네이션 없이 회사당 전체 반환 (설계서 §9.3).
필터는 search / status / dept_code 만 지원 (단일 회사 화면과 동일).
-->
<select id="listUsers" parameterType="map" resultType="map">
SELECT
SABUN AS sabun
, USER_ID AS user_id
, USER_NAME AS user_name
, COALESCE(USER_NAME_ENG, '') AS user_name_eng
, COALESCE(DEPT_CODE, '') AS dept_code
, COALESCE(DEPT_NAME, '') AS dept_name
, COALESCE(POSITION_CODE, '') AS position_code
, COALESCE(POSITION_NAME, '') AS position_name
, COALESCE(EMAIL, '') AS email
, COALESCE(TEL, '') AS tel
, COALESCE(CELL_PHONE, '') AS cell_phone
, COALESCE(USER_TYPE, '') AS user_type
, COALESCE(USER_TYPE_NAME, '') AS user_type_name
, COALESCE(TO_CHAR(CREATED_DATE, 'YYYY-MM-DD'), '') AS reg_date
, STATUS AS status
, COALESCE(LOCALE, '') AS locale
FROM USER_INFO
WHERE 1=1
<if test="search != null and search != ''">
AND (USER_ID ILIKE #{search}
OR USER_NAME ILIKE #{search}
OR DEPT_NAME ILIKE #{search}
OR POSITION_NAME ILIKE #{search}
OR USER_TYPE_NAME ILIKE #{search}
OR SABUN ILIKE #{search}
OR EMAIL ILIKE #{search}
OR TEL ILIKE #{search}
OR CELL_PHONE ILIKE #{search})
</if>
<if test="status != null and status != ''">
AND STATUS = #{status}
</if>
<if test="dept_code != null and dept_code != ''">
AND DEPT_CODE = #{dept_code}
</if>
ORDER BY CREATED_DATE DESC, USER_NAME ASC
<if test="per_company_limit_plus_one != null">
LIMIT #{per_company_limit_plus_one}
</if>
</select>
<!--
권한 그룹 목록 — 단일 회사 role.getRoleGroupList 와 컬럼 동일 (COMPANY_CODE 제외).
member_count / menu_count 서브쿼리 그대로 유지. 회사 DB 안에서 동작하므로
그 회사의 AUTHORITY_SUB_USER / AUTHORITY_SUB_MENU 만 카운트됨.
-->
<select id="listRoleGroups" parameterType="map" resultType="map">
SELECT
AM.OBJID AS objid
, AM.AUTH_NAME AS auth_name
, AM.AUTH_CODE AS auth_code
, AM.STATUS AS status
, AM.WRITER AS writer
, AM.CREATED_DATE AS created_date
, (SELECT COUNT(*) FROM AUTHORITY_SUB_USER WHERE MASTER_OBJID = AM.OBJID) AS member_count
, (SELECT COUNT(*) FROM AUTHORITY_SUB_MENU WHERE MASTER_OBJID = AM.OBJID) AS menu_count
FROM AUTHORITY_MASTER AM
WHERE 1=1
<if test="search != null and search != ''">
AND AM.AUTH_NAME ILIKE #{search}
</if>
ORDER BY AM.CREATED_DATE DESC
<if test="per_company_limit_plus_one != null">
LIMIT #{per_company_limit_plus_one}
</if>
</select>
<!--
배치 목록 — 단일 회사 batch.getBatchList 와 동일하게 SELECT *.
PostgreSQL 컬럼명이 그대로 lowercase Map key 로 떨어짐.
페이지네이션은 cross-tenant 1차 구현엔 비지원 (회사당 전체 반환).
-->
<select id="listBatches" parameterType="map" resultType="map">
SELECT *
FROM BATCH_CONFIGS
WHERE 1=1
<if test="search != null and search != ''">
AND (BATCH_NAME ILIKE '%' || #{search} || '%'
OR DESCRIPTION ILIKE '%' || #{search} || '%')
</if>
<if test="is_active != null and is_active != ''">
AND IS_ACTIVE = #{is_active}
</if>
ORDER BY CREATED_DATE DESC
<if test="per_company_limit_plus_one != null">
LIMIT #{per_company_limit_plus_one}
</if>
</select>
<!--
다국어 키 목록 — 단일 회사 multilang.getMultilangKeyList 와 컬럼 동일.
회사 DB 안에서 동작하므로 filter_company_code 분기 불필요 (그 회사 데이터로 정의상 한정).
1차 구현은 category_id 재귀 필터 비지원 — 필요해지면 후속 추가.
-->
<select id="listLangKeys" parameterType="map" resultType="map">
SELECT
KEY_ID AS key_id
, USAGE_NOTE AS usage_note
, LANG_KEY AS lang_key
, DESCRIPTION AS description
, IS_ACTIVE AS is_active
, CATEGORY_ID AS category_id
, CREATED_DATE AS created_date
, CREATED_BY AS created_by
, UPDATED_DATE AS updated_date
, UPDATED_BY AS updated_by
FROM MULTI_LANG_KEY_MASTER
WHERE 1=1
<if test="menu_code != null and menu_code != ''">
AND USAGE_NOTE = #{menu_code}
</if>
<if test="search != null and search != ''">
AND (LANG_KEY ILIKE #{search}
OR DESCRIPTION ILIKE #{search})
</if>
ORDER BY CREATED_DATE DESC, KEY_ID DESC
<if test="per_company_limit_plus_one != null">
LIMIT #{per_company_limit_plus_one}
</if>
</select>
</mapper>
@@ -202,4 +202,20 @@
</where> </where>
</select> </select>
<!--
Phase A (cross-tenant 집계, 2026-04-27): SUPER_ADMIN fan-out 대상 회사 목록.
listCompaniesForUi 와 다름 — 그 쿼리는 회사관리 화면 렌더용으로 모든 상태 포함.
이건 active 상태만, 라우팅 가능한(DB_NAME 박힌) 회사만.
-->
<select id="listActiveCompanies" resultType="map">
SELECT
COMPANY_CODE AS company_code
, COMPANY_NAME AS company_name
, DB_NAME AS db_name
FROM COMPANY_MNG
WHERE DB_STATUS = 'active'
AND DB_NAME IS NOT NULL
ORDER BY COMPANY_CODE
</select>
</mapper> </mapper>
+12 -5
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useLogin } from "@/hooks/useLogin"; import { useLogin } from "@/hooks/useLogin";
import { animatedThemeChange } from "@/lib/themeTransition"; import { animatedThemeChange } from "@/lib/themeTransition";
@@ -25,8 +25,11 @@ export default function LoginPage() {
const errRef = useRef<HTMLDivElement>(null); const errRef = useRef<HTMLDivElement>(null);
// next-themes 와 연동 — 메인 화면과 다크모드 상태 공유 // next-themes 와 연동 — 메인 화면과 다크모드 상태 공유
// mounted gate: SSR 시점엔 resolvedTheme 이 undefined 라 클라이언트 hydration 결과와 어긋남
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { theme, setTheme: setNextTheme, resolvedTheme } = useTheme(); const { theme, setTheme: setNextTheme, resolvedTheme } = useTheme();
const isDark = (resolvedTheme ?? theme) === "dark"; const isDark = mounted ? (resolvedTheme ?? theme) === "dark" : false;
// resolvedTheme 가 바뀌면 .inv-login 에 dark 클래스 동기화 (login.css 가 .inv-login.dark 셀렉터로 작성됨) // resolvedTheme 가 바뀌면 .inv-login 에 dark 클래스 동기화 (login.css 가 .inv-login.dark 셀렉터로 작성됨)
useEffect(() => { useEffect(() => {
@@ -116,9 +119,13 @@ export default function LoginPage() {
<div className="shooting-star" style={{ top: "35%", left: "55%" }} /> <div className="shooting-star" style={{ top: "35%", left: "55%" }} />
</div> </div>
<div className="pill"> <div className="pill" suppressHydrationWarning>
<button className={!isDark ? "on" : ""} onClick={(e) => setTheme("light", e)}>Light</button> {mounted && (
<button className={isDark ? "on" : ""} onClick={(e) => setTheme("dark", e)}>Dark</button> <>
<button className={!isDark ? "on" : ""} onClick={(e) => setTheme("light", e)}>Light</button>
<button className={isDark ? "on" : ""} onClick={(e) => setTheme("dark", e)}>Dark</button>
</>
)}
</div> </div>
<div ref={cardRef} className="login-card"> <div ref={cardRef} className="login-card">
@@ -109,8 +109,12 @@ export default function UserMngPage() {
}; };
return ( return (
<div className="flex min-h-screen flex-col bg-background"> // 페이지 자체는 스크롤 X — 헤더/툴바/페이지네이션은 고정,
<div className="w-full space-y-4 p-6"> // 테이블만 자체 스크롤 (UserTable 의 ResponsiveDataView scrollContainer prop).
// 부모(TabContent) 가 overflow-hidden 이라 h-full 로 받아 flex-col 내에서
// 마지막 자식 (UserTable wrapper) 이 flex-1 min-h-0 으로 남은 공간 차지.
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-1 border-b pb-3"> <div className="space-y-1 border-b pb-3">
<h1 className="text-xl font-bold tracking-tight"> </h1> <h1 className="text-xl font-bold tracking-tight"> </h1>
+3 -1
View File
@@ -238,10 +238,12 @@ export function UserTable({
<ResponsiveDataView<User> <ResponsiveDataView<User>
data={users} data={users}
columns={columns} columns={columns}
keyExtractor={(u) => u.user_id} keyExtractor={(u) => `${u.company_code || "C"}::${u.user_id}`}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="등록된 사용자가 없습니다." emptyMessage="등록된 사용자가 없습니다."
skeletonCount={10} skeletonCount={10}
compact
scrollContainer
cardTitle={(u) => u.user_name || ""} cardTitle={(u) => u.user_name || ""}
cardSubtitle={(u) => <span className="font-mono">{u.user_id}</span>} cardSubtitle={(u) => <span className="font-mono">{u.user_id}</span>}
cardHeaderRight={(u) => ( cardHeaderRight={(u) => (
@@ -56,6 +56,13 @@ export interface ResponsiveDataViewProps<T> {
// 스타일 커스터마이징 // 스타일 커스터마이징
tableContainerClassName?: string; tableContainerClassName?: string;
cardContainerClassName?: string; cardContainerClassName?: string;
// 컴팩트 모드 — 행 높이 줄임 (h-16 → h-10), 큰 데이터셋용
compact?: boolean;
// 자체 스크롤 모드 — 테이블 컨테이너에 max-height + overflow-y-auto 적용,
// 헤더 sticky. 부모가 height 결정 (예: flex-1 min-h-0).
// 페이지 전체 스크롤 대신 테이블 안에서 스크롤 처리.
scrollContainer?: boolean;
} }
// 중첩 객체에서 키 경로로 값을 꺼내는 헬퍼 // 중첩 객체에서 키 경로로 값을 꺼내는 헬퍼
@@ -80,7 +87,11 @@ export function ResponsiveDataView<T>({
onRowClick, onRowClick,
tableContainerClassName, tableContainerClassName,
cardContainerClassName, cardContainerClassName,
compact = false,
scrollContainer = false,
}: ResponsiveDataViewProps<T>) { }: ResponsiveDataViewProps<T>) {
const rowHeight = compact ? "h-10" : "h-16";
const headHeight = compact ? "h-9" : "h-12";
// cardFields 미지정 시 columns에서 자동 생성 // cardFields 미지정 시 columns에서 자동 생성
function resolveCardFields(item: T): RDVCardField<T>[] { function resolveCardFields(item: T): RDVCardField<T>[] {
if (typeof cardFields === "function") return cardFields(item); if (typeof cardFields === "function") return cardFields(item);
@@ -207,60 +218,69 @@ export function ResponsiveDataView<T>({
<div <div
className={cn( className={cn(
"hidden rounded-lg border bg-card shadow-sm lg:block", "hidden rounded-lg border bg-card shadow-sm lg:block",
// scrollContainer 모드: 부모가 height 결정 + 테이블 내부 세로 스크롤 + sticky 헤더.
// 부모는 보통 flex 컨테이너에서 flex-1 min-h-0 으로 남은 공간을 차지하게 두면 됨.
scrollContainer && "flex min-h-0 flex-1 flex-col overflow-hidden",
tableContainerClassName tableContainerClassName
)} )}
> >
<Table> <div className={cn(scrollContainer && "min-h-0 flex-1 overflow-y-auto")}>
<TableHeader> <Table>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50"> <TableHeader
{columns.map((col) => ( className={cn(
<TableHead scrollContainer && "sticky top-0 z-10 bg-card"
key={col.key}
style={col.width ? { width: col.width } : undefined}
className="h-12 text-sm font-semibold"
>
{col.label}
</TableHead>
))}
{renderActions && (
<TableHead
style={{ width: actionsWidth || "120px" }}
className="h-12 text-sm font-semibold"
>
{actionsLabel || "작업"}
</TableHead>
)} )}
</TableRow> >
</TableHeader> <TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableBody>
{data.map((item, index) => (
<TableRow
key={keyExtractor(item) ?? `row-${index}`}
className={cn(
"border-b transition-colors hover:bg-muted/50",
onRowClick && "cursor-pointer"
)}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => ( {columns.map((col) => (
<TableCell <TableHead
key={col.key} key={col.key}
className={cn("h-16 text-sm", col.className)} style={col.width ? { width: col.width } : undefined}
className={cn(headHeight, "text-sm font-semibold")}
> >
{col.render {col.label}
? col.render(getNestedValue(item, col.key), item, index) </TableHead>
: String(getNestedValue(item, col.key) ?? "-")}
</TableCell>
))} ))}
{renderActions && ( {renderActions && (
<TableCell className="h-16 text-sm"> <TableHead
<div className="flex gap-2">{renderActions(item)}</div> style={{ width: actionsWidth || "120px" }}
</TableCell> className={cn(headHeight, "text-sm font-semibold")}
>
{actionsLabel || "작업"}
</TableHead>
)} )}
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {data.map((item, index) => (
<TableRow
key={keyExtractor(item) ?? `row-${index}`}
className={cn(
"border-b transition-colors hover:bg-muted/50",
onRowClick && "cursor-pointer"
)}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(rowHeight, "text-sm", col.className)}
>
{col.render
? col.render(getNestedValue(item, col.key), item, index)
: String(getNestedValue(item, col.key) ?? "-")}
</TableCell>
))}
{renderActions && (
<TableCell className={cn(rowHeight, "text-sm")}>
<div className="flex gap-2">{renderActions(item)}</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div> </div>
{/* 모바일 카드 (lg 미만) */} {/* 모바일 카드 (lg 미만) */}
+24
View File
@@ -2,6 +2,7 @@
// 작성일: 2024-12-24 // 작성일: 2024-12-24
import { apiClient } from "./client"; import { apiClient } from "./client";
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
export type BatchExecutionType = "mapping" | "node_flow"; export type BatchExecutionType = "mapping" | "node_flow";
@@ -181,6 +182,29 @@ export class BatchAPI {
if (filter.page) params.append("page", filter.page.toString()); if (filter.page) params.append("page", filter.page.toString());
if (filter.limit) params.append("limit", filter.limit.toString()); if (filter.limit) params.append("limit", filter.limit.toString());
// SUPER_ADMIN 전사 모드 → cross-tenant fan-out (행마다 company_code 박힘)
if (isCrossTenantMode()) {
const ctResponse = await apiClient.get<any>(
`/admin/cross-tenant/batches?${params.toString()}`
);
const ct = ctResponse.data;
if (ct && ct.success && ct.data) {
const rows: BatchConfig[] = ct.data.rows || [];
return {
success: true,
data: rows,
// cross-tenant 1차는 페이지네이션 비지원 — 클라이언트 기준 1페이지로 표시
pagination: {
page: 1,
limit: rows.length,
total: rows.length,
totalPages: 1,
},
message: ct.message,
};
}
}
const response = await apiClient.get<any>( const response = await apiClient.get<any>(
`/batch-management/batch-configs?${params.toString()}` `/batch-management/batch-configs?${params.toString()}`
); );
+24
View File
@@ -4,6 +4,7 @@
*/ */
import { apiClient } from "./client"; import { apiClient } from "./client";
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
// ===================================================== // =====================================================
// 타입 정의 // 타입 정의
@@ -175,6 +176,12 @@ export async function getLanguages(): Promise<ApiResponse<Language[]>> {
/** /**
* 다국어 키 목록 조회 * 다국어 키 목록 조회
*
* 분기:
* - cross-tenant 모드 → /admin/cross-tenant/lang-keys
* 응답 행마다 company_code 박혀있어 화면에서 회사 컬럼/필터 가능.
* 1차 구현은 categoryId 재귀 필터 비지원 (필요해지면 후속 추가).
* - 단일 회사 모드 → /multilang/keys (기존)
*/ */
export async function getLangKeys(params?: { export async function getLangKeys(params?: {
company_code?: string; company_code?: string;
@@ -183,6 +190,23 @@ export async function getLangKeys(params?: {
searchText?: string; searchText?: string;
}): Promise<ApiResponse<LangKey[]>> { }): Promise<ApiResponse<LangKey[]>> {
try { try {
if (isCrossTenantMode()) {
const ctParams = new URLSearchParams();
// cross-tenant mapper 의 파라미터명 (snake_case) 으로 매핑
if (params?.menuCode) ctParams.append("menu_code", params.menuCode);
if (params?.searchText) ctParams.append("search", params.searchText);
const url = `/admin/cross-tenant/lang-keys${ctParams.toString() ? `?${ctParams.toString()}` : ""}`;
const ctResponse = await apiClient.get(url);
const ct = ctResponse.data;
if (ct && ct.success && ct.data) {
return {
success: true,
data: (ct.data.rows || []) as LangKey[],
} as ApiResponse<LangKey[]>;
}
}
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params?.company_code) queryParams.append("companyCode", params.company_code); if (params?.company_code) queryParams.append("companyCode", params.company_code);
if (params?.menuCode) queryParams.append("menuCode", params.menuCode); if (params?.menuCode) queryParams.append("menuCode", params.menuCode);
+20
View File
@@ -1,5 +1,6 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
import { ApiResponse } from "@/types/commonCode"; import { ApiResponse } from "@/types/commonCode";
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
/** /**
* 권한 그룹 인터페이스 * 권한 그룹 인터페이스
@@ -53,9 +54,28 @@ export interface MenuPermission {
export const roleAPI = { export const roleAPI = {
/** /**
* 권한 그룹 목록 조회 * 권한 그룹 목록 조회
*
* 분기:
* - cross-tenant 모드 (SUPER_ADMIN + company_code="*") → /admin/cross-tenant/roles
* 응답 행마다 company_code 박혀있어 화면에서 회사 컬럼/필터 가능.
* - 단일 회사 모드 → /roles (기존)
*
* 두 응답을 동일 shape `{ success, data: RoleGroup[] }` 로 정규화.
*/ */
async getList(params?: { company_code?: string; search?: string }): Promise<ApiResponse<RoleGroup[]>> { async getList(params?: { company_code?: string; search?: string }): Promise<ApiResponse<RoleGroup[]>> {
try { try {
if (isCrossTenantMode()) {
const response = await apiClient.get("/admin/cross-tenant/roles", { params });
const ct = response.data;
if (ct && ct.success && ct.data) {
return {
success: true,
data: (ct.data.rows || []) as RoleGroup[],
message: ct.message,
};
}
return ct;
}
const response = await apiClient.get("/roles", { params }); const response = await apiClient.get("/roles", { params });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
+39 -5
View File
@@ -1,4 +1,5 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
/** /**
* 사용자 관리 API 클라이언트 * 사용자 관리 API 클라이언트
@@ -23,15 +24,48 @@ interface ApiResponse<T> {
/** /**
* 사용자 목록 조회 * 사용자 목록 조회
*
* 분기:
* - cross-tenant 모드 → GET /admin/cross-tenant/users (전 회사 사용자 합산, 행마다 company_code)
* - 단일 회사 모드 → GET /admin/users (현 회사 사용자만)
*
* 두 응답을 hook 이 기대하는 동일 shape 로 정규화 — { success, data: { users, pagination }, total }
*/ */
export async function getUserList(params?: Record<string, any>) { export async function getUserList(params?: Record<string, any>) {
try { try {
if (isCrossTenantMode()) {
console.log("📡 [cross-tenant] 사용자 목록 API 호출:", params);
const response = await apiClient.get("/admin/cross-tenant/users", { params });
console.log("✅ [cross-tenant] 사용자 목록 API 응답:", response.data);
// cross-tenant 응답 { success, data: { rows, total, companies_queried, companies_failed } }
// → 기존 hook 이 기대하는 shape 로 정규화. 1차 구현은 페이지네이션 비지원이라
// 서버가 한 번에 다 줌 → 클라이언트 기준 total_count = rows.length.
const ct = response.data;
if (ct && ct.success && ct.data) {
const rows: any[] = ct.data.rows || [];
return {
success: true,
data: {
users: rows,
pagination: {
total_count: rows.length,
page: 1,
total_pages: 1,
},
},
total: rows.length,
// 디버깅/UX 용 부가 정보 — 필요 시 화면에서 사용
companies_queried: ct.data.companies_queried,
companies_failed: ct.data.companies_failed,
message: ct.message,
};
}
return ct;
}
console.log("📡 사용자 목록 API 호출:", params); console.log("📡 사용자 목록 API 호출:", params);
const response = await apiClient.get("/admin/users", { params });
const response = await apiClient.get("/admin/users", {
params: params,
});
console.log("✅ 사용자 목록 API 응답:", response.data); console.log("✅ 사용자 목록 API 응답:", response.data);
return response.data; return response.data;
} catch (error) { } catch (error) {
+46
View File
@@ -0,0 +1,46 @@
/**
* SUPER_ADMIN 의 cross-tenant(전사) 모드 판정 유틸.
*
* 백엔드 cross-tenant 엔드포인트가 동작하려면:
* 1. JWT.user_type === "SUPER_ADMIN"
* 2. 현재 컨텍스트 == META DB
* 둘 다 만족해야 함. 프론트는 (1) + (2)를 직접 못 보지만,
* SUPER_ADMIN 이 회사 도메인으로 switch 하면 JWT.company_code 가 그 회사 코드로 바뀐다.
* 그러므로 (user_type === "SUPER_ADMIN" && company_code === "*") 가 메타 컨텍스트의 정확한 신호.
*
* 회사 도메인으로 switch 한 SUPER_ADMIN 은 JWT.company_code 가 회사 코드로 바뀌어 false 가 됨
* → 자연스럽게 단일 회사 모드로 복귀.
*
* @see notes/gbpark/2026-04-27-cross-tenant-admin-aggregation.md §8.1
*/
interface JwtClaims {
user_type?: string;
company_code?: string;
user_id?: string;
}
/**
* 토큰의 Base64 페이로드만 디코드. 무결성 검증은 백엔드 책임.
*/
export function decodeJwtClaims(): JwtClaims | null {
if (typeof window === "undefined") return null;
const token = localStorage.getItem("authToken");
if (!token) return null;
try {
const payload = token.split(".")[1];
return JSON.parse(atob(payload));
} catch {
return null;
}
}
/**
* 현재 SUPER_ADMIN cross-tenant(전사) 모드인가?
* 어드민 14개 메뉴 API 클라이언트는 이걸로 분기해서
* /admin/cross-tenant/* 엔드포인트 또는 단일 회사 엔드포인트를 골라 호출.
*/
export function isCrossTenantMode(): boolean {
const c = decodeJwtClaims();
return c?.user_type === "SUPER_ADMIN" && c?.company_code === "*";
}
@@ -0,0 +1,465 @@
# Cross-Tenant 어드민 합산 설계 (SUPER_ADMIN 전사 보기)
작성일: 2026-04-27
작성자: hjjeong
관련: [docs/MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md), [notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md](2026-04-24-company-db-provisioning-execution-plan.md), [notes/gbpark/2026-04-24-company-mgmt-ui-schema.md](2026-04-24-company-mgmt-ui-schema.md)
---
## 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. 단계별 실행 체크리스트
### Phase A — 인프라 (최소)
- [ ] `CrossTenantContext` (가드 클래스)
- [ ] `CrossTenantAggregator` (직렬, cache off)
- [ ] `mapper/provisioning.xml` 에 `listActiveCompanies` 추가
- [ ] `SecurityConfig` 에 `/api/admin/cross-tenant/**` SUPER_ADMIN 가드
- [ ] `CrossTenantController` 빈 컨트롤러 + 1개 엔드포인트 (`/users`)
### Phase B — PoC: 사용자관리
- [ ] `mapper/admin-cross-tenant.xml` + `listUsers` SELECT
- [ ] `/api/admin/cross-tenant/users` 동작 확인 (개발 회사 2~3개 띄워놓고)
- [ ] 프론트 사용자관리 페이지에 host 분기 (admin.invyone.com → cross-tenant API 호출)
- [ ] 회사 컬럼 + 회사 필터 드롭다운 추가
### Phase C — 14개 메뉴 확산
- [ ] 위 4.3 표 순서대로 mapperId + 프론트 분기 추가
- [ ] 키 충돌 정책 (`company_code::pk`) 일괄 적용
- [ ] 행 클릭 → 회사 도메인 새 탭 오픈 패턴 구현
### 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 합산" 신설하여 본 문서 요약 + 링크
---
## 11. 검증 시나리오
### 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`.
### 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`.
### 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 동작).
---
## 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. 다음 세션 진입 시
본 문서가 진실의 원천. 이미 만들어진 [CompanyStatsService.enrichOne()](../../backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java) / [CompanyLifecycleService](../../backend-spring/src/main/java/com/erp/provisioning/CompanyLifecycleService.java) 를 참고하되, **새 코드는 raw JDBC 가 아니라 MyBatis sqlSession 을 그대로 쓴다** (TenantRoutingDataSource 가 있기 때문).
Phase A → B → C 순서 강제. Phase A 끝나기 전에 14개 메뉴 mapper 한번에 짜지 말 것 — Aggregator 가 흔들리면 다 같이 흔들림.
@@ -0,0 +1,272 @@
# Cross-Tenant 어드민 합산 — 실행 로그
작성일: 2026-04-28
작성자: hjjeong
관련 설계: [2026-04-27-cross-tenant-admin-aggregation.md](2026-04-27-cross-tenant-admin-aggregation.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` 플래그 응답 (검증 완료)
- 푸시 / 커밋 모두 안 함. 모든 변경 워킹트리에 워킹카피 상태로 남음.
---
## 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 등에선 무해히 무시됨.)
---
## 5. 워킹트리 상태 (2026-04-28 오후)
```
M .gitignore
M backend-spring/src/main/resources/mapper/provisioning.xml
M frontend/app/(auth)/login/page.tsx
M frontend/lib/api/batch.ts
M frontend/lib/api/multilang.ts
M frontend/lib/api/role.ts
M frontend/lib/api/user.ts
?? .java-version
?? backend-spring/src/main/java/com/erp/crosstenant/
?? backend-spring/src/main/resources/mapper/admin-cross-tenant.xml
?? frontend/lib/auth/crossTenantMode.ts
?? notes/gbpark/2026-04-27-cross-tenant-admin-aggregation.md
?? notes/gbpark/2026-04-28-cross-tenant-execution-log.md
```
페이지네이션 cap 추가 작업은 **새 파일 없이** 기존 신규/수정 파일 (admin-cross-tenant.xml, CrossTenant{Aggregator,Controller}.java) 안에서 이루어져 워킹트리 파일 목록 자체는 그대로.
푸시·커밋 모두 안 함.
---
## 6. 권장 다음 단계 (우선순위)
| # | 항목 | 비용 | 효용 |
|---|---|---|---|
| 1 | ~~Phase C 3개 메뉴 검증~~ | ✅ 완료 (2026-04-28 오전) | — |
| 2 | ~~페이지네이션 cap 도입~~ | ✅ 완료 (2026-04-28 오후) | — |
| 3 | 두번째 회사 프로비저닝 → 진짜 fan-out 효과 (`companies_queried: 2`, 행에 `TEST01` / `TEST02` 섞임) 확인 | 20~30분 | 머지 / 실패격리 동작 검증 |
| 4 | 화면 측 `truncated === true` 안내 박스 — "200건 표시 중, 더 보려면 검색 좁히거나 회사 도메인 전환" | 페이지당 10분 × 4 | UX 개선 |
| 5 | 전체 변경을 `feat: cross-tenant admin aggregation Phase A/B/C + pagination cap` 한 커밋으로 정리 | 5분 | 리포 위생 |
| 6 | 보류 4개 메뉴 UX 설계 — 특히 메뉴관리(트리)와 공통코드(카테고리 의존) | 별도 세션 | 14개 메뉴 완주 |
| 7 | Phase D (캐시 / 병렬) — 회사 N≥20 환경 만들어 측정 후 결정 | 별도 세션 | 운영 확장성 |
| 8 | Phase E — [MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md) §12 신설로 본 작업 통합 + 082~084 마이그레이션 표 갱신 | 30분 | 후임자 온보딩 |
---
## 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. 알려진 데이터 상태 (참고)
- **활성 회사**: TEST01 (시연용회사) 1개. 다른 1개("공통", `company_code='*'`) 는 가상 row → cross-tenant 대상 X.
- **TEST01 USER_INFO**: 8명 (강빈박, 관리자, 김대성, 명건희, 박창현, 정혜진, 오재옥, chpark). 7명은 시드 어드민 패턴 (`dept_code='DPT005'`, `user_name_eng='admin'`).
- **현재 로그인 토큰**: `user_id=hjjeong`, `user_type=SUPER_ADMIN`, `company_code=*` — META 컨텍스트로 라우팅 확인됨.