notes: hjjeong 작성 MD 3개를 notes/hjjeong/ 으로 이동 + 현재 상태 반영
CLAUDE.md "notes/{git-user}/{date}-{slug}.md" 규약대로 정리.
git config user.name=hjjeong 인데 gbpark/ 에 쌓이고 있던 3개 분리.
- 2026-04-27-cross-tenant-admin-aggregation.md (설계, 내용 갱신: Phase A/B/C 체크리스트, §11 검증 시나리오 실 결과 병기, §14 다음 단계)
- 2026-04-28-cross-tenant-execution-log.md (실행 로그, 내용 갱신: 커밋·푸시 상태, §4.4 *.localhost 같은 배치 묶음 기재, §5 커밋 분배 의도, §6 권장 단계 ✅/🟡/⏳/❌ 표시)
- 2026-04-28-localhost-tenant-routing-handoff.md (순수 이동)
cross-tenant 27.md 의 같은 폴더 참조였던 2026-04-24 두 문서는
gbpark/ 에 그대로 남아 ../gbpark/ 로 경로 보정. 외부 참조(../../docs,
../../backend-spring 등) 는 깊이 동일해 무수정.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 컨텍스트로 라우팅 확인됨.
|
||||
@@ -0,0 +1,292 @@
|
||||
# `*.localhost` 테넌트 라우팅 — dev 환경 패치 핸드오프
|
||||
|
||||
작성일: 2026-04-28
|
||||
작성자: hjjeong
|
||||
대상: 새 세션 (clean context) 에서 바로 실행
|
||||
관련 SoT: [docs/MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md)
|
||||
선행 작업: [2026-04-27-cross-tenant-admin-aggregation.md](2026-04-27-cross-tenant-admin-aggregation.md), [2026-04-28-cross-tenant-execution-log.md](2026-04-28-cross-tenant-execution-log.md)
|
||||
|
||||
---
|
||||
|
||||
## 0. 새 세션에서 1분 안에 컨텍스트 잡기
|
||||
|
||||
이 한 단락만 읽고 시작하면 됨:
|
||||
|
||||
> INVYONE 은 멀티테넌시 (회사별 PostgreSQL DB) 플랫폼. 운영에서는 `qnc.invyone.com` 같은 서브도메인으로 회사 DB 가 자동 라우팅된다. dev 환경에서도 같은 라우팅을 흉내내려면 `qnc.localhost:9771` 같은 호스트가 동작해야 하는데, **현재 백엔드/프론트가 `*.invyone.com` 만 서브도메인으로 인식**해서 dev 환경에선 단일 테넌트 모드 테스트가 막혀있다. 이 문서가 그 패치 작업.
|
||||
|
||||
핵심 사실:
|
||||
- 운영 배포 (`https://test02.invyone.com/login`) 는 정상 동작 — Porkbun 와일드카드 DNS + Traefik
|
||||
- localhost dev 에서 `http://test02.localhost:9771/login` 은 로그인 시 메타 DB 컨텍스트로 떨어져 실패
|
||||
- 4개 파일만 수정하면 해결. 하지만 운영 동작 깨뜨리지 않아야 함
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
dev 환경에서 다음이 동작하게:
|
||||
|
||||
```
|
||||
http://test02.localhost:9771/login
|
||||
↓
|
||||
TEST02 회사 단일 모드 로그인 화면
|
||||
↓
|
||||
test02_admin / 초기비번 으로 로그인 성공
|
||||
↓
|
||||
TEST02 회사 DB 컨텍스트로 어드민 14개 메뉴 사용
|
||||
```
|
||||
|
||||
운영(`*.invyone.com`) 동작은 **그대로 유지**.
|
||||
|
||||
---
|
||||
|
||||
## 2. 근본 원인 (이미 분석 완료)
|
||||
|
||||
### 원인 A — 백엔드: 2파트 호스트는 무조건 null
|
||||
|
||||
[backend-spring/.../tenant/SubdomainResolverFilter.java](../../backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java) `extractSubdomain()` 메서드:
|
||||
|
||||
```java
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length < 3) return null; // invyone.com (2파트) → null
|
||||
```
|
||||
|
||||
`test02.localhost` 는 2파트 → null → `DbContextHolder.setMeta()` 박혀 META DB 로 라우팅. 로그인 시 메타 DB의 USER_INFO 에서 `test02_admin` 을 찾으니 인증 실패.
|
||||
|
||||
### 원인 B — 프론트: `.invyone.com` 만 직접 호출 분기
|
||||
|
||||
[frontend/lib/api/client.ts](../../frontend/lib/api/client.ts) (lines ~16~40 부근):
|
||||
|
||||
```ts
|
||||
if (currentHost.endsWith(".invyone.com")) {
|
||||
return `https://${currentHost}/api`; // 직접 호출 (Host 헤더 보존)
|
||||
}
|
||||
// fallthrough → /api 상대 경로 → Next.js rewrite → Host 헤더 변조됨
|
||||
```
|
||||
|
||||
`*.localhost` 는 분기에 안 걸리고 Next rewrite 로 빠지면, Host 헤더가 `localhost:8081` 로 변조되어 백엔드가 서브도메인을 못 봄.
|
||||
|
||||
### 그 외 영향 받을 수 있는 파일
|
||||
|
||||
- [frontend/lib/tenant/subdomain.ts](../../frontend/lib/tenant/subdomain.ts) — `extractTenantSubdomain(host)` 헬퍼
|
||||
- [frontend/components/TenantGuard.tsx](../../frontend/components/TenantGuard.tsx) — 미등록 서브도메인 차단
|
||||
|
||||
위 두 개도 동일하게 `*.localhost` 를 인정해야 일관성 유지.
|
||||
|
||||
---
|
||||
|
||||
## 3. 해야 할 패치 (4~5개 파일)
|
||||
|
||||
### 3.1 백엔드 — `SubdomainResolverFilter.java`
|
||||
|
||||
`extractSubdomain()` 을 다음 로직으로 수정:
|
||||
|
||||
```java
|
||||
static String extractSubdomain(String host) {
|
||||
if (host == null || host.isBlank()) return null;
|
||||
|
||||
int colon = host.indexOf(':');
|
||||
if (colon != -1) host = host.substring(0, colon);
|
||||
host = host.toLowerCase();
|
||||
|
||||
if ("localhost".equals(host)) return null; // bare localhost → META
|
||||
if (IPV4.matcher(host).matches()) return null; // IP → META
|
||||
|
||||
String[] parts = host.split("\\.");
|
||||
|
||||
// 2파트 — "{sub}.localhost" 만 허용 (dev 전용)
|
||||
if (parts.length == 2) {
|
||||
if ("localhost".equals(parts[1])) {
|
||||
String first = parts[0];
|
||||
if (ReservedSubdomains.VALUES.contains(first)) return null;
|
||||
return first;
|
||||
}
|
||||
return null; // invyone.com 같은 베이스 도메인
|
||||
}
|
||||
|
||||
// 3파트 이상 (운영) — 첫 번째 파트가 서브도메인
|
||||
if (parts.length < 3) return null;
|
||||
String first = parts[0];
|
||||
if (ReservedSubdomains.VALUES.contains(first)) return null;
|
||||
return first;
|
||||
}
|
||||
```
|
||||
|
||||
- 운영 (3파트, `qnc.invyone.com`) 동작 변경 없음
|
||||
- 새로 추가: 2파트 + 두번째가 `localhost` → 첫 파트를 서브도메인으로 인정
|
||||
- 기존 `bare localhost` / IP 분기 그대로
|
||||
|
||||
### 3.2 프론트 — `lib/api/client.ts`
|
||||
|
||||
`.invyone.com` 분기 옆에 `.localhost` 분기 하나 추가:
|
||||
|
||||
```ts
|
||||
// 운영 (https) — Host 보존하며 Traefik 이 백엔드로 프록시
|
||||
if (currentHost.endsWith(".invyone.com")) {
|
||||
return `https://${currentHost}/api`;
|
||||
}
|
||||
|
||||
// dev (http) — *.localhost 도 동일 패턴으로 직접 호출 (Host 보존)
|
||||
// 단, bare "localhost" 는 제외 — 그건 메타 컨텍스트 (admin)
|
||||
if (currentHost.endsWith(".localhost") && currentHost !== "localhost") {
|
||||
return `http://${currentHost}/api`;
|
||||
}
|
||||
```
|
||||
|
||||
> **주의**: `localhost` 자체는 제외해야 함. `localhost:9771` 은 admin (메타) 도메인 역할이라 기존 NEXT_PUBLIC_API_URL=/api 분기가 처리해야 cross-tenant 가 정상 동작함.
|
||||
|
||||
### 3.3 프론트 — `lib/tenant/subdomain.ts`
|
||||
|
||||
`extractTenantSubdomain()` 도 동일 로직 적용. 백엔드와 같은 규칙:
|
||||
- bare `localhost` → null (메타)
|
||||
- `{sub}.localhost` → 첫 파트 (예약어 제외)
|
||||
- 3파트 이상 → 첫 파트 (예약어 제외)
|
||||
|
||||
### 3.4 프론트 — `components/TenantGuard.tsx`
|
||||
|
||||
[3.3 의 `extractTenantSubdomain`] 결과가 null 이면 통과, 그 외엔 백엔드 `/api/tenant/check?subdomain=xxx` 호출. 로직 자체는 변경 없음 — 위 3.3 만 고치면 자연스럽게 동작.
|
||||
|
||||
### 3.5 (선택) `lib/auth/crossTenantMode.ts`
|
||||
|
||||
cross-tenant 모드 판정은 JWT 의 `company_code === "*"` 기반이라 호스트와 무관. 변경 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증 시나리오
|
||||
|
||||
### 사전 — 환경 셋업 확인
|
||||
|
||||
```bash
|
||||
cd /Users/jhj/invyone
|
||||
direnv status # JWT_SECRET, PATH(postgresql@16) 로드 확인
|
||||
which pg_dump && pg_dump --version # 16.x 나와야 함
|
||||
java -version # 21.0.10 나와야 함
|
||||
|
||||
# 백엔드 (이전 세션 종료됐으면)
|
||||
cd backend-spring && ./gradlew bootRun
|
||||
|
||||
# 프론트 (이전 세션 종료됐으면)
|
||||
cd /Users/jhj/invyone/frontend && npm run dev
|
||||
```
|
||||
|
||||
### 시나리오 1 — dev 단일 테넌트 로그인 (이번 패치 핵심)
|
||||
|
||||
1. 브라우저 새 시크릿 창 (이전 세션 토큰 격리)
|
||||
2. http://test02.localhost:9771/login 접속
|
||||
3. 로그인 화면 정상 렌더 (`TenantGuard` 통과)
|
||||
4. 계정 입력:
|
||||
- user_id: `test02_admin`
|
||||
- password: `x7uouA7qoUEj` (TEST02 프로비저닝 시 발급된 초기 비번)
|
||||
5. 로그인 → 비밀번호 강제 변경 화면 (082 마이그레이션 `FORCE_PASSWORD_CHANGE` 동작)
|
||||
6. 새 비번 설정 → 메인 화면 진입
|
||||
7. 사이드바 → 어드민 메뉴 → **TEST02 회사 데이터만 보여야 함** (TEST01 X)
|
||||
|
||||
### 시나리오 2 — bare localhost 가 여전히 메타로 라우팅
|
||||
|
||||
1. http://localhost:9771/admin/userMng/userMngList (SUPER_ADMIN 토큰 보유 상태)
|
||||
2. 사용자관리 → cross-tenant 응답 (`companies_queried: 2`, 행에 TEST01/TEST02 섞임)
|
||||
|
||||
이 동작이 깨지면 `client.ts` 의 `.localhost !== "localhost"` 가드가 빠진 것.
|
||||
|
||||
### 시나리오 3 — 운영 도메인 회귀 없음
|
||||
|
||||
운영 배포는 별도 검증 필요. 본 패치는 운영 코드 경로 (3파트 호스트) 를 안 건드려서 회귀 위험 낮음.
|
||||
|
||||
### 시나리오 4 — 미등록 서브도메인 차단
|
||||
|
||||
http://nonexistent.localhost:9771 접속 → `/tenant-not-found` 로 리다이렉트 (`TenantGuard` 동작).
|
||||
|
||||
---
|
||||
|
||||
## 5. 함정 / 주의
|
||||
|
||||
| 함정 | 설명 |
|
||||
|---|---|
|
||||
| `localhost` 자체를 서브도메인으로 인식 | 2파트 분기에서 `bare localhost` 빠뜨리면 `localhost.com` 같은 호스트도 `localhost` 가 서브도메인이 됨. 위 코드는 첫 줄에 `if ("localhost".equals(host)) return null;` 으로 이미 차단 |
|
||||
| `admin.localhost` 가 메타로 안 가고 테넌트로 빠짐 | `ReservedSubdomains` 에 `admin` 이 이미 있어 자동으로 null 처리됨. 추가 작업 불필요 |
|
||||
| 운영의 3파트 동작 변경 | 위 패치는 3파트 분기를 그대로 둠. 운영 회귀 0 |
|
||||
| Next.js dev rewrite 캐시 | `next.config.mjs` 안 건드리면 영향 없음 |
|
||||
| CORS | dev 에서 `http://*.localhost:[*]` 패턴이 [`application.yml`의 `CORS_ALLOWED_ORIGINS`](../../backend-spring/src/main/resources/application.yml) 에 이미 포함돼 있는지 확인. 없으면 추가 |
|
||||
|
||||
CORS 디폴트 (`SecurityConfig.corsAllowedOrigins`) 확인:
|
||||
|
||||
```bash
|
||||
grep -A 1 "CORS_ALLOWED_ORIGINS\|allowed-origins" /Users/jhj/invyone/backend-spring/src/main/resources/application.yml
|
||||
```
|
||||
|
||||
`http://*.localhost:[*]` 같은 패턴이 빠져있으면 `application.yml` 디폴트 또는 `.envrc` 의 `CORS_ALLOWED_ORIGINS` 환경변수에 추가.
|
||||
|
||||
---
|
||||
|
||||
## 6. 작업 단계 권장 순서
|
||||
|
||||
1. **백엔드 수정** — `SubdomainResolverFilter.extractSubdomain()` + 단위 테스트 작성 (호스트 5~6 종류 대해 기대 결과 확인)
|
||||
2. **재컴파일** — `./gradlew classes` (devtools 자동 재시작)
|
||||
3. **백엔드 단독 검증** — 다음 curl 로 헤더 라우팅만 확인:
|
||||
|
||||
```bash
|
||||
# bare localhost → META
|
||||
curl -s -H "Host: localhost:9771" http://localhost:8081/api/tenant/check?subdomain=test02
|
||||
# test02.localhost → 라우팅
|
||||
curl -s -H "Host: test02.localhost:9771" http://localhost:8081/api/tenant/check?subdomain=test02
|
||||
# test02.invyone.com (운영) — 회귀 없음 확인
|
||||
curl -s -H "Host: test02.invyone.com" http://localhost:8081/api/tenant/check?subdomain=test02
|
||||
```
|
||||
|
||||
4. **프론트 수정** — `client.ts` / `subdomain.ts`
|
||||
5. **브라우저 검증** — 위 §4 시나리오 1~4
|
||||
6. **`MULTI_TENANCY_ARCHITECTURE.md` §4.2 갱신** — "로컬 개발 도커 up" 섹션에 "*.localhost 직접 dev (npm run dev)" 케이스 추가
|
||||
|
||||
---
|
||||
|
||||
## 7. 현재 워킹트리 상태 (2026-04-28 17:xx)
|
||||
|
||||
```
|
||||
M .gitignore
|
||||
M backend-spring/src/main/resources/mapper/provisioning.xml
|
||||
M frontend/app/(auth)/login/page.tsx
|
||||
M frontend/components/admin/UserTable.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
|
||||
?? notes/gbpark/2026-04-28-localhost-tenant-routing-handoff.md ← 본 문서
|
||||
```
|
||||
|
||||
**커밋·푸시 모두 안 됨.** 이전 cross-tenant 작업 분의 워킹카피가 모두 살아있는 상태. 본 패치 작업은 이 위에서 진행해도 되고, 먼저 cross-tenant 분량을 커밋한 뒤 진행해도 됨. 권장 — **cross-tenant 분량을 먼저 커밋 후** 본 패치를 별도 커밋으로 분리.
|
||||
|
||||
---
|
||||
|
||||
## 8. 알려진 환경 정보 (참고)
|
||||
|
||||
- **현재 활성 회사**: TEST01 (시연용회사) + TEST02 (시연용회사2). 두 회사 모두 `DB_STATUS='active'`. 같은 시드 데이터 8명 사용자 / 1 권한그룹 / 10 배치 / 646 다국어 키.
|
||||
- **TEST02 초기 admin 계정**: `test02_admin` / `x7uouA7qoUEj` (FORCE_PW_CHANGE 활성)
|
||||
- **현재 SUPER_ADMIN 토큰**: `hjjeong` 계정. 이전 세션에서 발급된 24h 토큰. 만료 시 재로그인 필요.
|
||||
- **로컬 백엔드**: 8081 (Java 21, Spring Boot 3.3.5, devtools)
|
||||
- **로컬 프론트**: 9771 (Next.js 15, turbopack)
|
||||
- **메타 DB**: `183.99.177.40:5432/invyone` (postgres / invyone0909!!)
|
||||
- **테넌트 DB**: `test01_invyone`, `test02_invyone`
|
||||
|
||||
---
|
||||
|
||||
## 9. 새 세션 시작 시 첫 명령
|
||||
|
||||
```bash
|
||||
# 1. 환경 진단
|
||||
cd /Users/jhj/invyone
|
||||
git status --short
|
||||
direnv status
|
||||
java -version
|
||||
|
||||
# 2. 본 문서 다시 읽기 (이미 읽었지만 새 세션 LLM 입장)
|
||||
cat notes/gbpark/2026-04-28-localhost-tenant-routing-handoff.md | head -50
|
||||
|
||||
# 3. 본격 작업 — 위 §3.1 부터
|
||||
```
|
||||
|
||||
이 문서가 진실의 원천. 막히면 §5 (함정), §6 (작업 순서) 참고.
|
||||
Reference in New Issue
Block a user