이런 분들에게 글을 추천드립니다.
1. setState를 동일한 값으로 업데이트 시 렌더링을 스킵한다는 말이 렌더링 프로세스의 시작을 예방한다거나 컴포넌트 함수 재호출을 예방한다는 의미는 아니라는 걸 모르시는 분
2.setState의 동일한 값 업데이트 시 렌더링 스킵이 이벤트 핸들러에서는 잘 작동하지만 useEffect에서는 잘 되지 않는 것처럼 보이는 이유가 궁금하신 분
문제 상황 설명
이런 코드가 있다고 가정하겠습니다.
//예시1
import React, { useState } from "react";
function App() {
const [state, setState] = useState(false);
console.log("컴포넌트 호출");
return (
<div>
<p>state: {state.toString()}</p>
<button id="btn1" onClick={() => setState(state)}>동일한 state로 업데이트</button>
</div>
);
}
export default App;
이 코드를 실행하면 콘솔에서 "컴포넌트 호출"이 한번 출력될 겁니다.
이제 button#btn1을 클릭하겠습니다.
그러면 아무 일도 일어나지 않습니다. 왜냐하면 동일한 state로 업데이트하려 했으니까요.
리엑트는 동일 값으로 setState를 하면 리렌더링 과정을 스킵한다고 알려져 있거든요.
문제는 다음부터입니다. 위 코드에서 하다가 다른 state를 하나 추가하고 useEffect를 이용해서 이전과 동일한 값으로 업데이트 해보겠습니다.
//예시2
import React, { useEffect, useState } from "react";
function App() {
const [state, setState] = useState(false);
const [useEffectState, setUseEffectState] = useState(false);
console.log("컴포넌트 호출");
useEffect(() => {
console.log("useEffect");
setUseEffectState(useEffectState);
// 함수형 업데이트를 사용해도 결과는 동일함
}, [state]);
return (
<div>
<p>depState: {useEffectState.toString()}</p>
<p>state: {state.toString()}</p>
<button id="def-state" onClick={() => setState(!state)}>다른 state로 업데이트</button>
<button id="same-state" onClick={() => setState(state)}>동일한 state로 업데이트</button>
</div>
);
}
export default App;
이 코드는 button#def-state를 클릭할 시 state가 업데이트되며 리렌더링됩니다.
그러면 console.log("컴포넌트 호출");가 실행되며 콘솔에 한번 출력됩니다.
그 다음에 useEffect가 비동기적으로 실행되며 useEffectState를 이전과 동일한 값으로 업데이트합니다.
이 때 다음 결과는 어떨까요?
두 가지로 예측가능합니다.
- useEffect에서 state를 변경했으니 리렌더링이 일어나며 console.log("컴포넌트 호출");가 한번 더 출력될 것이다.
- useEffect에서 이전과 동일한 state로 변경했으니 console.log("컴포넌트 호출");가 추가로 출력되지 않는다.
답은 뭘까요? 바로 아까 전에 동일한 state로 변경은 리렌더링이 스킵된다고 했으니까 2번 아닐까요?
아쉽게도 1번입니다!
왜 이런 결과가 나왔을까요?
리렌더링이 스킵된다는 말의 근거
동일 값 업데이트 시 리렌더링이 스킵된다는 이야기는 공식문서의 아래 부분에서 파생된 것 같습니다.
If your update function returns the exact same value as the current state, the subsequent rerender will be skipped completely.
만약 업데이트 함수에서 현재 상태값과 동일한 값을 리턴하면, 리렌더링 과정은 완전히 생략됩니다.
If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)
만약 state hook을 동일한 값으로 업데이트하면, 리엑트는 bail out(구제 혹은 중간 생략 정도로 번역했습니다.)합니다. 자식 컴포넌트의 렌더링이나 이펙트를 발생시키지 않고요.
출처
https://legacy.reactjs.org/docs/hooks-reference.html?fbclid=IwAR3rAXffa8s5dx1DSJgf_KNnvm9Roz39goXCinDpD3QJ1zl76OeKds7pMTs#bailing-out-of-a-dispatch
이 문제를 겪기 전에 저는 이 부분을 읽고 다른 분들의 블로그 글을 참조하여 이렇게 해석했었습니다.
- setState는 상태값 변경 요청을 예약한다. 예약은 setState를 호출한 시점에 동기적으로 이뤄지나, state 변경은 비동기적으로 한꺼번에 요청 사항을 모아 실행된다.(자세한 건 react의 batch update, 파이썬 아키텍처를 참고)
- 실행된 요청은 이전 상태값과 새 상태값이 동일한지 검사하는 비교 알고리즘(Object.is)을 수행한다. 그 결과로 두 값이 다르다 판단되면 렌더링을 실행한다. 이 때 렌더링이란 리엑트의 동작 프로세스에서 컴포넌트 함수를 재호출하는 렌더링 프로세스, 실제 돔에 반영하는 커밋 프로세스를 아울러 의미한다.”
- 두 값이 같다고 판단되면 렌더링 과정을 시작조차 하지 않는다.
여기서 중요한 건 "상태값 변경 요청 예약 => 요청 실행 => 비교 알고리즘 => 결과에 따라 조건부 리렌더링"이라는 과정이 마치 동기적인 것 처럼 일어난다는 겁니다.
중단에 관해 오해가 있는 것 같습니다.
저와 유사한 생각을 가진 분이 있었고 그는 리엑트의 github에다가 이슈를 등록했습니다.
'왜 내 코드에서 동일한 상태 값으로 변경했는데, bailing out(중단)이 일어나지 않았죠? "
리엑트 레포지토리의 collaborator인 gaearon의 대답했고, 이 내용을 기반으로 다른 토론의 답변자가 제시한 의견과, 제가 슬랙으로 구한 대답을 크로스체크한 결과는 이렇습니다.
- setState를 통한 상태값 변경 요청은 비교알고리즘으로 먼저 검사를 받은 후 리렌더링을 일으키는게 아닙니다. 항상 리렌더링을 트리거하되, 비동기적으로 비교 알고리즘이 실행되어 렌더링 프로세스를 조기 중단하는 것입니다.
- 비교 알고리즘에 의한 조기 중단은 리엑트의 렌더링 프로세스와 커밋 프로세스 중 후자를 예방(prevent)한다는 의미에 가까우며 렌더링 프로세스 시작의 예방을 보장하지 않습니다.
- bail out 기능은 자식 컴포넌트의 리렌더링을 방지하는 것이며 리렌더링 요청에 의한 state를 가진 컴포넌트 함수의 재호출을 방지하는 게 아닙니다.
- 상태값 변경 요청은 요청 시점에 따라 비교 알고리즘에 의한 최적화가 다르게 나타날 수 있습니다. 특히 렌더링 단계에서의 업데이트 요청은 항상 다시 실행합니다.
- useEffect가 실행되는 시점은 렌더링 프로세스 이후와 커밋 프로세스 이전입니다.
- 반면, 이벤트 핸들러가 실행되는 시점은 사용자가 실제 화면과 상호작용하는 단계이므로 커밋 프로세스 이후입니다.
쉽게 말해서, 동일 값 업데이트 요청 시 리렌더링이 되지 않는다는 해석은 오해라는 것입니다. 위 사항을 분석해볼까요?
비교 알고리즘은 리렌더링을 사전에 방지하는 게 아닙니다. "조기 중단"하는 겁니다.
먼저 리렌더링에 대해 짚고 넘어갑시다.
리엑트에서 동작 방식은 크게 렌더링 프로세스와 커밋 프로세스로 구성됩니다.
렌더링 프로세스는 각 컴포넌트 함수를 호출하여 리엑트 엘리먼트 구성, 엘리먼트를 연결하여 가상돔을 만드는 작업입니다. 커밋 프로세스는 렌더링 프로세스에서 산출된 가상돔의 내용을 실제 돔에 반영하는 것입니다.
리렌더링이 요청되면 위 프로세스를 다시 실행합니다. 다만, 렌더링 프로세스는 최초 렌더링과 다르게 새 가상돔을 구성하고 이전 가상돔과 비교하여 변경사항만을 산출하는 재조정 작업에 들어갑니다.
다시 예시2로 돌아가보겠습니다.
//예시2
import React, { useEffect, useState } from "react";
function App() {
const [state, setState] = useState(false);
const [useEffectState, setUseEffectState] = useState(false);
console.log("컴포넌트 호출");
useEffect(() => {
console.log("useEffect");
setUseEffectState(useEffectState);
// 함수형 업데이트를 사용해도 결과는 동일함
}, [state]);
return (
<div>
<p>depState: {useEffectState.toString()}</p>
<p>state: {state.toString()}</p>
<button id="def-state" onClick={() => setState(!state)}>다른 state로 업데이트</button>
<button id="same-state" onClick={() => setState(state)}>동일한 state로 업데이트</button>
</div>
);
}
위 컴포넌트에서 button#def-state를 클릭했을 때 useEffect에서 발생하는 setState 요청은 동일한 값으로 변경임에도 리렌더링이 일어나는 이유가 궁금했죠?
그런데, 이 때 리렌더링이 일어났다고 생각하는 이유가 뭐였죠?
바로 console.log("컴포넌트 호출");가 콘솔에 한번 더 출력되었기 때문입니다.
이걸로 버튼을 클릭했을 때 컴포넌트 함수의 재호출 횟수를 기록했고, 이것이 한번 더 출력되었으므로 리렌더링이 두 번 일어났다고 생각했던 겁니다.
그런데, 실제로는 그렇지 않습니다. 이를 증명하기 위해서 크롬의 react-dev-tool 익스텐션을 설치했습니다.
리엑트의 리렌더링이나 성능 측정에 유용한 툴입니다.
이 툴을 사용해서 버튼을 클릭했을 때 몇 번 리렌더링이 일어나는지 보겠습니다.
결과처럼, 리렌더링은 한번 일어났습니다. 즉, useEffect에서의 동일 값 업데이트 요청은 중단된 것이 맞습니다!
이를 정리하면, 아래와 같습니다.
useEffect에서의 업데이트 요청으로 리렌더링이 발생하며 컴포넌트 함수가 재호출되어 콘솔에 글자가 재출력되었다. 하지만, 동일 값 업데이트에 의해 비교 알고리즘에서 잡히며 후속 리렌더링 과정은 중단되었다.
이 때문에 조기 중단이 컴포넌트 함수의 호출 자체를 예방하는 건 아니라는 겁니다.
추측이지만, 비교 알고리즘은 리렌더링 과정에서 비동기적으로 실행되어서 그 결과에 따라 진행중인 렌더링을 중도에 중단하는 것 같습니다.
bail out은 자식 컴포넌트의 호출을 리렌더링을 방지하는 것이며 state를 가진 컴포넌수의 재호출을 방지하는게 아닙니다.
예시2의 코드에다가 자식 컴포넌트를 추가해보겠습니다. 아래의 Test 컴포넌트는 호출될 때마다 콘솔에 "하위 컴포넌트 호출"이 찍힐 겁니다.
//@Test.jsx
import React from "react";
function Test() {
console.log("하위 컴포넌트 호출");
return <div className="test">하위 컴포넌트</div>;
}
export default Test;
//@App.jsx
import React, { useEffect, useState } from "react";
import Test from "./Test";
function App() {
const [state, setState] = useState(false);
const [useEffectState, setUseEffectState] = useState(false);
console.log("컴포넌트 호출-----");
useEffect(() => {
console.log("useEffect");
setUseEffectState(useEffectState);
// 함수형 업데이트를 사용해도 결과는 동일함
}, [state]);
return (
<div>
<p>depState: {useEffectState.toString()}</p>
<p>state: {state.toString()}</p>
<button id="def-state" onClick={() => setState(!state)}>다른 state로 업데이트</button>
<button id="same-state" onClick={() => setState(state)}>동일한 state로 업데이트</button>
<Test />
</div>
);
}
export default App;
코드를 실행하고 아까처럼 button#def-state를 클릭해보겠습니다.
버튼을 클릭하면 state가 다른 값으로 변경되며 해당 컴포넌트와 하위 컴포넌트가 재호출되고 리렌더링됩니다.
그 다음에 useEffect로 동일값 업데이트가 이뤄지면 앞서 본 '중단 규칙'대로 해당 컴포넌트의 재호출이 이뤄진 후에 리렌더링이 스킵되지만 자식 컴포넌트는 호출조차 되지 않습니다.
이게 bail out입니다. 해당 컴포넌트가 아니라 자식 컴포넌트의 리렌더링을 방지하여 성능을 올리는 기능이죠.
전 이게 해당 컴포넌트에도 적용되는 줄 오해했던 겁니다.
이벤트 핸들러에서의 동일 값 업데이트 요청과 useEffect에서의 동일 값 업데이트 요청은 리엑트의 동작 프로세스 내에서 호출 시점이 달라서 비교 최적화 프로세스도 다릅니다.
리엑트에서 리렌더링을 일으키는 경우는 다음과 같습니다.
- 컴포넌트의 state가 변경된 경우
- 부모 컴포넌트가 리렌더링되는 경우
- (memo HOC를 사용한 경우) props나 다른 의존성이 변경된 경우
그러면 setState를 호출하는 것이 리렌더링 요청을 하는 것이라 생각해보겠습니다.
useEffect는 컴포넌트 라이프 사이클에서 마운트, 업데이트, 언마운트 시점의 이후, 커밋 이전에 실행됩니다.
반면, 이벤트 리스너의 콜백 함수는 커밋 이후에 사용자의 상호작용 과정에서 실행되는게 일반적입니다.
이 시점의 차이가 비교 알고리즘과 리렌더링 중단 시점에 중요한 영향을 끼칩니다.
아까의 github에 오른 이슈의 답변에서도 렌더링 단계에서의 업데이트 요청은 항상 다시 실행한다고 했으니 useEffect에서의 동일값 업데이트 요청은 다른 시점에 일어나는 이벤트 리스너와 다를 수 밖에 없는 것 같습니다.
결론
setState의 동일 값 업데이트 요청은 리렌더링을 조기 중단할 수 있지만, 사전에 예방할 순 없습니다. 즉, 컴포넌트 함수의 재호출을 막을 수는 없습니다.
답변자들은 애초에 이것이 성능 개선을 위한 최적화의 일부이지, 이걸로 어떤 다른 결과를 만들려고 하면 안된다고 경고합니다.
리엑트는 동일한 state에서 동일한 산출물이 나와야 한다는 원칙을 가지고 있습니다.
따라서 렌더링 횟수에 따라 산출물이 달라진다는 건 state가 아닌 다른 외부 변수에 의존할 가능성을 의미하며 이는 바람직하지 않습니다.
저 역시도 단순히 기술적 호기심으로 시작했던 질문이며, state 변경 요청을 동일하게 하고 그 렌더링 중단으로 뭔가를 하는 로직은 사용해본 적도 없고 사용할 기회도 없는 것 같더군요.
다른 것보다도 컴포넌트 함수 호출이 이뤄지면 반드시 이후의 렌더링 프로세스와 커밋 프로세스가 이뤄진다고 생각했는데, 그것이 중도에 중단될 수 있다는 점을 배웠습니다.
그런데, bail out을 실험해보니 자식 컴포넌트의 리렌더링은 재호출조차도 방지하는 것 같더군요.
이 부분을 잘 연구하면 성능 개선에 좋은 답이 나오지 않을까 생각합니다.
참고 자료
'react' 카테고리의 다른 글
[React] CSR에서 SSR로의 전환, react 18은 어떤 SSR을 하는가. (1) | 2024.12.12 |
---|---|
[react] 함수형 컴포넌트 내에 다른 함수형 컴포넌트를 선언할 때 생길 수 있는 문제(trouble-shooting) (0) | 2024.08.01 |
[react] useEffect에서 clean up함수를 사용하면서 early return하기 (0) | 2024.04.16 |
[React] 상태관리 라이브러리 비교하기(redux, zustand, jotai, recoil) (0) | 2024.03.22 |
[react] 상태관리 라이브러리는 정말로 필요한가?(context api) (0) | 2024.03.20 |