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:
@@ -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/
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 보존한 상태로 받음.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (작업 순서) 참고.
|
||||
Reference in New Issue
Block a user