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>
  );
});

문제 분석

  1. 전체 객체 전달: nodes 전체가 props로 전달됨
  2. 참조 변경: 하나의 노드만 변경되어도 nodes 객체 참조가 바뀜
  3. React.memo 실패: 얕은 비교로 인해 모든 컴포넌트가 리렌더링
  4. 성능 저하: 노드가 많아질수록 성능이 급격히 악화

💡 해결책: Context + Selector 패턴

Context + Selector 패턴은 다음과 같은 아이디어를 기반으로 합니다:

  1. 중앙 상태 관리: Context로 전역 상태 제공
  2. 선택적 구독: 각 컴포넌트가 필요한 데이터만 구독
  3. Map 기반 저장: O(1) 접근 성능과 부분 업데이트
  4. 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개만 리렌더링 (변경된 노드 + 직접 연결된 노드)
};

🎯 핵심 포인트와 주의사항

✅ 언제 사용하면 좋을까?

  1. 복잡한 상태 구조: 여러 컴포넌트가 같은 상태를 공유
  2. 빈번한 업데이트: 상태가 자주 변경되는 경우
  3. 성능이 중요한 앱: 리렌더링 최적화가 필요한 경우
  4. 대량의 데이터: 많은 아이템을 다루는 리스트나 에디터

⚠️ 주의사항

  1. 복잡성 증가: 단순한 상태에는 과도할 수 있음
  2. 구독 시스템: 실제 프로덕션에서는 더 정교한 구독 메커니즘 필요
  3. 메모리 누수: 구독 해제를 잊지 말 것
  4. 초기 구현 비용: 설계와 구현에 더 많은 시간 필요

🔄 대안: 상태 관리 라이브러리

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의 기본 기능만으로도 강력한 성능 최적화를 가능하게 합니다. 특히 복잡한 상태를 가진 대규모 애플리케이션에서 그 진가를 발휘합니다.

핵심은 다음 세 가지입니다:

  1. 상태 구조 최적화: Map 기반 저장으로 O(1) 접근
  2. 선택적 구독: 필요한 데이터만 구독하여 불필요한 리렌더링 방지
  3. Observer 패턴: 관련 컴포넌트에게만 변경 사항 알림

REACT에서 자주 업데이트되는 데이터리스트 관리방식