diff --git a/_pipeline b/_pipeline deleted file mode 120000 index 0c56a44d..00000000 --- a/_pipeline +++ /dev/null @@ -1 +0,0 @@ -/Users/gbpark/agent-pipeline/test-vex \ No newline at end of file diff --git a/frontend/app/form-popup/page.tsx b/frontend/app/form-popup/page.tsx index b28e2c42..16dca47e 100644 --- a/frontend/app/form-popup/page.tsx +++ b/frontend/app/form-popup/page.tsx @@ -12,7 +12,7 @@ * (main) 레이아웃 바깥이라 헤더/사이드바 없이 깔끔한 팝업 UI. */ -import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { X } from 'lucide-react'; @@ -25,7 +25,25 @@ import type { FieldConfig, Template } from '@/types/invyone-component'; export default function FormPopupPage() { return ( - 로딩 중...}> + +
+ 불러오는 중... +
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+
+ } + > ); @@ -44,6 +62,9 @@ function FormPopupContent() { const [formRow, setFormRow] = useState | null>(null); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(null); + // 사용자가 폼을 편집하기 시작했는지 플래그 — 백그라운드 재검증이 + // template 을 교체해 현재 입력 UX 를 깨뜨리지 않도록 보호. + const formRowDirtyRef = useRef(false); useEffect(() => { if (!templateId) { @@ -52,11 +73,11 @@ function FormPopupContent() { return; } - // localStorage 에서 부모가 넘겨준 초기 데이터 읽기 + 즉시 삭제 (1회성). - // 여기서는 initialRow / primaryTable / templateName 만 받고 template 은 - // 항상 templateId 로 fresh fetch 한다. (부모 캐시는 views 가 stale 일 수 있음) + // localStorage 에서 부모가 넘겨준 seed 읽기 + 즉시 삭제 (1회성). + // template 이 함께 넘어오면 즉시 렌더 후 백그라운드에서 재검증. let seededName = ''; let seededTable = ''; + let seededTemplate: any = null; try { const raw = localStorage.getItem(`form-popup:${key}`); if (raw) { @@ -70,6 +91,7 @@ function FormPopupContent() { seededName = String(data.templateName); setTemplateName(seededName); } + if (data.template) seededTemplate = data.template; localStorage.removeItem(`form-popup:${key}`); } else { setFormRow({}); @@ -78,49 +100,49 @@ function FormPopupContent() { setFormRow({}); } + // 1) seed 가 있으면 즉시 렌더 상태로 전환 (로딩 플래시 제거) + if (seededTemplate) { + setTemplate(seededTemplate as Template); + if (Array.isArray(seededTemplate.fields)) { + setFields(seededTemplate.fields as FieldConfig[]); + } + if (!seededName) setTemplateName(seededTemplate.name ?? ''); + if (!seededTable) { + setPrimaryTable( + seededTemplate.primary_table ?? seededTemplate.PRIMARY_TABLE ?? '', + ); + } + setLoaded(true); + } + + // 2) 항상 백그라운드 재검증 — seed 가 stale 일 수 있으므로. 사용자가 + // 폼을 편집하기 시작했으면(dirty) 조용히 skip 해서 UX 깨지지 않게. getTemplateInfo(templateId) .then((tpl) => { - // 진단용 로그 — template.views 구조 / screenResolutions 존재 여부 확인 - /* eslint-disable no-console */ - const v = (tpl as any)?.views ?? (tpl as any)?.VIEWS; - console.log('[form-popup fetch]', { - hasTpl: !!tpl, - keys: tpl ? Object.keys(tpl as any) : null, - viewsType: typeof v, - viewsIsString: typeof v === 'string', - viewsPreview: - typeof v === 'string' ? String(v).slice(0, 200) : undefined, - createSR: - typeof v === 'object' - ? (v as any)?.screenResolutions?.create - : undefined, - globalSR: - typeof v === 'object' - ? (v as any)?.screenResolution - : undefined, - hasScreenResolutions: - typeof v === 'object' - ? !!(v as any)?.screenResolutions - : undefined, - }); - /* eslint-enable no-console */ - if (tpl) { - setTemplate(tpl as Template); - if (Array.isArray((tpl as any).fields)) { - setFields((tpl as any).fields as FieldConfig[]); - } - if (!seededName) setTemplateName((tpl as any).name ?? ''); - if (!seededTable) { - const pt = - (tpl as any).primary_table ?? (tpl as any).PRIMARY_TABLE ?? ''; - setPrimaryTable(pt); - } + if (!tpl) { + if (!seededTemplate) setLoaded(true); + return; + } + // dirty 면 교체하지 않고 skip — 사용자가 이미 폼 편집 중 + if (formRowDirtyRef.current && seededTemplate) return; + setTemplate(tpl as Template); + if (Array.isArray((tpl as any).fields)) { + setFields((tpl as any).fields as FieldConfig[]); + } + if (!seededName) setTemplateName((tpl as any).name ?? ''); + if (!seededTable) { + const pt = + (tpl as any).primary_table ?? (tpl as any).PRIMARY_TABLE ?? ''; + setPrimaryTable(pt); } setLoaded(true); }) .catch((err: any) => { - setError(err?.message ?? '템플릿 로드 실패'); - setLoaded(true); + // seed 로 이미 렌더됐으면 에러는 무시. seed 없었으면 에러 표시. + if (!seededTemplate) { + setError(err?.message ?? '템플릿 로드 실패'); + setLoaded(true); + } }); }, [templateId, key]); @@ -130,6 +152,7 @@ function FormPopupContent() { }, [fields]); const handleFormRowChange = useCallback((patch: Record) => { + formRowDirtyRef.current = true; setFormRow((prev) => ({ ...(prev ?? {}), ...patch })); }, []); @@ -211,9 +234,40 @@ function FormPopupContent() { ], ); - // 부모가 localStorage 로 template 을 시드해주면 같은 useEffect 턴에 loaded=true - // 되므로 로딩 플래시가 사실상 보이지 않는다. 폴백 API 호출 중에만 잠깐 빈 화면. - if (!loaded) return null; + // 로딩 상태에서도 dev 모드 JIT 컴파일 시간 동안 빈 화면 대신 헤더 + 스켈레톤 + // 을 즉시 표시해서 사용자 체감 반응성을 개선. (dev 모드는 첫 진입에 번들 + // 컴파일로 수초 걸릴 수 있음) + if (!loaded) { + return ( +
+
+ + {templateName + ? `${templateName} ${mode === 'create' ? '등록' : '수정'}` + : '불러오는 중...'} + + +
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+
+ ); + } if (error) return (
⚠ {error}
diff --git a/frontend/components/dash/BlockRenderer.tsx b/frontend/components/dash/BlockRenderer.tsx new file mode 100644 index 00000000..de04ec19 --- /dev/null +++ b/frontend/components/dash/BlockRenderer.tsx @@ -0,0 +1,160 @@ +'use client'; + +/** + * BlockRenderer — 하나의 BlockV2 를 실제 React 컴포넌트로 렌더한다. + * ComponentRegistry 에서 componentId 로 정의를 찾아 위임. + * + * TemplateRenderer (line grid) 와 PopupTemplateRenderer (absolute) 양쪽에서 + * 공유되는 최소 단위. 별도 파일로 분리한 이유는 팝업 번들 경량화 — + * PopupTemplateRenderer 가 TemplateRenderer 전체(line layout 알고리즘 800+줄) + * 를 딸려 로드하지 않도록. + */ + +import React from 'react'; +import type { BlockV2, CanvasV2 } from '@/types/invyone-component'; +import { ComponentRegistry } from '@/lib/registry/ComponentRegistry'; +// side-effect: 컴포넌트 레지스트리 등록 +import '@/lib/registry/components'; +import type { TemplateRenderContext, ViewKey } from './TemplateRenderer'; + +export function BlockRenderer({ + block, + context, + view, + canvas, + runtimeSize, +}: { + block: BlockV2; + context: TemplateRenderContext; + view: ViewKey; + /** + * 선택: 제공되면 block 의 %좌표를 px 로 환산해 컴포넌트에 전달한다. + * line grid 의 실제 runtime cell 크기를 component.size 로 전달한다. + */ + canvas?: CanvasV2; + runtimeSize?: { + width: number; + height: number; + }; +}) { + const resolvedTableName = + block.config?.selectedTable || + block.config?.tableName || + context.primaryTable; + const resolvedColumnName = + block.config?.columnName || + block.config?.column_name || + block.config?.fieldKey || + block.config?.bindField || + block.config?.column; + const resolvedValue = + resolvedColumnName != null + ? context.formRow?.[resolvedColumnName] + : undefined; + const runtimeConfig = + resolvedColumnName != null + ? { ...block.config, defaultValue: resolvedValue } + : block.config; + const handleFormValueChange = ( + fieldNameOrPatch: string | Record, + value?: any, + ) => { + if (typeof fieldNameOrPatch === 'string') { + context.onFormRowChange?.({ [fieldNameOrPatch]: value }); + return; + } + context.onFormRowChange?.(fieldNameOrPatch); + }; + + const def = ComponentRegistry.getComponent(block.componentId); + if (!def?.component) { + return ( +
+
+
+ {block.componentId || '(empty)'} +
+
미등록
+
+
+ ); + } + const Cmp = def.component as React.ComponentType; + + const bw = canvas?.baseWidth ?? 0; + const bh = canvas?.baseHeight ?? 0; + const position = { + x: bw > 0 ? block.xPct * bw : 0, + y: bh > 0 ? block.yPct * bh : 0, + z: 1, + }; + const size = { + width: bw > 0 ? block.wPct * bw : 0, + height: bh > 0 ? block.hPct * bh : 0, + }; + const isButtonLike = + block.componentId === 'button' || + block.componentId === 'button-bar' || + block.componentId === 'pagination'; + const effectiveSize = + isButtonLike && runtimeSize + ? { + width: + runtimeSize.width > 0 + ? Math.min(size.width, Math.max(0, runtimeSize.width - 2)) + : size.width, + height: + runtimeSize.height > 0 + ? Math.min(size.height, Math.max(0, runtimeSize.height - 2)) + : size.height, + } + : size; + + return ( + + handleFormValueChange(fieldName, value) + } + onChange={(value: any) => + resolvedColumnName + ? handleFormValueChange(resolvedColumnName, value) + : undefined + } + originalData={context.formRow} + _originalData={context.formRow} + onSearch={context.onSearch} + searchParams={context.searchParams} + onRowSelect={context.onRowSelect} + onAdd={context.onAdd} + onEdit={context.onEdit} + onDelete={context.onDelete} + onFormSubmit={context.onFormSubmit} + onFormCancel={context.onFormCancel} + selectedRow={context.selectedRow} + view={view} + /> + ); +} diff --git a/frontend/components/dash/DashboardCard.tsx b/frontend/components/dash/DashboardCard.tsx index e2f56902..836da22a 100644 --- a/frontend/components/dash/DashboardCard.tsx +++ b/frontend/components/dash/DashboardCard.tsx @@ -183,21 +183,21 @@ export function DashboardCard({ } const key = newPopupKey(); try { - // initialRow / primaryTable / templateName 만 넘긴다. - // template 객체 자체는 넘기지 않는다 — 부모 DashboardCard 가 들고 있는 - // template 은 경량/정규화 전일 수 있어 views.screenResolutions 가 - // 누락된 상태로 팝업에 흘러들어가는 stale 버그를 유발한다. - // 팝업은 templateId 로 자기 자신이 재fetch 한다. + // Optimistic seed — 부모가 들고 있는 template 을 넘겨서 팝업이 로딩 + // 플래시 없이 즉시 렌더하게 한다. 팝업은 이걸로 즉시 렌더 후 + // 백그라운드에서 getTemplateInfo 로 재검증해서 stale 이면 교체. localStorage.setItem( `form-popup:${key}`, JSON.stringify({ initialRow, primaryTable, templateName, + template, + fetchedAt: Date.now(), }), ); } catch { - /* storage 실패해도 팝업은 열어줌 — 팝업 쪽에서 빈 데이터로 시작 */ + /* storage 실패해도 팝업은 열어줌 — 팝업 쪽에서 API 로 fetch */ } popupKeysRef.current.add(key); const url = `/form-popup?templateId=${encodeURIComponent( diff --git a/frontend/components/dash/PopupTemplateRenderer.tsx b/frontend/components/dash/PopupTemplateRenderer.tsx index 1aa87867..510f4f6c 100644 --- a/frontend/components/dash/PopupTemplateRenderer.tsx +++ b/frontend/components/dash/PopupTemplateRenderer.tsx @@ -31,13 +31,11 @@ import type { Template, } from '@/types/invyone-component'; import { ensureV2Views } from '@/lib/utils/templateMigrate'; -import { - BlockRenderer, - type TemplateRenderContext, - type ViewKey, +import { BlockRenderer } from './BlockRenderer'; +import type { + TemplateRenderContext, + ViewKey, } from './TemplateRenderer'; -// side-effect: 컴포넌트 레지스트리 등록 (BlockRenderer 가 사용) -import '@/lib/registry/components'; interface PopupTemplateRendererProps { template: Template | any; diff --git a/frontend/components/dash/TemplateRenderer.tsx b/frontend/components/dash/TemplateRenderer.tsx index d53dab1f..baf798e8 100644 --- a/frontend/components/dash/TemplateRenderer.tsx +++ b/frontend/components/dash/TemplateRenderer.tsx @@ -8,7 +8,6 @@ import type { Template, ViewV2, } from '@/types/invyone-component'; -import { ComponentRegistry } from '@/lib/registry/ComponentRegistry'; import { allocateRowHeightsPx, classifyRows, @@ -16,8 +15,8 @@ import { toTrackTemplate, } from '@/lib/layout/lineLayout'; import { ensureV2Views } from '@/lib/utils/templateMigrate'; -// side-effect: 컴포넌트 레지스트리 등록 -import '@/lib/registry/components'; +import { BlockRenderer } from './BlockRenderer'; +export { BlockRenderer } from './BlockRenderer'; /** * TemplateRenderer — INVYONE 템플릿 렌더러 @@ -616,144 +615,3 @@ const LINE_CSS = ` } `; -// ───────────────────────────────────────────────────────────────────────────── -// BlockRenderer — ComponentRegistry 위임. -// PopupTemplateRenderer 등 외부 파일에서도 재사용 가능하도록 export. -// ───────────────────────────────────────────────────────────────────────────── - -export function BlockRenderer({ - block, - context, - view, - canvas, - runtimeSize, -}: { - block: BlockV2; - context: TemplateRenderContext; - view: ViewKey; - /** - * 선택: 제공되면 block 의 %좌표를 px 로 환산해 컴포넌트에 전달한다. - * line grid 의 실제 runtime cell 크기를 component.size 로 전달한다. - */ - canvas?: CanvasV2; - runtimeSize?: { - width: number; - height: number; - }; -}) { - const resolvedTableName = - block.config?.selectedTable || - block.config?.tableName || - context.primaryTable; - const resolvedColumnName = - block.config?.columnName || - block.config?.column_name || - block.config?.fieldKey || - block.config?.bindField || - block.config?.column; - const resolvedValue = - resolvedColumnName != null - ? context.formRow?.[resolvedColumnName] - : undefined; - const runtimeConfig = - resolvedColumnName != null - ? { ...block.config, defaultValue: resolvedValue } - : block.config; - const handleFormValueChange = (fieldNameOrPatch: string | Record, value?: any) => { - if (typeof fieldNameOrPatch === 'string') { - context.onFormRowChange?.({ [fieldNameOrPatch]: value }); - return; - } - context.onFormRowChange?.(fieldNameOrPatch); - }; - - const def = ComponentRegistry.getComponent(block.componentId); - if (!def?.component) { - return ( -
-
-
- {block.componentId || '(empty)'} -
-
미등록
-
-
- ); - } - const Cmp = def.component as React.ComponentType; - - const bw = canvas?.baseWidth ?? 0; - const bh = canvas?.baseHeight ?? 0; - const position = { - x: bw > 0 ? block.xPct * bw : 0, - y: bh > 0 ? block.yPct * bh : 0, - z: 1, - }; - const size = { - width: bw > 0 ? block.wPct * bw : 0, - height: bh > 0 ? block.hPct * bh : 0, - }; - const isButtonLike = - block.componentId === 'button' || - block.componentId === 'button-bar' || - block.componentId === 'pagination'; - const effectiveSize = - isButtonLike && runtimeSize - ? { - width: - runtimeSize.width > 0 - ? Math.min(size.width, Math.max(0, runtimeSize.width - 2)) - : size.width, - height: - runtimeSize.height > 0 - ? Math.min(size.height, Math.max(0, runtimeSize.height - 2)) - : size.height, - } - : size; - - return ( - - handleFormValueChange(fieldName, value) - } - onChange={(value: any) => - resolvedColumnName ? handleFormValueChange(resolvedColumnName, value) : undefined - } - originalData={context.formRow} - _originalData={context.formRow} - onSearch={context.onSearch} - searchParams={context.searchParams} - onRowSelect={context.onRowSelect} - onAdd={context.onAdd} - onEdit={context.onEdit} - onDelete={context.onDelete} - onFormSubmit={context.onFormSubmit} - onFormCancel={context.onFormCancel} - selectedRow={context.selectedRow} - view={view} - /> - ); -} diff --git a/frontend/lib/utils/templateAdapter.ts b/frontend/lib/utils/templateAdapter.ts index c0dec5b9..b28cd9aa 100644 --- a/frontend/lib/utils/templateAdapter.ts +++ b/frontend/lib/utils/templateAdapter.ts @@ -49,19 +49,40 @@ export interface TemplateSavePayload { */ export async function saveTemplate(p: TemplateSavePayload): Promise { const v2Layout = convertLegacyToV2(p.layout as any); + + // screenResolutions 병합을 key-level 폴백으로 강화. + // 기존: `p.viewScreenResolutions ?? 전체폴백` → {} 나 일부 키 누락 시 정보 유실 + // 개선: 항상 3뷰 모두 값이 보장되게 key 별로 폴백 처리 + const fallbackSr = p.layout.screenResolution; + const vsrIn = (p.viewScreenResolutions ?? {}) as Record; + const screenResolutions = { + list: vsrIn.list ?? fallbackSr, + create: vsrIn.create ?? fallbackSr, + edit: vsrIn.edit ?? fallbackSr, + }; + const viewsJson = { list: (v2Layout as any)?.components ?? [], create: p.v2Views?.create ?? [], edit: p.v2Views?.edit ?? [], gridSettings: p.layout.gridSettings, - screenResolution: p.layout.screenResolution, - screenResolutions: p.viewScreenResolutions ?? { - list: p.layout.screenResolution, - create: p.layout.screenResolution, - edit: p.layout.screenResolution, - }, + screenResolution: fallbackSr, + screenResolutions, mainTableName: p.primaryTable, }; + + // 진단 로그 — 저장 payload 확인용. 문제 재현 시 여기서 확인 가능. + /* eslint-disable no-console */ + console.log("[saveTemplate] payload", { + templateId: p.templateId, + viewsKeys: Object.keys(viewsJson), + screenResolutions, + listCount: viewsJson.list.length, + createCount: viewsJson.create.length, + editCount: viewsJson.edit.length, + }); + /* eslint-enable no-console */ + const payload: Record = { name: p.name, category: p.category ?? "custom", diff --git a/notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md b/notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md new file mode 100644 index 00000000..1a5af2c4 --- /dev/null +++ b/notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md @@ -0,0 +1,241 @@ +# 회사별 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 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` 는 리뷰 전 초안이므로 이 문서가 우선.