43 KiB
43 KiB
화면 임베딩 및 데이터 전달 시스템 구현 계획서
📋 목차
개요
배경
현재 화면관리 시스템은 단일 화면 단위로만 동작하며, 화면 간 데이터 전달이나 화면 임베딩이 불가능합니다. 실무에서는 "입고 등록"과 같이 좌측에서 데이터를 선택하고 우측으로 전달하여 처리하는 복잡한 워크플로우가 필요합니다.
핵심 요구사항
- 화면 임베딩: 기존 화면을 다른 화면 안에 재사용
- 데이터 전달: 한 화면에서 선택한 데이터를 다른 화면의 컴포넌트로 전달
- 유연한 매핑: 테이블뿐만 아니라 입력 필드, 셀렉트 박스, 리피터 등 모든 컴포넌트에 데이터 주입 가능
- 변환 함수: 합계, 평균, 개수 등 데이터 변환 지원
현재 문제점
1. 화면 재사용 불가
- 각 화면은 독립적으로만 동작
- 동일한 기능을 여러 화면에서 중복 구현
2. 화면 간 데이터 전달 불가
- 한 화면에서 선택한 데이터를 다른 화면으로 전달할 수 없음
- 사용자가 수동으로 복사/붙여넣기 해야 함
3. 복잡한 워크플로우 구현 불가
- "발주 목록 조회 → 품목 선택 → 입고 등록"과 같은 프로세스를 단일 화면에서 처리 불가
- 여러 화면을 오가며 작업해야 하는 불편함
4. 컴포넌트별 데이터 주입 불가
- 테이블에만 데이터를 추가할 수 있음
- 입력 필드, 셀렉트 박스 등에 자동으로 값을 설정할 수 없음
목표
주요 목표
- 화면 임베딩 시스템 구축: 기존 화면을 컨테이너로 사용
- 범용 데이터 전달 시스템: 모든 컴포넌트 타입 지원
- 시각적 매핑 설정 UI: 드래그앤드롭으로 매핑 규칙 설정
- 실시간 미리보기: 데이터 전달 결과를 즉시 확인
부가 목표
- 조건부 데이터 전달 (필터링)
- 데이터 변환 함수 (합계, 평균, 개수 등)
- 양방향 데이터 동기화
- 트랜잭션 지원 (전체 성공 또는 전체 실패)
시스템 아키텍처
전체 구조
┌─────────────────────────────────────────────────────────┐
│ Screen Split Panel │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Left Screen │ │ Right Screen │ │
│ │ (Source) │ │ (Target) │ │
│ │ │ │ │ │
│ │ ┌────────────┐ │ │ ┌────────────┐ │ │
│ │ │ Table │ │ │ │ Form │ │ │
│ │ │ (Select) │ │ │ │ │ │ │
│ │ └────────────┘ │ │ └────────────┘ │ │
│ │ │ │ │ │
│ │ [✓] Row 1 │ │ Input: ____ │ │
│ │ [✓] Row 2 │ │ Select: [ ] │ │
│ │ [ ] Row 3 │ │ │ │
│ │ │ │ ┌────────────┐ │ │
│ └──────────────────┘ │ │ Table │ │ │
│ │ │ │ (Append) │ │ │
│ │ │ └────────────┘ │ │
│ ▼ │ │ │
│ [선택 품목 추가] ──────────▶│ Row 1 (Added) │ │
│ │ Row 2 (Added) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
레이어 구조
┌─────────────────────────────────────────┐
│ Presentation Layer (UI) │
│ - ScreenSplitPanel │
│ - EmbeddedScreen │
│ - DataMappingConfig │
└─────────────────────────────────────────┘
│
┌─────────────────────────────────────────┐
│ Business Logic Layer │
│ - DataTransferService │
│ - MappingEngine │
│ - TransformFunctions │
└─────────────────────────────────────────┘
│
┌─────────────────────────────────────────┐
│ Data Access Layer │
│ - screen_embedding (테이블) │
│ - screen_data_transfer (테이블) │
│ - component_data_receiver (인터페이스) │
└─────────────────────────────────────────┘
데이터베이스 설계
1. screen_embedding (화면 임베딩 설정)
CREATE TABLE screen_embedding (
id SERIAL PRIMARY KEY,
-- 부모 화면 (컨테이너)
parent_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
-- 자식 화면 (임베드될 화면)
child_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
-- 임베딩 위치
position VARCHAR(20) NOT NULL, -- 'left', 'right', 'top', 'bottom', 'center'
-- 임베딩 모드
mode VARCHAR(20) NOT NULL, -- 'view', 'select', 'form', 'edit'
-- 추가 설정
config JSONB,
-- {
-- "width": "50%",
-- "height": "100%",
-- "resizable": true,
-- "multiSelect": true,
-- "showToolbar": true
-- }
-- 멀티테넌시
company_code VARCHAR(20) NOT NULL,
-- 메타데이터
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
REFERENCES screen_info(screen_id) ON DELETE CASCADE,
CONSTRAINT fk_child_screen FOREIGN KEY (child_screen_id)
REFERENCES screen_info(screen_id) ON DELETE CASCADE
);
-- 인덱스
CREATE INDEX idx_screen_embedding_parent ON screen_embedding(parent_screen_id, company_code);
CREATE INDEX idx_screen_embedding_child ON screen_embedding(child_screen_id, company_code);
2. screen_data_transfer (데이터 전달 설정)
CREATE TABLE screen_data_transfer (
id SERIAL PRIMARY KEY,
-- 소스 화면 (데이터 제공)
source_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
-- 타겟 화면 (데이터 수신)
target_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
-- 소스 컴포넌트 (선택 영역)
source_component_id VARCHAR(100),
source_component_type VARCHAR(50), -- 'table', 'list', 'grid'
-- 데이터 수신자 설정 (JSONB 배열)
data_receivers JSONB NOT NULL,
-- [
-- {
-- "targetComponentId": "table-입고처리품목",
-- "targetComponentType": "table",
-- "mode": "append",
-- "mappingRules": [
-- {
-- "sourceField": "품목코드",
-- "targetField": "품목코드",
-- "transform": null
-- }
-- ],
-- "condition": {
-- "field": "상태",
-- "operator": "equals",
-- "value": "승인"
-- }
-- }
-- ]
-- 전달 버튼 설정
button_config JSONB,
-- {
-- "label": "선택 품목 추가",
-- "position": "center",
-- "icon": "ArrowRight",
-- "validation": {
-- "requireSelection": true,
-- "minSelection": 1,
-- "maxSelection": 100,
-- "customValidation": "function(rows) { return rows.length > 0; }"
-- }
-- }
-- 멀티테넌시
company_code VARCHAR(20) NOT NULL,
-- 메타데이터
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
CONSTRAINT fk_source_screen FOREIGN KEY (source_screen_id)
REFERENCES screen_info(screen_id) ON DELETE CASCADE,
CONSTRAINT fk_target_screen FOREIGN KEY (target_screen_id)
REFERENCES screen_info(screen_id) ON DELETE CASCADE
);
-- 인덱스
CREATE INDEX idx_screen_data_transfer_source ON screen_data_transfer(source_screen_id, company_code);
CREATE INDEX idx_screen_data_transfer_target ON screen_data_transfer(target_screen_id, company_code);
3. screen_split_panel (분할 패널 설정)
CREATE TABLE screen_split_panel (
id SERIAL PRIMARY KEY,
-- 부모 화면 (분할 패널 컨테이너)
screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id),
-- 좌측 화면 임베딩
left_embedding_id INTEGER REFERENCES screen_embedding(id),
-- 우측 화면 임베딩
right_embedding_id INTEGER REFERENCES screen_embedding(id),
-- 데이터 전달 설정
data_transfer_id INTEGER REFERENCES screen_data_transfer(id),
-- 레이아웃 설정
layout_config JSONB,
-- {
-- "splitRatio": 50, // 좌:우 비율 (0-100)
-- "resizable": true,
-- "minLeftWidth": 300,
-- "minRightWidth": 400,
-- "orientation": "horizontal" // 'horizontal' | 'vertical'
-- }
-- 멀티테넌시
company_code VARCHAR(20) NOT NULL,
-- 메타데이터
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_screen FOREIGN KEY (screen_id)
REFERENCES screen_info(screen_id) ON DELETE CASCADE,
CONSTRAINT fk_left_embedding FOREIGN KEY (left_embedding_id)
REFERENCES screen_embedding(id) ON DELETE SET NULL,
CONSTRAINT fk_right_embedding FOREIGN KEY (right_embedding_id)
REFERENCES screen_embedding(id) ON DELETE SET NULL,
CONSTRAINT fk_data_transfer FOREIGN KEY (data_transfer_id)
REFERENCES screen_data_transfer(id) ON DELETE SET NULL
);
-- 인덱스
CREATE INDEX idx_screen_split_panel_screen ON screen_split_panel(screen_id, company_code);
타입 정의
1. 화면 임베딩 타입
// 임베딩 모드
type EmbeddingMode =
| "view" // 읽기 전용
| "select" // 선택 모드 (체크박스)
| "form" // 폼 입력 모드
| "edit"; // 편집 모드
// 임베딩 위치
type EmbeddingPosition =
| "left"
| "right"
| "top"
| "bottom"
| "center";
// 화면 임베딩 설정
interface ScreenEmbedding {
id: number;
parentScreenId: number;
childScreenId: number;
position: EmbeddingPosition;
mode: EmbeddingMode;
config: {
width?: string; // "50%", "400px"
height?: string; // "100%", "600px"
resizable?: boolean;
multiSelect?: boolean;
showToolbar?: boolean;
showSearch?: boolean;
showPagination?: boolean;
};
companyCode: string;
}
2. 데이터 전달 타입
// 컴포넌트 타입
type ComponentType =
| "table" // 테이블
| "input" // 입력 필드
| "select" // 셀렉트 박스
| "textarea" // 텍스트 영역
| "checkbox" // 체크박스
| "radio" // 라디오 버튼
| "date" // 날짜 선택
| "repeater" // 리피터 (반복 그룹)
| "form-group" // 폼 그룹
| "hidden"; // 히든 필드
// 데이터 수신 모드
type DataReceiveMode =
| "append" // 기존 데이터에 추가
| "replace" // 기존 데이터 덮어쓰기
| "merge"; // 기존 데이터와 병합 (키 기준)
// 변환 함수
type TransformFunction =
| "none" // 변환 없음
| "sum" // 합계
| "average" // 평균
| "count" // 개수
| "min" // 최소값
| "max" // 최대값
| "first" // 첫 번째 값
| "last" // 마지막 값
| "concat" // 문자열 결합
| "join" // 배열 결합
| "custom"; // 커스텀 함수
// 조건 연산자
type ConditionOperator =
| "equals"
| "notEquals"
| "contains"
| "notContains"
| "greaterThan"
| "lessThan"
| "greaterThanOrEqual"
| "lessThanOrEqual"
| "in"
| "notIn";
// 매핑 규칙
interface MappingRule {
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: TransformFunction; // 변환 함수
transformConfig?: any; // 변환 함수 설정
defaultValue?: any; // 기본값
required?: boolean; // 필수 여부
}
// 조건
interface Condition {
field: string;
operator: ConditionOperator;
value: any;
}
// 데이터 수신자
interface DataReceiver {
targetComponentId: string; // 타겟 컴포넌트 ID
targetComponentType: ComponentType;
mode: DataReceiveMode;
mappingRules: MappingRule[];
condition?: Condition; // 조건부 전달
validation?: {
required?: boolean;
minRows?: number;
maxRows?: number;
customValidation?: string; // JavaScript 함수 문자열
};
}
// 버튼 설정
interface TransferButtonConfig {
label: string;
position: "left" | "right" | "center";
icon?: string;
variant?: "default" | "outline" | "ghost";
size?: "sm" | "default" | "lg";
validation?: {
requireSelection: boolean;
minSelection?: number;
maxSelection?: number;
confirmMessage?: string;
customValidation?: string;
};
}
// 데이터 전달 설정
interface ScreenDataTransfer {
id: number;
sourceScreenId: number;
targetScreenId: number;
sourceComponentId?: string;
sourceComponentType?: string;
dataReceivers: DataReceiver[];
buttonConfig: TransferButtonConfig;
companyCode: string;
}
3. 분할 패널 타입
// 레이아웃 설정
interface LayoutConfig {
splitRatio: number; // 0-100 (좌측 비율)
resizable: boolean;
minLeftWidth?: number; // 최소 좌측 너비 (px)
minRightWidth?: number; // 최소 우측 너비 (px)
orientation: "horizontal" | "vertical";
}
// 분할 패널 설정
interface ScreenSplitPanel {
id: number;
screenId: number;
leftEmbedding: ScreenEmbedding;
rightEmbedding: ScreenEmbedding;
dataTransfer: ScreenDataTransfer;
layoutConfig: LayoutConfig;
companyCode: string;
}
4. 컴포넌트 인터페이스
// 모든 데이터 수신 가능 컴포넌트가 구현해야 하는 인터페이스
interface DataReceivable {
// 컴포넌트 ID
componentId: string;
// 컴포넌트 타입
componentType: ComponentType;
// 데이터 수신
receiveData(data: any[], mode: DataReceiveMode): Promise<void>;
// 현재 데이터 가져오기
getData(): any;
// 데이터 초기화
clearData(): void;
// 검증
validate(): boolean;
// 이벤트 리스너
onDataReceived?: (data: any[]) => void;
onDataCleared?: () => void;
}
// 선택 가능 컴포넌트 인터페이스
interface Selectable {
// 선택된 행/항목 가져오기
getSelectedRows(): any[];
// 선택 초기화
clearSelection(): void;
// 전체 선택
selectAll(): void;
// 선택 이벤트
onSelectionChanged?: (selectedRows: any[]) => void;
}
컴포넌트 구조
1. ScreenSplitPanel (최상위 컨테이너)
interface ScreenSplitPanelProps {
config: ScreenSplitPanel;
onDataTransferred?: (data: any[]) => void;
}
export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanelProps) {
const leftScreenRef = useRef<EmbeddedScreenHandle>(null);
const rightScreenRef = useRef<EmbeddedScreenHandle>(null);
const [splitRatio, setSplitRatio] = useState(config.layoutConfig.splitRatio);
// 데이터 전달 핸들러
const handleTransferData = async () => {
// 1. 좌측 화면에서 선택된 데이터 가져오기
const selectedRows = leftScreenRef.current?.getSelectedRows() || [];
if (selectedRows.length === 0) {
toast.error("선택된 항목이 없습니다.");
return;
}
// 2. 검증
if (config.dataTransfer.buttonConfig.validation) {
const validation = config.dataTransfer.buttonConfig.validation;
if (validation.minSelection && selectedRows.length < validation.minSelection) {
toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`);
return;
}
if (validation.maxSelection && selectedRows.length > validation.maxSelection) {
toast.error(`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`);
return;
}
if (validation.confirmMessage) {
const confirmed = await confirm(validation.confirmMessage);
if (!confirmed) return;
}
}
// 3. 데이터 전달
try {
await rightScreenRef.current?.receiveData(
selectedRows,
config.dataTransfer.dataReceivers
);
toast.success("데이터가 전달되었습니다.");
onDataTransferred?.(selectedRows);
// 4. 좌측 선택 초기화 (옵션)
if (config.dataTransfer.buttonConfig.clearAfterTransfer) {
leftScreenRef.current?.clearSelection();
}
} catch (error) {
toast.error("데이터 전달 중 오류가 발생했습니다.");
console.error(error);
}
};
return (
<div className="flex h-full">
{/* 좌측 패널 */}
<div style={{ width: `${splitRatio}%` }}>
<EmbeddedScreen
ref={leftScreenRef}
embedding={config.leftEmbedding}
/>
</div>
{/* 리사이저 */}
{config.layoutConfig.resizable && (
<Resizer
onResize={(newRatio) => setSplitRatio(newRatio)}
/>
)}
{/* 전달 버튼 */}
<div className="flex items-center justify-center px-4">
<Button
onClick={handleTransferData}
variant={config.dataTransfer.buttonConfig.variant || "default"}
size={config.dataTransfer.buttonConfig.size || "default"}
>
{config.dataTransfer.buttonConfig.icon && (
<Icon name={config.dataTransfer.buttonConfig.icon} className="mr-2" />
)}
{config.dataTransfer.buttonConfig.label}
</Button>
</div>
{/* 우측 패널 */}
<div style={{ width: `${100 - splitRatio}%` }}>
<EmbeddedScreen
ref={rightScreenRef}
embedding={config.rightEmbedding}
/>
</div>
</div>
);
}
2. EmbeddedScreen (임베드된 화면)
interface EmbeddedScreenProps {
embedding: ScreenEmbedding;
}
export interface EmbeddedScreenHandle {
getSelectedRows(): any[];
clearSelection(): void;
receiveData(data: any[], receivers: DataReceiver[]): Promise<void>;
getData(): any;
}
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
({ embedding }, ref) => {
const [screenData, setScreenData] = useState<any>(null);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
// 화면 데이터 로드
useEffect(() => {
loadScreenData(embedding.childScreenId);
}, [embedding.childScreenId]);
// 외부에서 호출 가능한 메서드
useImperativeHandle(ref, () => ({
getSelectedRows: () => selectedRows,
clearSelection: () => {
setSelectedRows([]);
},
receiveData: async (data: any[], receivers: DataReceiver[]) => {
// 각 데이터 수신자에게 데이터 전달
for (const receiver of receivers) {
const component = componentRefs.current.get(receiver.targetComponentId);
if (!component) {
console.warn(`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`);
continue;
}
// 조건 확인
let filteredData = data;
if (receiver.condition) {
filteredData = filterData(data, receiver.condition);
}
// 매핑 적용
const mappedData = applyMappingRules(filteredData, receiver.mappingRules);
// 데이터 전달
await component.receiveData(mappedData, receiver.mode);
}
},
getData: () => {
const allData: Record<string, any> = {};
componentRefs.current.forEach((component, id) => {
allData[id] = component.getData();
});
return allData;
}
}));
// 컴포넌트 등록
const registerComponent = (id: string, component: DataReceivable) => {
componentRefs.current.set(id, component);
};
return (
<div className="h-full overflow-auto">
{screenData && (
<InteractiveScreenViewer
screenData={screenData}
mode={embedding.mode}
onSelectionChanged={setSelectedRows}
onComponentMount={registerComponent}
/>
)}
</div>
);
}
);
3. DataReceivable 구현 예시
TableComponent
class TableComponent implements DataReceivable {
componentId: string;
componentType: ComponentType = "table";
private rows: any[] = [];
async receiveData(data: any[], mode: DataReceiveMode): Promise<void> {
switch (mode) {
case "append":
this.rows = [...this.rows, ...data];
break;
case "replace":
this.rows = data;
break;
case "merge":
// 키 기반 병합 (예: id 필드)
const existingIds = new Set(this.rows.map(r => r.id));
const newRows = data.filter(r => !existingIds.has(r.id));
this.rows = [...this.rows, ...newRows];
break;
}
this.render();
this.onDataReceived?.(data);
}
getData(): any {
return this.rows;
}
clearData(): void {
this.rows = [];
this.render();
this.onDataCleared?.();
}
validate(): boolean {
return this.rows.length > 0;
}
private render() {
// 테이블 리렌더링
}
}
InputComponent
class InputComponent implements DataReceivable {
componentId: string;
componentType: ComponentType = "input";
private value: any = "";
async receiveData(data: any[], mode: DataReceiveMode): Promise<void> {
// 입력 필드는 단일 값이므로 첫 번째 항목만 사용
if (data.length > 0) {
this.value = data[0];
this.render();
this.onDataReceived?.(data);
}
}
getData(): any {
return this.value;
}
clearData(): void {
this.value = "";
this.render();
this.onDataCleared?.();
}
validate(): boolean {
return this.value !== null && this.value !== undefined && this.value !== "";
}
private render() {
// 입력 필드 리렌더링
}
}
API 설계
1. 화면 임베딩 API
// GET /api/screen-embedding/:parentScreenId
export async function getScreenEmbeddings(
parentScreenId: number,
companyCode: string
): Promise<ApiResponse<ScreenEmbedding[]>> {
const query = `
SELECT * FROM screen_embedding
WHERE parent_screen_id = $1
AND company_code = $2
ORDER BY position
`;
const result = await pool.query(query, [parentScreenId, companyCode]);
return { success: true, data: result.rows };
}
// POST /api/screen-embedding
export async function createScreenEmbedding(
embedding: Omit<ScreenEmbedding, "id">,
companyCode: string
): Promise<ApiResponse<ScreenEmbedding>> {
const query = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode, config, company_code
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`;
const result = await pool.query(query, [
embedding.parentScreenId,
embedding.childScreenId,
embedding.position,
embedding.mode,
JSON.stringify(embedding.config),
companyCode
]);
return { success: true, data: result.rows[0] };
}
// PUT /api/screen-embedding/:id
export async function updateScreenEmbedding(
id: number,
embedding: Partial<ScreenEmbedding>,
companyCode: string
): Promise<ApiResponse<ScreenEmbedding>> {
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (embedding.position) {
updates.push(`position = $${paramIndex++}`);
values.push(embedding.position);
}
if (embedding.mode) {
updates.push(`mode = $${paramIndex++}`);
values.push(embedding.mode);
}
if (embedding.config) {
updates.push(`config = $${paramIndex++}`);
values.push(JSON.stringify(embedding.config));
}
updates.push(`updated_at = NOW()`);
values.push(id, companyCode);
const query = `
UPDATE screen_embedding
SET ${updates.join(", ")}
WHERE id = $${paramIndex++}
AND company_code = $${paramIndex++}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rowCount === 0) {
return { success: false, message: "임베딩 설정을 찾을 수 없습니다." };
}
return { success: true, data: result.rows[0] };
}
// DELETE /api/screen-embedding/:id
export async function deleteScreenEmbedding(
id: number,
companyCode: string
): Promise<ApiResponse<void>> {
const query = `
DELETE FROM screen_embedding
WHERE id = $1 AND company_code = $2
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return { success: false, message: "임베딩 설정을 찾을 수 없습니다." };
}
return { success: true };
}
2. 데이터 전달 API
// GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
export async function getScreenDataTransfer(
sourceScreenId: number,
targetScreenId: number,
companyCode: string
): Promise<ApiResponse<ScreenDataTransfer>> {
const query = `
SELECT * FROM screen_data_transfer
WHERE source_screen_id = $1
AND target_screen_id = $2
AND company_code = $3
`;
const result = await pool.query(query, [sourceScreenId, targetScreenId, companyCode]);
if (result.rowCount === 0) {
return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." };
}
return { success: true, data: result.rows[0] };
}
// POST /api/screen-data-transfer
export async function createScreenDataTransfer(
transfer: Omit<ScreenDataTransfer, "id">,
companyCode: string
): Promise<ApiResponse<ScreenDataTransfer>> {
const query = `
INSERT INTO screen_data_transfer (
source_screen_id, target_screen_id, source_component_id, source_component_type,
data_receivers, button_config, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
const result = await pool.query(query, [
transfer.sourceScreenId,
transfer.targetScreenId,
transfer.sourceComponentId,
transfer.sourceComponentType,
JSON.stringify(transfer.dataReceivers),
JSON.stringify(transfer.buttonConfig),
companyCode
]);
return { success: true, data: result.rows[0] };
}
// PUT /api/screen-data-transfer/:id
export async function updateScreenDataTransfer(
id: number,
transfer: Partial<ScreenDataTransfer>,
companyCode: string
): Promise<ApiResponse<ScreenDataTransfer>> {
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (transfer.dataReceivers) {
updates.push(`data_receivers = $${paramIndex++}`);
values.push(JSON.stringify(transfer.dataReceivers));
}
if (transfer.buttonConfig) {
updates.push(`button_config = $${paramIndex++}`);
values.push(JSON.stringify(transfer.buttonConfig));
}
updates.push(`updated_at = NOW()`);
values.push(id, companyCode);
const query = `
UPDATE screen_data_transfer
SET ${updates.join(", ")}
WHERE id = $${paramIndex++}
AND company_code = $${paramIndex++}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rowCount === 0) {
return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." };
}
return { success: true, data: result.rows[0] };
}
3. 분할 패널 API
// GET /api/screen-split-panel/:screenId
export async function getScreenSplitPanel(
screenId: number,
companyCode: string
): Promise<ApiResponse<ScreenSplitPanel>> {
const query = `
SELECT
ssp.*,
le.* as left_embedding,
re.* as right_embedding,
sdt.* as data_transfer
FROM screen_split_panel ssp
LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id
LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id
LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id
WHERE ssp.screen_id = $1
AND ssp.company_code = $2
`;
const result = await pool.query(query, [screenId, companyCode]);
if (result.rowCount === 0) {
return { success: false, message: "분할 패널 설정을 찾을 수 없습니다." };
}
return { success: true, data: result.rows[0] };
}
// POST /api/screen-split-panel
export async function createScreenSplitPanel(
panel: Omit<ScreenSplitPanel, "id">,
companyCode: string
): Promise<ApiResponse<ScreenSplitPanel>> {
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. 좌측 임베딩 생성
const leftEmbedding = await createScreenEmbedding(panel.leftEmbedding, companyCode);
// 2. 우측 임베딩 생성
const rightEmbedding = await createScreenEmbedding(panel.rightEmbedding, companyCode);
// 3. 데이터 전달 설정 생성
const dataTransfer = await createScreenDataTransfer(panel.dataTransfer, companyCode);
// 4. 분할 패널 생성
const query = `
INSERT INTO screen_split_panel (
screen_id, left_embedding_id, right_embedding_id, data_transfer_id,
layout_config, company_code
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`;
const result = await client.query(query, [
panel.screenId,
leftEmbedding.data!.id,
rightEmbedding.data!.id,
dataTransfer.data!.id,
JSON.stringify(panel.layoutConfig),
companyCode
]);
await client.query("COMMIT");
return { success: true, data: result.rows[0] };
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
구현 단계
Phase 1: 기본 인프라 구축 (1-2주)
1.1 데이터베이스 마이그레이션
screen_embedding테이블 생성screen_data_transfer테이블 생성screen_split_panel테이블 생성- 인덱스 및 외래키 설정
- 샘플 데이터 삽입
1.2 타입 정의
- TypeScript 인터페이스 작성
types/screen-embedding.tstypes/data-transfer.tstypes/split-panel.ts
1.3 백엔드 API
- 화면 임베딩 CRUD API
- 데이터 전달 설정 CRUD API
- 분할 패널 CRUD API
- 컨트롤러 및 서비스 레이어 구현
Phase 2: 화면 임베딩 기능 (2-3주)
2.1 EmbeddedScreen 컴포넌트
- 기본 임베딩 기능
- 모드별 렌더링 (view, select, form, edit)
- 선택 모드 구현 (체크박스)
- 이벤트 핸들링
2.2 DataReceivable 인터페이스 구현
- TableComponent
- InputComponent
- SelectComponent
- TextareaComponent
- RepeaterComponent
- FormGroupComponent
- HiddenComponent
2.3 컴포넌트 등록 시스템
- 컴포넌트 마운트 시 자동 등록
- 컴포넌트 ID 관리
- 컴포넌트 참조 관리
Phase 3: 데이터 전달 시스템 (2-3주)
3.1 매핑 엔진
- 매핑 규칙 파싱
- 필드 매핑 적용
- 변환 함수 구현
- sum, average, count
- min, max
- first, last
- concat, join
3.2 조건부 전달
- 조건 파싱
- 필터링 로직
- 복합 조건 지원
3.3 검증 시스템
- 필수 필드 검증
- 최소/최대 행 수 검증
- 커스텀 검증 함수 실행
Phase 4: 분할 패널 UI (2-3주)
4.1 ScreenSplitPanel 컴포넌트
- 기본 레이아웃
- 리사이저 구현
- 전달 버튼
- 반응형 디자인
4.2 설정 UI
- 화면 선택 드롭다운
- 매핑 규칙 설정 UI
- 드래그앤드롭 매핑
- 미리보기 기능
4.3 시각적 피드백
- 데이터 전달 애니메이션
- 로딩 상태 표시
- 성공/실패 토스트
Phase 5: 고급 기능 (2-3주)
5.1 양방향 동기화
- 우측 → 좌측 데이터 반영
- 실시간 업데이트
5.2 트랜잭션 지원
- 전체 성공 또는 전체 실패
- 롤백 기능
5.3 성능 최적화
- 대량 데이터 처리
- 가상 스크롤링
- 메모이제이션
Phase 6: 테스트 및 문서화 (1-2주)
6.1 단위 테스트
- 매핑 엔진 테스트
- 변환 함수 테스트
- 검증 로직 테스트
6.2 통합 테스트
- 전체 워크플로우 테스트
- 실제 시나리오 테스트
6.3 문서화
- 사용자 가이드
- 개발자 문서
- API 문서
사용 시나리오
시나리오 1: 입고 등록
요구사항
- 발주 목록에서 품목을 선택하여 입고 등록
- 선택된 품목의 정보를 입고 처리 품목 테이블에 추가
- 공급자 정보를 자동으로 입력 필드에 설정
- 총 품목 수를 자동 계산
설정
const 입고등록_설정: ScreenSplitPanel = {
screenId: 100,
leftEmbedding: {
childScreenId: 10, // 발주 목록 조회 화면
position: "left",
mode: "select",
config: {
width: "50%",
multiSelect: true,
showSearch: true,
showPagination: true
}
},
rightEmbedding: {
childScreenId: 20, // 입고 등록 폼 화면
position: "right",
mode: "form",
config: {
width: "50%"
}
},
dataTransfer: {
sourceScreenId: 10,
targetScreenId: 20,
sourceComponentId: "table-발주목록",
sourceComponentType: "table",
dataReceivers: [
{
targetComponentId: "table-입고처리품목",
targetComponentType: "table",
mode: "append",
mappingRules: [
{ sourceField: "품목코드", targetField: "품목코드" },
{ sourceField: "품목명", targetField: "품목명" },
{ sourceField: "발주수량", targetField: "발주수량" },
{ sourceField: "미입고수량", targetField: "입고수량" }
]
},
{
targetComponentId: "input-공급자",
targetComponentType: "input",
mode: "replace",
mappingRules: [
{
sourceField: "공급자",
targetField: "value",
transform: "first"
}
]
},
{
targetComponentId: "input-품목수",
targetComponentType: "input",
mode: "replace",
mappingRules: [
{
sourceField: "품목코드",
targetField: "value",
transform: "count"
}
]
}
],
buttonConfig: {
label: "선택 품목 추가",
position: "center",
icon: "ArrowRight",
validation: {
requireSelection: true,
minSelection: 1
}
}
},
layoutConfig: {
splitRatio: 50,
resizable: true,
minLeftWidth: 400,
minRightWidth: 600,
orientation: "horizontal"
}
};
시나리오 2: 수주 등록
요구사항
- 견적서 목록에서 품목을 선택하여 수주 등록
- 고객 정보를 자동으로 폼에 설정
- 품목별 수량 및 금액 자동 계산
- 총 금액 합계 표시
설정
const 수주등록_설정: ScreenSplitPanel = {
screenId: 101,
leftEmbedding: {
childScreenId: 30, // 견적서 목록 조회 화면
position: "left",
mode: "select",
config: {
width: "40%",
multiSelect: true
}
},
rightEmbedding: {
childScreenId: 40, // 수주 등록 폼 화면
position: "right",
mode: "form",
config: {
width: "60%"
}
},
dataTransfer: {
sourceScreenId: 30,
targetScreenId: 40,
dataReceivers: [
{
targetComponentId: "table-수주품목",
targetComponentType: "table",
mode: "append",
mappingRules: [
{ sourceField: "품목코드", targetField: "품목코드" },
{ sourceField: "품목명", targetField: "품목명" },
{ sourceField: "수량", targetField: "수량" },
{ sourceField: "단가", targetField: "단가" },
{
sourceField: "수량",
targetField: "금액",
transform: "custom",
transformConfig: {
formula: "수량 * 단가"
}
}
]
},
{
targetComponentId: "input-고객명",
targetComponentType: "input",
mode: "replace",
mappingRules: [
{ sourceField: "고객명", targetField: "value", transform: "first" }
]
},
{
targetComponentId: "input-총금액",
targetComponentType: "input",
mode: "replace",
mappingRules: [
{
sourceField: "금액",
targetField: "value",
transform: "sum"
}
]
}
],
buttonConfig: {
label: "견적서 불러오기",
position: "center",
icon: "Download"
}
},
layoutConfig: {
splitRatio: 40,
resizable: true,
orientation: "horizontal"
}
};
시나리오 3: 출고 등록
요구사항
- 재고 목록에서 품목을 선택하여 출고 등록
- 재고 수량 확인 및 경고
- 출고 가능 수량만 필터링
- 창고별 재고 정보 표시
설정
const 출고등록_설정: ScreenSplitPanel = {
screenId: 102,
leftEmbedding: {
childScreenId: 50, // 재고 목록 조회 화면
position: "left",
mode: "select",
config: {
width: "45%",
multiSelect: true
}
},
rightEmbedding: {
childScreenId: 60, // 출고 등록 폼 화면
position: "right",
mode: "form",
config: {
width: "55%"
}
},
dataTransfer: {
sourceScreenId: 50,
targetScreenId: 60,
dataReceivers: [
{
targetComponentId: "table-출고품목",
targetComponentType: "table",
mode: "append",
mappingRules: [
{ sourceField: "품목코드", targetField: "품목코드" },
{ sourceField: "품목명", targetField: "품목명" },
{ sourceField: "재고수량", targetField: "가용수량" },
{ sourceField: "창고", targetField: "출고창고" }
],
condition: {
field: "재고수량",
operator: "greaterThan",
value: 0
}
},
{
targetComponentId: "input-총출고수량",
targetComponentType: "input",
mode: "replace",
mappingRules: [
{
sourceField: "재고수량",
targetField: "value",
transform: "sum"
}
]
}
],
buttonConfig: {
label: "출고 품목 추가",
position: "center",
icon: "ArrowRight",
validation: {
requireSelection: true,
confirmMessage: "선택한 품목을 출고 처리하시겠습니까?"
}
}
},
layoutConfig: {
splitRatio: 45,
resizable: true,
orientation: "horizontal"
}
};
기술적 고려사항
1. 성능 최적화
대량 데이터 처리
- 가상 스크롤링 적용
- 청크 단위 데이터 전달
- 백그라운드 처리
메모리 관리
- 컴포넌트 언마운트 시 참조 해제
- 이벤트 리스너 정리
- 메모이제이션 활용
2. 보안
권한 검증
- 화면 접근 권한 확인
- 데이터 전달 권한 확인
- 멀티테넌시 격리
데이터 검증
- 입력값 검증
- SQL 인젝션 방지
- XSS 방지
3. 에러 처리
사용자 친화적 메시지
- 명확한 오류 메시지
- 복구 방법 안내
- 로그 기록
트랜잭션 롤백
- 부분 실패 시 전체 롤백
- 데이터 일관성 유지
4. 확장성
플러그인 시스템
- 커스텀 변환 함수 등록
- 커스텀 검증 함수 등록
- 커스텀 컴포넌트 타입 추가
이벤트 시스템
- 데이터 전달 전/후 이벤트
- 커스텀 이벤트 핸들러
마일스톤
M1: 기본 인프라 (2주)
- 데이터베이스 스키마 완성
- 백엔드 API 완성
- 타입 정의 완성
M2: 화면 임베딩 (3주)
- EmbeddedScreen 컴포넌트 완성
- DataReceivable 인터페이스 구현 완료
- 선택 모드 동작 확인
M3: 데이터 전달 (3주)
- 매핑 엔진 완성
- 변환 함수 구현 완료
- 조건부 전달 동작 확인
M4: 분할 패널 UI (3주)
- ScreenSplitPanel 컴포넌트 완성
- 설정 UI 완성
- 입고 등록 시나리오 완성
M5: 고급 기능 및 최적화 (3주)
- 양방향 동기화 완성
- 성능 최적화 완료
- 전체 테스트 통과
M6: 문서화 및 배포 (1주)
- 사용자 가이드 작성
- 개발자 문서 작성
- 프로덕션 배포
예상 일정
총 소요 기간: 약 15주 (3.5개월)
- Week 1-2: Phase 1 (기본 인프라)
- Week 3-5: Phase 2 (화면 임베딩)
- Week 6-8: Phase 3 (데이터 전달)
- Week 9-11: Phase 4 (분할 패널 UI)
- Week 12-14: Phase 5 (고급 기능)
- Week 15: Phase 6 (테스트 및 문서화)
성공 지표
기능적 지표
- 입고 등록 시나리오 완벽 동작
- 수주 등록 시나리오 완벽 동작
- 출고 등록 시나리오 완벽 동작
- 모든 컴포넌트 타입 데이터 수신 가능
- 모든 변환 함수 정상 동작
성능 지표
- 1000개 행 데이터 전달 < 1초
- 화면 로딩 시간 < 2초
- 메모리 사용량 < 100MB
사용성 지표
- 설정 UI 직관적
- 에러 메시지 명확
- 문서 완성도 90% 이상
리스크 관리
기술적 리스크
- 복잡도 증가: 단계별 구현으로 관리
- 성능 문제: 초기부터 최적화 고려
- 호환성 문제: 기존 시스템과 충돌 방지
일정 리스크
- 예상 기간 초과: 버퍼 2주 확보
- 우선순위 변경: 핵심 기능 먼저 구현
인력 리스크
- 담당자 부재: 문서화 철저히
- 지식 공유: 주간 리뷰 미팅
결론
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.