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_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 운영 (프로덕션)
와일드카드 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.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) 테넌트 서브도메인 → 직접 백엔드 포트 (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 바깥이라 수동으로 확인해야 한다:
.env의CORS_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_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 집계 매핑
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 에서 자동화) |