feat: V2 화면디자이너 보안 강화 및 재설계 계획서 추가

- Backend: saveLayoutV2 입력값 검증/트랜잭션 적용, menuCopyService company_code 필터링 추가
- Frontend: V2ErrorBoundary 적용, 대형 레이아웃 경고/차단, version 검증 강화
- Docs: 화면디자이너 메타 컴포넌트 기반 재설계 계획서 (screen-designer-upgrade-plan.md)

Made-with: Cursor
This commit is contained in:
DDD1542 2026-02-27 23:32:37 +09:00
parent a8ad26cf30
commit aa401ce179
8 changed files with 1259 additions and 91 deletions

View File

@ -4,6 +4,10 @@
"command": "node",
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
},
"agent-pipeline": {
"command": "node",
"args": ["/Users/gbpark/ERP-node/_local/agent-pipeline/build/index.js"]
},
"Framelink Figma MCP": {
"command": "npx",
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]

3
.gitignore vendored
View File

@ -1,6 +1,9 @@
# Claude Code (로컬 전용 - Git 제외)
.claude/
# 개인 로컬 전용 (agent-pipeline 등)
_local/
# Dependencies
node_modules/
npm-debug.log*

View File

@ -1533,16 +1533,18 @@ export class MenuCopyService {
// === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreenId = existingCopy.screen_id;
// 원본 V2 레이아웃 조회
// 원본 V2 레이아웃 조회 (company_code 필터링 추가)
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[originalScreenId]
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[originalScreenId, companyCode]
);
// 대상 V2 레이아웃 조회
// 대상 V2 레이아웃 조회 (company_code 필터링 추가)
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[existingScreenId]
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[existingScreenId, companyCode]
);
// 변경 여부 확인 (V2 레이아웃 비교)
@ -1662,10 +1664,11 @@ export class MenuCopyService {
isUpdate,
} of screenDefsToProcess) {
try {
// 원본 V2 레이아웃 조회
// 원본 V2 레이아웃 조회 (company_code 필터링 추가)
const layoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[originalScreenId]
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[originalScreenId, companyCode]
);
const layoutData = layoutV2Result.rows[0]?.layout_data;

View File

@ -5145,11 +5145,21 @@ export class ScreenManagementService {
}
if (!layout) {
logger.info(`[getLayoutV2] 레이아웃 없음`, {
screenId,
companyCode,
isSuperAdmin,
});
return null;
}
logger.info(`[getLayoutV2] 레이아웃 조회 성공`, {
screenId,
companyCode,
componentCount: Array.isArray(layout.layout_data?.components) ? layout.layout_data.components.length : 0,
version: layout.layout_data?.version,
});
return layout.layout_data;
}
@ -5169,8 +5179,6 @@ export class ScreenManagementService {
const hasConditionConfig = 'conditionConfig' in layoutData;
const conditionConfig = layoutData.conditionConfig || null;
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
@ -5187,44 +5195,79 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
const mainTableName = layoutData.mainTableName;
if (mainTableName) {
await query(
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
[mainTableName, screenId],
);
console.log(`✅ [saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`);
// 입력값 검증 (보안: XSS, DoS 방어)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData;
// 1. version 검증
if (!pureLayoutData.version || !['2.0', '2.1'].includes(pureLayoutData.version)) {
throw new Error('지원하지 않는 레이아웃 버전입니다. (허용: 2.0, 2.1)');
}
// 2. components 배열 크기 제한 (최대 500개)
if (Array.isArray(pureLayoutData.components) && pureLayoutData.components.length > 500) {
throw new Error('컴포넌트는 최대 500개까지 저장 가능합니다.');
}
// 3. JSON 크기 제한 (최대 10MB)
const jsonSize = JSON.stringify(pureLayoutData).length;
if (jsonSize > 10 * 1024 * 1024) {
throw new Error('레이아웃 데이터가 너무 큽니다. (최대 10MB)');
}
// 4. layerName 길이 제한 (최대 100자)
if (layerName && layerName.length > 100) {
throw new Error('레이어 이름은 최대 100자까지 입력 가능합니다.');
}
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData;
const dataToSave = {
version: "2.0",
version: pureLayoutData.version,
...pureLayoutData,
};
if (hasConditionConfig) {
// conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
);
} else {
// conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)],
);
}
// 트랜잭션으로 묶어서 데이터 정합성 보장
await transaction(async (client) => {
// 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
const mainTableName = layoutData.mainTableName;
if (mainTableName) {
await client.query(
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
[mainTableName, screenId],
);
logger.info(`[saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`, {
screenId,
companyCode,
mainTableName,
});
}
if (hasConditionConfig) {
// conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
);
} else {
// conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)],
);
}
logger.info(`[saveLayoutV2] 레이아웃 저장 완료`, {
screenId,
companyCode,
layerId,
componentCount: Array.isArray(pureLayoutData.components) ? pureLayoutData.components.length : 0,
dataSizeKB: Math.round(jsonSize / 1024),
});
});
}
/**
@ -5358,13 +5401,18 @@ export class ScreenManagementService {
throw new Error("기본 레이어는 삭제할 수 없습니다.");
}
await query(
const result = await query(
`DELETE FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
logger.info(`[deleteLayer] 레이어 삭제 완료`, {
screenId,
companyCode,
layerId,
affectedRows: result.rowCount || 0,
});
}
/**

File diff suppressed because it is too large Load Diff

View File

@ -98,6 +98,7 @@ import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { V2ErrorBoundary } from "@/lib/v2-core/components/V2ErrorBoundary";
import { MultilangSettingsModal } from "./modals/MultilangSettingsModal";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
@ -2089,8 +2090,31 @@ export default function ScreenDesigner({
mainTableName: currentMainTableName, // 화면의 기본 테이블
};
// 🛡️ 대형 레이아웃 경고 (100개 이상 컴포넌트)
const totalComponents = updatedComponents.length;
if (totalComponents >= 100) {
toast.warning(`컴포넌트 개수가 많습니다 (${totalComponents}개). 저장/로드 시간이 길어질 수 있습니다.`, {
duration: 5000,
});
}
// V2/POP API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution);
// JSON 페이로드 크기 검증 (5MB 경고, 10MB 제한)
const payloadSize = JSON.stringify(v2Layout).length;
const MAX_WARNING_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB
if (payloadSize > MAX_PAYLOAD_SIZE) {
toast.error(`레이아웃 크기가 너무 큽니다 (${(payloadSize / 1024 / 1024).toFixed(2)}MB / 10MB 제한). 레이아웃을 분할하거나 컴포넌트를 줄여주세요.`);
return; // 저장 중단
} else if (payloadSize > MAX_WARNING_SIZE) {
toast.warning(`레이아웃 크기가 큽니다 (${(payloadSize / 1024 / 1024).toFixed(2)}MB). 저장에 시간이 걸릴 수 있습니다.`, {
duration: 5000,
});
}
if (USE_POP_API) {
// POP 모드: screen_layouts_pop 테이블에 저장
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
@ -7012,25 +7036,33 @@ export default function ScreenDesigner({
};
return (
<RealtimePreview
key={fullKey}
component={componentWithLabel}
isSelected={
selectedComponent?.id === component.id ||
groupState.selectedComponents.includes(component.id)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(component, e)}
onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
onConfigChange={(config) => {
<V2ErrorBoundary
key={`boundary-${fullKey}`}
componentId={component.id}
componentType={component.widgetType || component.componentType || "unknown"}
fallbackStyle="compact"
recoverable={true}
autoRetryCount={0}
>
<RealtimePreview
key={fullKey}
component={componentWithLabel}
isSelected={
selectedComponent?.id === component.id ||
groupState.selectedComponents.includes(component.id)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(component, e)}
onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
onConfigChange={(config) => {
// console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
// 컴포넌트의 componentConfig 업데이트
@ -7220,6 +7252,7 @@ export default function ScreenDesigner({
);
})}
</RealtimePreview>
</V2ErrorBoundary>
);
})}

View File

@ -1022,6 +1022,27 @@ export function loadLayoutV2(layoutData: any): LayoutV2 & {
components: Array<ComponentV2 & { config: Record<string, any> }>;
layers: Array<Layer & { components: Array<ComponentV2 & { config: Record<string, any> }> }>;
} {
// 🛡️ Version 엄격 검증
if (!layoutData || typeof layoutData !== "object") {
throw new Error("레이아웃 데이터가 올바르지 않습니다.");
}
const version = layoutData.version;
const ALLOWED_VERSIONS = ["2.0", "2.1"];
if (!version || !ALLOWED_VERSIONS.includes(version)) {
throw new Error(`지원하지 않는 레이아웃 버전입니다 (${version}). 지원 버전: ${ALLOWED_VERSIONS.join(", ")}`);
}
// 컴포넌트 배열 검증
if (layoutData.components && !Array.isArray(layoutData.components)) {
throw new Error("components는 배열이어야 합니다.");
}
if (layoutData.layers && !Array.isArray(layoutData.layers)) {
throw new Error("layers는 배열이어야 합니다.");
}
const parsed = layoutV2Schema.parse(layoutData || { version: "2.1", components: [], layers: [] });
// 마이그레이션: components만 있고 layers가 없는 경우 Default Layer 생성
@ -1061,6 +1082,16 @@ export function saveLayoutV2(
components: Array<ComponentV2 & { config?: Record<string, any> }>,
layers?: Array<Layer & { components: Array<ComponentV2 & { config?: Record<string, any> }> }>,
): LayoutV2 {
// 🛡️ 입력값 검증 (백엔드 전송 전 pre-validation)
const totalComponents = layers
? layers.reduce((sum, layer) => sum + layer.components.length, 0)
: components.length;
// 컴포넌트 개수 제한 (1000개)
if (totalComponents > 1000) {
throw new Error(`컴포넌트 개수 제한 초과 (${totalComponents}/1000). 레이아웃을 분할하거나 불필요한 컴포넌트를 제거하세요.`);
}
// 레이어가 있는 경우 레이어 구조 저장
if (layers && layers.length > 0) {
const savedLayers = layers.map((layer) => ({
@ -1068,11 +1099,19 @@ export function saveLayoutV2(
components: layer.components.map(saveComponentV2),
}));
return {
const layout: LayoutV2 = {
version: "2.1",
layers: savedLayers,
components: savedLayers.flatMap((l) => l.components), // 하위 호환성
};
// JSON 크기 검증 (10MB 제한)
const jsonSize = JSON.stringify(layout).length;
if (jsonSize > 10_000_000) {
throw new Error(`레이아웃 크기 제한 초과 (${(jsonSize / 1024 / 1024).toFixed(2)}MB / 10MB). 레이아웃을 분할하세요.`);
}
return layout;
}
// 레이어가 없는 경우 (기존 방식) - Default Layer로 감싸서 저장
@ -1087,9 +1126,17 @@ export function saveLayoutV2(
components: savedComponents,
};
return {
const layout: LayoutV2 = {
version: "2.1",
layers: [defaultLayer],
components: savedComponents,
};
// JSON 크기 검증
const jsonSize = JSON.stringify(layout).length;
if (jsonSize > 10_000_000) {
throw new Error(`레이아웃 크기 제한 초과 (${(jsonSize / 1024 / 1024).toFixed(2)}MB / 10MB).`);
}
return layout;
}

View File

@ -153,34 +153,38 @@ export class V2ErrorBoundary extends Component<
}
private renderCompactFallback(): ReactNode {
const { componentType, recoverable = true } = this.props;
const { componentType, componentId, recoverable = true } = this.props;
const { error } = this.state;
return (
<div className="rounded-md border border-destructive/50 bg-destructive/5 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive" />
<span className="text-sm font-medium text-destructive">
{componentType}
</span>
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<AlertCircle className="h-3.5 w-3.5 text-destructive flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-destructive truncate">
{componentType || "컴포넌트"}
</p>
{error && (
<p className="text-[10px] text-muted-foreground truncate">
{error.message.substring(0, 60)}
{error.message.length > 60 ? "..." : ""}
</p>
)}
</div>
</div>
{recoverable && (
<Button
variant="ghost"
size="sm"
onClick={this.handleRetry}
className="h-6 px-2 text-xs flex-shrink-0"
title="재시도"
>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div>
{error && (
<p className="mt-1 text-xs text-muted-foreground">
{error.message.substring(0, 100)}
{error.message.length > 100 ? "..." : ""}
</p>
)}
{recoverable && (
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
className="mt-2"
>
<RefreshCw className="mr-1 h-3 w-3" />
</Button>
)}
</div>
);
}