Skip to content

L5 워크플로우 Y/N 다이아몬드 조건 분기 구현 예시 ​

작성자: Manus AI
적용 대상: React 19, TypeScript, tRPC 11, Drizzle ORM, MySQL 기반의 Process Center 문서 성격: 현재 구현을 즉시 덮어쓰는 패치가 아닌, 조건 분기 기능을 확장할 때 적용할 수 있는 참조 코드입니다.

현재 포탈은 task_flow_edges에 L5/L6 간 연결과 조건 라벨을 저장하고, TaskFlowMap.tsx에서 이를 연결선으로 렌더링합니다. 다이아몬드 조건 분기를 도입할 때에는 조건 자체를 별도 노드로 저장하고, Y/N은 그 노드에서 나가는 연결의 결과값으로 저장하는 구조가 가장 명확합니다. [1] [2] [3]

구분현재 구조권장 확장 구조
워크플로우 단위L5별 task_flow_edgesL5별 workflow_steps + workflow_transitions
처리 단계L6 또는 다른 L5L6·다른 L5를 참조하는 process 단계
조건 분기연결선의 자유 텍스트 라벨질문을 가진 gateway 단계
분기 결과연결선 라벨에 임의 입력YES, NO, DEFAULT 열거값
화면 표시사각형 노드와 연결선처리 노드 사각형, 조건 노드 다이아몬드

1. 목표 모델 ​

다음과 같은 구조를 한 개의 L5 워크플로우로 저장합니다. BO 등록은 처리 단계이고, 승인되었습니까?는 조건 게이트웨이입니다. 게이트웨이에서 나가는 두 개의 연결에는 각각 YES와 NO 결과를 부여합니다.

text
┌────────────┐      ┌────────────────┐      Y      ┌────────────┐
│ BO 등록 L6 │ ───▶ │ 승인되었습니까? │ ─────────▶ │ BO 확정 L6 │
└────────────┘      └────────────────┘             └────────────┘
                              ◇
                              │ N
                              ▼
                       ┌──────────────┐
                       │ 보완 요청 L6 │
                       └──────────────┘

다이아몬드는 프로세스 노드가 아니므로 L1~L7 계층에 새 레벨을 추가하지 않습니다. 대신 특정 L5에 속하는 워크플로우 전용 단계로 저장합니다. 이 분리는 기존 프로세스 분류 체계와 실행 순서 제어를 혼동하지 않게 합니다.

2. 데이터베이스 모델 예시 ​

현재 task_flow_edges는 원본 L6/L5 노드 ID를 직접 가리키므로, 게이트웨이를 연결의 출발점·도착점으로 만들 수 없습니다. 따라서 기존 테이블을 무리하게 확장하기보다 workflow_steps와 workflow_transitions를 새로 둡니다. 기존 연결 데이터는 이후 마이그레이션 단계에서 process 유형 단계와 DEFAULT 연결로 옮길 수 있습니다.

2.1 Drizzle 스키마 ​

ts
// drizzle/schema.ts
import { index, int, mysqlEnum, mysqlTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core";

export const workflowSteps = mysqlTable(
  "workflow_steps",
  {
    id: int("id").autoincrement().primaryKey(),
    l5Id: int("l5Id").notNull(),

    // process: L6 또는 다른 L5를 참조하는 단계
    // gateway: 질문을 가진 다이아몬드 조건 단계
    kind: mysqlEnum("kind", ["process", "gateway"]).notNull(),
    processNodeId: int("processNodeId"),
    question: varchar("question", { length: 240 }),
    questionEn: varchar("questionEn", { length: 240 }),
    displayOrder: int("displayOrder").default(0).notNull(),
    createdBy: varchar("createdBy", { length: 96 }).notNull(),
    createdAt: timestamp("createdAt").defaultNow().notNull(),
    updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
  },
  table => ({
    l5Index: index("workflow_steps_l5_idx").on(table.l5Id),
    // 동일 L5에서 같은 원본 프로세스 노드를 중복 단계로 만들지 않으려는 경우에 사용합니다.
    l5ProcessUnique: uniqueIndex("workflow_steps_l5_process_unique").on(table.l5Id, table.processNodeId),
  }),
);

export const workflowTransitions = mysqlTable(
  "workflow_transitions",
  {
    id: int("id").autoincrement().primaryKey(),
    l5Id: int("l5Id").notNull(),
    sourceStepId: int("sourceStepId").notNull(),
    targetStepId: int("targetStepId").notNull(),
    // gateway에서 나가는 연결은 YES 또는 NO, 일반 처리 단계의 연결은 DEFAULT입니다.
    outcome: mysqlEnum("outcome", ["DEFAULT", "YES", "NO"]).default("DEFAULT").notNull(),
    label: varchar("label", { length: 240 }),
    conditionExpression: text("conditionExpression"),
    displayOrder: int("displayOrder").default(0).notNull(),
    createdBy: varchar("createdBy", { length: 96 }).notNull(),
    createdAt: timestamp("createdAt").defaultNow().notNull(),
  },
  table => ({
    l5Index: index("workflow_transitions_l5_idx").on(table.l5Id),
    sourceTargetUnique: uniqueIndex("workflow_transitions_source_target_unique").on(table.sourceStepId, table.targetStepId),
  }),
);

process 단계에는 processNodeId가 반드시 있어야 하고, gateway 단계에는 question이 반드시 있어야 합니다. 이 조건은 DB 제약 조건만으로 처리하기보다 서버 입력 검증에서 명시적으로 검사하는 편이 오류 메시지와 권한 처리를 일관되게 만들기 쉽습니다.

2.2 마이그레이션 SQL 예시 ​

Drizzle 마이그레이션 파일 생성 후 검토한 SQL을 적용합니다. 아래는 개념 확인용 예시입니다.

sql
CREATE TABLE workflow_steps (
  id INT AUTO_INCREMENT PRIMARY KEY,
  l5Id INT NOT NULL,
  kind ENUM('process', 'gateway') NOT NULL,
  processNodeId INT NULL,
  question VARCHAR(240) NULL,
  questionEn VARCHAR(240) NULL,
  displayOrder INT NOT NULL DEFAULT 0,
  createdBy VARCHAR(96) NOT NULL,
  createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX workflow_steps_l5_idx (l5Id),
  UNIQUE KEY workflow_steps_l5_process_unique (l5Id, processNodeId)
);

CREATE TABLE workflow_transitions (
  id INT AUTO_INCREMENT PRIMARY KEY,
  l5Id INT NOT NULL,
  sourceStepId INT NOT NULL,
  targetStepId INT NOT NULL,
  outcome ENUM('DEFAULT', 'YES', 'NO') NOT NULL DEFAULT 'DEFAULT',
  label VARCHAR(240) NULL,
  conditionExpression TEXT NULL,
  displayOrder INT NOT NULL DEFAULT 0,
  createdBy VARCHAR(96) NOT NULL,
  createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX workflow_transitions_l5_idx (l5Id),
  UNIQUE KEY workflow_transitions_source_target_unique (sourceStepId, targetStepId)
);

3. 서버: 검증과 tRPC API 예시 ​

3.1 입력 스키마와 핵심 검증 ​

gateway에 대해서는 질문 입력, YES와 NO 결과, 그리고 중복 결과 연결을 검사합니다. 아래 예시는 현재 flowRouter의 관리자 권한 절차와 전역 순환 방지 검사 방식을 재사용하는 형태입니다. [3]

ts
// server/routers/workflow.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as workflowRepository from "../repositories/workflowRepository";
import { canManageProcesses } from "../processAccess";
import { protectedProcedure, publicProcedure, router } from "../_core/trpc";

const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
  if (!canManageProcesses(ctx.user.role)) {
    throw new TRPCError({ code: "FORBIDDEN", message: "관리자만 워크플로우를 수정할 수 있습니다." });
  }
  return next({ ctx });
});

const outcomeSchema = z.enum(["DEFAULT", "YES", "NO"]);

function actorOf(user: { name?: string | null; email?: string | null; openId: string }) {
  return user.name?.trim() || user.email || user.openId;
}

async function assertTransition(input: {
  l5Id: number;
  sourceStepId: number;
  targetStepId: number;
  outcome: "DEFAULT" | "YES" | "NO";
}) {
  if (input.sourceStepId === input.targetStepId) {
    throw new TRPCError({ code: "BAD_REQUEST", message: "같은 단계를 연결할 수 없습니다." });
  }

  const [source, target] = await Promise.all([
    workflowRepository.getWorkflowStepById(input.sourceStepId),
    workflowRepository.getWorkflowStepById(input.targetStepId),
  ]);

  if (!source || !target || source.l5Id !== input.l5Id || target.l5Id !== input.l5Id) {
    throw new TRPCError({ code: "BAD_REQUEST", message: "현재 L5 워크플로우에 속한 단계만 연결할 수 있습니다." });
  }

  if (source.kind === "process" && input.outcome !== "DEFAULT") {
    throw new TRPCError({ code: "BAD_REQUEST", message: "일반 처리 단계에서는 기본 연결만 사용할 수 있습니다." });
  }

  if (source.kind === "gateway" && input.outcome === "DEFAULT") {
    throw new TRPCError({ code: "BAD_REQUEST", message: "조건 분기에서는 Y 또는 N 결과를 선택해야 합니다." });
  }

  // 한 게이트웨이에서 동일한 결과(Y 또는 N)를 두 번 만들지 않습니다.
  if (source.kind === "gateway" && await db.hasTransitionOutcome(source.id, input.outcome)) {
    throw new TRPCError({ code: "CONFLICT", message: `이미 ${input.outcome} 분기가 등록되어 있습니다.` });
  }

  if (await workflowRepository.createsWorkflowCycle(input.l5Id, input.sourceStepId, input.targetStepId)) {
    throw new TRPCError({ code: "CONFLICT", message: "순환 흐름을 만드는 연결은 등록할 수 없습니다." });
  }
}

3.2 라우터 예시 ​

ts
export const workflowRouter = router({
  get: publicProcedure
    .input(z.object({ l5Id: z.number().int().positive() }))
    .query(async ({ input }) => db.getWorkflowDefinition(input.l5Id)),

  createGateway: adminProcedure
    .input(z.object({
      l5Id: z.number().int().positive(),
      question: z.string().trim().min(1).max(240),
      questionEn: z.string().trim().max(240).nullable().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const l5 = await db.getProcessNodeById(input.l5Id);
      if (l5?.level !== "L5") {
        throw new TRPCError({ code: "BAD_REQUEST", message: "L5에만 워크플로우 조건 분기를 추가할 수 있습니다." });
      }
      return workflowRepository.createWorkflowStep({
        l5Id: input.l5Id,
        kind: "gateway",
        processNodeId: null,
        question: input.question,
        questionEn: input.questionEn ?? null,
        displayOrder: await db.getNextWorkflowStepOrder(input.l5Id),
        createdBy: actorOf(ctx.user),
      });
    }),

  addProcessStep: adminProcedure
    .input(z.object({
      l5Id: z.number().int().positive(),
      processNodeId: z.number().int().positive(),
    }))
    .mutation(async ({ ctx, input }) => {
      const [l5, processNode, existing] = await Promise.all([
        db.getProcessNodeById(input.l5Id),
        db.getProcessNodeById(input.processNodeId),
        workflowRepository.getWorkflowStepByProcessNode(input.l5Id, input.processNodeId),
      ]);

      const selectable = processNode && (
        (processNode.level === "L6" && processNode.parentId === input.l5Id) ||
        (processNode.level === "L5" && processNode.id !== input.l5Id)
      );
      if (l5?.level !== "L5" || !selectable) {
        throw new TRPCError({ code: "BAD_REQUEST", message: "현재 L5의 하위 L6 또는 다른 L5만 워크플로우 단계로 추가할 수 있습니다." });
      }

      // 재선택해도 같은 처리 단계를 중복 생성하지 않습니다.
      if (existing) return existing;

      return workflowRepository.createWorkflowStep({
        l5Id: input.l5Id,
        kind: "process",
        processNodeId: input.processNodeId,
        question: null,
        questionEn: null,
        displayOrder: await db.getNextWorkflowStepOrder(input.l5Id),
        createdBy: actorOf(ctx.user),
      });
    }),

  connect: adminProcedure
    .input(z.object({
      l5Id: z.number().int().positive(),
      sourceStepId: z.number().int().positive(),
      targetStepId: z.number().int().positive(),
      outcome: outcomeSchema,
      label: z.string().trim().max(240).nullable().optional(),
      conditionExpression: z.string().trim().max(2000).nullable().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      await assertTransition(input);
      return workflowRepository.createWorkflowTransition({
        ...input,
        label: input.label ?? null,
        conditionExpression: input.conditionExpression ?? null,
        displayOrder: 0,
        createdBy: actorOf(ctx.user),
      });
    }),
});

3.3 DB 도우미 예시 ​

ts
// server/repositories/workflowRepository.ts
import { and, asc, eq, or } from "drizzle-orm";
import { workflowSteps, workflowTransitions } from "../drizzle/schema";

export async function getWorkflowDefinition(l5Id: number) {
  const db = await requireDb();
  const [steps, transitions] = await Promise.all([
    db.select().from(workflowSteps)
      .where(eq(workflowSteps.l5Id, l5Id))
      .orderBy(asc(workflowSteps.displayOrder), asc(workflowSteps.id)),
    db.select().from(workflowTransitions)
      .where(eq(workflowTransitions.l5Id, l5Id))
      .orderBy(asc(workflowTransitions.displayOrder), asc(workflowTransitions.id)),
  ]);
  return { steps, transitions };
}

export async function hasTransitionOutcome(sourceStepId: number, outcome: "YES" | "NO") {
  const db = await requireDb();
  const row = await db.select({ id: workflowTransitions.id })
    .from(workflowTransitions)
    .where(and(eq(workflowTransitions.sourceStepId, sourceStepId), eq(workflowTransitions.outcome, outcome)))
    .limit(1);
  return Boolean(row[0]);
}

export async function createsWorkflowCycle(l5Id: number, sourceStepId: number, targetStepId: number) {
  const db = await requireDb();
  const transitions = await db.select().from(workflowTransitions)
    .where(eq(workflowTransitions.l5Id, l5Id));
  const outgoing = new Map<number, number[]>();
  transitions.forEach(edge => outgoing.set(edge.sourceStepId, [...(outgoing.get(edge.sourceStepId) ?? []), edge.targetStepId]));

  const stack = [targetStepId];
  const visited = new Set<number>();
  while (stack.length) {
    const current = stack.pop()!;
    if (current === sourceStepId) return true;
    if (visited.has(current)) continue;
    visited.add(current);
    stack.push(...(outgoing.get(current) ?? []));
  }
  return false;
}

4. 프론트엔드: 다이아몬드 노드 렌더링 예시 ​

4.1 타입과 공통 레이블 ​

기존 TaskFlowMap.tsx의 FlowTask/FlowEdge를 아래의 단계·전이 타입으로 바꿉니다. process 단계의 label은 참조한 L6 또는 다른 L5의 이름이고, gateway 단계의 label은 조건 질문입니다. [2]

tsx
type WorkflowStep = {
  id: number;
  l5Id: number;
  kind: "process" | "gateway";
  processNodeId: number | null;
  question: string | null;
  questionEn: string | null;
  processName?: string | null;
  processNameEn?: string | null;
};

type WorkflowTransition = {
  id: number;
  sourceStepId: number;
  targetStepId: number;
  outcome: "DEFAULT" | "YES" | "NO";
  label: string | null;
  conditionExpression: string | null;
};

function stepLabel(step: WorkflowStep, locale: "ko" | "en") {
  if (step.kind === "gateway") {
    return locale === "en" && step.questionEn ? step.questionEn : step.question ?? "";
  }
  return locale === "en" && step.processNameEn ? step.processNameEn : step.processName ?? "";
}

function transitionLabel(edge: WorkflowTransition) {
  if (edge.outcome === "YES") return "Y";
  if (edge.outcome === "NO") return "N";
  return edge.label ?? "";
}

4.2 접근 가능한 다이아몬드 컴포넌트 ​

다이아몬드는 45도 회전한 컨테이너 안에서 다시 -45도 회전한 텍스트를 보여줍니다. 버튼으로 구현하면 향후 선택·수정 다이얼로그를 키보드로 열 수 있습니다.

tsx
// client/src/components/WorkflowGatewayNode.tsx
import type { CSSProperties } from "react";

type WorkflowGatewayNodeProps = {
  question: string;
  selected?: boolean;
  onSelect?: () => void;
  style?: CSSProperties;
};

export function WorkflowGatewayNode({ question, selected = false, onSelect, style }: WorkflowGatewayNodeProps) {
  return (
    <button
      type="button"
      onClick={onSelect}
      aria-label={`조건 분기: ${question}`}
      className={`absolute grid h-[112px] w-[112px] place-items-center border shadow-[2px_2px_0_#b8c8d9] transition-transform duration-150 active:scale-[0.97] ${
        selected ? "border-[#1f3a5f] bg-[#dce8f3]" : "border-[#1f3a5f] bg-[#edf3f8]"
      }`}
      style={{ ...style, transform: "rotate(45deg)" }}
    >
      <span className="w-[78px] -rotate-45 text-center text-[11px] font-bold leading-tight text-[#1f3a5f]">
        {question}
      </span>
    </button>
  );
}

4.3 세로형 맵에서 단계와 Y/N 연결선 표시 ​

기존처럼 상→하 방향을 유지하되, 사각형 처리 단계의 하단에서 다이아몬드 상단으로, 다이아몬드 하단에서 후속 단계 상단으로 연결합니다. 다이아몬드의 좌우 분기를 사용할 경우에는 startX와 endX를 보정해 겹침을 줄일 수 있습니다.

tsx
// TaskFlowMap.tsx 내부 발췌
{transitions.map(edge => {
  const source = graph.positions.get(edge.sourceStepId);
  const target = graph.positions.get(edge.targetStepId);
  if (!source || !target) return null;

  const startX = source.x + source.width / 2;
  const startY = source.y + source.height;
  const endX = target.x + target.width / 2;
  const endY = target.y;
  const midY = (startY + endY) / 2;
  const label = transitionLabel(edge);

  return (
    <g key={edge.id}>
      <path
        d={`M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`}
        fill="none"
        stroke="#1f3a5f"
        strokeWidth="1.5"
        markerEnd="url(#flow-arrow)"
      />
      {label && (
        <g>
          <rect x={(startX + endX) / 2 - 16} y={midY - 11} width="32" height="20" fill="#fffdf7" stroke="#b8c8d9" />
          <text x={(startX + endX) / 2} y={midY + 3} textAnchor="middle" fontSize="10" fontWeight="700" fill="#1f3a5f">
            {label}
          </text>
        </g>
      )}
    </g>
  );
})}

{steps.map(step => {
  const position = graph.positions.get(step.id)!;
  return step.kind === "gateway" ? (
    <WorkflowGatewayNode
      key={step.id}
      question={stepLabel(step, locale)}
      onSelect={() => setSelectedStepId(step.id)}
      style={{ left: position.x, top: position.y }}
    />
  ) : (
    <div key={step.id} className="absolute w-[166px] border border-[#8e7b67] bg-[#fffdf7] px-3 py-2 shadow-[2px_2px_0_#b8c8d9]" style={{ left: position.x, top: position.y }}>
      <p className="mono-face text-[8px] tracking-[0.08em] text-[#1f3a5f]">L6</p>
      <p className="mt-1 truncate text-xs font-bold">{stepLabel(step, locale)}</p>
    </div>
  );
})}

5. 프론트엔드: 관리자 설정 패널 예시 ​

관리자는 먼저 질문을 가진 게이트웨이를 만들고, 다음으로 선행 단계·후행 단계·결과값을 연결합니다. 일반 L6/L5 단계에서 출발한 연결은 DEFAULT만 선택할 수 있게 하며, 게이트웨이가 출발점일 때만 Y/N 토글을 노출합니다.

tsx
const [question, setQuestion] = useState("");
const [sourceStepId, setSourceStepId] = useState<number | null>(null);
const [targetStepId, setTargetStepId] = useState<number | null>(null);
const [outcome, setOutcome] = useState<"YES" | "NO">("YES");

const selectedSource = steps.find(step => step.id === sourceStepId);
const sourceIsGateway = selectedSource?.kind === "gateway";

const createGateway = trpc.workflow.createGateway.useMutation({
  onSuccess: () => {
    setQuestion("");
    utils.workflow.get.invalidate({ l5Id });
  },
});

const connect = trpc.workflow.connect.useMutation({
  onSuccess: () => utils.workflow.get.invalidate({ l5Id }),
});

return (
  <section className="border-t border-[#c7baa8] p-5">
    <h3 className="text-sm font-bold">조건 분기 추가</h3>
    <div className="mt-3 flex gap-2">
      <Input
        value={question}
        onChange={event => setQuestion(event.target.value)}
        placeholder="예: 승인되었습니까?"
        aria-label="조건 질문"
      />
      <button
        type="button"
        className="editorial-button bg-[#1f3a5f] px-3 py-2 text-xs text-white"
        disabled={!question.trim() || createGateway.isPending}
        onClick={() => createGateway.mutate({ l5Id, question: question.trim() })}
      >
        다이아몬드 추가
      </button>
    </div>

    <h3 className="mt-6 text-sm font-bold">단계 연결</h3>
    <div className="mt-3 grid grid-cols-[1fr_1fr_auto] gap-3">
      <WorkflowStepSelect value={sourceStepId} onChange={setSourceStepId} steps={steps} label="선행 단계" />
      <WorkflowStepSelect value={targetStepId} onChange={setTargetStepId} steps={steps} label="후행 단계" />
      {sourceIsGateway ? (
        <select value={outcome} onChange={event => setOutcome(event.target.value as "YES" | "NO")} className="h-10 border border-[#bcae9b] bg-white px-3 text-sm">
          <option value="YES">Y</option>
          <option value="NO">N</option>
        </select>
      ) : (
        <span className="grid h-10 place-items-center border border-[#d8cdbd] px-3 text-xs text-[#706152]">기본 연결</span>
      )}
    </div>
    <button
      type="button"
      className="editorial-button mt-3 bg-[#1f3a5f] px-3 py-2 text-xs text-white"
      disabled={!sourceStepId || !targetStepId || connect.isPending}
      onClick={() => connect.mutate({
        l5Id,
        sourceStepId: sourceStepId!,
        targetStepId: targetStepId!,
        outcome: sourceIsGateway ? outcome : "DEFAULT",
      })}
    >
      연결 저장
    </button>
  </section>
);

6. API 응답 예시 ​

프론트엔드는 다음처럼 단계와 연결을 한 번에 받습니다. 별도의 노드 배열을 쓰므로 다이아몬드도 기존 L6와 동일한 배치·선택·연결 루프에 포함됩니다.

json
{
  "steps": [
    { "id": 10, "kind": "process", "processNodeId": 270002, "processName": "BO등록" },
    { "id": 20, "kind": "gateway", "processNodeId": null, "question": "승인되었습니까?" },
    { "id": 30, "kind": "process", "processNodeId": 270003, "processName": "BO확정" },
    { "id": 40, "kind": "process", "processNodeId": 270004, "processName": "보완 요청" }
  ],
  "transitions": [
    { "id": 1, "sourceStepId": 10, "targetStepId": 20, "outcome": "DEFAULT" },
    { "id": 2, "sourceStepId": 20, "targetStepId": 30, "outcome": "YES" },
    { "id": 3, "sourceStepId": 20, "targetStepId": 40, "outcome": "NO" }
  ]
}

7. 검증 기준 ​

구분확인 항목
권한일반 사용자의 게이트웨이 생성·연결·삭제 요청은 FORBIDDEN으로 차단합니다.
입력게이트웨이 질문은 필수이며, 240자를 넘기지 않습니다.
결과값게이트웨이의 발신 연결은 Y 또는 N만 허용합니다. 처리 단계의 발신 연결은 기본 연결만 허용합니다.
중복같은 게이트웨이에 Y 또는 N 연결을 각각 한 개만 허용합니다.
무결성현재 L5에 속하지 않는 단계를 연결하지 못하게 하고, 순환 연결을 차단합니다.
화면다이아몬드에는 질문이 읽히고, 연결선에는 Y/N이 표시되며, 키보드로 노드를 선택할 수 있어야 합니다.
회귀기존 task_flow_edges 기반 워크플로우는 마이그레이션 전까지 읽기 전용 호환 렌더러로 유지하거나, 단계·전이 데이터로 일괄 변환합니다.

8. 적용 순서 ​

먼저 새 테이블과 서버 검증을 추가한 뒤, 기존 L5의 연결을 process 단계와 DEFAULT 전이로 변환합니다. 이후 관리자 패널에 다이아몬드 추가와 Y/N 연결 UI를 제공하고, 마지막으로 기존 TaskFlowMap을 새 API 응답을 렌더링하도록 교체합니다. 한 번에 기존 테이블의 의미를 바꾸는 방식은 데이터 이력과 롤백을 어렵게 만들 수 있으므로 피하는 편이 안전합니다.

References ​

[1] 현재 워크플로우 연결 테이블: drizzle/schema.ts
[2] 현재 워크플로우 렌더링 컴포넌트: client/src/components/TaskFlowMap.tsx
[3] 현재 조건 연결 검증·API: server/routers/flow.ts