GoJS로 노드 기반 비주얼 여정 플로우 에디터 만들기 회고록
시나리오 기반 CRM 피쳐
지금으로부터 글을 쓰기 약 2년전이었던 것 같습니다.
CRM 서비스에서 캠페인 여정 에디터를 만들어야 했습니다. "회원가입하면 환영 푸시를 보내고, 3일 뒤에도 안 열었으면 SMS를 보내고, 열었으면 웹훅을 쏘고…" — 이런 자동화 흐름을 비개발자가 직접 시각적으로 설계할 수 있는 에디터요.
사이드바에서 위젯(메시지 발송, 대기, 조건 분기 등)을 드래그해서 캔버스에 놓고, 선으로 이어서 하나의 여정을 만드는 방식. Node-RED 같은..
막상 만들려니까 고려할 게 많았어요:
- 다이어그램 캔버스 (팬/줌)
- 노드 간 연결선 (곡선, 방향성)
- 연결 규칙 (순환 참조 금지, 레벨 제한)
- 드래그앤드롭으로 노드 추가
- 노드별 커스텀 UI (아이콘, 상태 표시, 컨텍스트 메뉴)
- 자동 레이아웃 (위→아래 방향)
- 읽기 전용 모드 (리포트 뷰)
결론을 먼저 말하자면 GoJS를 선택해서 개발했습니다.
라이브러리 선택: 왜 GoJS였는가
후보는 세 가지였습니다.
ReactFlow / Vue Flow — 노드를 Vue 컴포넌트로 렌더링할 수 있어서 디자인 자유도가 높습니다. 근데 캠페인 여정 에디터에는 치명적인 문제가 있었어요. 자동 레이아웃이 없습니다. 사용자가 노드를 추가할 때마다 "이 노드는 저 노드 아래에 와야 해"를 자동으로 배치해줘야 하는데, dagre 같은 외부 라이브러리를 붙여야 하고 그마저도 GoJS만큼 매끄럽지 않았어요. 그리고 당시 Vue Flow는 아직 초기 단계라 프로덕션에 쓰기엔 불안했습니다.
JointJS — 엔터프라이즈급이고 기능도 충분한데, 라이선스가 GoJS보다 비쌌고 Vue 통합 레퍼런스가 거의 없었습니다. 삽질 비용이 예상됐어요.
GoJS — 역시 상용이라 비용이 들지만, 결정적으로 LayeredDigraphLayout이 내장되어 있었습니다. 위→아래 방향 자동 정렬, 노드 간 간격 조정, 레이어 분리를 설정 몇 줄로 해결할 수 있었어요. 거기에 포트(연결점) 시스템, 링크 유효성 검증, 트랜잭션 기반 모델 변경까지 — 워크플로우 에디터에 필요한 기능이 거의 다 내장되어 있었습니다. 공식 문서와 예제도 압도적으로 많았고요.
결국 "자동 레이아웃 + 연결 규칙 검증"이 내장인지 아닌지가 갈림길이었습니다. 직접 구현하면 몇 달은 더 걸렸을 거예요.
layout: new go.LayeredDigraphLayout({
direction: 90, // 위→아래
columnSpacing: 20,
layerSpacing: 10,
isOngoing: false, // 수동 트리거
isInitial: false,
})
아키텍처: 완성된 지금은 좀 달라요.
처음에는 하나의 composable에 다 넣었습니다. 금방 1500줄을 넘겼어요. GoJS 초기화, 노드 템플릿, 링크 템플릿, 드래그앤드롭, 모델 조작, 이벤트 핸들링이 전부 한 파일에.
결국 역할별로 서비스를 분리했습니다:
use-journey-campaign-flow/
├── index.ts # 진입점, context 생성
├── types.ts # 타입 정의
├── diagram-service.ts # 다이어그램 초기화, 이벤트 리스너
├── node-templates.ts # 노드 비주얼 정의
├── link-templates.ts # 연결선 비주얼 정의
├── port-templates.ts # 연결점(포트) 정의
├── drag-drop-service.ts # 외부→캔버스 드래그앤드롭
├── model-service.ts # 노드/링크 CRUD, 유효성 검증
├── report-service.ts # 리포트 모드 전용 로직
└── utils.ts # 유틸리티
모든 서비스가 FlowContext라는 공유 컨텍스트를 받습니다. 다이어그램 인스턴스, 노드/링크 데이터, 모드 상태 등을 담고 있어요.
interface FlowContext {
myDiagram: ShallowRef<go.Diagram | undefined>;
flowMode: Ref<'Draft' | 'Reported'>;
isReadOnly: ComputedRef<boolean>;
nodeDataList: Ref<NodeData[]>;
linkDataList: Ref<LinkData[]>;
// ...
}
이렇게 하니까 각 서비스를 독립적으로 테스트할 수 있고, 새 기능 추가할 때 어디를 건드려야 하는지 명확해졌습니다.
연결 규칙: "아무 데나 연결하면 안 됩니다"
가장 까다로웠던 부분. 사용자가 노드 A에서 노드 B로 선을 그을 때, 이게 유효한 연결인지 실시간으로 판단해야 합니다.
linkingTool.isValidLink = (fromNode, fromPort, toNode, toPort) => {
// 자기 자신에게 연결 금지
if (fromNode === toNode) return false;
// 순환 참조 금지 — B가 A의 조상이면 연결 불가
if (isAncestor(ctx, toNode, fromNode)) return false;
// 레벨 제한 — 하위 노드에서 상위로 역방향 연결 금지
const fromLevel = fromNode.findTreeLevel();
const toLevel = toNode.findTreeLevel();
if (fromLevel < toLevel) return false;
return true;
};
isAncestor 함수는 트리를 역방향으로 순회하면서 순환 참조를 감지합니다. 이게 없으면 무한 루프가 생길 수 있는 워크플로우가 만들어져요.
GoJS의 linkingTool이 이 검증을 드래그 중에 실시간으로 호출해줍니다. 유효하지 않은 노드 위에 마우스를 올리면 연결이 안 되는 걸 시각적으로 보여줄 수 있어요.
드래그앤드롭: 팔레트 → 캔버스
사이드바에 노드 타입 목록(팔레트)이 있고, 거기서 캔버스로 드래그해서 노드를 추가합니다. 이게 생각보다 까다로웠어요.
문제: HTML 드래그 이벤트의 좌표와 GoJS 캔버스의 좌표계가 다릅니다. 줌/팬 상태에 따라 변환해야 해요.
function getCurrentPointFromMouseEvent(ctx: FlowContext, event: DragEvent): go.Point {
const pixelRatio = ctx.myDiagram.value.computePixelRatio();
const scale = ctx.myDiagram.value.scale;
const canvas = event.target as HTMLCanvasElement;
const bbox = canvas.getBoundingClientRect();
const mx = event.clientX - bbox.left * (canvas.width / pixelRatio / bbox.width);
const my = event.clientY - bbox.top * (canvas.height / pixelRatio / bbox.height);
// 뷰 좌표 → 문서 좌표 변환
return ctx.myDiagram.value.transformViewToDoc(new go.Point(mx, my));
}
드래그 중에는 고스트 노드를 캔버스에 표시합니다. "여기에 놓으면 이 위치에 생깁니다"를 시각적으로 보여주는 거예요. 드롭하면 고스트를 제거하고 실제 노드를 생성합니다.
function handleEventDropOnDiagram(ctx: FlowContext, ev: DragEvent) {
ctx.myDiagram.value.startTransaction('NodeDrop');
try {
const ghostNode = ctx.myDiagram.value.findNodeForKey('GHOST');
// 고스트 위치에 실제 노드 생성
createNode(ctx, nodeType, ghostNode.data.view.location);
ctx.myDiagram.value.remove(ghostNode);
ctx.myDiagram.value.commitTransaction('NodeDrop');
} catch (e) {
ctx.myDiagram.value.rollbackTransaction();
}
}
트랜잭션으로 감싸는 이유 — 노드 생성 중 에러가 나면 rollback해서 다이어그램을 깨끗한 상태로 유지합니다.
노드 템플릿: GoJS의 강점이자 약점
GoJS는 노드 UI를 자체 템플릿 시스템으로 정의합니다. HTML/CSS가 아니라 GoJS의 Panel, Shape, TextBlock 조합으로요.
// 노드 하나를 정의하는 데 이만큼의 코드가 필요
const node = new go.Node('Vertical');
const header = new go.Panel('Horizontal');
const icon = new go.Picture();
icon.width = 24;
icon.height = 24;
icon.bind(new go.Binding('source', 'category', getNodeImage));
header.add(icon);
// ... 수십 줄 더
장점: 캔버스 렌더링이라 수백 개 노드에서도 성능이 좋습니다. DOM 노드가 아니니까요.
약점: Vue 컴포넌트를 직접 쓸 수 없습니다. 디자인 변경할 때마다 GoJS 템플릿 코드를 수정해야 하고, CSS가 아니라 프로퍼티로 스타일링해야 해요. 이게 생산성을 꽤 깎습니다.
타협점: 컨텍스트 메뉴, 툴팁, 팝오버 같은 오버레이 UI는 GoJS의 HTMLInfo를 써서 Vue 컴포넌트로 렌더링했습니다. 캔버스 위에 HTML을 띄우는 방식이에요.
const htmlInfo = new go.HTMLInfo();
htmlInfo.show = (obj) => {
// Vue 컴포넌트의 ref를 호출해서 표시
contextMenuRef.value?.show(obj.part as go.Node);
};
htmlInfo.hide = () => {
contextMenuRef.value?.hide();
};
노드를 선언적으로 구성하기
GoJS 템플릿 코드는 금방 스파게티가 됩니다. 노드 하나에 헤더, 아이콘, 상태 텍스트, 조건 태그, 리포트 패널까지 들어가면 수백 줄이 되거든요. 노드 타입이 10개 넘으니까 복붙 지옥이 시작됐어요.
해결: 노드 내부 요소를 조합 가능한 빌더 함수로 쪼갰습니다.
// 리포트 패널의 각 행을 선언적으로 정의
const reportRows = [
metricRow('발송', (r) => r.sendCount),
coloredMetricRow('도달', (r) => r.deliveredCount, '#3B82F6', { showPercent: true }),
coloredMetricRow('클릭', (r) => r.clickCount, '#10B981', { showPercent: true }),
currencyMetricRow('매출', (r) => r.revenue),
];
// 이 정의만으로 GoJS Panel이 자동 생성됨
const reportPanel = makeMetricGroup(reportRows);
metricRow, coloredMetricRow, currencyMetricRow — 이 세 가지 빌더로 모든 리포트 행을 표현합니다. 새 지표를 추가할 때 한 줄만 넣으면 되고, 스타일(색상, 퍼센트 표시, 볼드)은 타입에 따라 자동 적용돼요.
내부적으로 makeMetricGroup이 GoJS의 Panel.Table을 생성하면서 행 사이에 divider를 넣고, 데이터 바인딩을 걸고, 숫자 포맷팅까지 처리합니다. 선언부와 렌더링 로직이 완전히 분리된 거죠.
돌이켜보면
좋았던 점: GoJS의 자동 레이아웃과 연결 규칙 시스템이 개발 시간을 크게 줄여줬습니다. 직접 구현했으면 몇 달은 더 걸렸을 거예요. 서비스 분리 패턴도 유지보수를 편하게 만들었고요.
아쉬웠던 점: 템플릿 시스템이 Vue 컴포넌트와 동떨어져 있어서, 디자인 변경 비용이 높습니다. CSS 한 줄이면 될 걸 GoJS 프로퍼티 여러 개를 바꿔야 하는 경우가 많았어요. 그리고 GoJS 자체가 상용 라이브러리라 라이선스 비용이 부담.
다시 한다면: 2025년 기준으로는 Vue Flow가 많이 성숙해졌으니, 자동 레이아웃이 필수가 아닌 경우라면 Vue Flow를 고려했을 겁니다. 노드를 Vue 컴포넌트로 렌더링할 수 있어서 디자인 자유도가 훨씬 높거든요. 하지만 복잡한 연결 규칙 + 자동 레이아웃이 필수라면 여전히 GoJS가 현실적인 선택이라고 생각합니다.