Files
invyone/docs/MULTI_TENANCY_ARCHITECTURE.md
T
hjjeong b782bb298f merge: origin/gbpark-node → hjjeong (60 commits, 5 conflicts resolved)
충돌 해결 5개 파일:
- .gitignore: .envrc/.direnv (hjjeong direnv 셋업) + .omc/ (gbpark) 양쪽 보존
- docs/MULTI_TENANCY_ARCHITECTURE.md: *.localhost dev 분기 + *.invyone.com/solution.invyone.com 통합
- frontend/lib/api/client.ts: 1-b *.localhost:8081 dev + 1-c DEV_TENANT_HOST(nip.io):8083 + invyone.com 신 도메인
- frontend/lib/tenant/subdomain.ts: IPv4 차단 + *.invyone.com + DEV_TENANT_HOST + *.localhost 모두 처리
- frontend/app/(auth)/login/page.tsx: B안 채택 — buttons 항상 렌더, className 만 mounted 가드 (next-themes 표준 패턴)

검증:
- backend: ./gradlew compileJava 성공 (Java 21)
- frontend: 머지된 4개 파일 관련 타입 에러 0개

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:51:06 +09:00

16 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_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.tshttp://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.ymlCORS_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`;
}
// 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 바깥이라 신규 환경 붙일 때 수동 작업:

  • CORS_ALLOWED_ORIGINS k8s/configmap.yaml 에 명시 (*.invyone.com 패턴 포함)
  • DB 마이그레이션 RUN_079/080/081 운영 DB 에 1회 실행 완료
  • docker/deploy/backend-spring.Dockerfilepostgresql16-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.ymlk8s/secrets-real.yamlapply 하지 않는다 (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_MNGDB_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 에서 자동화)