운영 도메인이 실제로는 v1.invyone.com / solution.invyone.com / api.invyone.com 인데 코드/문서 곳곳에 v1.invion.com / api.invion.com 등 미존재 도메인이 박혀 있어 정리. 변경 파일 (21): - frontend lib/api/client.ts, lib/utils/apiUrl.ts: hostname 체크 endsWith(\".invyone.com\") 일반화 - frontend lib/api/dashboard.ts, file.ts, flow.ts, FileViewerModal*2.tsx: 도메인 치환 - frontend invion-layout-v5.html: 시안 내 placeholder 도메인 정리 - backend-spring SecurityConfig.java: CORS 주석 예시 정리 - docker/deploy/docker-compose.yml, k8s/traefik-dynamic.yaml: traefik Host 라벨 정리 - scripts/prod/deploy.sh: 안내 메시지 정리 - .cursor/rules/api-client-usage.mdc, project-conventions.mdc: AI 가이드 정리 - docs/* 4개: 아키텍처/플로우 문서 도메인 정리 - notes/gbpark/* 3개: 과거 메모 정리 신규: - docs/DOMAIN_MAPPING.md: 운영/개발/폐기 도메인 영구 기록. AI 에이전트와 신규 개발자가 헷갈리지 않도록 단일 진실 출처. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
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_namesubdomain—^[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 로컬 개발 (본인 머신에서 docker up)
*.localhost 자동 매핑 사용. RFC 6761 + 최신 브라우저가 자동 해석.
브라우저가 test07.localhost:9772 요청
→ Chrome/Firefox/Edge 가 자동으로 127.0.0.1:9772 해석
→ hosts 편집 0, 설정 0
조건: 본인 PC 에서 docker compose up 으로 프론트/백엔드를 직접 띄워야 함.
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 로 해석되므로 반드시 따옴표로 감싸기.
// SecurityConfig.java (발췌)
config.setAllowedOriginPatterns(patterns); // setAllowedOrigins 아님
config.setAllowCredentials(true);
# application.yml
cors:
allowed-origins: "${CORS_ALLOWED_ORIGINS:...}" # 반드시 quote
6. 프론트엔드 API URL 규칙
frontend/lib/api/client.ts 의 분기 순서가 중요:
// 1) 테넌트 서브도메인 → 동일 호스트의 /api (Traefik 이 프록시)
// Host 헤더 보존 + https mixed-content 회피.
if (currentHost.endsWith(".invyone.com")) {
return `https://${currentHost}/api`;
}
// 2) (레거시) invyone.com 메인 도메인
if (currentHost === "v1.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) 분기로 처리해 브라우저가 직접
https://<host>/api로 Traefik 에 요청 → Traefik 이 Host 보존하며 backend 로 프록시.
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 바깥이라 신규 환경 붙일 때 수동 작업:
CORS_ALLOWED_ORIGINSk8s/configmap.yaml에 명시 (*.invyone.com패턴 포함)- DB 마이그레이션
RUN_079/080/081운영 DB 에 1회 실행 완료 docker/deploy/backend-spring.Dockerfile에postgresql16-client포함TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true(configmap + deployment env wiring)- Porkbun 와일드카드 DNS A 레코드 (
*.invyone.com → 183.99.177.40) - Traefik dynamic config
*.invyone.com라우트 (서버/opt/docker/traefik/dynamic/invyone-tenant.yml) - Porkbun API 키 서버
/opt/docker/traefik/.env에 박음 - Let's Encrypt DNS-01 와일드카드 인증서 발급 (Apr 24 ~ Jul 23, 자동 갱신)
- 프론트
TenantGuard+/tenant-not-found페이지
⚠️ K8s Secret 수동 갱신 포인트
.gitea/workflows/deploy.yml 은 k8s/secrets-real.yaml 을 apply 하지 않는다 (namespace/configmap/pvc/backend/frontend/networkpolicy 만). Secret 내용(DB 비번 등) 이 바뀌면 서버에서 1회 수동 적용:
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=/apirewrite 쓰기 (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 에서 자동화) |