home

렌더링 중 만나는 setState의 효율성

글 분류
main
키워드
performance
생성일
2023/06/28 00:32
최근 수정일
2025/02/20 07:04
작성중

⚽️ 목표

렌더링 도중 state update을 테스트해보자.
import { useState } from 'react'; export default function CountLabel({ count }) { const [prevCount, setPrevCount] = useState(count); const [trend, setTrend] = useState(null); if (prevCount !== count) { setPrevCount(count); setTrend(count > prevCount ? 'increasing' : 'decreasing'); // return null } return ( <> <h1>{count}</h1> {trend && <p>The count is {trend}</p>} </> ); }
TypeScript
복사
공식 문서를 읽던 도중 “렌더링 중에 조건문에 해당될경우 state를 update하는 패턴”을 알게됐다. 공식 문서 상에는 “렌더링 중 state가 set될 경우 바로 해당 컴포넌트의 return 문을 실행하고 children을 렌더링하지 않고 다시 리렌더링한다. 이로 인해 children은 두번 렌더링되지 않는다.” 라고 설명하고 있다.
추가적으로 if문에 early return을 할 경우 redering을 더빨리 할 수 있다고한다. 이 코드의 퍼포먼스적 장점과 제한사항을 react profiler를 이용해 알아보자.

TL;DR

early return을 이용한 렌더링 중 setState를 자주 활용하자.
벤치마크 상으로도 제일 좋은 결과를 보이고 있으며 실제로도 꼭 필요한 연산만 수행된다.
해당 패턴은 최대한 피하는게 좋지만, 어쩔 수 없이 변하는 값을 state에 담아야할 경우 렌더링중 early return setState를 활용하자.

1. 들어가기 전에

개발모드에선 setState의 순수성을 검사하기 위해 setState가 두번 실행된다. 해당 포스트에선 실제 렌더링 횟수 계산에 오해가 생기지 않게 strict mode 설정을 끄고 진행했다.

목표(확인하고자 하는 케이스)

1.
렌더링 중 setState - return 없는 경우
부모 컴포넌트에서 렌더링 도중 setState가 수행된다면 자식 컴포넌트는 렌더링 하지 않을까?
2.
early return시 자식 컴포넌트 렌더링 여부
부모 컴포넌트에서 렌더링 도중 setState가 수행되고 return이 수행 될경우 부모 컴포넌트의 렌더링조차 멈추고 다시 처음부터 렌더링 할까?
3.
useEffect를 이용한 업데이트 시 차이점

퍼포먼스 측정 방법

Profiler 컴포넌트 - 렌더간 평균값을 쉽게 구하기 위해 Profiler 컴포넌트를 사용했다.

다루지 않는 것

해당 포스트는 Profiler 컴포넌트의 사용법은 다루지 않는다.

2. 코드

graph TD
  A("PC(ParentComponent)") 
  B("CC(Child Component)")
Mermaid
복사
Parent Component(부모 컴포넌트, “이하 PC”), Child Component(자식 컴포넌트, “이하 CC”)가 존재한다.
PC에 rendering 중 setState 로직이 존재하며 CC는 부모로 부터 받은 props를 출력하는 간단한 컴포넌트다.
increment또는 decrement 버튼을 클릭할 경우 CC에서 출력하는 간단한 로직을 가지고 있는 코드다.

PC(Parent Component)

const ParentComponent: NextPage = () => { // eslint-disable-next-line no-console console.log("부모 컴포넌트 렌더링"); const [count, setCount] = useState(0); const [prevCount, setPrevCount] = useState(count); const [trend, setTrend] = useState<null | string>(null); if (prevCount !== count) { setPrevCount(count); setTrend(count > prevCount ? "increasing" : "decreasing"); } return ( <main> {Boolean(new Array(100000).fill(0).forEach((index, i) => i + 1 + index))} {/* eslint-disable-next-line no-console */} {console.log("부모 컴포넌트 return")} <button type="button" onClick={() => { setCount((prev) => prev + 1); }} > increment </button> <button type="button" onClick={() => { setCount((prev) => prev - 1); }} > decrement </button> <ChildComponent count={count} /> <p>{trend}</p> </main> ); }; export default ParentComponent;
TypeScript
복사
state는 총 3개가 존재한다.
현재 count값을 가지고 있는 count
이전 count 값을 가지고 있는 prevCount
이전 count와 비교했을때 증가한지 감소한지 여부를 나타내는 trend
렌더링 중 setState
if 문 → 조건문이 존재하지 않으면 state가 계속 set된다. state가 계속 set될 경우 무한 렌더링으로 이어진다.
count와 prevCount를 비교하여 count가 현재 increasing인지 decreasing인지 비교한 후 setState하는 로직이 존재한다.
부모 컴포넌트의 렌더링을 확인하기위해 두개의 console.log가 존재한다.
“부모 컴포넌트 렌더링”, “부모 컴포넌트 return” → early return될 경우 부모 컴포넌트의 return문 실행 여부를 확인하기 위해 “부모 컴포넌트 return” console.log도 추가했다.
명확한 퍼포먼스 차이를 확인하기 위해 의미없는 10만개의 배열 생성 및 조작 로직을 추가했다.

CC(Child Component)

export const ChildComponent: FunctionComponent<ChildComponentProps> = ({ count }) => { // eslint-disable-next-line no-console console.log("자식 컴포넌트 렌더링"); new Array(100000).fill(0).forEach((index, i) => i + 1 + index); return <h1>{count}</h1>; };
TypeScript
복사
ChildComponent는 부모로 부터 받은 Props인 Count를 출력한다.
명확한 퍼포먼스 차이를 확인하기 위해 의미없는 10만개의 배열 생성 및 조작 로직을 추가했다.

3-1. 렌더링 중 setState - return 없는 경우

렌더링 도중 state가 set될 경우 해당 컴포넌트의 return까지 실행되지만 자식 컴포넌트는 렌더링 되지 않는다.
sequenceDiagram
	par render phase
		par phase 1
		  PC --> PC: 1. increment 버튼 클릭
		  PC --> PC: 2. PC 버튼 이벤트 핸들러에서 setState -> 리렌더링 시작
			Note right of PC: console.log(부모 컴포넌트 렌더링)
			PC --> PC: 3. if문을 만나 렌더링 도중 setState 추가실행
			PC --> PC: 4. if문 내에 return문이 존재하지 않으므로 PC의 return문 까지 실행
			Note right of PC: console.log(부모 컴포넌트 return)
			PC --> PC: 5. CC의 렌더링은 skip
		end
		par phase 2
		  PC --> PC: 첫번째 렌더링의 1~4까지 다시 실행
			Note right of PC: console.log(부모 컴포넌트 렌더링)
			Note right of PC: console.log(부모 컴포넌트 return)
			PC ->> CC: CC 렌더링
		  CC --> CC: 렌더 수행
			Note right of CC: console.log(자식 컴포넌트 렌더링)
		end
			Note over PC, CC: 렌더 종료
	end
			Note over PC, CC: 변동사항 commit
Mermaid
복사
PC에서 incrememt 버튼 클릭 시 CC는 한번만 렌더 된다.
PC에서 렌더링중 setState 후 return 문이 존재하지 않으므로 return 문 까지 수행한 후 다시 리렌더링 한다. → 이때 CC는 렌더링하지 않는다.
쉬운 구분을 위해 [phase 1], [phase 2]로 구분했지만 실제 렌더는 한번만 이루어진다.

렌더링 퍼포먼스 측정

2.43ms
10번 테스트 결과의 평균값을 확인했다.

3.2 early return 시 부모 컴포넌트 리렌더링 여부

코드상 변경된 부분

렌더링 도중 state가 set됐을 때 early return될 경우 렌더링 퍼포먼스가 개선된다.
sequenceDiagram
	par render phase
		par phase 1
		  PC --> PC: 1. increment 버튼 클릭
		  PC --> PC: 2. PC 버튼 이벤트 핸들러에서 setState -> 리렌더링 시작
			Note right of PC: console.log(부모 컴포넌트 렌더링)
			PC --> PC: 3. if문을 만나 렌더링 도중 setState 추가실행
			PC --> PC: 4. if문 내에 return문이 존재하므로 early return되어<br> 기존 jsx 반환로직이 실행되지 않음
			PC --> PC: 5. CC의 렌더링은 skip
		end
		par phase 2
		  PC --> PC: 첫번째 렌더링의 1~4까지 다시 실행
			Note right of PC: console.log(부모 컴포넌트 렌더링)
			Note right of PC: console.log(부모 컴포넌트 return)
			PC ->> CC: CC 렌더링
		  CC --> CC: 렌더 수행
			Note right of CC: console.log(자식 컴포넌트 렌더링)
		end
			Note over PC, CC: 렌더 종료
	end
		Note over PC, CC: 변동사항 commit

	  
Mermaid
복사
3-1. 렌더링 중 setState - return 없는 경우와 동일하게 최종 setState 시 렌더 한번만 수행된다.
3-1. 렌더링 중 setState - return 없는 경우와 다른점은 기존 jsx return 함수가 수행되지 않았다는 점이다. → jsx return 로직이 실행되지 않으므로 당연하게도 퍼포먼스적 우위가 존재한다.
쉬운 구분을 위해 [phase 1], [phase 2]로 구분했지만 실제 렌더는 한번만 이루어진다.

렌더링 퍼포먼스 측정

2.15ms
10번 테스트 결과의 평균값을 확인했다.
early return 시 유의미한 퍼포먼스 개선이 수행된다.
실제 jsx return 로직은 컴포넌트마다 다르므로 정확한 수치는 아니다. 퍼포먼스 벤치마크를 맹신하지 말자.
와! 퍼포먼스 벤치마크에 대한 좋은글이 있다고???!

3.3 useEffect를 이용한 업데이트 시 차이점

코드상 변경된 부분

퍼포먼스적으로 제일 아쉬운 방식. 총 렌더 횟수는 *2
sequenceDiagram
	par 첫번째 렌더링
	  PC --> PC: 1. increment 버튼 클릭
	  PC --> PC: 2. PC 버튼 이벤트 핸들러에서 setState -> 리렌더링 시작
		Note right of PC: console.log(부모 컴포넌트 렌더링)
		Note right of PC: console.log(부모 컴포넌트 return)
		PC ->> CC: CC 렌더링
	  CC --> CC: 렌더 수행
		Note right of CC: console.log(자식 컴포넌트 렌더링)
		Note over PC, CC: 변동사항 commit
	end
	par 두번째 렌더링
	  PC --> PC: commit후 useEffect는 변동된 state를 감지했으므로<br>useEffect 내부 로직 실행
		Note right of PC: console.log(부모 컴포넌트 렌더링)
		Note right of PC: console.log(부모 컴포넌트 return)
		PC ->> CC: CC 렌더링
	  CC --> CC: 렌더 수행
		Note right of CC: console.log(자식 컴포넌트 렌더링)
		Note over PC, CC: 변동사항 commit
	end
		Note over PC, CC: 렌더 종료
	  
Mermaid
복사
3-1. 자식 컴포넌트의 리렌더링 여부, 3.2 early return 시 부모 컴포넌트 리렌더링 여부의 [phase 1], [phase 2]와 다르게 [첫번째 렌더링], [두번째 렌더링]으로 표기된점을 유의해서 보자.
위 두개 항목은 실제 하나의 렌더링 페이즈이지만 구분의 편의성을 위해 phase 1, phase 2로 구분했다.
하지만 이전 항목과 다르게 이번 항목은 useEffect를 사용했기 때문에 실제로 렌더가 2번 일어난다.
부모컴포넌트도 두번 렌더 되므로 자식 component도 두번 렌더(커밋)된다.

렌더링 퍼포먼스 측정

3.4ms
당연하게도 렌더가 두번되므로 3개의 케이스중 제일 많은 시간이 걸린다.

4. 결론

1.
렌더링 중 setState - return 없는 경우
2.
early return시 자식 컴포넌트 렌더링 여부
3.
useEffect를 이용한 업데이트 시 차이점
가장 효율적인 케이스는 early return을 활용한 2번이다.
의미없는 return 연산도 존재하지 않으며 렌더도 한번에 끝나게된다.
1번과 2번의 차이가 크지 않지만 의미없는 return문 연산이 존재하지 않는 케이스가 당연하게도 퍼포먼스적으로 우위를 가진다.
useEffect를 이용한 방법은 제일 비효율적인 방법이다.
return도 전부다 수행되며 useEffect 특성 상 commit 후 실행되므로 한번의 클릭마다 두번의 commit이 실행된다.
벤치마크 상에서도 제일 오랜 시간이 걸렸으며 필요하지 않은 commit 2회가 제일 큰 단점으로 작용했다.

해당 방식을 사용해야할까?

공식 문서에서도 언급하고 있듯이 “해당 패턴은 흔치않은 패턴이며 실제로 코드를 이해하기가 어렵게 만드는 패턴이다.”
// as-is export default function CountLabel({ count }) { const [prevCount, setPrevCount] = useState(count); const [trend, setTrend] = useState(null); if (prevCount !== count) { setPrevCount(count); setTrend(count > prevCount ? 'increasing' : 'decreasing'); } return ( <> <h1>{count}</h1> {trend && <p>The count is {trend}</p>} </> ); } // to-be export default function CountLabel({ count }) { const [prevCount, setPrevCount] = useState(count); return ( <> <h1>{count}</h1> <p>The count is { count > prevCount : "increasing" : "decreasing"} </p> </> ); }
TypeScript
복사
관리하고자 하는 값을 다른 state로 부터 추론할 수 있을 경우 따로 state로 선언할 필요는 없다.
(공식문서의 예시 코드의 경우 물론 해당 패턴의 예시를 들기위한 코드이지만) 해당 예시 코드의 trend “state를 따로 선언하고 관리할 필요 없이 return 문에서 조건부 렌더링을 이용하여 이용하여 간단하게 prevCount와 count를 이용하여 변경된 count state가 increasing 중인지 decreasing인지 추론할 수 있다.”
대부분의 케이스의 경우 위의 코드 tobe와 같은 방법으로 “가독성과 퍼포먼스 모두를 챙길수 있다.” → rendering 시 setState를 만날경우 아무리 early return이 수행된다할 지라도 다시 rendering을 수행하는건 피할수 없는 퍼포먼스적 단점이다. 한번의 렌더링 중 간단한 조건부 렌더링으로 출력 값을 결정하는것 보다 느릴 수 밖에 없다.
하지만, 어쩔 수 없이 state에 정보를 담아야할 경우 렌더 중 early return setState는 useEffect 보다 좋은 방법이다.
의미없는 children의 render도 없으며, 자기 자신의 return문 연산조차 수행되지 않기 때문이다.

참조