ERP-node/PHASE_FLOW_MANAGEMENT_SYSTE...

29 KiB

플로우 관리 시스템 구현 계획서

1. 개요

1.1 목적

제품의 수명주기나 업무 프로세스를 시각적인 플로우로 정의하고 관리하는 시스템을 구축합니다. 각 플로우 단계는 데이터베이스 테이블의 레코드 조건으로 정의되며, 데이터를 플로우 단계 간 이동시키고 이력을 관리할 수 있습니다.

1.2 주요 기능

  • 플로우 정의 및 시각적 편집
  • 플로우 단계별 조건 설정
  • 화면관리 시스템과 연동하여 플로우 위젯 배치
  • 플로우 단계별 데이터 카운트 및 리스트 조회
  • 데이터의 플로우 단계 이동
  • 상태 변경 이력 관리 (오딧 로그)

1.3 사용 예시

DTG 제품 수명주기 관리

  • 플로우 이름: "DTG 제품 라이프사이클"
  • 연결 테이블: product_dtg
  • 플로우 단계:
    1. 구매 (조건: status = '구매완료' AND install_date IS NULL)
    2. 설치 (조건: status = '설치완료' AND disposal_date IS NULL)
    3. 폐기 (조건: status = '폐기완료')

2. 데이터베이스 스키마

2.1 flow_definition (플로우 정의)

CREATE TABLE flow_definition (
  id SERIAL PRIMARY KEY,
  name VARCHAR(200) NOT NULL,              -- 플로우 이름
  description TEXT,                         -- 플로우 설명
  table_name VARCHAR(200) NOT NULL,        -- 연결된 테이블명
  is_active BOOLEAN DEFAULT true,          -- 활성화 여부
  created_by VARCHAR(100),
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_flow_definition_table ON flow_definition(table_name);

2.2 flow_step (플로우 단계)

CREATE TABLE flow_step (
  id SERIAL PRIMARY KEY,
  flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE,
  step_name VARCHAR(200) NOT NULL,         -- 단계 이름
  step_order INTEGER NOT NULL,             -- 단계 순서
  condition_json JSONB,                    -- 조건 설정 (JSON 형태)
  color VARCHAR(50) DEFAULT '#3B82F6',     -- 단계 표시 색상
  position_x INTEGER DEFAULT 0,            -- 캔버스 상의 X 좌표
  position_y INTEGER DEFAULT 0,            -- 캔버스 상의 Y 좌표
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_flow_step_definition ON flow_step(flow_definition_id);

condition_json 예시:

{
  "type": "AND",
  "conditions": [
    {
      "column": "status",
      "operator": "equals",
      "value": "구매완료"
    },
    {
      "column": "install_date",
      "operator": "is_null",
      "value": null
    }
  ]
}

2.3 flow_step_connection (플로우 단계 연결)

CREATE TABLE flow_step_connection (
  id SERIAL PRIMARY KEY,
  flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE,
  from_step_id INTEGER NOT NULL REFERENCES flow_step(id) ON DELETE CASCADE,
  to_step_id INTEGER NOT NULL REFERENCES flow_step(id) ON DELETE CASCADE,
  label VARCHAR(200),                      -- 연결선 라벨 (선택사항)
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_flow_connection_definition ON flow_step_connection(flow_definition_id);

2.4 flow_data_status (데이터의 현재 플로우 상태)

CREATE TABLE flow_data_status (
  id SERIAL PRIMARY KEY,
  flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE,
  table_name VARCHAR(200) NOT NULL,        -- 원본 테이블명
  record_id VARCHAR(100) NOT NULL,         -- 원본 테이블의 레코드 ID
  current_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL,
  updated_by VARCHAR(100),
  updated_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(flow_definition_id, table_name, record_id)
);

CREATE INDEX idx_flow_data_status_record ON flow_data_status(table_name, record_id);
CREATE INDEX idx_flow_data_status_step ON flow_data_status(current_step_id);

2.5 flow_audit_log (플로우 상태 변경 이력)

CREATE TABLE flow_audit_log (
  id SERIAL PRIMARY KEY,
  flow_definition_id INTEGER NOT NULL REFERENCES flow_definition(id) ON DELETE CASCADE,
  table_name VARCHAR(200) NOT NULL,
  record_id VARCHAR(100) NOT NULL,
  from_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL,
  to_step_id INTEGER REFERENCES flow_step(id) ON DELETE SET NULL,
  changed_by VARCHAR(100),
  changed_at TIMESTAMP DEFAULT NOW(),
  note TEXT                                 -- 변경 사유
);

CREATE INDEX idx_flow_audit_record ON flow_audit_log(table_name, record_id, changed_at DESC);
CREATE INDEX idx_flow_audit_definition ON flow_audit_log(flow_definition_id);

3. 백엔드 API 설계

3.1 플로우 정의 관리 API

3.1.1 플로우 생성

POST /api/flow/definitions
Body: {
  name: string;
  description?: string;
  tableName: string;
}
Response: { success: boolean; data: FlowDefinition }

3.1.2 플로우 목록 조회

GET /api/flow/definitions
Query: { tableName?: string; isActive?: boolean }
Response: { success: boolean; data: FlowDefinition[] }

3.1.3 플로우 상세 조회

GET /api/flow/definitions/:id
Response: {
  success: boolean;
  data: {
    definition: FlowDefinition;
    steps: FlowStep[];
    connections: FlowStepConnection[];
  }
}

3.1.4 플로우 수정

PUT /api/flow/definitions/:id
Body: { name?: string; description?: string; isActive?: boolean }
Response: { success: boolean; data: FlowDefinition }

3.1.5 플로우 삭제

DELETE /api/flow/definitions/:id
Response: { success: boolean }

3.2 플로우 단계 관리 API

3.2.1 단계 추가

POST /api/flow/definitions/:flowId/steps
Body: {
  stepName: string;
  stepOrder: number;
  conditionJson?: object;
  color?: string;
  positionX?: number;
  positionY?: number;
}
Response: { success: boolean; data: FlowStep }

3.2.2 단계 수정

PUT /api/flow/steps/:stepId
Body: {
  stepName?: string;
  stepOrder?: number;
  conditionJson?: object;
  color?: string;
  positionX?: number;
  positionY?: number;
}
Response: { success: boolean; data: FlowStep }

3.2.3 단계 삭제

DELETE /api/flow/steps/:stepId
Response: { success: boolean }

3.3 플로우 연결 관리 API

3.3.1 단계 연결 생성

POST /api/flow/connections
Body: {
  flowDefinitionId: number;
  fromStepId: number;
  toStepId: number;
  label?: string;
}
Response: { success: boolean; data: FlowStepConnection }

3.3.2 연결 삭제

DELETE /api/flow/connections/:connectionId
Response: { success: boolean }

3.4 플로우 실행 API

3.4.1 단계별 데이터 카운트 조회

GET /api/flow/:flowId/step/:stepId/count
Response: {
  success: boolean;
  data: { count: number }
}

3.4.2 단계별 데이터 리스트 조회

GET /api/flow/:flowId/step/:stepId/data
Query: { page?: number; pageSize?: number }
Response: {
  success: boolean;
  data: {
    records: any[];
    total: number;
    page: number;
    pageSize: number;
  }
}

3.4.3 데이터를 다음 단계로 이동

POST /api/flow/move
Body: {
  flowId: number;
  recordId: string;
  toStepId: number;
  note?: string;
}
Response: { success: boolean }

3.4.4 데이터의 플로우 이력 조회

GET /api/flow/audit/:flowId/:recordId
Response: {
  success: boolean;
  data: FlowAuditLog[]
}

4. 프론트엔드 구조

4.1 플로우 관리 화면 (/flow-management)

4.1.1 파일 구조

frontend/src/app/flow-management/
├── page.tsx                          # 메인 페이지
├── components/
│   ├── FlowList.tsx                  # 플로우 목록
│   ├── FlowEditor.tsx                # 플로우 편집기 (React Flow)
│   ├── FlowStepPanel.tsx             # 단계 속성 편집 패널
│   ├── FlowConditionBuilder.tsx     # 조건 설정 빌더
│   └── FlowPreview.tsx               # 플로우 미리보기

4.1.2 주요 컴포넌트

FlowEditor.tsx

  • React Flow 라이브러리 사용
  • 플로우 단계를 노드로 표시
  • 단계 간 연결선 표시
  • 드래그앤드롭으로 노드 위치 조정
  • 노드 클릭 시 FlowStepPanel 표시

FlowConditionBuilder.tsx

  • 테이블 컬럼 선택
  • 연산자 선택 (equals, not_equals, in, not_in, greater_than, less_than, is_null, is_not_null)
  • 값 입력
  • AND/OR 조건 그룹핑
  • 조건 추가/제거

4.2 화면관리 연동

4.2.1 새로운 컴포넌트 타입 추가

types/screen.ts에 추가:

export interface FlowWidgetComponent extends BaseComponent {
  type: "flow-widget";
  flowId?: number;
  layout?: "horizontal" | "vertical";
  cardWidth?: string;
  cardHeight?: string;
  showCount?: boolean;
  showConnections?: boolean;
}

export type ComponentData =
  | ContainerComponent
  | WidgetComponent
  | GroupComponent
  | DataTableComponent
  | ButtonComponent
  | SplitPanelComponent
  | RepeaterComponent
  | FlowWidgetComponent; // 추가

4.2.2 FlowWidgetConfigPanel.tsx 생성

// 플로우 위젯 설정 패널
interface FlowWidgetConfigPanelProps {
  component: FlowWidgetComponent;
  onUpdateProperty: (property: string, value: any) => void;
}

export function FlowWidgetConfigPanel({
  component,
  onUpdateProperty,
}: FlowWidgetConfigPanelProps) {
  const [flows, setFlows] = useState<FlowDefinition[]>([]);

  useEffect(() => {
    // 플로우 목록 불러오기
    loadFlows();
  }, []);

  return (
    <div className="space-y-4">
      <div>
        <Label>플로우 선택</Label>
        <Select
          value={component.flowId?.toString()}
          onValueChange={(v) => onUpdateProperty("flowId", parseInt(v))}
        >
          {flows.map((flow) => (
            <SelectItem key={flow.id} value={flow.id.toString()}>
              {flow.name}
            </SelectItem>
          ))}
        </Select>
      </div>

      <div>
        <Label>레이아웃</Label>
        <Select
          value={component.layout}
          onValueChange={(v) => onUpdateProperty("layout", v)}
        >
          <SelectItem value="horizontal">가로</SelectItem>
          <SelectItem value="vertical">세로</SelectItem>
        </Select>
      </div>

      <div>
        <Label>카드 너비</Label>
        <Input
          value={component.cardWidth || "200px"}
          onChange={(e) => onUpdateProperty("cardWidth", e.target.value)}
        />
      </div>

      <div>
        <Label>데이터 카운트 표시</Label>
        <Checkbox
          checked={component.showCount ?? true}
          onCheckedChange={(checked) => onUpdateProperty("showCount", checked)}
        />
      </div>

      <div>
        <Label>연결선 표시</Label>
        <Checkbox
          checked={component.showConnections ?? true}
          onCheckedChange={(checked) =>
            onUpdateProperty("showConnections", checked)
          }
        />
      </div>
    </div>
  );
}

4.2.3 RealtimePreview.tsx 수정

// flow-widget 타입 렌더링 추가
if (component.type === "flow-widget" && component.flowId) {
  return <FlowWidgetPreview component={component} />;
}

4.2.4 FlowWidgetPreview.tsx 생성

interface FlowWidgetPreviewProps {
  component: FlowWidgetComponent;
  interactive?: boolean; // InteractiveScreenViewer에서 true
}

export function FlowWidgetPreview({
  component,
  interactive,
}: FlowWidgetPreviewProps) {
  const [flowData, setFlowData] = useState<any>(null);
  const [stepCounts, setStepCounts] = useState<Record<number, number>>({});

  useEffect(() => {
    if (component.flowId) {
      loadFlowData(component.flowId);
    }
  }, [component.flowId]);

  const loadFlowData = async (flowId: number) => {
    const response = await fetch(`/api/flow/definitions/${flowId}`);
    const result = await response.json();
    if (result.success) {
      setFlowData(result.data);
      // 각 단계별 데이터 카운트 조회
      loadStepCounts(result.data.steps);
    }
  };

  const loadStepCounts = async (steps: FlowStep[]) => {
    const counts: Record<number, number> = {};
    for (const step of steps) {
      const response = await fetch(
        `/api/flow/${component.flowId}/step/${step.id}/count`
      );
      const result = await response.json();
      if (result.success) {
        counts[step.id] = result.data.count;
      }
    }
    setStepCounts(counts);
  };

  const handleStepClick = async (stepId: number) => {
    if (!interactive) return;
    // 단계 클릭 시 데이터 리스트 모달 표시
    // TODO: 구현
  };

  const layout = component.layout || "horizontal";
  const cardWidth = component.cardWidth || "200px";
  const cardHeight = component.cardHeight || "120px";

  return (
    <div
      className={`flex gap-4 ${
        layout === "vertical" ? "flex-col" : "flex-row"
      }`}
    >
      {flowData?.steps.map((step: FlowStep, index: number) => (
        <React.Fragment key={step.id}>
          <Card
            className="cursor-pointer hover:shadow-lg transition-shadow"
            style={{
              width: cardWidth,
              height: cardHeight,
              borderColor: step.color,
            }}
            onClick={() => handleStepClick(step.id)}
          >
            <CardHeader>
              <CardTitle className="text-sm">{step.stepName}</CardTitle>
            </CardHeader>
            <CardContent>
              {component.showCount && (
                <div className="text-2xl font-bold">
                  {stepCounts[step.id] || 0}
                </div>
              )}
            </CardContent>
          </Card>

          {component.showConnections && index < flowData.steps.length - 1 && (
            <div
              className={`flex items-center justify-center ${
                layout === "vertical" ? "h-8" : "w-8"
              }`}
            >
              {layout === "vertical" ? "↓" : "→"}
            </div>
          )}
        </React.Fragment>
      ))}
    </div>
  );
}

4.3 데이터 리스트 모달

4.3.1 FlowStepDataModal.tsx 생성

interface FlowStepDataModalProps {
  flowId: number;
  stepId: number;
  stepName: string;
  isOpen: boolean;
  onClose: () => void;
}

export function FlowStepDataModal({
  flowId,
  stepId,
  stepName,
  isOpen,
  onClose,
}: FlowStepDataModalProps) {
  const [data, setData] = useState<any[]>([]);
  const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());

  useEffect(() => {
    if (isOpen) {
      loadStepData();
    }
  }, [isOpen, stepId]);

  const loadStepData = async () => {
    const response = await fetch(`/api/flow/${flowId}/step/${stepId}/data`);
    const result = await response.json();
    if (result.success) {
      setData(result.data.records);
    }
  };

  const handleMoveToNextStep = async () => {
    // 선택된 레코드들을 다음 단계로 이동
    // TODO: 구현
  };

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="max-w-5xl max-h-[80vh]">
        <DialogHeader>
          <DialogTitle>{stepName} - 데이터 목록</DialogTitle>
        </DialogHeader>

        <div className="flex-1 overflow-auto">
          {/* 데이터 테이블 */}
          <DataTable data={data} onSelectionChange={setSelectedRows} />
        </div>

        <DialogFooter>
          <Button variant="outline" onClick={onClose}>
            닫기
          </Button>
          <Button
            onClick={handleMoveToNextStep}
            disabled={selectedRows.size === 0}
          >
            다음 단계로 이동
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

4.4 오딧 로그 화면

4.4.1 FlowAuditLog.tsx 생성

interface FlowAuditLogProps {
  flowId: number;
  recordId: string;
}

export function FlowAuditLog({ flowId, recordId }: FlowAuditLogProps) {
  const [logs, setLogs] = useState<FlowAuditLog[]>([]);

  useEffect(() => {
    loadAuditLogs();
  }, [flowId, recordId]);

  const loadAuditLogs = async () => {
    const response = await fetch(`/api/flow/audit/${flowId}/${recordId}`);
    const result = await response.json();
    if (result.success) {
      setLogs(result.data);
    }
  };

  return (
    <div className="space-y-4">
      <h3 className="text-lg font-semibold">상태 변경 이력</h3>

      <div className="space-y-2">
        {logs.map((log) => (
          <Card key={log.id}>
            <CardContent className="pt-4">
              <div className="flex items-center gap-2">
                <Badge>{log.fromStepName || "시작"}</Badge>
                <span></span>
                <Badge variant="outline">{log.toStepName}</Badge>
              </div>
              <div className="text-sm text-gray-500 mt-2">
                <div>변경자: {log.changedBy}</div>
                <div>변경일시: {new Date(log.changedAt).toLocaleString()}</div>
                {log.note && <div>변경사유: {log.note}</div>}
              </div>
            </CardContent>
          </Card>
        ))}
      </div>
    </div>
  );
}

5. 백엔드 구현

5.1 서비스 파일 구조

backend-node/src/services/
├── flowDefinitionService.ts      # 플로우 정의 관리
├── flowStepService.ts            # 플로우 단계 관리
├── flowConnectionService.ts      # 플로우 연결 관리
├── flowExecutionService.ts       # 플로우 실행 (카운트, 데이터 조회)
├── flowDataMoveService.ts        # 데이터 이동 및 오딧 로그
└── flowConditionParser.ts        # 조건 JSON을 SQL WHERE절로 변환

5.2 flowConditionParser.ts 핵심 로직

export interface FlowCondition {
  column: string;
  operator:
    | "equals"
    | "not_equals"
    | "in"
    | "not_in"
    | "greater_than"
    | "less_than"
    | "is_null"
    | "is_not_null";
  value: any;
}

export interface FlowConditionGroup {
  type: "AND" | "OR";
  conditions: FlowCondition[];
}

export class FlowConditionParser {
  /**
   * 조건 JSON을 SQL WHERE 절로 변환
   */
  static toSqlWhere(conditionGroup: FlowConditionGroup): {
    where: string;
    params: any[];
  } {
    const conditions: string[] = [];
    const params: any[] = [];
    let paramIndex = 1;

    for (const condition of conditionGroup.conditions) {
      const column = this.sanitizeColumnName(condition.column);

      switch (condition.operator) {
        case "equals":
          conditions.push(`${column} = $${paramIndex}`);
          params.push(condition.value);
          paramIndex++;
          break;

        case "not_equals":
          conditions.push(`${column} != $${paramIndex}`);
          params.push(condition.value);
          paramIndex++;
          break;

        case "in":
          if (Array.isArray(condition.value) && condition.value.length > 0) {
            const placeholders = condition.value
              .map(() => `$${paramIndex++}`)
              .join(", ");
            conditions.push(`${column} IN (${placeholders})`);
            params.push(...condition.value);
          }
          break;

        case "not_in":
          if (Array.isArray(condition.value) && condition.value.length > 0) {
            const placeholders = condition.value
              .map(() => `$${paramIndex++}`)
              .join(", ");
            conditions.push(`${column} NOT IN (${placeholders})`);
            params.push(...condition.value);
          }
          break;

        case "greater_than":
          conditions.push(`${column} > $${paramIndex}`);
          params.push(condition.value);
          paramIndex++;
          break;

        case "less_than":
          conditions.push(`${column} < $${paramIndex}`);
          params.push(condition.value);
          paramIndex++;
          break;

        case "is_null":
          conditions.push(`${column} IS NULL`);
          break;

        case "is_not_null":
          conditions.push(`${column} IS NOT NULL`);
          break;
      }
    }

    const joinOperator = conditionGroup.type === "OR" ? " OR " : " AND ";
    const where = conditions.length > 0 ? conditions.join(joinOperator) : "1=1";

    return { where, params };
  }

  /**
   * SQL 인젝션 방지를 위한 컬럼명 검증
   */
  private static sanitizeColumnName(columnName: string): string {
    // 알파벳, 숫자, 언더스코어만 허용
    if (!/^[a-zA-Z0-9_]+$/.test(columnName)) {
      throw new Error(`Invalid column name: ${columnName}`);
    }
    return columnName;
  }
}

5.3 flowExecutionService.ts 핵심 로직

export class FlowExecutionService {
  /**
   * 특정 플로우 단계에 해당하는 데이터 카운트
   */
  async getStepDataCount(flowId: number, stepId: number): Promise<number> {
    // 1. 플로우 정의 조회
    const flowDef = await this.getFlowDefinition(flowId);

    // 2. 플로우 단계 조회
    const step = await this.getFlowStep(stepId);

    // 3. 조건 JSON을 SQL WHERE절로 변환
    const { where, params } = FlowConditionParser.toSqlWhere(
      step.conditionJson
    );

    // 4. 카운트 쿼리 실행
    const query = `SELECT COUNT(*) as count FROM ${flowDef.tableName} WHERE ${where}`;
    const result = await db.query(query, params);

    return parseInt(result.rows[0].count);
  }

  /**
   * 특정 플로우 단계에 해당하는 데이터 리스트
   */
  async getStepDataList(
    flowId: number,
    stepId: number,
    page: number = 1,
    pageSize: number = 20
  ): Promise<{ records: any[]; total: number }> {
    const flowDef = await this.getFlowDefinition(flowId);
    const step = await this.getFlowStep(stepId);
    const { where, params } = FlowConditionParser.toSqlWhere(
      step.conditionJson
    );

    const offset = (page - 1) * pageSize;

    // 전체 카운트
    const countQuery = `SELECT COUNT(*) as count FROM ${flowDef.tableName} WHERE ${where}`;
    const countResult = await db.query(countQuery, params);
    const total = parseInt(countResult.rows[0].count);

    // 데이터 조회
    const dataQuery = `
      SELECT * FROM ${flowDef.tableName} 
      WHERE ${where} 
      ORDER BY id DESC 
      LIMIT $${params.length + 1} OFFSET $${params.length + 2}
    `;
    const dataResult = await db.query(dataQuery, [...params, pageSize, offset]);

    return {
      records: dataResult.rows,
      total,
    };
  }
}

5.4 flowDataMoveService.ts 핵심 로직

export class FlowDataMoveService {
  /**
   * 데이터를 다음 플로우 단계로 이동
   */
  async moveDataToStep(
    flowId: number,
    recordId: string,
    toStepId: number,
    userId: string,
    note?: string
  ): Promise<void> {
    const client = await db.getClient();

    try {
      await client.query("BEGIN");

      // 1. 현재 상태 조회
      const currentStatus = await this.getCurrentStatus(
        client,
        flowId,
        recordId
      );
      const fromStepId = currentStatus?.currentStepId || null;

      // 2. flow_data_status 업데이트 또는 삽입
      if (currentStatus) {
        await client.query(
          `
          UPDATE flow_data_status 
          SET current_step_id = $1, updated_by = $2, updated_at = NOW()
          WHERE flow_definition_id = $3 AND record_id = $4
        `,
          [toStepId, userId, flowId, recordId]
        );
      } else {
        const flowDef = await this.getFlowDefinition(flowId);
        await client.query(
          `
          INSERT INTO flow_data_status 
          (flow_definition_id, table_name, record_id, current_step_id, updated_by)
          VALUES ($1, $2, $3, $4, $5)
        `,
          [flowId, flowDef.tableName, recordId, toStepId, userId]
        );
      }

      // 3. 오딧 로그 기록
      await client.query(
        `
        INSERT INTO flow_audit_log 
        (flow_definition_id, table_name, record_id, from_step_id, to_step_id, changed_by, note)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
      `,
        [
          flowId,
          currentStatus?.tableName || recordId,
          recordId,
          fromStepId,
          toStepId,
          userId,
          note,
        ]
      );

      await client.query("COMMIT");
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  }

  /**
   * 데이터의 플로우 이력 조회
   */
  async getAuditLogs(flowId: number, recordId: string): Promise<any[]> {
    const query = `
      SELECT 
        fal.*,
        fs_from.step_name as from_step_name,
        fs_to.step_name as to_step_name
      FROM flow_audit_log fal
      LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
      LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id
      WHERE fal.flow_definition_id = $1 AND fal.record_id = $2
      ORDER BY fal.changed_at DESC
    `;

    const result = await db.query(query, [flowId, recordId]);
    return result.rows;
  }
}

6. 구현 단계

Phase 1: 기본 구조 (1주)

  • 데이터베이스 마이그레이션 생성 및 실행
  • 백엔드 서비스 기본 구조 생성
  • 플로우 정의 CRUD API 구현
  • 플로우 단계 CRUD API 구현
  • 플로우 연결 API 구현

Phase 2: 플로우 편집기 (1주)

  • React Flow 라이브러리 설치 및 설정
  • FlowEditor 컴포넌트 구현
  • FlowList 컴포넌트 구현
  • FlowStepPanel 구현 (단계 속성 편집)
  • FlowConditionBuilder 구현 (조건 설정 UI)
  • 플로우 저장/불러오기 기능

Phase 3: 화면관리 연동 (3일)

  • FlowWidgetComponent 타입 정의
  • FlowWidgetConfigPanel 구현
  • FlowWidgetPreview 구현
  • RealtimePreview에 flow-widget 렌더링 추가
  • InteractiveScreenViewer에 flow-widget 렌더링 추가

Phase 4: 플로우 실행 기능 (1주)

  • FlowConditionParser 구현 (조건 → SQL 변환)
  • FlowExecutionService 구현 (카운트, 데이터 조회)
  • 단계별 데이터 카운트 API
  • 단계별 데이터 리스트 API
  • FlowStepDataModal 구현
  • 데이터 선택 및 표시 기능

Phase 5: 데이터 이동 및 오딧 (3일)

  • FlowDataMoveService 구현
  • 데이터 이동 API
  • 오딧 로그 API
  • FlowAuditLog 컴포넌트 구현
  • 데이터 이동 시 트랜잭션 처리

Phase 6: 테스트 및 최적화 (3일)

  • 단위 테스트 작성
  • 통합 테스트 작성
  • 성능 최적화 (인덱스, 쿼리 최적화)
  • 사용자 테스트 및 피드백 반영

7. 기술 스택

7.1 프론트엔드

  • React Flow: 플로우 시각화 및 편집
  • Shadcn/ui: UI 컴포넌트
  • TanStack Query: 데이터 페칭 및 캐싱

7.2 백엔드

  • Node.js + Express: API 서버
  • PostgreSQL: 데이터베이스
  • TypeScript: 타입 안전성

8. 주요 고려사항

8.1 성능

  • 플로우 단계별 데이터 카운트 조회 시 인덱스 활용
  • 대용량 데이터 처리 시 페이징 필수
  • 조건 파싱 결과 캐싱

8.2 보안

  • SQL 인젝션 방지: 파라미터 바인딩 사용
  • 컬럼명 검증: 알파벳, 숫자, 언더스코어만 허용
  • 사용자 권한 확인

8.3 확장성

  • 복잡한 조건 (중첩 AND/OR) 지원 가능하도록 설계
  • 플로우 분기 (조건부 분기) 지원 가능하도록 설계
  • 자동 플로우 이동 (트리거) 추후 추가 가능

8.4 사용성

  • 플로우 편집기의 직관적인 UI
  • 드래그앤드롭으로 쉬운 조작
  • 실시간 데이터 카운트 표시
  • 오딧 로그를 통한 추적 가능성

9. 예상 일정

Phase 기간 담당
Phase 1: 기본 구조 1주 Backend
Phase 2: 플로우 편집기 1주 Frontend
Phase 3: 화면관리 연동 3일 Frontend
Phase 4: 플로우 실행 1주 Backend + Frontend
Phase 5: 데이터 이동 및 오딧 3일 Backend + Frontend
Phase 6: 테스트 및 최적화 3일 전체
총 예상 기간 약 4주

10. 향후 확장 계획

10.1 자동 플로우 이동

  • 특정 조건 충족 시 자동으로 다음 단계로 이동
  • 예: 결재 승인 시 자동으로 '승인완료' 단계로 이동

10.2 플로우 분기

  • 조건에 따라 다른 경로로 분기
  • 예: 금액에 따라 '일반 승인' 또는 '특별 승인' 경로

10.3 플로우 알림

  • 특정 단계 진입 시 담당자에게 알림
  • 이메일, 시스템 알림 등

10.4 플로우 템플릿

  • 자주 사용하는 플로우 패턴을 템플릿으로 저장
  • 템플릿에서 새 플로우 생성

10.5 플로우 통계 대시보드

  • 단계별 체류 시간 분석
  • 병목 구간 식별
  • 처리 속도 통계

11. 참고 자료

11.1 React Flow

11.2 유사 시스템

  • Jira Workflow
  • Trello Board
  • GitHub Projects

12. 결론

이 플로우 관리 시스템은 제품 수명주기, 업무 프로세스, 승인 프로세스 등 다양한 비즈니스 워크플로우를 시각적으로 정의하고 관리할 수 있는 강력한 도구입니다.

화면관리 시스템과의 긴밀한 통합을 통해 사용자는 코드 없이도 복잡한 워크플로우를 구축하고 실행할 수 있으며, 오딧 로그를 통해 모든 상태 변경을 추적할 수 있습니다.

단계별 구현을 통해 안정적으로 개발하고, 향후 확장 가능성을 염두에 두어 장기적으로 유지보수하기 쉬운 시스템을 구축할 수 있습니다.