본문 바로가기

Frontend

표출할 데이터가 많은 경우 drag 버벅거리는 이슈

React Flow로 캔버스에서 테이블 컴포넌트를 드래그 하는 기능을 구현했다. 데이터가 없이 빈 컴포넌트를 드래그 하는데 아무 문제가 없었다. 그런데 데이터가 조금만 많아져도 드래그 시 성능 문제가 드러났다. 노드를 드래그할 때 마우스 커서는 잘 움직이는데, 노드는 몇 박자씩 늦게 따라오고 있었다. 즉, 드래그 속도가 크게 버벅이는 문제가 있었다.

 

원인: 빈번하게 렌더되는 무거운 컴포넌트

노드 하나가 통째로 무거운 DOM 덩어리였다. 테이블 컴포넌트는 대략 이런 구조였다.

<TableBody>
  {rows.map((row, rowIndex) => (
    <TableRow key={rowIndex} hover>
      {columns.map((column) => (
        <TableCell key={column.field}>
          {formatCellValue(row[column.field])}
        </TableCell>
      ))}
    </TableRow>
  ))}
</TableBody>

 

100행 × 20열만 넘어가도 <tr>, <td> DOM 노드가 수천 개씩 생긴다. 이 자체만으로도 한 번 렌더가 비싼 컴포넌트였다.

 

또한, 드래그 중에 이 비싼 컴포넌트가 다시 렌더되고 있었다. React Flow에서 노드를 드래그하면, 내부적으로는 대략 이런 일이 벌어진다.

  1. 사용자가 마우스를 1px만 움직여도 pointermove 이벤트가 발생한다.
  2. React Flow는 내부적으로 노드의 position 값을 업데이트한다.
  3. position이 변경되었기 때문에, NodeProps가 매 프레임 새로 만들어진다.
  4. 이 새로운 props가 커스텀 노드(UI 컴포넌트)에 전달된다.
  5. React는 “props가 바뀌었네?”라고 판단하고 노드를 다시 렌더한다.

즉, 드래그하는 동안 매 프레임마다 해당 노드가 새 props로 리렌더된다.

이 때 노드 내부가 단순한 div나 텍스트면 문제가 없다. 하지만 DOM이 수백, 수천 개짜리 테이블이라면 얘기가 완전히 달라진다.

React는 매 렌더마다: TableBody 내부를 다시 순회하고 JSX 트리를 재생성하고 수천 개의 <td>와 <tr>을 비교하고 브라우저는 해당 DOM의 레이아웃과 페인트를 다시 계산해야 한다.

 

해결 방법 1: React.memo + 값 비교 함수로 불필요한 리렌더 제거

드래그 중 성능 병목의 근본 원인은 노드가 매 프레임 리렌더되는 데 있었다.

이를 막기 위해 커스텀 노드를 React.memo로 감싼 뒤, UI에 영향을 주는 props만 비교하는 함수를 구현했다.

const arePropsEqual = (prev, next) => {
  if (prev.id !== next.id) return false;
  if (prev.selected !== next.selected) return false;
  if (prev.data !== next.data) return false;
  return true;
};

export default memo(UiDisplayTableNode, arePropsEqual);

 

노드 위치(position), dragging 여부처럼 UI에 영향을 주지 않는 값이 바뀌어도 다시 렌더하지 않는다. 테이블 데이터(data)나 선택 상태(selected)처럼 화면을 바꾸는 값이 바뀔 때만 렌더하도록 수정했다.

해결 방법 2: useMemo로 columns/rows 파싱 비용 최소화

드래그 중 매 프레임 새 props가 들어오다 보니, props에서 columns와 rows를 계산하는 코드도 반복적으로 실행되고 있었다. 예전에는 단순히 이런 형태였다.

const columns = data?.payload?.columns ?? [];
const rows = data?.payload?.rows ?? [];

 

문제는 data.payload가 객체라면 프레임마다 이 구조를 다시 읽고 새 배열을 만들어낸다는 점이다.

그래서 이 처리 자체를 useMemo로 감싸서 payload가 실제로 변경될 때만 계산하도록 만들었다.

const { columns, rows } = useMemo(() => {
  const payload = data?.payload;
  return {
    columns: payload?.columns ?? [],
    rows: payload?.rows ?? [],
  };
}, [data?.payload]);

 

결과

DevTools Performance 프로파일 기준, 최적화 전 JS Scripting이 6,230ms였던 것이 최적화 후 2,485ms로 감소하여 약 60% 감소했다. 또한 INP(사용자 입력이 화면에 반영될 때까지 걸리는 시간, 즉, 드래그했는데 박스가 마우스를 늦게 따라오는 느낌)는 683ms → 91ms로 약 86% 개선되며 드래그 시 프레임 지연이 사실상 사라졌다.

항목 BEFORE AFTER 변화
Scripting 6,230ms 2,485ms 60.1% 감소

 

항목 BEFORE AFTER 변화
INP (Interaction to Next Paint) 683ms 91ms 86.7% 개선