Navigation

10. 렌더 성능

메모이제이션은 필요를 확인한 경우에만 적용합니다. 실제로 무거운 초기화와 갱신만 초기화 함수, 트랜지션, 지연 값으로 미룹니다.

10.1 확인한 이유가 없으면 useMemo, useCallback, memo를 쓰지 않습니다

효과를 확인하지 않은 방어적 useMemo, useCallback, memo를 막습니다.

useMemo, useCallback, memo는 아래 네 경우에만 씁니다. 어느 경우든 TypeScript 규칙 6.5에 따라 이유를 남깁니다.

허용 근거확인할 내용
외부 라이브러리의 참조 계약참조 변경이 상태 초기화나 구독 재설치로 이어짐
불필요한 이펙트 재구독객체, 배열이 이펙트 밖에서도 필요하고, 재구독이 확인됐고 의존성을 더 줄일 수 없음
실측 병목계산이나 렌더 비용을 실제로 측정했음
지연 값을 받는 하위 트리규칙 10.3가 memo를 요구함

계산이나 함수가 다시 실행된다는 사실만으로 메모이제이션하지 않습니다. 이펙트에서만 쓰는 객체, 배열은 이펙트 안에서 만들고 원본 값에 의존합니다. React는 useMemo, useCallback 캐시를 버릴 수 있으므로 정확성을 캐시에 의존하지 않습니다. 컴파일러가 같은 최적화를 이미 제공하면 수동 메모이제이션을 더하지 않습니다.

단순 가공을 습관적으로 메모이제이션합니다.

const columns = useMemo(() => {
	return toTableColumns(props.columns);
}, [props.columns]);

근거가 없으면 감싸지 않고 그대로 계산합니다.

const columns = toTableColumns(props.columns);

측정한 계산 비용을 근거로 메모이제이션합니다.

// 열 2,000개의 표시 계약 변환이 입력마다 45ms 측정. 같은 columns의 반복 계산 생략
const columns = useMemo(() => {
	return toTableColumns(props.columns);
}, [props.columns]);

이펙트에서만 쓰는 배열은 안에서 만들고 원본 값에 의존합니다.

/**
 * 입력된 상품 목록이 바뀔 때만 변경 알림 재구독
 */
useEffect(() => {
	return subscribeToProductChanges(props.products.map((product) => product.id));
}, [props.products]);

함께 보기

10.2 무거운 초기값 계산은 초기화 함수로 감쌉니다

무거운 초기 상태 계산이 이후 렌더에서 반복되지 않습니다.

useState의 초기값 계산이 무거우면 값을 직접 넣지 않고 초기화 함수로 전달합니다. 이후 렌더의 반복 계산을 피하는 용도이므로 별도 측정 근거는 요구하지 않습니다.

초기값형태
localStorage 파싱, 인덱스 생성, 큰 배열 정규화초기화 함수로 감쌉니다
단순 숫자, 문자열 또는 그대로 전달하는 프롭감싸지 않습니다
이후 프롭스 변화를 따라가야 하는 값초기 상태로 복제하지 않습니다

개발 환경의 StrictMode에서는 초기화 함수를 두 번 호출할 수 있으므로 저장, 구독 같은 부수효과를 넣지 않습니다. localStorage는 클라이언트에서만 읽으며, 서버 렌더링에서는 저장소를 읽는 시점이 화면의 클라이언트 초기화 계약을 따릅니다.

무거운 초기값 계산이 렌더마다 반복됩니다.

const [searchIndex] = useState(toSearchIndex(product_list));
const [draftFilter] = useState(parseStoredProductFilter(localStorage.getItem("product-filter")));

초기화 함수로 넘겨 이후 렌더에서 다시 계산하지 않습니다.

const [searchIndex] = useState(() => toSearchIndex(product_list));
// 서버 렌더링을 하지 않는 클라이언트 전용 화면에서만 저장소를 초기값으로 읽음
const [draftFilter] = useState(() => parseStoredProductFilter(localStorage.getItem("product-filter")));

함께 보기

10.3 측정한 근거가 있을 때만 무거운 렌더를 미룹니다

측정한 렌더 병목에만 트랜지션과 지연 값을 적용합니다.

startTransition, useTransition, useDeferredValue는 렌더 비용을 측정한 뒤 사용합니다. 목록 행 수와 조작별 소요 시간을 확인하고, 예상 규모만 근거로 삼지 않습니다.

지연 도구 고르기

flowchart LR
	q1{"무거운 렌더를 일으키는<br>갱신 함수를 직접 부르는가?"} -- 예 --> q2{"트랜지션 진행 표시가<br>필요한가?"} -- 아니요 --> r3("startTransition")
	q1 -- 아니요 --> r1("useDeferredValue")
	q2 -- 예 --> r2("useTransition 의 isPending")
상황선택
직접 호출하는 상태 갱신이 무거운 렌더를 일으킴startTransition으로 호출을 감쌉니다. 프롭으로 받은 갱신 함수도 같습니다
값은 즉시 반응해야 하지만 파생 렌더는 미룰 수 있음useDeferredValue로 지연 값을 만듭니다
갱신 함수를 호출할 수 없고 프롭, 훅 반환값만 받음useDeferredValue를 씁니다
트랜지션 진행 표시가 필요함대기 상태를 주지 않는 startTransition 대신 useTransition의 isPending을 씁니다

입력값 자체, 폼 오류, 즉시 비활성화 같은 급한 반응은 트랜지션에 넣지 않습니다. await 뒤에는 React가 트랜지션 문맥을 이어 가지 못하므로 상태 갱신을 다시 startTransition으로 감쌉니다.

최적화 조건

무거운 작업최적화 조건
하위 트리 렌더같은 프롭이면 렌더를 건너뛸 수 있어야 합니다. 컴파일러가 이를 제공하지 않으면 지연 값을 받는 컴포넌트를 memo로 감쌉니다
함께 전달하는 객체, 콜백매번 달라지면 memo가 있어도 다시 렌더되므로 참조를 확인합니다
지연 값에서 파생되는 계산컴포넌트 memo 대신 useMemo로 지연 값이 바뀔 때만 재계산합니다. 측정 근거를 주석으로 남깁니다

startTransition의 콜백은 즉시 실행되며 안쪽의 무거운 동기 계산이나 네트워크 요청 자체를 미루지 않습니다. useDeferredValue는 고정 지연 시간이 없고 요청 횟수를 줄이는 디바운스도 아닙니다.

행 20개 목록을 다시 렌더하는 갱신까지 트랜지션으로 감쌉니다.

const [selectedCategoryId, setSelectedCategoryId] = useState("all");
const categoryRows = responseCategoryListSuspense.data.categories.slice(0, 20);

const handleCategoryClick = (nextCategoryId: string) => {
	startTransition(() => {
		setSelectedCategoryId(nextCategoryId);
	});
};

return <UiCategoryRows rows={categoryRows} selectedCategoryId={selectedCategoryId} />;

측정 근거가 있는 갱신만 트랜지션으로 감싸고 행 20개 목록은 그대로 둡니다.

const handleCategoryClick = (nextCategoryId: string) => {
	setSelectedCategoryId(nextCategoryId);
};

const handleStatusFilterChange = (nextStatus: ProductStatusFilter) => {
	// 행 12,000개에서 필터 전환에 320ms, 클릭이 밀림
	startTransition(() => {
		setStatusFilter(nextStatus);
	});
};

입력과 무거운 파생 렌더를 같은 값에 묶습니다.

const [keyword, setKeyword] = useState("");
const filteredRows = rows.filter((row) => fuzzyMatchRow(row, keyword));

입력은 즉시 반응하고 무거운 파생 계산만 늦춥니다.

const [keyword, setKeyword] = useState("");
const deferredKeyword = useDeferredValue(keyword);

// 행 12,000개에서 매 렌더 필터링 180ms 측정. 지연한 검색어에만 재계산
const filteredRows = useMemo(() => {
	return rows.filter((row) => fuzzyMatchRow(row, deferredKeyword));
}, [deferredKeyword, rows]);

return <PgProductRows rows={filteredRows} />;

진행 표시가 필요하면 useTransition의 isPending을 씁니다.

const [isPending, startTransition] = useTransition();

const handleStatusFilterChange = (nextStatus: ProductStatusFilter) => {
	// 행 12,000개에서 필터 전환에 320ms, 클릭이 밀림
	startTransition(() => {
		setStatusFilter(nextStatus);
	});
};

return <UiFilterBar isBusy={isPending} onStatusChange={handleStatusFilterChange} />;

await 뒤의 상태 갱신은 다시 startTransition으로 감쌉니다.

const handleStatusFilterChange = (nextStatus: ProductStatusFilter) => {
	startTransition(async () => {
		const nextRows = await fetchFilteredRows(nextStatus);

		// await 뒤에는 트랜지션 범위가 끊김. 다시 감싸야 급하지 않은 갱신으로 남음
		startTransition(() => {
			setRows(nextRows);
		});
	});
};

지연 값을 받는 무거운 하위 트리는 memo로 감쌉니다.

// 행 12,000개 표. 부모가 입력값으로 다시 렌더할 때 이 트리까지 따라 그리지 않도록 memo로 감쌈
export const PgProductRows = memo((props: PgProductRowsProps) => {
	return <UiTable rows={props.products} />;
});

함께 보기