Files
invyone/docs/MULTI_TENANCY_ARCHITECTURE.md
T
gbpark 8be7e16e56
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m28s
서브도메인설정
2026-04-24 04:56:40 +09:00

11 KiB

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

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


1. 개요

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

qnc.invyone.com   → qnc_vexplor   DB
kookje.invyone.com → kookje_vexplor DB
vexplor (메타 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_vexplor")
[JwtAuthenticationFilter]
  ↓
[Controller → Service → sqlSession.selectList(...)]
  ↓
[TenantRoutingDataSource.determineTargetDataSource()]
  ↓ DbContextHolder.get() = "qnc_vexplor"
[HikariDataSource for qnc_vexplor] → 실제 SQL 실행

2.3 메타 DB 의 역할

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

컬럼 용도
COMPANY_CODE 회사 식별자 (PK)
SUBDOMAIN 서브도메인 prefix (UNIQUE)
DB_NAME 실제 tenant DB 이름 (예: qnc_vexplor) (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}_vexplor" (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 운영 (프로덕션)

와일드카드 DNS + Nginx 리버스 프록시. 한 번 세팅하면 회사 무한히 추가해도 추가 작업 0.

[DNS Provider]
  A  *.invyone.com  →  운영서버공인IP

[Nginx conf]
  server {
    listen 443 ssl;
    server_name *.invyone.com;
    ssl_certificate .../fullchain.pem;     # Let's Encrypt wildcard
    location / {
      proxy_pass http://frontend:3000;
      proxy_set_header Host $host;         # ★ Host 헤더 보존 필수
    }
    location /api/ {
      proxy_pass http://backend:8081;
      proxy_set_header Host $host;         # ★
    }
  }
  • Let's Encrypt 와일드카드 인증서: certbot certonly --manual --preferred-challenges=dns -d invyone.com -d '*.invyone.com'
  • 이후 사용자 http://qnc.invyone.com 접속 → Nginx 가 Host 헤더 보존해 backend 에 프록시 → SubdomainResolverFilter → 자동 라우팅.

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 요약 표

환경 접속 방식 설정 필요도
운영 와일드카드 DNS + Nginx + Let's Encrypt wildcard TLS 최초 1회, 이후 0
로컬 (직접 up) *.localhost 자동 0
원격 공유 개발 서버 nip.io (추천) / hosts 편집 / 개발서버 Nginx 경우 따라

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) 테넌트 서브도메인 → 직접 백엔드 포트 (Next rewrite 우회)
if (currentHost.endsWith(".invyone.com")) {
  return `http://${currentHost}:8083/api`;
}
// 2) 프로덕션 메인 도메인
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 같은 rewrite 방식은 Host 헤더를 변조해 서브도메인 파싱이 실패한다. 테넌트 도메인은 반드시 직접 포트로.


7. 운영 배포 체크리스트

Git push 한 번으로 자동 배포되는 구조라도, 아래 3가지는 Git 바깥이라 수동으로 확인해야 한다:

  • .envCORS_ALLOWED_ORIGINS*.invyone.com:[*] 패턴 포함
  • DB 마이그레이션 RUN_079, RUN_080, RUN_081 운영 DB 에서 1회 실행
  • Docker 이미지 재빌드 (Dockerfile 의 postgresql16-client 반영) — docker compose build --no-cache backend-spring
  • tenant.provisioning.require-super-admin=true (프로덕션에선 필수, 개발은 false)
  • (최초 1회) 와일드카드 DNS A 레코드 + Nginx wildcard server_name + Let's Encrypt wildcard 인증서

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 집계 매핑

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 에서 자동화)