기술 블로그
체크박스 아이템 최적화 본문
[react] 대량의 체크박스(5,000건) 렌더링 최적화하기
프로젝트를 진행하다 보면 대량의 데이터를 다뤄야 하는 상황을 마주하곤 합니다. 최근 정산 데이터 수정 모달에서 약 5,000건의 데이터를 테이블로 렌더링하고, 각 행을 체크박스로 선택하는 기능을 구현했습니다.
하지만 구현 후 테스트 과정에서 체크박스를 클릭할 때마다 1~2초 수준의 심각한 지연이 발생했습니다. 이번 포스트에서는 배열 기반 상태 관리의 함정과 이를 Set 및 React.memo로 해결한 과정을 정리합니다.
1. 문제 상황과 원인 분석
처음에는 일반적인 방식인 배열(Array)을 사용하여 선택된 아이템의 상태를 관리했습니다.
const [selectedItems, setSelectedItems] = useState<DataListEditItem[]>([]);
// 개별 선택 핸들러
const handleSelectRow = (item: DataListEditItem) => {
setSelectedItems(prev =>
prev.some(selected => selected.seq === item.seq)
? prev.filter(selected => selected.seq !== item.seq)
: [...prev, item],
);
};
// 컬럼 정의 내 체크 여부 판단
cell: ({ row }) => (
<input
type="checkbox"
checked={selectedItems.some(item => item.seq === row.original.seq)}
/>
),
언뜻 보면 큰 문제가 없어 보이지만, 데이터가 5,000건 이상으로 늘어나면 시간 복잡도 측면에서 병목이 발생합니다.
시간복잡도로 보는 병목
체크박스 하나를 클릭할 때마다 다음과 같은 연산이 일어납니다.
selectedItems상태가 변경되어 전체 컴포넌트가 리렌더링됩니다.- 5,000개 행이 모두 다시 그려집니다.
- 각 행은
selectedItems.some()을 실행하여 체크 여부를 판단합니다.
Array.some()은 최악의 경우 배열 전체를 순회하는 O(s) 연산입니다(s = 선택된 아이템 수). 이를 5,000개 행마다 반복하므로 전체 비용은 O(n × s)가 됩니다. 만약 전체 선택 후 1개를 해제한다면 약 2,500만 번(5,000 × 5,000)의 비교 연산이 수행되는 셈입니다.
2. 해결 단계 1: Set 기반으로 전환
첫 번째 개선은 조회 성능이 뛰어난 Set 자료구조를 사용하는 것입니다. Set.has()는 O(1)의 시간 복잡도를 가지므로, 5,000행 전체를 확인해도 O(n)으로 연산량이 급감합니다.
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
const handleSelectRow = (item: DataListEditItem) => {
setSelectedSeqs(prev => {
const next = new Set(prev);
if (next.has(item.seq)) next.delete(item.seq);
else next.add(item.seq);
return next;
});
};
// 조회 시 O(1)
cell: ({ row }) => (
<input
type="checkbox"
checked={selectedSeqs.has(row.original.seq)}
/>
),
| 연산 | 배열 (Before) | Set (After) |
|---|---|---|
| 개별 선택/해제 | O(n × s) ≈ O(n²) | O(n) |
| 체크 여부 비교 (1행) | O(s) | O(1) |
3. 해결 단계 2: 행 단위 React.memo 적용
Set을 통해 연산 비용은 줄였지만, 여전히 상태가 변경될 때마다 5,000개의 행이 모두 리렌더링되는 근본적인 문제가 남아 있습니다. 이를 해결하기 위해 각 행을 memo로 감싸고 커스텀 비교 함수를 적용했습니다.
// SimpleTable 내부 Row 컴포넌트화
const MemoizedRow = memo(
<T,>({ row, isSelected }: MemoizedRowProps<T>) => (
<tr>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
),
(prev, next) =>
prev.row.original === next.row.original &&
prev.isSelected === next.isSelected // 데이터와 선택 상태가 같으면 리렌더링 방지
);
이 방식을 통해 체크박스 하나를 클릭했을 때, 실제 변경이 일어난 단 1개의 행만 다시 그려지도록 최적화했습니다.
4. 최종 비교 및 정리
각 단계별 최적화 결과는 다음과 같습니다.
| 구분 | 배열 기반 | Set 전환 | Set + Memo |
|---|---|---|---|
| checked 비교 | O(s) | O(1) | O(1) |
| 개별 클릭 시 리렌더링 | 5,000개 행 | 5,000개 행 | 1개 행 |
| 5,000건 체감 속도 | 1~2초 지연 | 다소 개선 | 즉시 반응 |
데이터셋이 작을 때는 배열 기반으로도 충분하지만, 수천 건 이상의 인터랙티브한 테이블을 구현해야 한다면 Set을 통한 조회 최적화와 memo를 통한 렌더링 최적화 조합을 필수적으로 고려해야 합니다.
특히 Set으로의 전환은 코드 수정량이 적으면서도 O(n²)에서 O(n)으로 성능을 끌어올릴 수 있는 가장 효율적인 방법입니다.
'프론트엔드' 카테고리의 다른 글
| 초성 검색을 지원하는 Local Async SelectBox 구현하기 (0) | 2026.02.09 |
|---|---|
| [rn] prebuild + fastlane 같이 사용하기 (1) | 2025.08.05 |
| expo prebuild로 프로덕션 빌드하기 (0) | 2025.07.25 |
| expo - ios 빌드 및 출시 (0) | 2025.07.25 |
| 프론트엔드에서의 경로 유지 멀티파일 전송과 성능 비교에 관하여 (0) | 2024.08.26 |
