# 회사별 DB 자동 프로비저닝 & 서브도메인 라우팅 — 실행 계획

작성일: 2026-04-24
작성자: gbpark (강빈)
원본 설계: `~/Downloads/COMPANY_DB_PROVISIONING_PLAN.md` (chpark, 2026-04-23)
리뷰 반영: Claude + Codex 크로스 리뷰 → 강빈 확인 후 최종 결정

---

## TL;DR

- 원본 설계 방향 **OK**. 바로 Phase 1 착수.
- 초기 설정에 **Hikari `minIdle=0`** + **프로비저닝 상태 머신** 2가지만 반드시 포함.
- 커넥션 폭증(회사 20~30개 이상)은 그 시점에 `max_connections` 한 번 올리는 걸로 대응. 지금 고민 불필요.
- 현재 운영 중 회사 없음 → 기존 데이터 이관 이슈 **제로**. 클린 스타트.

---

## 1. 전제 재확인 (리뷰 후 결정)

### 1.1 "회사 DB 생성 후 DDL 변경 없음" 전제
**해석 재정의 (gbpark 확인):**
- "최고 관리자 기준 **베이스 템플릿**은 DDL 변경 없음" 의 의미.
- 각 회사 DB 내부에서 사용자가 `DdlService`를 통해 컬럼/테이블 추가하는 건 **그 회사 DB 안에서만** 발생.
- 다른 회사 DB에 전파될 필요 없음 → 오히려 DB-per-tenant의 구조적 장점.
- 따라서 "N개 DB에 DDL 일괄 전파" 문제는 구조적으로 제거됨 ✓

### 1.2 기존 운영 회사 데이터 이관
- 현재 운영 중 회사 **없음**. 클린 스타트.
- 원본 문서에는 이 시나리오가 빠져있으나 해당 없으므로 무시.

### 1.3 메타 DB SPOF
- 지금 단일 DB 구조에서도 어차피 SPOF. 동일 조건.
- 추후(회사 수 증가 시) 이중화 검토 대상으로만 남겨둠. 지금은 불필요.

---

## 2. 초기 설계에 반드시 포함할 것 (★ 빠뜨리면 나중에 고통)

### 2.1 Hikari 커넥션 풀 정책

**이유:** 회사마다 DataSource가 복제되므로, 기본 `minIdle=2` 그대로 두면 회사 50개일 때 상시 100개 커넥션 점유 → Postgres 기본 `max_connections=100` 한계 도달.

**기존 (`backend-spring/src/main/resources/application.yml` 17~26줄):**
```yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2             # ← 문제
      idle-timeout: 600000        # 10분
```

**회사 DB용 풀 생성 시 (TenantRoutingDataSource 내부):**
```yaml
maximum-pool-size: 5         # 회사당 최대 5개면 충분
minimum-idle: 0              # ★ 평소 0개 (안 쓰는 회사는 통로 0)
connection-timeout: 30000
idle-timeout: 60000          # 1분 놀면 즉시 정리
max-lifetime: 1800000
```

→ 메타 DB용 설정(`application.yml`)은 기존 그대로 유지. 회사 DB 풀 만드는 코드에서만 이 정책 박을 것.

### 2.2 프로비저닝 상태 머신

**이유:** `CREATE DATABASE` → 스키마 복제 → 관리자 계정 생성 → 메타 기록, 이 4단계 중 어디서 터져도 고아 DB/미등록 상태가 남음. 보상 로직 없으면 손으로 청소해야 함.

**`COMPANY_MNG.DB_STATUS` 컬럼 값:**
```
provisioning      ← CREATE DATABASE 시작 시 기록
schema_copied     ← pg_dump 복제 성공 후
admin_created     ← 관리자 계정 INSERT 성공 후
active            ← 메타 DB 기록 완료, 로그인 허용
failed            ← 중간 실패 시. 관리 화면에서 재시도 or 청소
suspended         ← 회사 삭제/중지 (기존 설계 그대로)
```

**원칙:**
- 각 단계 성공마다 메타 DB의 `DB_STATUS` 업데이트 (각 단계가 독립 트랜잭션).
- `active` 상태 아닌 DB는 로그인 필터에서 403 반환.
- `failed` 상태는 SUPER_ADMIN이 "재프로비저닝" 또는 "DROP DATABASE 후 레코드 삭제" 선택 가능.

---

## 3. Phase별 실행 체크리스트 (원본 Phase 1~6 기반, 필수 항목 추가)

### Phase 1 — 메타 스키마 & 도메인 파싱
- [ ] `COMPANY_MNG` 컬럼 추가: `DB_NAME`, `SUBDOMAIN`, `DB_HOST`, `DB_STATUS`
- [ ] `SubdomainResolverFilter` (`OncePerRequestFilter`) 구현
- [ ] `DbContextHolder` (ThreadLocal) 구현
- [ ] 라우팅은 no-op: 모두 메타 DB로 접속 + 서브도메인 인식 로그만 확인
- [ ] `invyone.com` / `admin.invyone.com` 은 메타 DB 강제 (예외 경로)

### Phase 2 — 멀티 DataSource 스위칭
- [ ] `TenantRoutingDataSource extends AbstractRoutingDataSource` 구현
- [ ] **★ 회사 DataSource 생성 시 Hikari `minIdle=0` 박기** (2.1 참조)
- [ ] `resolvedDataSources` 캐시 (Map) + lazy init
- [ ] 수동 생성한 테스트 DB 2개로 회사 전환 검증
- [ ] JWT `companyCode`/`dbName` 클레임 검증 (필터 결정값 불일치 → 401)

### Phase 3 — 프로비저닝 서비스
- [ ] `/api/admin/provisioning/companies` POST 구현
- [ ] **★ `COMPANY_MNG.DB_STATUS` 상태 머신 구현** (2.2 참조)
- [ ] 테이블 그룹 상수 정의 + `/api/admin/provisioning/table-groups` GET
- [ ] 마법사 UI (3-step): 회사정보 → 테이블 선택 → 확인&생성
- [ ] `pg_dump --schema-only` 기반 스키마 복제
- [ ] DB 이름 검증 정규식: `^[a-z][a-z0-9_]{2,30}$`
- [ ] `CREATE DATABASE` 실패 시 `DROP DATABASE` 보상 로직

### Phase 4 — 관리자 계정 자동 생성 + 프론트 마법사
- [ ] `{prefix}_admin` 자동 생성 (BCrypt)
- [ ] 첫 로그인 비밀번호 변경 강제 화면
- [ ] 초기 비밀번호는 환경변수 (`INITIAL_ADMIN_PASSWORD`)로 분리. 하드코딩 금지.

### Phase 5 — SUPER_ADMIN "서브도메인 관리" 메뉴
- [ ] 경로: `/admin/sysMng/subdomainList`
- [ ] 목록/상태/재프로비저닝 UI
- [ ] `failed` 상태 → 재시도 or 청소 버튼
- [ ] `DB_STATUS` 변경 이력 감사 로그

### Phase 6 — 운영 자동화
- [ ] 회사 DB별 `pg_basebackup` 스케줄
- [ ] 회사별 커넥션 풀 모니터링 대시보드
- [ ] (스키마 마이그레이션 Runner는 **불필요** — 1.1 참조)

---

## 4. 미결정 → 결정 (원본 9장 업데이트)

| 항목 | 결정 |
|---|---|
| 단일 Postgres 인스턴스에 N개 DB? | **YES.** 초기엔 같은 인스턴스. 회사 20~30개 넘으면 그때 `max_connections` 올림 (기본 100 → 500). 그래도 부족하면 DB 서버 분산 (`DB_HOST` 컬럼이 대비). |
| `pg_dump` 실행 위치 | 백엔드 컨테이너에 포함. K8s 이미지에 `postgresql-client` 추가. 사이드카는 오버엔지니어링. |
| SUPER_ADMIN 회사 DB 임시 전환 | **Phase 5 이후 검토.** 당장 불필요. |
| 회사 삭제 시 처리 | **soft delete** (`DB_STATUS='suspended'`) 기본. 완전 삭제는 SUPER_ADMIN 수동 확인 거친 후에만. |
| 초기 비밀번호 | **환경변수** `INITIAL_ADMIN_PASSWORD` (Phase 4에서 처리). 하드코딩 금지. |
| 메타 DB 이중화 | **Phase 6 이후** 회사 수 보고 결정. 지금은 동일 SPOF로 운영. |

---

## 5. 지금 당장 건드릴 것 (오늘 작업)

1. `backend-spring/src/main/resources/application.yml` — 메타 DB용 Hikari는 그대로 유지 (변경 없음).
2. `COMPANY_MNG` 테이블 ALTER → DDL 작성 (Phase 1).
3. `SubdomainResolverFilter` 뼈대 작성.
4. `DbContextHolder` 작성.

---

## 6. 오늘 건드리지 말 것

- 회사 DB 실제 프로비저닝 로직 (Phase 3 작업).
- 프론트 마법사 UI (Phase 3).
- `TenantRoutingDataSource` (Phase 2).

**Phase 1 은 라우팅 no-op 상태에서 서브도메인 인식 로그만 보는 단계.** 건드리는 범위 최소화해서 회귀 없게.

---

## 7. 참고 — 기술 스니펫

### 7.1 `COMPANY_MNG` ALTER DDL
```sql
ALTER TABLE COMPANY_MNG
  ADD COLUMN DB_NAME     VARCHAR(64),
  ADD COLUMN SUBDOMAIN   VARCHAR(64) UNIQUE,
  ADD COLUMN DB_HOST     VARCHAR(128),
  ADD COLUMN DB_STATUS   VARCHAR(20) DEFAULT 'active';
```

### 7.2 `SubdomainResolverFilter` 뼈대
```java
@Component
public class SubdomainResolverFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
      throws ServletException, IOException {
    String host = req.getHeader("Host");                // qnc.invyone.com:port
    String subdomain = extractSubdomain(host);          // "qnc"
    try {
      if (subdomain == null || "admin".equals(subdomain) || isBaseDomain(host)) {
        DbContextHolder.setMeta();
      } else {
        String dbName = companyResolver.resolveDbName(subdomain);  // 메타 DB 조회(캐시)
        DbContextHolder.set(dbName);
      }
      chain.doFilter(req, res);
    } finally {
      DbContextHolder.clear();
    }
  }
}
```

### 7.3 `DbContextHolder`
```java
public final class DbContextHolder {
  private static final String META = "__META__";
  private static final ThreadLocal<String> CTX = new ThreadLocal<>();

  public static void set(String dbName)  { CTX.set(dbName); }
  public static void setMeta()           { CTX.set(META); }
  public static String get()             { return CTX.get(); }
  public static boolean isMeta()         { return META.equals(CTX.get()); }
  public static void clear()             { CTX.remove(); }
}
```

### 7.4 회사 DB용 Hikari 빌더 (Phase 2에서 사용)
```java
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://" + dbHost + ":5432/" + dbName);
cfg.setUsername(...);
cfg.setPassword(...);
cfg.setMaximumPoolSize(5);
cfg.setMinimumIdle(0);              // ★
cfg.setIdleTimeout(60_000);         // 1분
cfg.setConnectionTimeout(30_000);
cfg.setMaxLifetime(1_800_000);
return new HikariDataSource(cfg);
```

---

## 8. 커넥션 터지는 시점 빠른 참조

| 회사 수 | minIdle=0 (권장) | minIdle=2 (기본) |
|---|---|---|
| 10 | 활성만 ~10개 | **20개 상시** |
| 50 | 활성만 ~30개 | **100개 상시 = 한계** |
| 100 | 활성만 ~50개 | **200개 = 이미 터짐** |
| 500 | 활성만 ~150개 | — (`max_connections` 증설 필수) |

→ `minIdle=0` 박으면 회사 100개까지 기본 설정(`max_connections=100`)으로 감당 가능.

---

## 9. 다음 세션 진입 시

이 문서가 진실의 원천. 원본 `~/Downloads/COMPANY_DB_PROVISIONING_PLAN.md` 는 리뷰 전 초안이므로 이 문서가 우선.
