Files
vexplor/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md
2025-12-23 09:31:18 +09:00

43 KiB

화면 임베딩 및 데이터 전달 시스템 구현 계획서

📋 목차

  1. 개요
  2. 현재 문제점
  3. 목표
  4. 시스템 아키텍처
  5. 데이터베이스 설계
  6. 타입 정의
  7. 컴포넌트 구조
  8. API 설계
  9. 구현 단계
  10. 사용 시나리오

개요

배경

현재 화면관리 시스템은 단일 화면 단위로만 동작하며, 화면 간 데이터 전달이나 화면 임베딩이 불가능합니다. 실무에서는 "입고 등록"과 같이 좌측에서 데이터를 선택하고 우측으로 전달하여 처리하는 복잡한 워크플로우가 필요합니다.

핵심 요구사항

  • 화면 임베딩: 기존 화면을 다른 화면 안에 재사용
  • 데이터 전달: 한 화면에서 선택한 데이터를 다른 화면의 컴포넌트로 전달
  • 유연한 매핑: 테이블뿐만 아니라 입력 필드, 셀렉트 박스, 리피터 등 모든 컴포넌트에 데이터 주입 가능
  • 변환 함수: 합계, 평균, 개수 등 데이터 변환 지원

현재 문제점

1. 화면 재사용 불가

  • 각 화면은 독립적으로만 동작
  • 동일한 기능을 여러 화면에서 중복 구현

2. 화면 간 데이터 전달 불가

  • 한 화면에서 선택한 데이터를 다른 화면으로 전달할 수 없음
  • 사용자가 수동으로 복사/붙여넣기 해야 함

3. 복잡한 워크플로우 구현 불가

  • "발주 목록 조회 → 품목 선택 → 입고 등록"과 같은 프로세스를 단일 화면에서 처리 불가
  • 여러 화면을 오가며 작업해야 하는 불편함

4. 컴포넌트별 데이터 주입 불가

  • 테이블에만 데이터를 추가할 수 있음
  • 입력 필드, 셀렉트 박스 등에 자동으로 값을 설정할 수 없음

목표

주요 목표

  1. 화면 임베딩 시스템 구축: 기존 화면을 컨테이너로 사용
  2. 범용 데이터 전달 시스템: 모든 컴포넌트 타입 지원
  3. 시각적 매핑 설정 UI: 드래그앤드롭으로 매핑 규칙 설정
  4. 실시간 미리보기: 데이터 전달 결과를 즉시 확인

부가 목표

  • 조건부 데이터 전달 (필터링)
  • 데이터 변환 함수 (합계, 평균, 개수 등)
  • 양방향 데이터 동기화
  • 트랜잭션 지원 (전체 성공 또는 전체 실패)

시스템 아키텍처

전체 구조

┌─────────────────────────────────────────────────────────┐
│                  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.ts
  • types/data-transfer.ts
  • types/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개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.