https://www.youtube.com/watch?v=ED2H_y6dmC8
09:50:42
Add "manual trigger" node → "수동 트리거" 노드 추가
Add "http request" node → "HTTP 요청" 노드 추가
Create node selector component → 노드 선택기 컴포넌트 생성
1. 데이터베이스 스키마 확장 - 새로운 노드 타입 추가
prisma/schema.prisma
- Prisma 스키마에 새로운 노드 타입을 추가
enum NodeType {
INITIAL
MANUAL_TRIGGER // 수동 트리거 노드
HTTP_REQUEST // HTTP 요청 노드
}
- 변경사항을 데이터베이스에 반영하기 위해 마이그레이션을 실행
npx prisma migrate dev
// Enter a name for the new migration: › new-nodes
2. 노드 컴포넌트 설정 파일 준비
src/config/node-components.ts
- 노드 타입과 컴포넌트를 매핑
- 이 단계에서는 먼저 구조만 잡아둠
import { InitialNode } from "@/components/initial-node";
import { NodeType } from '@/generated/prisma/enums';
import type { NodeTypes } from "@xyflow/react"
export const nodeComponents = {
[NodeType.INITIAL]: InitialNode,
// 나중에 추가할 노드들
// [NodeType.HTTP_REQUEST]: HttpRequestNode,
// [NodeType.MANUAL_TRIGGER]: ManualTriggerNode,
} as const satisfies NodeTypes;
export type RegisteredNodeType = keyof typeof nodeComponents;
3. 고유 ID 생성 라이브러리 설치
- 노드마다 고유한 ID가 필요하므로 CUID2 라이브러리를 설치
npm install @paralleldrive/cuid2
4. 노드 선택기 컴포넌트 생성
src/components/node-selector.tsx
- 사용자가 추가할 노드를 선택할 수 있는 UI
"use client"
...
// 노드 옵션 타입 정의
export type NodeTypeOption = {
type: NodeType,
label : string,
description: string,
icon: React.ComponentType<{ className?: string}>
}
// 트리거 노드와 실행 노드 정의
const triggerNodes: NodeTypeOption[]=[
{
type: NodeType.MANUAL_TRIGGER,
label: "Trigger manually",
description: "Runs the flow on clicking a button. Good for getting started quickly",
icon: MousePointerIcon,
}
]
const executionNodes: NodeTypeOption[] =[
{
type: NodeType.HTTP_REQUEST,
label: "HTTP Request",
description: "Makes an HTTP request",
icon: GlobeIcon
}
]
interface NodeSelectorProps{
open: boolean;
onOpenChage: (open:boolean) => void;
children: React.ReactNode;
}
export function NodeSelector({
open,
onOpenChage,
children
}: NodeSelectorProps) {
return(
<Sheet open={open} onOpenChange={onOpenChage}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader>
<SheetTitle>What triggers this workflow?</SheetTitle>
<SheetDescription>
A trigger is a step that starts your workflow.
</SheetDescription>
</SheetHeader>
{/* 트리거 노드 목록 */}
<div>
{triggerNodes.map((nodeType) => (
// 노드 옵션 렌더링
))}
</div>
<Separator/>
{/* 실행 노드 목록 */}
<div>
{executionNodes.map((nodeType) => (
// 노드 옵션 렌더링
))}
</div>
</SheetContent>
</Sheet>
)
}
5. AddNodeButton에 노드 선택기 연결
src/features/editor/components/add-node-button.tsx
- 버튼 클릭 시 노드 선택기가 열리도록 구현
"use client"
import { memo } from "react"
import { Button } from "@/components/ui/button"
import { PlusIcon } from "lucide-react"
import { NodeSelector } from "@/components/node-selector"
import { useState } from "react"
export const AddNodeButton = memo(()=>{
const [selectorOpen, setSelectorOpen] = useState(false)
return(
<NodeSelector open={selectorOpen} onOpenChage={setSelectorOpen}>
<Button
onClick={()=>{}}
size="icon"
variant="outline"
className="bg-background"
>
<PlusIcon/>
</Button>
</NodeSelector>
)
})
AddNodeButton.displayName = "AddNodeButton";
버튼을 클릭하면 오른쪽에서 창이 뜸.


6. InitialNode에도 노드 선택기 추가
src/components/initial-node.tsx
- 초기 노드를 클릭했을 때도 노드 선택기가 열리도록 수정
"use client"
import { memo } from "react"
import { NodeProps } from "@xyflow/react"
import { PlaceholderNode } from "./react-flow/placeholder-node"
import { PlusIcon } from "lucide-react"
import { WorkflowNode } from "./workflow-node"
import { NodeSelector } from "./node-selector"
import { useState } from "react"
export const InitialNode = memo((props:NodeProps) => {
const [selectorOpen, setSelectorOpen] = useState(false)
return(
<NodeSelector open={selectorOpen} onOpenChage={setSelectorOpen}>
<WorkflowNode showToolbar={false}>
<PlaceholderNode
{...props}
onClick={()=>setSelectorOpen(true)}
>
<div className="cursor-pointer flex items-center justify-center">
<PlusIcon className="size-4"/>
</div>
</PlaceholderNode>
</WorkflowNode>
</NodeSelector>
)
})
InitialNode.displayName = "InitialNode"
7. 노드 선택 로직 구현
src/components/node-selector.tsx
- 실제 노드 추가 로직을 구현
export function NodeSelector({
open,
onOpenChage,
children
}: NodeSelectorProps) {
const {setNodes, getNodes, screenToFlowPosition} = useReactFlow();
const handleNodeSelect = useCallback((selection: NodeTypeOption) => {
// 수동 트리거는 하나만 허용
if(selection.type === NodeType.MANUAL_TRIGGER) {
const nodes = getNodes();
const hasManualTrigger = nodes.some(
(node) => node.type === NodeType.MANUAL_TRIGGER
)
if(hasManualTrigger) {
toast.error("Only one manual trigger is allowed per workflow")
return;
}
}
setNodes((nodes) => {
const hasInitialTrigger = nodes.some(
(node) => node.type === NodeType.INITIAL
);
// 화면 중앙에서 랜덤한 위치 계산
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 2
const flowPosition = screenToFlowPosition({
x: centerX + (Math.random() - 0.5) * 200,
y: centerY + (Math.random() - 0.5) * 200,
});
const newNode = {
id: createId(),
data: {},
position: flowPosition,
type: selection.type
}
// Initial 노드가 있으면 교체, 없으면 추가
if(hasInitialTrigger) {
return [newNode]
}
return [...nodes, newNode];
});
onOpenChage(false)
}, [setNodes, getNodes, screenToFlowPosition, onOpenChage])
// onClick 핸들러에 handleNodeSelect 연결
// ...
}
- 이제 노드 선택기에서 노드를 클릭하면 캔버스에 실제로 추가됨

작업전 참고
https://reactflow.dev/ui/components/base-handle
Base Handle - React Flow
A handle component with basic styling
reactflow.dev
8. React Flow Handle 컴포넌트 설치 및 재구성
- 노드 간 연결을 위한 Handle 컴포넌트를 설치
npx shadcn@3.3.1 add https://ui.reactflow.dev/base-handle
- 기존 src/components/base-handle.tsx 를 src/components/react-flow/base-handle.tsx 로 이동해서 구조를 정리함.
9. BaseExecutionNode 컴포넌트 구현
src/features/executions/components/base-execution-node.tsx
- 실행 노드의 기본 구조를 정의하는 컴포넌트를 만든다.
"use client"
import { type NodeProps, Position } from "@xyflow/react"
import type { LucideIcon } from "lucide-react"
import Image from "next/image"
import { memo, type ReactNode} from 'react'
import { BaseNode, BaseNodeContent } from '@/components/react-flow/base-node'
import { BaseHandle } from "./react-flow/base-handle"
import { WorkflowNode } from "./workflow-node"
interface BaseExecutionNodeProps extends NodeProps{
icon: LucideIcon | string;
name: string;
description?:string;
children?: ReactNode;
// status?: NodeStatus;
onSettings?: () => void;
onDoubleClick?: () => void
}
export const BaseExecutionNode = memo(
({
id,
icon: Icon,
name,
description,
children,
onSettings,
onDoubleClick
}:BaseExecutionNodeProps) => {
// TODO : add delete methid
const handleDelete = () => {};
return(
<WorkflowNode
name={name}
description={description}
onDelete={handleDelete}
onSettings={onSettings}
>
{/* TODO : Wrap within NodeStatusIndicator*/}
<BaseNode onDoubleClick={onDoubleClick}>
<BaseNodeContent>
{typeof Icon === "string" ? (
<Image src={Icon} alt={name} width={16} height={16}/>
) : (
<Icon className="size-4 text-muted-foreground"/>
)}
{children}
<BaseHandle
id="target-1"
type="target"
position={Position.Left}
/>
<BaseHandle
id="source-1"
type="source"
position={Position.Right}
/>
</BaseNodeContent>
</BaseNode>
</WorkflowNode>
)
})
BaseExecutionNode.displayName = "BaseExecutionNode"
10. HTTP Request 노드 구현
src/features/executions/components/http-request/node.tsx
type HttpRequestNodeData = {
endpoint?: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: string;
[key: string]: unknown
};
export const HttpRequestNode = memo((props: NodeProps<HttpRequestNodeType>) => {
const nodeData = props.data as HttpRequestNodeData;
const description = nodeData?.endpoint
? `${nodeData.method || "GET"} : ${nodeData.endpoint}`
: "Not configured";
return(
<BaseExecutionNode
{...props}
id={props.id}
icon={GlobeIcon}
name="HTTP Request"
description={description}
onSettings={() => {}}
onDoubleClick={() => {}}
/>
)
})
HttpRequestNode.displayName = "HttpRequestNode"
11. 노드 컴포넌트 등록 (HTTP Request)
src/config/node-components.ts
- HTTP Request 노드를 등록
export const nodeComponents = {
[NodeType.INITIAL]: InitialNode,
[NodeType.HTTP_REQUEST]: HttpRequestNode,
// [NodeType.MANUAL_TRIGGER]: ManualTriggerNode,
} as const satisfies NodeTypes;

12. BaseTriggerNode 컴포넌트 구현
src/features/triggers/components/base-trigger-node.tsx
- 트리거 노드의 기본 구조를 정의한다. 실행 노드와 다르게 source 핸들만 있다.
export const BaseTriggerNode = memo(({
id,
icon: Icon,
name,
description,
children,
onSettings,
onDoubleClick
}: BaseTriggerNodeProps) => {
const handleDelete = () => {};
return(
<WorkflowNode
name={name}
description={description}
onDelete={handleDelete}
onSettings={onSettings}
>
<BaseNode onDoubleClick={onDoubleClick} className="rounded-l-2x1 relative group">
<BaseNodeContent>
{typeof Icon === "string" ? (
<Image src={Icon} alt={name} width={16} height={16}/>
) : (
<Icon className="size-4 text-muted-foreground"/>
)}
{children}
{/* 트리거는 출력만 있음 */}
<BaseHandle
id="source-1"
type="source"
position={Position.Right}
/>
</BaseNodeContent>
</BaseNode>
</WorkflowNode>
)
})
BaseTriggerNode.displayName = "BaseTriggerNode"
13. Manual Trigger 노드 구현
src/features/triggers/components/manual-tigger/node.tsx
export const ManualTriggerNode = memo((props: NodeProps) => {
return(
<BaseTriggerNode
{...props}
icon={MousePointerIcon}
name="When clicking 'Execute workflow'"
/>
)
})
14. 노드 컴포넌트 최종 등록
src/config/node-components.ts
- 모든 노드 등록
import { InitialNode } from "@/components/initial-node";
import { HttpRequestNode } from "@/features/executions/components/http-request/node";
import { ManualTriggerNode } from "@/features/triggers/components/manual-tigger/node";
import { NodeType } from '@/generated/prisma/enums';
import type { NodeTypes } from "@xyflow/react"
export const nodeComponents = {
[NodeType.INITIAL] : InitialNode,
[NodeType.HTTP_REQUEST]: HttpRequestNode,
[NodeType.MANUAL_TRIGGER]: ManualTriggerNode,
} as const satisfies NodeTypes;
export type RegisteredNodeType = keyof typeof nodeComponents;

- 노드 타입 확장: Prisma Enum을 활용한 타입 안전성 확보
- 컴포넌트 재사용: Base 컴포넌트 패턴으로 중복 코드 최소화
- 상태 관리: React Flow의 useReactFlow 훅을 활용한 노드 관리
- UX 고려: 수동 트리거 중복 방지, 랜덤 위치 배치 등 사용자 경험 개선
- 구조화: features 폴더로 기능별 코드 분리
'Clone Coding' 카테고리의 다른 글
| AI 코딩 시대, 도구보다 중요한 건 '기본기' (Cursor AI 활용법) (0) | 2026.02.01 |
|---|---|
| *잠시 중단* N8N & Zapier - Editor State (0) | 2026.01.26 |
| N8N & Zapier - Editor Setup (1) | 2026.01.20 |
| N8N & Zapier - Workflow Page (0) | 2026.01.17 |
| N8N & Zapier - Workflows UI (1) | 2026.01.16 |