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에서 노드를 드래그하면, 내부적으로는 대략 이런 일이 벌어진다.
- 사용자가 마우스를 1px만 움직여도 pointermove 이벤트가 발생한다.
- React Flow는 내부적으로 노드의 position 값을 업데이트한다.
- position이 변경되었기 때문에, NodeProps가 매 프레임 새로 만들어진다.
- 이 새로운 props가 커스텀 노드(UI 컴포넌트)에 전달된다.
- 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% 개선 |
'Frontend' 카테고리의 다른 글
| prev와 batch를 통한 상태 업데이트 (0) | 2025.12.07 |
|---|---|
| key로 컴포넌트 안의 state를 초기화 하기 (0) | 2025.12.07 |
| React Query에서 두 번째 요청이 더 빠르게 느껴지는 이유 (0) | 2025.12.07 |
| React-Arborist 트리에서 한글 입력 오류 해결 (3) | 2025.08.25 |
| React-Arborist 트리 UI에서 Input 포커스 문제 해결 (0) | 2025.08.23 |