dev *.localhost 테넌트 라우팅 + direnv/Java 21 dev 환경 정비

SubdomainResolverFilter.extractSubdomain() 가 2파트 {sub}.localhost 호스트도
첫 파트로 파싱 (RFC 6761). 운영 3파트 경로 무변경. 단위 테스트 8건 추가.

frontend/lib/api/client.ts 에 *.localhost (bare 제외) 직접 호출 분기 1-b 추가.
8081 로 직결해 Host 헤더 보존. subdomain.ts 도 동일 규칙 적용.

application.yml CORS 디폴트에 http://*.localhost:[*] 패턴 추가.
docs/MULTI_TENANCY_ARCHITECTURE.md §4.2 (실행 모드 A/B) + §6 (1-b 분기) 갱신.
.gitignore 에 .envrc/.direnv 추가, .java-version=21 명시 (jenv 호환).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-04-28 16:57:10 +09:00
parent e16fb16987
commit 383b837a60
9 changed files with 439 additions and 17 deletions
+4
View File
@@ -2,6 +2,10 @@
.claude/
CLAUDE.local.md
# direnv (per-developer JAVA_HOME / shell env)
.envrc
.direnv/
# Syncthing local stub (each machine has its own; real patterns in .stignore-shared)
.stignore
.stfolder/
+1
View File
@@ -0,0 +1 @@
21
@@ -85,7 +85,11 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
}
/**
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/localhost/www/admin 제외.
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/bare localhost/예약어 제외.
*
* 운영 (3파트, e.g. qnc.invyone.com) → 첫 파트
* dev (2파트, {sub}.localhost) → 첫 파트 (RFC 6761, 별도 DNS 불필요)
* 그 외 (invyone.com 같은 베이스 / bare localhost / IP) → null (META)
*/
static String extractSubdomain(String host) {
if (host == null || host.isBlank()) return null;
@@ -99,8 +103,18 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
if (IPV4.matcher(host).matches()) return null;
String[] parts = host.split("\\.");
if (parts.length < 3) return null; // invyone.com (2파트) → null
// 2파트 — "{sub}.localhost" 만 허용 (dev 전용). invyone.com 같은 베이스 도메인은 null.
if (parts.length == 2) {
if (!"localhost".equals(parts[1])) return null;
String first = parts[0];
if (first.isEmpty()) return null;
if (ReservedSubdomains.VALUES.contains(first)) return null;
return first;
}
// 3파트 이상 (운영) — 첫 번째 파트가 서브도메인
if (parts.length < 3) return null;
String first = parts[0];
if (ReservedSubdomains.VALUES.contains(first)) return null;
@@ -42,8 +42,8 @@ cors:
# 콤마 구분 문자열. setAllowedOriginPatterns 로 매칭됨.
# Spring CORS 문법: 포트 와일드카드는 `[*]` 로 표기. YAML 이 `[...]` 를 sequence 로 해석하지
# 않도록 반드시 따옴표로 감싸기.
# dev 디폴트: localhost + 사무실 Tailscale IP + 테넌트 서브도메인 (모든 포트) 패턴.
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
# dev 디폴트: localhost + 사무실 Tailscale IP + *.localhost 테넌트 (RFC 6761) + 테넌트 서브도메인 (모든 포트) 패턴.
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.localhost:[*],http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
file:
upload-dir: ./uploads
@@ -0,0 +1,66 @@
package com.erp.tenant;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* extractSubdomain() 단위 테스트.
* - 운영 (3파트, *.invyone.com) 동작 회귀 없음
* - dev (2파트, *.localhost) 신규 지원
* - bare localhost / IP / 베이스 도메인 / 예약어 → null (META)
*/
class SubdomainResolverFilterTest {
@Test
void nullOrBlank_returnsNull() {
assertNull(SubdomainResolverFilter.extractSubdomain(null));
assertNull(SubdomainResolverFilter.extractSubdomain(""));
assertNull(SubdomainResolverFilter.extractSubdomain(" "));
}
@Test
void bareLocalhost_returnsNull() {
assertNull(SubdomainResolverFilter.extractSubdomain("localhost"));
assertNull(SubdomainResolverFilter.extractSubdomain("localhost:9771"));
}
@Test
void ipv4_returnsNull() {
assertNull(SubdomainResolverFilter.extractSubdomain("127.0.0.1"));
assertNull(SubdomainResolverFilter.extractSubdomain("183.99.177.40:8081"));
}
@Test
void baseDomain_returnsNull() {
assertNull(SubdomainResolverFilter.extractSubdomain("invyone.com"));
assertNull(SubdomainResolverFilter.extractSubdomain("invyone.com:443"));
}
@Test
void productionThreePartHost_returnsFirstPart() {
assertEquals("qnc", SubdomainResolverFilter.extractSubdomain("qnc.invyone.com"));
assertEquals("test02", SubdomainResolverFilter.extractSubdomain("test02.invyone.com:443"));
}
@Test
void devLocalhostHost_returnsFirstPart() {
assertEquals("test02", SubdomainResolverFilter.extractSubdomain("test02.localhost"));
assertEquals("test02", SubdomainResolverFilter.extractSubdomain("test02.localhost:9771"));
assertEquals("qnc", SubdomainResolverFilter.extractSubdomain("QNC.LOCALHOST"));
}
@Test
void reservedSubdomain_returnsNull() {
assertNull(SubdomainResolverFilter.extractSubdomain("admin.invyone.com"));
assertNull(SubdomainResolverFilter.extractSubdomain("solution.invyone.com"));
assertNull(SubdomainResolverFilter.extractSubdomain("admin.localhost:9771"));
assertNull(SubdomainResolverFilter.extractSubdomain("www.localhost"));
}
@Test
void emptyFirstPart_returnsNull() {
assertNull(SubdomainResolverFilter.extractSubdomain(".localhost"));
}
}
+23 -7
View File
@@ -155,17 +155,29 @@ invyone (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이
- **Traefik static + env**: `notes/gbpark/2026-04-24-traefik-wildcard/docker-compose.yml` 참고 (Porkbun provider + environment 블록 포함)
- **Let's Encrypt acme-dns.json**: 서버 `/opt/docker/traefik/letsencrypt/` 에 저장 (90일 자동 갱신)
### 4.2 로컬 개발 (본인 머신에서 docker up)
### 4.2 로컬 개발 (본인 머신)
**`*.localhost` 자동 매핑** 사용. RFC 6761 + 최신 브라우저가 자동 해석.
**`*.localhost` 자동 매핑** 사용. RFC 6761 + 최신 브라우저가 자동 해석. hosts 편집/DNS 설정 0.
```
브라우저가 test07.localhost:9772 요청
→ Chrome/Firefox/Edge 가 자동으로 127.0.0.1:9772 해석
→ hosts 편집 0, 설정 0
브라우저가 test02.localhost:<port> 요청
→ Chrome/Firefox/Edge 가 자동으로 127.0.0.1:<port> 해석
```
**조건**: 본인 PC 에서 `docker compose up` 으로 프론트/백엔드를 직접 띄워야 함.
두 가지 실행 모드 모두 동작:
**모드 A — `docker compose up` (프론트/백 통합)**
- 단일 포트(예: 9772) 로 프론트 + Next rewrite 통한 백엔드 프록시
- `client.ts``*.invyone.com` 분기와 같은 패턴으로 `*.localhost` 도 직접 호출 → Host 헤더 보존
- 접속: `http://test02.localhost:9772/login`
**모드 B — `npm run dev` + `./gradlew bootRun` (분리 실행)**
- 프론트 9771, 백엔드 8081 분리. `client.ts``http://test02.localhost:8081/api` 로 직결
- 백엔드 `SubdomainResolverFilter` 가 2파트 `{sub}.localhost` 호스트를 첫 파트로 파싱 (3파트 운영 경로와 같은 분기 트리)
- 접속: `http://test02.localhost:9771/login`
- 단, **bare `localhost:9771`** 은 메타 컨텍스트 (cross-tenant admin) 로 떨어짐 — 이 경우엔 NEXT_PUBLIC_API_URL=/api → Next rewrite 경로
### 4.3 공유 개발 서버 (원격 서버 한 대로 여러 명이 접속)
@@ -241,13 +253,17 @@ cors:
if (currentHost.endsWith(".invyone.com")) {
return `https://${currentHost}/api`;
}
// 1-b) dev *.localhost — 같은 이유로 직접 호출. bare localhost 는 제외 (메타).
if (currentHost.endsWith(".localhost") && currentHost !== "localhost") {
return `http://${currentHost}:8081/api`;
}
// 2) (레거시) invion.com 메인 도메인
if (currentHost === "v1.invion.com") return "https://api.invion.com/api";
// 3) NEXT_PUBLIC_API_URL (docker-compose 주입)
// 4) localhost 기본값
```
> ★ NEXT_PUBLIC_API_URL=/api 같은 Next rewrite 는 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 위 1) 분기로 처리해 브라우저가 직접 `https://<host>/api` 로 Traefik 에 요청 → Traefik 이 Host 보존하며 backend 로 프록시.
> ★ NEXT_PUBLIC_API_URL=/api 같은 Next rewrite 는 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 위 1)/1-b) 분기로 처리해 브라우저가 직접 `<host>/api` 로 요청 → 운영은 Traefik 이, dev 는 backend 8081 이 Host 보존한 상태로 받음.
---
+7
View File
@@ -24,6 +24,13 @@ const getApiBaseUrl = (): string => {
return `https://${currentHost}/api`;
}
// 1-b. dev 환경의 *.localhost 도 같은 이유로 직접 호출 (Host 헤더 보존).
// bare "localhost" 는 메타 컨텍스트(admin/cross-tenant)이므로 이 분기에서 제외.
// 백엔드 dev 포트 8081 로 직결.
if (currentHost.endsWith(".localhost") && currentHost !== "localhost") {
return `http://${currentHost}:8081/api`;
}
// 2. 프로덕션 메인 도메인
if (currentHost === "v1.invion.com") {
return "https://api.invion.com/api";
+28 -6
View File
@@ -1,8 +1,13 @@
/**
* 테넌트 서브도메인 헬퍼.
* 메인 사이트(solution, www, admin 등 예약어) null 을 리턴해서
* 메인 사이트(solution, www, admin 등 예약어) 와 베이스 도메인은 null 을 리턴해서
* TenantGuard 가 체크를 스킵하게 한다.
*
* 백엔드 SubdomainResolverFilter.extractSubdomain() 와 동일한 규칙을 따라야 함:
* - bare localhost / IP / 베이스 도메인 → null (메타)
* - {sub}.localhost (dev) → 첫 파트 (예약어 제외)
* - {sub}.invyone.com (운영) → 첫 파트 (예약어 제외)
*
* 백엔드 provisioning 의 RESERVED_SUBDOMAINS 와 같은 값을 유지할 것.
*/
const RESERVED_MAIN = new Set([
@@ -22,16 +27,33 @@ const RESERVED_MAIN = new Set([
"console",
]);
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
export function extractTenantSubdomain(host: string): string | null {
if (!host) return null;
const cleanHost = host.split(":")[0].toLowerCase();
if (!cleanHost.endsWith(".invyone.com")) return null;
if (!cleanHost) return null;
const prefix = cleanHost.substring(0, cleanHost.length - ".invyone.com".length);
if (!prefix) return null;
if (cleanHost === "localhost") return null;
if (IPV4.test(cleanHost)) return null;
if (RESERVED_MAIN.has(prefix)) return null;
const parts = cleanHost.split(".");
return prefix;
// 2파트 — "{sub}.localhost" 만 허용 (dev). invyone.com 같은 베이스 도메인은 null.
if (parts.length === 2) {
if (parts[1] !== "localhost") return null;
const first = parts[0];
if (!first) return null;
if (RESERVED_MAIN.has(first)) return null;
return first;
}
// 3파트 이상 (운영 *.invyone.com 등) — 첫 파트가 서브도메인
if (parts.length < 3) return null;
const first = parts[0];
if (!first) return null;
if (RESERVED_MAIN.has(first)) return null;
return first;
}
@@ -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 (작업 순서) 참고.