중간저장

This commit is contained in:
2026-04-24 04:56:30 +09:00
parent 563aef6490
commit 94c9b4b602
8 changed files with 539 additions and 208 deletions
-1
View File
@@ -1 +0,0 @@
/Users/gbpark/agent-pipeline/test-vex
+99 -45
View File
@@ -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 (
<Suspense fallback={<div className="p-6 text-sm"> ...</div>}>
<Suspense
fallback={
<div className="bg-background flex h-screen flex-col">
<div className="border-border flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-semibold"> ...</span>
</div>
<div className="flex-1 overflow-hidden p-6">
<div className="grid animate-pulse grid-cols-2 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-9 w-full rounded bg-muted" />
</div>
))}
</div>
</div>
</div>
}
>
<FormPopupContent />
</Suspense>
);
@@ -44,6 +62,9 @@ function FormPopupContent() {
const [formRow, setFormRow] = useState<Record<string, any> | null>(null);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(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<string, any>) => {
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 (
<div className="bg-background flex h-screen flex-col">
<div className="border-border flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-semibold">
{templateName
? `${templateName} ${mode === 'create' ? '등록' : '수정'}`
: '불러오는 중...'}
</span>
<button
type="button"
onClick={handleCancel}
className="hover:bg-muted flex h-7 w-7 items-center justify-center rounded"
title="닫기 (Esc)"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-hidden p-6">
<div className="grid animate-pulse grid-cols-2 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-9 w-full rounded bg-muted" />
</div>
))}
</div>
</div>
</div>
);
}
if (error)
return (
<div className="p-6 text-sm text-destructive"> {error}</div>
+160
View File
@@ -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<string, any>,
value?: any,
) => {
if (typeof fieldNameOrPatch === 'string') {
context.onFormRowChange?.({ [fieldNameOrPatch]: value });
return;
}
context.onFormRowChange?.(fieldNameOrPatch);
};
const def = ComponentRegistry.getComponent(block.componentId);
if (!def?.component) {
return (
<div className="flex h-full w-full items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center text-[10px] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
<div>
<div className="font-mono text-[11px] font-bold">
{block.componentId || '(empty)'}
</div>
<div className="opacity-70"></div>
</div>
</div>
);
}
const Cmp = def.component as React.ComponentType<any>;
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 (
<Cmp
component={{
id: block.id,
componentType: block.componentId,
tableName: resolvedTableName,
columnName: resolvedColumnName,
column_name: resolvedColumnName,
value: resolvedValue,
position,
size: effectiveSize,
componentConfig: runtimeConfig,
component_config: runtimeConfig,
style: {},
}}
componentConfig={runtimeConfig}
config={runtimeConfig}
tableName={resolvedTableName}
columnName={resolvedColumnName}
column_name={resolvedColumnName}
value={resolvedValue}
isDesignMode={false}
isPreview={true}
formData={context.formRow}
form_data={context.formRow}
onFormDataChange={(fieldName: string, value: any) =>
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}
/>
);
}
+6 -6
View File
@@ -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(
@@ -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;
+2 -144
View File
@@ -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<string, any>, value?: any) => {
if (typeof fieldNameOrPatch === 'string') {
context.onFormRowChange?.({ [fieldNameOrPatch]: value });
return;
}
context.onFormRowChange?.(fieldNameOrPatch);
};
const def = ComponentRegistry.getComponent(block.componentId);
if (!def?.component) {
return (
<div className="flex h-full w-full items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center text-[10px] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
<div>
<div className="font-mono text-[11px] font-bold">
{block.componentId || '(empty)'}
</div>
<div className="opacity-70"></div>
</div>
</div>
);
}
const Cmp = def.component as React.ComponentType<any>;
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 (
<Cmp
component={{
id: block.id,
componentType: block.componentId,
tableName: resolvedTableName,
columnName: resolvedColumnName,
column_name: resolvedColumnName,
value: resolvedValue,
position,
size: effectiveSize,
componentConfig: runtimeConfig,
component_config: runtimeConfig,
style: {},
}}
componentConfig={runtimeConfig}
config={runtimeConfig}
tableName={resolvedTableName}
columnName={resolvedColumnName}
column_name={resolvedColumnName}
value={resolvedValue}
isDesignMode={false}
isPreview={true}
formData={context.formRow}
form_data={context.formRow}
onFormDataChange={(fieldName: string, value: any) =>
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}
/>
);
}
+27 -6
View File
@@ -49,19 +49,40 @@ export interface TemplateSavePayload {
*/
export async function saveTemplate(p: TemplateSavePayload): Promise<void> {
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<string, any>;
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<string, any> = {
name: p.name,
category: p.category ?? "custom",
@@ -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<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` 는 리뷰 전 초안이므로 이 문서가 우선.