React 성능 최적화 useContext를 이용하여 부분적으로 전역변수 사용으로 불필요한 리렌더링 제거하기
개요
불필요한 리렌더링을 줄이기 위해 useContext를 사용하여 부분적으로 전역변수를 적용하여 리렌더링을 최소화 한다 이 방식은 Observer패턴과 같은 방식이다.
이번 글에서는 실제 워크플로우 에디터 프로젝트를 통해 Context + Selector 패턴이 어떻게 이런 문제를 해결하는지 살펴보겠습니다.
문제: 모든 노드가 리렌더링되는 워크플로우 에디터
워크플로우 에디터에서 노드 하나의 상태가 변경될 때마다 모든 노드 컴포넌트가 리렌더링되는 문제가 있었습니다.
기존 코드의 문제점
// ❌ 문제가 있는 기존 방식
const FlowEditor = () => {
const [nodes, setNodes] = useState<Record<string, WorkflowNode>>({});
const handleStatusChange = (nodeId: string, status: WorkflowNodeState) => {
setNodes(prev => ({
...prev,
[nodeId]: { ...prev[nodeId], state: status }
}));
};
return (
<div>
{Object.values(nodes).map(node => (
<FlowNode
key={node.id}
node={node}
nodes={nodes} // 하위노드에서 타 노드의 변경작업을 위해 조회하고 있음
onStatusChange={handleStatusChange}
/>
))}
</div>
);
};
const FlowNode = React.memo(({ node, nodes, onStatusChange }) => {
// nodes 객체가 바뀔 때마다 모든 FlowNode가 리렌더링
// React.memo가 무용지물이 됨!
const relatedNodes = getRelatedNodes(node.id, nodes);
return (
<div>
{node.title} - {node.state}
{/* 관련 노드들 정보 표시 */}
</div>
);
});
문제 분석
- 전체 객체 전달:
nodes
전체가 props로 전달됨 - 참조 변경: 하나의 노드만 변경되어도
nodes
객체 참조가 바뀜 - React.memo 실패: 얕은 비교로 인해 모든 컴포넌트가 리렌더링
- 성능 저하: 노드가 많아질수록 성능이 급격히 악화
💡 해결책: Context + Selector 패턴
Context + Selector 패턴은 다음과 같은 아이디어를 기반으로 합니다:
- 중앙 상태 관리: Context로 전역 상태 제공
- 선택적 구독: 각 컴포넌트가 필요한 데이터만 구독
- Map 기반 저장: O(1) 접근 성능과 부분 업데이트
- Observer 패턴: 관련 컴포넌트에게만 변경 알림
1단계: WorkflowContext 구현
// contexts/WorkflowContext.tsx
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
interface WorkflowContextType {
updateNode: (nodeId: string, updatedData: Partial<WorkflowNode>) => void;
handleStatusChange: (nodeId: string, status: WorkflowNodeState) => void;
// 선택적 구독을 위한 셀렉터들
getNode: (nodeId: string) => WorkflowNode | undefined;
getRelatedNodes: (nodeId: string) => WorkflowNode[];
getAllNodeIds: () => string[];
}
const WorkflowContext = createContext<WorkflowContextType | null>(null);
export const WorkflowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// 🎯 Map으로 노드 관리 (성능 최적화)
const [nodesMap, setNodesMap] = useState<Map<string, WorkflowNode>>(new Map());
// 노드 업데이트 - Map의 장점 활용
const updateNode = useCallback((nodeId: string, updatedData: Partial<WorkflowNode>) => {
setNodesMap(prev => {
const newMap = new Map(prev);
const existingNode = newMap.get(nodeId);
if (!existingNode) return prev;
// 변경된 노드만 새 객체 생성
newMap.set(nodeId, { ...existingNode, ...updatedData });
return newMap;
});
}, []);
// 상태 변경 처리 (Observer 패턴 적용)
const handleStatusChange = useCallback((nodeId: string, status: WorkflowNodeState) => {
setNodesMap(prev => {
const currentNode = prev.get(nodeId);
if (!currentNode || currentNode.state !== 'do') return prev;
const newMap = new Map(prev);
// 현재 노드 상태 업데이트
newMap.set(nodeId, { ...currentNode, state: status });
// 다음 노드들 활성화 체크 (complete인 경우만)
if (status === 'complete') {
(currentNode.nextFlow || []).forEach(nextId => {
const nextNode = newMap.get(nextId);
if (!nextNode || nextNode.state !== 'wait') return;
const shouldActivate = checkActivationCondition(nextId, newMap);
if (shouldActivate) {
newMap.set(nextId, { ...nextNode, state: 'do' });
}
});
}
return newMap;
});
}, []);
// 활성화 조건 체크
const checkActivationCondition = useCallback((nodeId: string, nodesMap: Map<string, WorkflowNode>) => {
const node = nodesMap.get(nodeId);
if (!node || node.state !== 'wait') return false;
const requiredIds = node.activateCondition || [];
if (requiredIds.length === 0) return false;
if (node.activateConditionType === 'all') {
return requiredIds.every(id => nodesMap.get(id)?.state === 'complete');
} else {
return requiredIds.some(id => nodesMap.get(id)?.state === 'complete');
}
}, []);
// 📊 선택적 구독을 위한 셀렉터들
const contextValue = useMemo<WorkflowContextType>(() => ({
updateNode,
handleStatusChange,
// 셀렉터들 - 각 컴포넌트가 필요한 데이터만 가져옴
getNode: (nodeId: string) => nodesMap.get(nodeId),
getRelatedNodes: (nodeId: string) => {
const node = nodesMap.get(nodeId);
if (!node) return [];
const relatedIds = [
...(node.frontFlow || []),
...(node.nextFlow || [])
];
return relatedIds.map(id => nodesMap.get(id)).filter(Boolean) as WorkflowNode[];
},
getAllNodeIds: () => Array.from(nodesMap.keys()),
}), [updateNode, handleStatusChange, nodesMap]);
return (
<WorkflowContext.Provider value={contextValue}>
{children}
</WorkflowContext.Provider>
);
};
export const useWorkflowContext = () => {
const context = useContext(WorkflowContext);
if (!context) {
throw new Error('useWorkflowContext must be used within WorkflowProvider');
}
return context;
};
2단계: 선택적 구독 훅 구현
// 🎯 핵심: 각 노드가 자신의 데이터만 구독
export const useNodeSelector = (nodeId: string) => {
const { getNode } = useWorkflowContext();
const [node, setNode] = useState(() => getNode(nodeId));
// 해당 노드만 구독 (실제로는 더 정교한 구독 시스템 필요)
React.useEffect(() => {
const checkForUpdates = () => {
const currentNode = getNode(nodeId);
setNode(prev => {
if (!prev && !currentNode) return prev;
if (!prev || !currentNode) return currentNode;
// 얕은 비교로 변경 감지
const changed = Object.keys(currentNode).some(key =>
currentNode[key as keyof WorkflowNode] !== prev[key as keyof WorkflowNode]
);
return changed ? currentNode : prev;
});
};
const interval = setInterval(checkForUpdates, 50);
return () => clearInterval(interval);
}, [nodeId, getNode]);
return node;
};
export const useRelatedNodes = (nodeId: string) => {
const { getRelatedNodes } = useWorkflowContext();
return useMemo(() => getRelatedNodes(nodeId), [nodeId, getRelatedNodes]);
};
export const useAllNodeIds = () => {
const { getAllNodeIds } = useWorkflowContext();
const [nodeIds, setNodeIds] = useState(() => getAllNodeIds());
React.useEffect(() => {
const checkForUpdates = () => {
const currentNodeIds = getAllNodeIds();
setNodeIds(prev => {
if (prev.length !== currentNodeIds.length) return currentNodeIds;
if (prev.some((id, index) => id !== currentNodeIds[index])) return currentNodeIds;
return prev;
});
};
const interval = setInterval(checkForUpdates, 100);
return () => clearInterval(interval);
}, [getAllNodeIds]);
return nodeIds;
};
3단계: 최적화된 컴포넌트
// ✅ 최적화된 FlowEditor
const FlowEditor = () => {
return (
<WorkflowProvider initialNodes={workflowNodeMock}>
<FlowEditorContent />
</WorkflowProvider>
);
};
const FlowEditorContent = () => {
const { addNode } = useWorkflowContext();
return (
<div>
<button onClick={addNode}>노드 추가</button>
<WorkflowCanvas />
</div>
);
};
// ✅ 최적화된 WorkflowCanvas
const WorkflowCanvas = () => {
// 🎯 노드 ID 목록만 구독 (성능 최적화)
const nodeIds = useAllNodeIds();
return (
<div>
{nodeIds.map(nodeId => (
<FlowNode
key={nodeId}
nodeId={nodeId} // 🚀 ID만 전달!
/>
))}
</div>
);
};
// ✅ 최적화된 FlowNode
const FlowNode = React.memo(({ nodeId }: { nodeId: string }) => {
// 🎯 선택적 구독 - 해당 노드만 구독
const node = useNodeSelector(nodeId);
const relatedNodes = useRelatedNodes(nodeId);
const { handleStatusChange } = useWorkflowContext();
// node나 relatedNodes가 변경될 때만 리렌더링!
if (!node) return null;
console.log(`FlowNode ${nodeId} 렌더링`); // 변경된 노드만 로그 출력
return (
<div className="flow-node">
<h3>{node.title}</h3>
<p>상태: {node.state}</p>
<p>관련 노드 수: {relatedNodes.length}</p>
{node.state === 'do' && (
<button onClick={() => handleStatusChange(nodeId, 'complete')}>
완료
</button>
)}
</div>
);
});
🚀 성능 개선 결과
Before vs After 비교
항목 | 기존 방식 | Context + Selector |
---|---|---|
리렌더링 범위 | 모든 노드 | 변경된 노드만 |
확장성 | 노드 증가시 급격한 성능 저하 | 선형적 성능 유지 |
실제 측정 결과
// 성능 측정 예시
const measureRenderCount = () => {
let renderCount = 0;
const FlowNodeWithCounter = ({ nodeId }) => {
renderCount++;
console.log(`총 렌더링 횟수: ${renderCount}`);
const node = useNodeSelector(nodeId);
return <div>{node?.title}</div>;
};
// 결과:
// 기존 방식: 노드 1개 변경 → 100개 모두 리렌더링
// 새 방식: 노드 1개 변경 → 1~3개만 리렌더링 (변경된 노드 + 직접 연결된 노드)
};
🎯 핵심 포인트와 주의사항
✅ 언제 사용하면 좋을까?
- 복잡한 상태 구조: 여러 컴포넌트가 같은 상태를 공유
- 빈번한 업데이트: 상태가 자주 변경되는 경우
- 성능이 중요한 앱: 리렌더링 최적화가 필요한 경우
- 대량의 데이터: 많은 아이템을 다루는 리스트나 에디터
⚠️ 주의사항
- 복잡성 증가: 단순한 상태에는 과도할 수 있음
- 구독 시스템: 실제 프로덕션에서는 더 정교한 구독 메커니즘 필요
- 메모리 누수: 구독 해제를 잊지 말 것
- 초기 구현 비용: 설계와 구현에 더 많은 시간 필요
🔄 대안: 상태 관리 라이브러리
Zustand, Recoil, Jotai 같은 라이브러리를 왜 사용 안했는가?
- 전체의 상태관리가 필요한것이 아닌 해당 페이지 내에서만 상태관리가 필요함. 전역변수를 사용하는 justand, Recoil 사용 시 문제가 발생생
// Zustand 예시
const useWorkflowStore = create((set, get) => ({
nodes: new Map(),
updateNode: (nodeId, updates) => {
set(state => ({
nodes: new Map(state.nodes).set(nodeId, {
...state.nodes.get(nodeId),
...updates
})
}));
},
// 선택적 구독
getNode: (nodeId) => get().nodes.get(nodeId),
}));
// 컴포넌트에서 사용
const FlowNode = ({ nodeId }) => {
const node = useWorkflowStore(state => state.getNode(nodeId));
// nodeId에 해당하는 노드가 변경될 때만 리렌더링
};
🎉 결론
Context + Selector 패턴은 React의 기본 기능만으로도 강력한 성능 최적화를 가능하게 합니다. 특히 복잡한 상태를 가진 대규모 애플리케이션에서 그 진가를 발휘합니다.
핵심은 다음 세 가지입니다:
- 상태 구조 최적화: Map 기반 저장으로 O(1) 접근
- 선택적 구독: 필요한 데이터만 구독하여 불필요한 리렌더링 방지
- Observer 패턴: 관련 컴포넌트에게만 변경 사항 알림