업무자동화라는 것을 아는가!
지금이야 n8n 같은 업무자동화 툴이 워낙 유명하지만, 내가 이 기능을 처음 구현할 때만 해도 워크플로우라는 개념은 꽤 생소했다. 워크플로우는 여러 작업을 하나의 업무 흐름으로 연결하는 방식이다. 쉽게 말하면 사람이 순서대로 처리하던 일을 화면에서 블록처럼 연결해 자동화하는 것이다.

위는 샘플로 만든 깃허브 댓글을 확인해서 인기 리포트를 만들어주는 플로우다.
여기서 네모난 각각의 작업 블록을 Node, Node와 Node 사이의 선 연결을 Edge라고 한다. 이런 Node를 화면에 배치하고 Edge로 연결할 수 있는 편집 화면이 필요했고, 이를 구현하기 위해 React Flow를 사용하기로 했다.
처음에는 단순히 React Flow에 데이터를 넣어 Node와 Edge만 그리면 될 것이라고 생각했지만 실제로 구현하기 시작하니 백엔드의 워크플로우 데이터와 React Flow의 UI가 필요로 하는 데이터 구조가 달랐고, 개별 Node마다 수많은 다른 UI와 설정 Form도 만들어야 했고... 거기다 Node 수는 계속해서 증식되었다.
설계에 대해 고난과 역경을 겪으며,, 결론적으로는 다음과 같은 흐름의 구조를 갖게 되었다.
Node 전체 정의 조회 ↓ 워크플로우 상세 조회 ↓ Adapter로 React Flow 포맷에 맞춰 변환 ↓ Canvas별 Zustand Store에서 로컬 편집 상태 관리 ↓ Dispatcher로 Node 정의와 현재 설정값에 맞는 UI와 Form 구성 ↓ 사용자가 워크플로우 편집 ↓ 다시 Adapter를 이용해 백엔드 포맷으로 변환해 저장
이 글에서는 위의 흐름대로 워크플로우 데이터를 받아 화면에 그리고, 사용자가 편집한 데이터를 다시 저장하기까지 어떻게 해서 구조화해 나갔는지를 기록해보려고 한다.
React Flow란?

React Flow는 Node 기반 편집기를 만들기 위한 React 라이브러리다. Node를 드래그하거나 Edge로 연결하는 기능뿐 아니라 줌, 캔버스 이동, 선택, 좌표 계산 등 워크플로우 에디터를 만들 때 필요한 기본 기능을 제공한다. (딱 내가 필요로 하던것!)
React Flow는 화면을 그리기 위해 아까 플로우 연결에서 설명했던 대로 기본적으로 nodes와 edges를 받는다.
const nodes = [ { id: "node-1", position: { x: 0, y: 0 }, // 배치 위치 data: {}, }, ]; const edges = [ { source: "node-1", // 시작 노드 target: "node-2", // 타겟 노드 }, ];
이런 형식의 데이터를 통해 React Flow는 nodes와 edges를 화면에 그려준다. 그리고 사용자가 Node를 움직이거나 Edge를 연결하면 어떤 값이 바뀌었는지도 알려준다.
하지만 여기서 문제는 백엔드에서 받은 데이터를 React Flow의 nodes, edges 형태에 그대로 넣을 수 없었다는 것이다.
전역 Node 데이터를 한꺼번에 받아와보자
React Flow로 워크플로우 화면을 만들기 전에 먼저 서비스에서 사용할 수 있는 모든 Node의 정의 정보를 받아오기로 했다. 왜냐하면 워크플로우 상세 API만으로는 Node를 화면에 어떻게 보여줘야 하는지 알 수 없었기 때문이다.
예를 들어 사용자의 워크플로우 상세에는 아래와 같은 현재 사용자가 설정한 값이 들어 있었다.
{ "id": "node-1", "name": "안내 메일 발송", "type": "email.send", "position": { // 사용자가 Node를 어디에 배치했는가 "x": 300, "y": 100 }, "parameters": { // 노드 설정 - 어떤 입력란이 있는가 "subject": "문의 답변입니다" // 사용자가 Node에 어떤 값을 설정했는가 } }
하지만 이것만으로는 화면을 완성할 수 없었는데, 노드 설정에서 subject를 일반 Input으로 보여줘야 하는지, retryCount는 숫자 입력인지, 이 Node의 출력은 몇 개인지 같은 Node의 기본 정보를 알 수 없었기 때문이다.
그래서 백엔드와 합의하에 각 Node를 어떻게 화면에 표현해야 하는지 정의한 정보를 별도로 한 번 전달하게 되었고, 이를 프론트에서는 화면 렌더링 시 제일 처음 받아와 Node Definition으로 사용했다.
{ "email.send": { "displayName": "메일 발송", "group": "output", "outputs": ["main"], "properties": [ // 노드 설정 - 어떤 입력란이 있는가 { "name": "subject", "type": "STRING" }, { "name": "retryCount", "type": "NUMBER" }, { "name": "format", "type": "OPTIONS" } ] } }
백엔드와 프론트 사이 Adapter 두기
워크플로우 상세와 Node Definition을 준비했지만, 이 데이터를 그대로 React Flow에 넣을 수는 없었다. 백엔드에서 저장하는 데이터 구조와 React Flow가 화면을 그릴 때 사용하는 데이터 구조가 달랐기 때문이다.
백엔드는 워크플로우를 저장하고 실행하기 위한 형태로 데이터를 가지고 있었고, React Flow는 Node와 Edge를 화면에 그리기 위한 형태를 필요로 했다.
그래서 두 데이터 사이에 Adapter를 두고, 백엔드 데이터를 React Flow의 nodes, edges로 변환하는 과정을 거쳤다.
백엔드 Workflow → Adapter → React Flow nodes / edges
Node Definition을 사용자 워크플로우와 매칭
Adapter에서 워크플로우 상세의 각 Node는 type을 기준으로 앞에서 받아둔 Node Definition과 매칭하는 과정을 첫번째로 거쳤다. 예를 들어 Node의 type이 email.send라면 해당 Definition을 찾아 사용자가 설정한 값과 합쳤다.
const definition = nodeDefinitions[node.type]; const reactFlowNode = { id: node.id, position: node.position, data: { ...node, definition, }, };
이렇게 만들어진 Node에는 현재 사용자가 설정한 값과 화면을 구성하기 위한 정보가 함께 들어가게 되었고 변환된 nodes, edges는 이후 Zustand에 저장해, 이후 사용자가 조회, 수정, 삭제, 생성할 때도 이 데이터를 이용해 편리하게 사용할 수 있도록 하였다.
Node 이름을 ID로 바꿔 Edge 만들기
백엔드와 React Flow의 또 다른 차이는 Node의 연결 정보를 표현하는 방식이었고 이를 Adapter에서 처리해주기로 했다.
백엔드는 아래처럼 Node의 이름을 기준으로 연결 정보를 가지고 있었는데,
이는 직원 정보 조회 → 안내 메일 발송으로 연결되어 있다는 의미다.
const connections = { "직원 정보 조회": { main: [[{ node: "안내 메일 발송", index: 0 }]], }, };
하지만 앞서 보았듯 React Flow에서 Edge를 만들 때는 Node의 이름이 아니라 **각 Node의 id**가 필요하다.
const edge = { source: "node-a", target: "node-b", };
따라서 Node의 이름으로 id를 찾을 수 있도록, 먼저 Node 이름 → Node ID 형태의 Map을 만들었다.
const nameToIdMap = new Map<string, string>(); nodes.forEach((node) => { nameToIdMap.set(node.name, node.id); });
예를 들어 다음과 같이 이름과 id가 짝지어진다.
직원 정보 조회 → node-a
안내 메일 발송 → node-b
이런식으로 하여 백엔드 연결 정보에서 얻은 Node 이름을 이용해 실제 id를 찾고, React Flow의 source와 target에 넣어 Edge를 만들 수 있게 되었다.
const edge = { source: nameToIdMap.get(sourceName), target: nameToIdMap.get(targetName), };
여러 출력을 Handle로 변환하기
If나 Switch처럼 하나의 Node에서 여러 방향으로 연결되는 경우에는 어느 출력에서 시작된 Edge인지도 구분해야 했는데, 백엔드는 아래 처럼 각 출력을 배열의 index로 구분하고 있었다.
main: [ [{ node: "메일 발송" }], // index 0 [{ node: "종료" }], // index 1 ]
React Flow에서는 이 출력 위치를 Handle로 구분하므로, 배열의 index를 그대로 Handle 번호로 사용했다.
main[0] → source-0 main[1] → source-1
fromPorts.forEach((toPorts, outputIndex) => { toPorts.forEach((connection) => { edges.push({ source: sourceId, target: nameToIdMap.get(connection.node), sourceHandle: `source-${outputIndex}`, }); }); });
이런 방식으로 여러 출력이 있는 Node에서도 각 Edge가 어느 출력에서 시작됐는지 유지할 수 있었다.
Dispatcher로 Node와 설정 UI 구성하기
Adapter를 통해 React Flow에서 사용할 nodes, edges까지 만들고 나면 다음에는 각 Node를 화면에 어떤 모습으로 보여줄지 결정해야 했다.
문제는 워크플로우에 새로운 Node가 계속 추가된다는 것이었다.
Trigger, Schedule, Switch, If, Transform, AI, Output....
Node마다 직접 조건을 만들어 컴포넌트를 연결할 수도 있지만 이렇게 만들면 Node가 하나 추가될 때마다 프론트 코드에도 조건이 하나씩 늘어날거라는 생각이 들었다.
if (type === "email.send") ... if (type === "slack.send") ... if (type === "employee.search") ...
그래서 Node의 이름을 직접 판단하는 대신, Node의 UI별로 그룹화하여 Node Definition에 들어 있는 정보를 보고 어떤 UI를 사용할지 결정하는 Dispatcher를 만들게 되었다.
Node Dispatcher로 Node UI 결정하기
앞에서 받아온 Node Definition에 group이라는 데이터를 통해 Node의 UI를 결정하기로 디자이너, 백엔드와 합의했다. 총 7개로 정의를 내려 그 외의 UI를 가진 Node가 나오지 않게 제한했다.
{ type: "email.send", group: "output", outputs: ["main"] }
그리고 Node Dispatcher에서는 이런 정보를 기준으로 사용할 컴포넌트를 결정했다.
function getNodeComponent(node) { if (node.outputs.length > 1) { // 여러 output이 나와야 할 경우(예외) return MultiOutputNode; } switch (node.group) { case "trigger": return TriggerNode; case "output": return EndNode; case "transform": return TransformNode; default: // 정의되지 않았더라도 오류 나지 않도록 처리 return DefaultNode; } }
이렇게 해서 새로운 Node가 추가되더라도 group정보만 가지고 있다면 새로운 컴포넌트를 만들지 않고 기존 UI를 그대로 사용할 수 있었다.
Parameter Dispatcher로 설정 Form 만들기
Node를 클릭하면 해당 Node의 설정값을 수정할 수 있는 Form이 열린다. 여기서도 마찬가지로 Node마다 EmailForm, SlackForm, AiForm처럼 별도의 Form을 만들면 Node가 늘어날 때마다 프론트 코드도 함께 늘어나게 된다.
그래서 Node Definition에 해당 Node가 가져야 할 Form 형식인 properties를 백엔드에서 내려주도록 설계하였다.
{ "properties": [ { "name": "subject", "type": "STRING" // string 입력창 필요 }, { "name": "retryCount", "type": "NUMBER" // number 입력창 필요 }, { "name": "enabled", "type": "BOOLEAN" // switch UI 필요 } ] }
위의 Parameter type에 사용할 UI를 아래처럼 Dispathcer를 통해 연결하고 컴포넌트가 매칭될 수 있게 하였다.
const parameterComponents = { STRING: StringInput, NUMBER: NumberInput, BOOLEAN: Switch, OPTIONS: Select, }; properties.map((property) => { const Component = parameterComponents[property.type]; // 타입을 읽어서 어떤 컴포넌트가 사용되어야 하는지 판단 return <Component property={property} />; // property 정보를 내려주어 해당 Parameter 컴포넌트에서 사용 });
추가적으로 단순히 type만으로 UI를 결정할 수 없는 경우에는 추가 정보도 함께 확인했다. 예를 들어 같은 STRING 타입이라도 어떤 값을 입력받는지에 따라 필요한 UI가 달랐다.
STRING → Input
STRING + multiline → Textarea
STRING + code → Code Editor
그래서 기본 type과 함께 typeOptions 같은 추가 정보를 확인해 사용할 입력 컴포넌트를 결정했다.
또 특정 값을 선택했을 때만 다른 Parameter를 보여줘야 하는 경우에는 노출 조건도 Node Definition에 포함시켰다. 프론트에서는 현재 Form 값과 이 조건을 비교해 해당 Parameter를 보여줄지 결정했다.
중첩된 Parameter가 있는 경우에는 별도의 Form 구조를 새로 만들지 않고 내부 properties를 다시 같은 Parameter Dispatcher에 전달해 재귀적으로 렌더링하는 방식을 사용했다.
변환된 nodes와 edges를 Zustand에서 관리하기
사용자가 Canvas에서 Node를 움직이고, 추가하고, 삭제하고, Edge를 연결하기 시작하면 nodes, edges는 더 이상 단순한 서버 조회 결과가 아니다.
이 순간부터는 사용자가 현재 편집하고 있는 워크플로우 상태가 된다. 그래서 Adapter에서 변환한 nodes, edges를 로컬 Zustand에 저장하고 현재 편집 상태로 관리하는 게 좋겠다고 판단했다.
Zustand의 nodes, edges를 React Flow에 전달하고, 변경 이벤트도 Store의 action과 연결했다.
그리고 마지막에 저장을 누르면, 그제서야 로컬에 있던 변경된 데이터가 서버에 요청이 가는 것이다!
Context를 이용해 Canvas별 Zustand Store 분리하기
여기서 한 가지 문제가 있었다. Zustand Store를 일반적인 전역 Store로 하나만 만드니 여러 Canvas가 같은 nodes, edges를 공유하게 된 것이다.
Canvas A에서 Node를 이동했는데 두 Canvas가 같은 Store를 보고 있다면 Canvas B의 상태에도 영향을 줄 수 있게 되었다.
그래서 Canvas를 감싸는 Provider가 생성될 때 Zustand Store도 새로 만들도록 했다.
const [store] = useState(() => createStore<WorkflowDetailState>((set, get) => ({ nodes: initialNodes, edges: initialEdges, // ... })), ); return ( <WorkflowDetailContext.Provider value={store}> {children} </WorkflowDetailContext.Provider> );
위의 코드처럼 WorkflowDetailProvider가 만들어질 때 initialNodes, initialEdges를 초기값으로 새로운 Store를 생성했다.
Canvas A → WorkflowDetailProvider A → Store A
Canvas B → WorkflowDetailProvider B → Store B
각 Canvas마다 별도의 Zustand Store를 만들고, Canvas 내부 컴포넌트들이 해당 Store를 사용할 수 있도록 Context로 전달했다. 이렇게 Canvas와 Store를 1:1로 만들면서 여러 워크플로우를 동시에 띄우더라도 각 Canvas의 편집 상태를 독립적으로 유지할 수 있었다.
이 구조로 얻은 것
처음에는 React Flow를 이용해 Node와 Edge를 화면에 그리는 것이 가장 어려운 문제라 생각했다. 거기에 두려움을 느끼고 어떻게 n8n 같은 걸 혼자서 구현하라고 하느냐며 팀장님께 징징댔던 게 엊그제 같은데, 이제 실제로 만들어져 다른 회사의 서비스에 들어갈 플로우까지 제작이 되었다.

Node가 계속 추가되고 여러 화면에서 같은 Canvas를 사용하게 되면서 중요해졌던 부분은 백엔드 데이터, UI, 편집 상태 사이의 역할을 어떻게 나눌 것인가였던 것 같다.
1. Node가 늘어나도 프론트 코드가 늘어나지 않도록
가장 큰 변화는 새로운 Node를 추가하는 방식이었다. Node 이름마다 새로운 컴포넌트와 Form을 만드는 대신, Node Definition을 Dispatcher가 해석하도록 만들었다. Dispathcer가 데이터를 보고 어떤 노드와 파라미터를 선택해야 할지 골라주는 구조이다.
Node Definition → Dispatcher → 기존 UI 조합
기존에 지원하고 있는 Node UI와 Parameter 타입으로 표현할 수 있다면 백엔드에서 Definition을 추가하는 것만으로 새로운 Node를 화면에 구성할 수 있었다. 이 구조를 만든 뒤 약 50개의 Node를 별도의 프론트 구현 없이 확장할 수 있었다.
물론 완전히 새로운 형태의 Node나 새로운 Parameter 타입이 필요하면 프론트 작업이 필요하다. 하지만 한 번 새로운 UI를 추가하면 이후 같은 타입의 Node에서는 다시 재사용할 수 있다.
2. Canvas가 백엔드 구조를 몰라도 되는 구조 설계
백엔드의 이름 기반 연결이나 구조는 Adapter에서 프론트 구조로 변환했다. Adapter라는 건 추상화된 개념이고 결국은 함수로 데이터 구조를 변환했다는 것이다.
하지만 이렇게 해서 Canvas에서는 React Flow의 nodes, edges만 사용하면 되기 때문에 UI 코드에서 백엔드의 저장 구조를 직접 신경 쓸 필요가 없었다는 큰 장점이 있었다.
3. 편집 상태를 Canvas 단위로 분리
각 Canvas가 별도의 Zustand Store를 가지도록 만들면서 여러 Canvas를 동시에 사용하더라도 편집 상태가 서로 섞이지 않게 했다.
Canvas A → Store A
Canvas B → Store B
또 React Query가 가지고 있는 서버 상태와 Zustand가 가지고 있는 편집 상태도 분리해 서버에 저장된 값과 사용자가 아직 저장하지 않은 값을 구분할 수 있었다.
4. 같은 Canvas를 여러 화면에서 사용
사용자 화면과 백오피스는 저장 시점이나 편집 방식이 달랐지만 Canvas 패키지를 만들어 해당 패키지 자체에서는 이런 정책을 직접 처리하지 않도록 했다.
사용자 화면 ─┐ ─ Workflow Canvas 백오피스 ──┘
화면마다 달라지는 저장이나 실행 방식은 외부에서 처리하고, Canvas는 Node와 Edge를 보여주고 편집하는 역할에 집중하도록 했다.
몇달간 혼자서 고분군투 했지만,, 결과적으로 잘 사용되고 있는걸 보니 뿌듯하다.
정리하면서 초기 구현할때 스터디 하며 정리했던 기록을 보니 짠하기도 하다ㅎㅎㅎ..
여러 구조를 뜯어보고 설계에 대한 고민을 했던 시간들이 나에게 좋은 자양분이 될것 같다.
