# `*.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 (작업 순서) 참고.
