diff --git a/.gitignore b/.gitignore index f42ff16d..0566c110 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.java-version b/.java-version new file mode 100644 index 00000000..aabe6ec3 --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java b/backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java index 425442f2..3e7fa32d 100644 --- a/backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java +++ b/backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java @@ -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; diff --git a/backend-spring/src/main/resources/application.yml b/backend-spring/src/main/resources/application.yml index 2ea122e3..973b6076 100644 --- a/backend-spring/src/main/resources/application.yml +++ b/backend-spring/src/main/resources/application.yml @@ -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 diff --git a/backend-spring/src/test/java/com/erp/tenant/SubdomainResolverFilterTest.java b/backend-spring/src/test/java/com/erp/tenant/SubdomainResolverFilterTest.java new file mode 100644 index 00000000..50319d39 --- /dev/null +++ b/backend-spring/src/test/java/com/erp/tenant/SubdomainResolverFilterTest.java @@ -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")); + } +} diff --git a/docs/MULTI_TENANCY_ARCHITECTURE.md b/docs/MULTI_TENANCY_ARCHITECTURE.md index 255ff619..f01e02d4 100644 --- a/docs/MULTI_TENANCY_ARCHITECTURE.md +++ b/docs/MULTI_TENANCY_ARCHITECTURE.md @@ -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: 요청 + → Chrome/Firefox/Edge 가 자동으로 127.0.0.1: 해석 ``` -**조건**: 본인 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:///api` 로 Traefik 에 요청 → Traefik 이 Host 보존하며 backend 로 프록시. +> ★ NEXT_PUBLIC_API_URL=/api 같은 Next rewrite 는 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 위 1)/1-b) 분기로 처리해 브라우저가 직접 `/api` 로 요청 → 운영은 Traefik 이, dev 는 backend 8081 이 Host 보존한 상태로 받음. --- diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index a9351b94..9cabe93c 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -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"; diff --git a/frontend/lib/tenant/subdomain.ts b/frontend/lib/tenant/subdomain.ts index 96f2d46a..7adcc79a 100644 --- a/frontend/lib/tenant/subdomain.ts +++ b/frontend/lib/tenant/subdomain.ts @@ -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; } diff --git a/notes/gbpark/2026-04-28-localhost-tenant-routing-handoff.md b/notes/gbpark/2026-04-28-localhost-tenant-routing-handoff.md new file mode 100644 index 00000000..48d0c68e --- /dev/null +++ b/notes/gbpark/2026-04-28-localhost-tenant-routing-handoff.md @@ -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 (작업 순서) 참고.