# INVYONE 멀티테넌시 · 서브도메인 라우팅 아키텍처

> **DB-per-tenant + 서브도메인 기반 자동 라우팅.**
> 회사 하나 = 전용 PostgreSQL DB 하나 = 전용 서브도메인 하나.
> 회사 간 데이터 물리적 격리 + 코드 변경 없이 무제한 확장.

---

## 1. 개요

INVYONE 은 **회사(=tenant)별로 독립된 PostgreSQL 데이터베이스**를 가진다. 접속한 서브도메인에 따라 요청이 자동으로 해당 회사 DB 로 라우팅된다.

```
qnc.invyone.com   → qnc_invyone   DB
kookje.invyone.com → kookje_invyone DB
invyone (메타 DB)  → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이터
```

### 설계 원칙

- **데이터 격리**: 회사 간 테이블/쿼리 섞일 가능성 원천 차단 (다른 DB).
- **운영 격리**: 회사별 백업·복구 독립. 한 회사 DB 장애가 다른 회사에 영향 없음.
- **스키마 불변 전제**: 최고 관리자 기준 베이스 템플릿은 DDL 변경 없음. 각 회사 DB 내부에서 컬럼 추가해도 해당 회사 DB 안에만 영향.
- **제로 코드 확장**: 회사 추가 시 API 호출 한 번으로 전체 세팅 (DB 생성 + 스키마 복제 + 템플릿 데이터 + 관리자 계정).

---

## 2. 핵심 구성 요소

### 2.1 백엔드 (Spring)

| 파일 | 역할 |
|---|---|
| `com.erp.tenant.DbContextHolder` | 요청 스레드의 현재 tenant DB 이름 보관 (ThreadLocal) |
| `com.erp.tenant.SubdomainResolverFilter` | Host 헤더에서 서브도메인 파싱 → `DbContextHolder.set(dbName)` |
| `com.erp.tenant.CompanyResolver` | 서브도메인 → `COMPANY_MNG.DB_NAME` 조회 (ConcurrentHashMap 캐시) |
| `com.erp.tenant.TenantRoutingDataSource` | `AbstractRoutingDataSource` 확장. `DbContextHolder` 값으로 실제 DataSource 라우팅 |
| `com.erp.tenant.TenantDataSourceFactory` | Hikari 풀 빌더. META=`minIdle:2/max:10`, Tenant=**`minIdle:0`**/max:5 |
| `com.erp.tenant.DataSourceConfig` | `@Primary DataSource` 로 Spring Boot auto-config 덮음 |
| `com.erp.provisioning.*` | 회사 생성 오케스트레이션 (6단계) + REST API |

### 2.2 데이터 흐름

```
[브라우저: qnc.invyone.com]
  ↓ (HTTP Host 헤더)
[SubdomainResolverFilter]
  ↓ subdomain=qnc → CompanyResolver.resolveDbName("qnc")
  ↓ DbContextHolder.set("qnc_invyone")
[JwtAuthenticationFilter]
  ↓
[Controller → Service → sqlSession.selectList(...)]
  ↓
[TenantRoutingDataSource.determineTargetDataSource()]
  ↓ DbContextHolder.get() = "qnc_invyone"
[HikariDataSource for qnc_invyone] → 실제 SQL 실행
```

### 2.3 메타 DB 의 역할

`invyone` DB (COMPANY_MNG 테이블) 는 **라우팅 룩업 테이블**만 담당:

| 컬럼 | 용도 |
|---|---|
| `COMPANY_CODE` | 회사 식별자 (PK) |
| `SUBDOMAIN` | 서브도메인 prefix (UNIQUE) |
| `DB_NAME` | 실제 tenant DB 이름 (예: `qnc_invyone`) (UNIQUE) |
| `DB_HOST` | tenant DB 호스트 (분산 대비, 현재 단일) |
| `DB_STATUS` | `provisioning` / `active` / `failed` / `suspended` |
| `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT`, `DB_QUOTA_GB` | UI 메타 |

---

## 3. 회사 생성 플로우 (프로비저닝)

### 3.1 API

| Method | URL | 설명 |
|---|---|---|
| GET | `/api/admin/provisioning/table-groups` | 복사 가능한 템플릿 그룹 (필수 3 + 선택 3) |
| GET | `/api/admin/provisioning/check` | subdomain / db_prefix / company_code 실시간 검증 |
| POST | `/api/admin/provisioning/companies` | 회사 생성 시작 (202 Accepted + provisioning_id) |
| GET | `/api/admin/provisioning/status/{id}` | 진행 상태 폴링 (2초 간격 권장) |
| GET | `/api/admin/provisioning/companies-stats` | 메인 화면용 회사 목록 + derived 집계 |

### 3.2 6단계 상태 머신

```
1. REGISTER_META    COMPANY_MNG 에 status='provisioning' 선반영
2. CREATE_DATABASE  CREATE DATABASE "{prefix}_invyone" (postgres 기본 DB 경유)
3. COPY_SCHEMA      pg_dump --schema-only | psql  (ProcessBuilder 배열 인자)
4. COPY_DATA        선택된 그룹 테이블 JDBC 복사 (company_code 필터)
5. CREATE_ADMIN     {prefix}_admin BCrypt 계정 생성
6. FINALIZE         db_status='active' + CompanyResolver 캐시 무효화
```

**실패 시 보상**: `DROP DATABASE IF EXISTS` (3회 백오프) + `db_status='failed'` 표시.
모든 단계 idempotent. UNIQUE 제약으로 race condition 방어.

### 3.3 필수 필드 (강빈 스펙)

최소 **4개**만 필수:

- `company_code` — `^[A-Z][A-Z0-9_]{2,30}$`
- `company_name`
- `subdomain` — `^[a-z][a-z0-9-]{2,30}$` (예약어 `www|admin|api|app|static|assets|...` 금지)
- `db_prefix` — `^[a-z][a-z0-9_]{2,30}$`

사업자번호·대표자·이메일 등 선택. 나중에 수정 가능.

---

## 4. 환경별 도메인 전략 ★★★

서브도메인 기반 멀티테넌시는 **환경마다 DNS 해결 방식이 달라야** 한다. 이 절이 이 문서 전체에서 가장 중요.

### 4.1 운영 (프로덕션) — **2026-04-24 실운영 중**

**와일드카드 DNS (Porkbun) + Traefik v2.11 리버스 프록시 + Let's Encrypt DNS-01 와일드카드 TLS.**
한 번 세팅하면 회사 무한히 추가해도 추가 작업 0.

```
[DNS Provider — Porkbun]
  A  *.invyone.com  →  183.99.177.40   (운영서버 공인IP)
  A  solution.invyone.com  →  183.99.177.40   (메인 사이트, 명시적)

[Traefik v2.11 — 운영서버 /opt/docker/traefik/docker-compose.yml]
  - HTTP (80) → HTTPS 자동 redirect
  - HTTPS (443) → K8s NodePort 로 전달 (HostRegexp 로 *.invyone.com 매칭)
  - certResolvers:
      le    — HTTP-01 challenge (개별 도메인용, solution 등)
      ledns — DNS-01 challenge via Porkbun API (와일드카드 *.invyone.com)

[Traefik dynamic config — /opt/docker/traefik/dynamic/invyone-tenant.yml]
  HostRegexp(`{sub:[a-z0-9-]+}.invyone.com`)
    → service invyone-tenant-frontend (host.docker.internal:30000)
  HostRegexp(...) && PathPrefix(`/api`)
    → service invyone-tenant-api (host.docker.internal:30081)
  tls.domains.sans: "*.invyone.com"   # 와일드카드 인증서 발급 조건

[Porkbun API Key]
  서버의 /opt/docker/traefik/.env 에 보관 (git 에 없음)
    PORKBUN_API_KEY / PORKBUN_SECRET_API_KEY
```

- 사용자 `https://qnc.invyone.com` 접속 → Porkbun DNS 풀림 → Traefik HostRegexp 매칭
  → Host 헤더 보존해 backend 로 프록시 → SubdomainResolverFilter → 자동 라우팅.
- `solution.invyone.com` 은 Host() 명시 라우터로 Traefik priority 높아 와일드카드 위에 탐.
- Traefik 은 **K8s 바깥에 도커로 별도 실행** (`.gitea/workflows/deploy.yml` 과 무관 — 설정 변경은 서버에서 수동 + `docker compose restart traefik`)

#### 재현 스냅샷 (2026-04-24)

- **DNS 와일드카드**: Porkbun 콘솔에서 `A * 183.99.177.40` 한 줄 추가 (전파 즉시)
- **Traefik dynamic 라우트**: `notes/gbpark/2026-04-24-traefik-wildcard/invyone-tenant.yml` 참고
- **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 로컬 개발 (본인 머신)

**`*.localhost` 자동 매핑** 사용. RFC 6761 + 최신 브라우저가 자동 해석. hosts 편집/DNS 설정 0.

```
브라우저가 test02.localhost:<port> 요청
  → Chrome/Firefox/Edge 가 자동으로 127.0.0.1:<port> 해석
```

두 가지 실행 모드 모두 동작:

**모드 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 공유 개발 서버 (원격 서버 한 대로 여러 명이 접속)

로컬 머신이 아닌 원격 IP(예: Tailscale `100.x.x.x`, 사무실 고정 IP 등) 에 도커가 떠 있을 때는 `*.localhost` 안 통함.

선택지:

- **옵션 A — `nip.io` / `sslip.io`** (설치 0)
  ```
  test07.100-126-230-80.nip.io → 100.126.230.80 (자동)
  ```
  CORS 패턴에 `http://*.<서버IP hyphens>.nip.io:[*]` 추가만 하면 됨.

- **옵션 B — Windows hosts 수동 편집** (hosts 와일드카드 미지원이라 회사마다 한 줄)
  ```
  <서버IP>  test07.invyone.com
  <서버IP>  qnc.invyone.com
  ```
  회사 늘어날 때마다 한 줄씩. 테스트 3~4개 회사면 감당 가능.

- **옵션 C — 개발 서버에 Nginx + DuckDNS/공개 와일드카드 도메인** (운영과 동일 경험)

### 4.4 요약 표

| 환경 | 접속 방식 | 설정 필요도 |
|---|---|---|
| **운영** | Porkbun 와일드카드 DNS + Traefik v2.11 + Let's Encrypt DNS-01 wildcard | **완료 (2026-04-24)** |
| **로컬 (직접 up)** | `*.localhost` 자동 | 0 |
| **원격 공유 개발 서버** | `nip.io` (추천) / hosts 편집 | 경우 따라 |

---

## 5. CORS 허용 패턴

모든 환경에서 브라우저 CORS 가 문제되지 않도록 `.env` 또는 `application.yml` 의 `CORS_ALLOWED_ORIGINS` 에 아래 패턴 **전부** 포함 권장:

```
http://localhost:3000,
http://localhost:9772,
http://localhost:9771,
http://*.localhost:[*],               # 로컬 직접 up
http://*.invyone.com:[*],             # 개발 테스트 (hosts 편집 or 실 와일드카드)
https://*.invyone.com:[*],
http://*.invyone.com,
https://*.invyone.com,
http://*.[개발서버IP].nip.io:[*]      # nip.io 사용 시 환경에 맞게 치환
```

> ★ `setAllowedOriginPatterns` 사용 (단순 `setAllowedOrigins` 아님).
> ★ YAML 에서 `[*]` 는 flow sequence 로 해석되므로 **반드시 따옴표로 감싸기**.

```java
// SecurityConfig.java (발췌)
config.setAllowedOriginPatterns(patterns);       // setAllowedOrigins 아님
config.setAllowCredentials(true);
```

```yaml
# application.yml
cors:
  allowed-origins: "${CORS_ALLOWED_ORIGINS:...}"    # 반드시 quote
```

---

## 6. 프론트엔드 API URL 규칙

`frontend/lib/api/client.ts` 의 분기 **순서가 중요**:

```typescript
// 1) 테넌트 서브도메인 → 동일 호스트의 /api (Traefik 이 프록시)
//    Host 헤더 보존 + https mixed-content 회피.
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`;
}
// 1-c) dev <prefix>.<IPv4>(.nip.io|.sslip.io) — 사무실 도커 등 와일드카드 DNS 없는 환경.
if (DEV_TENANT_HOST.test(currentHost)) {
  return `http://${currentHost}:8083/api`;
}
// 2) (레거시) invyone.com 메인 도메인
if (currentHost === "v1.invyone.com" || currentHost === "solution.invyone.com") {
  return "https://api.invyone.com/api";
}
// 3) NEXT_PUBLIC_API_URL (docker-compose 주입)
// 4) localhost 기본값
```

> ★ NEXT_PUBLIC_API_URL=/api 같은 Next rewrite 는 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 위 1)/1-b) 분기로 처리해 브라우저가 직접 `<host>/api` 로 요청 → 운영은 Traefik 이, dev 는 backend 8081 이 Host 보존한 상태로 받음.

---

## 6.1 TenantGuard — 미등록 서브도메인 차단

프론트 레이아웃에 `<TenantGuard>` 를 wrap. 페이지 마운트 시 호스트 분석 → 예약어(solution/www/admin 등) 제외 → 백엔드 `/api/tenant/check?subdomain=xxx` 호출 → `exists: false` 면 `/tenant-not-found` 로 replace.

| 파일 | 역할 |
|---|---|
| `backend-spring/.../tenant/TenantController.java` | `GET /api/tenant/check` 퍼블릭 API (메타 DB 강제 라우팅) |
| `frontend/lib/tenant/subdomain.ts` | `extractTenantSubdomain(host)` — 예약어면 null |
| `frontend/components/TenantGuard.tsx` | client-side guard, sessionStorage 로 재체크 방지 |
| `frontend/app/tenant-not-found/page.tsx` | 미등록 회사 에러 페이지 (v5 solid+glow) |
| `frontend/app/layout.tsx` | `<TenantGuard>{children}</TenantGuard>` |

예약어 리스트는 백엔드의 `ProvisioningController.RESERVED_SUBDOMAINS` 와 **같은 값으로 유지**해야 함.

---

## 7. 운영 배포 체크리스트 (2026-04-24 완료 상태)

Git push 한 번으로 자동 배포되는 구조지만, 아래는 **Git 바깥**이라 신규 환경 붙일 때 수동 작업:

- [x] `CORS_ALLOWED_ORIGINS` `k8s/configmap.yaml` 에 명시 (`*.invyone.com` 패턴 포함)
- [x] DB 마이그레이션 `RUN_079/080/081` 운영 DB 에 1회 실행 완료
- [x] `docker/deploy/backend-spring.Dockerfile` 에 `postgresql16-client` 포함
- [x] `TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true` (configmap + deployment env wiring)
- [x] Porkbun 와일드카드 DNS A 레코드 (`*.invyone.com → 183.99.177.40`)
- [x] Traefik dynamic config `*.invyone.com` 라우트 (서버 `/opt/docker/traefik/dynamic/invyone-tenant.yml`)
- [x] Porkbun API 키 서버 `/opt/docker/traefik/.env` 에 박음
- [x] Let's Encrypt DNS-01 와일드카드 인증서 발급 (Apr 24 ~ Jul 23, 자동 갱신)
- [x] 프론트 `TenantGuard` + `/tenant-not-found` 페이지

### ⚠️ K8s Secret 수동 갱신 포인트

`.gitea/workflows/deploy.yml` 은 `k8s/secrets-real.yaml` 을 **apply 하지 않는다** (namespace/configmap/pvc/backend/frontend/networkpolicy 만). Secret 내용(DB 비번 등) 이 바뀌면 서버에서 1회 수동 적용:

```bash
kubectl apply -f /path/to/secrets-real.yaml -n invyone
kubectl rollout restart deployment backend-spring -n invyone
```

DB 비번 회전 시 **반드시** Secret 도 같이 갱신해야 pod 이 새 비번으로 재연결.

---

## 8. 관련 마이그레이션

| 마이그레이션 | 내용 |
|---|---|
| `db/migrations/RUN_079_MIGRATION.md` | `COMPANY_MNG` 에 `DB_NAME`, `SUBDOMAIN`, `DB_HOST`, `DB_STATUS` 컬럼 + SUBDOMAIN UNIQUE 인덱스 |
| `db/migrations/RUN_080_MIGRATION.md` | `DB_NAME` UNIQUE 인덱스 (race condition 방어) |
| `db/migrations/RUN_081_MIGRATION.md` | `PLAN`, `INDUSTRY`, `TEMPLATES_COUNT`, `DB_QUOTA_GB` (회사관리 UI 메타) |

---

## 9. 설계/실행 노트 (원본)

상세 설계 문서는 아래 참조:

- `notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md` — 초기 설계 + Codex 리뷰 반영 + 실행 체크리스트
- `notes/gbpark/2026-04-24-company-mgmt-ui-schema.md` — 회사관리 UI 필드 ↔ COMPANY_MNG 컬럼 ↔ derived 집계 매핑
- `notes/gbpark/2026-04-24-traefik-wildcard/` — 운영 Traefik 와일드카드 설정 산출물 (docker-compose.yml, invyone-tenant.yml, db-rename.sh)

---

## 10. 금지사항 (요약)

- ❌ 테넌트 도메인에서 `NEXT_PUBLIC_API_URL=/api` rewrite 쓰기 (Host 헤더 소실)
- ❌ `setAllowedOrigins` 로만 CORS 설정 (와일드카드 매칭 안 됨 → `setAllowedOriginPatterns` 사용)
- ❌ `application.yml` 에 `[*]` 를 따옴표 없이 노출 (YAML sequence 로 해석됨)
- ❌ `pg_dump` 버전과 서버 버전 불일치 (`postgresql16-client` 등 서버 버전에 맞춰 고정)
- ❌ DDL 직접 문자열 concat (회사명·컬럼명은 반드시 화이트리스트 정규식 검증 후 큰따옴표 quote)
- ❌ 회사 DB 수만큼 Hikari `minIdle` 증가 (★ 회사 DB 풀은 반드시 `minIdle=0`)

---

## 11. 무한 확장 체크

현재 아키텍처는 회사 N 개일 때:

| N | 영향 | 조치 |
|---|---|---|
| ~20 | 없음 | 0 |
| ~100 | Postgres `max_connections` 기본값(100) 근접 | `max_connections=500` 증가 |
| ~500 | 단일 Postgres 인스턴스 부담 | `COMPANY_MNG.DB_HOST` 로 여러 서버 분산 (이미 컬럼 준비됨) |
| ∞ | 풀 메모리 누적 | tenant pool LRU eviction (Phase 6 에서 자동화) |
