리액트에서 렌더링 과정에 대해 알아보자.
브라우저에서의 렌더링
브라우저에서의 렌더링이란 HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그리는 과정을 의미한다. 사용자가 결국 보게되는 최종 결과물을 만드는 작업이기때문에 중요한 과정이며 렌더링 방식이 성능에도 크게 작용하게 된다.
리액트의 렌더링
리액트에서도 렌더링이 존재한다. 리액트의 렌더링은 DOM트리를 만드는 과정이다.
리액트의 렌더링과 브라우저의 렌더링은 조금 다르므로 명확히 정의하고 넘어갈 필요가 있다.
브라우저의 렌더링 : HTML,CSS로 웹 브라우저를 만드는 것
리액트의 렌더링 : 컴포넌트들이 어떻게 UI를 구성하고 어떤 DOM결과를 브라우저에 제공할지 계산하는 과정
렌더링 시나리오
리액트에서 렌더링이 발생하는 시나리오는 아래와 같다.
1. 최초 렌더링 : 사용자가 처음 애플리케이션에 진입하는 경우
2. 클래스형 컴포넌트 setState 변화
3. 클래스형 컴포넌트의 forceUpdate : 강제로 리렌더링을 시킬수 있음
4. 함수형 컴포넌트 useState의 setter함수 실행
5. 함수형 컴포넌트 useReducer() 의 dispatch실행
6. 컴포넌트의 key props 변경
리액트의 key props가 필요한 이유?
보통 배열로 하위 컴포넌트를 사용하다 보면 key를 설정해 달라는 경고를 본적이 있을 것이다. 리액트의 key는 리렌더링이 발생하는 동안 형제 요소들 사이에서 동일한 요소를 식별하는 값이다. 리렌더링이 발생하면 리액트는 current트리와 workInProgress트리 사이에서 어떤 컴포넌트 변경이 있었는지 구별한다.
리액트 파이버 트리 구조에서 형제 컴포넌트를 구별하기 위해서 sibling 속성을 사용하는데 만약 key가 없다면 이 sibling인덱스만을 기준으로 판단하게 된다.
렌더링 프로세스
렌더링 프로세스가 시작되면 리액트는 컴포넌트의 루트부터 아래쪽으로 뻗어가며 업데이트가 필요하다고 지정되어 있는 컴포넌트를 찾은 후 클래스형 컴포넌트라면 render()를, 함수형 컴포넌트라면 FunctionComponet() 즉 그 컴포넌트를 호출한 후 결과를 저장한다.
이 결과물은 JSX문법으로 구성되어 있기 때문에 React.createElement()를 호출하는 구문으로 변환된다. 이런 렌더링 결과물을 수집한 이후 가상 DOM과 비교하여 실제 DOM에 반영하기 위한 변경사항을 수집한다.
이 과정이 바로 리액트의 재조정(Reconciliation) 이며 이 과정이 모두 끝난 이후 하나의 동기 시퀀스로 DOM에 적용하여 결과물이 보이게 되는 것이다.
이때 리액트는 렌더 단계와 커밋 단계 2단계로 나뉜다.
렌더
컴포넌트를 렌더링 하고 변경사항을 계산하는 작업이다.
크게 type , props , key 3가지를 보며 3개중 하나라도 변경된 것이 있으면 변경될 컴포넌트로 체크한다.
커밋
렌더 단계의 변경 사항을 실제 DOM에 적용하여 사용자에게 보여주는 단계
리액트가 DOM을 커밋단계에서 업데이트 하여 만들어진 DOM 노드 및 인스턴스를 가리키도록 리액트 내부의 참조를 업데이트한다.
클래스형 컴포넌트에서는 componentDidMount,componentDidUpdate 메서드를 호출하며 함수형 컴포넌트에서는 useLayoutEffect 훅을 호출한다.
위 렌더&커밋단계에서 알 수 있듯이 리액트의 렌더링이 일어났다고 반드시 DOM이 업데이트 되진 않는다.
렌더링 결과로 변경사항이 없다면 커밋단계는 생략되지 않는다!
이 두단계로 구성된 리액트의 렌더링은 항상 동기식으로 작동했었다. 따라서 이 렌더링 과정이 길어지는 경우 성능저하로 이뤄졌다.
지금은 렌더링이 비동기식으로 진행된다 했는데 사실 이 방식도 장점만 존재하는 것은 아니다.
비동기식 렌더링의 장점
특정 작업이 시간이 오래걸리는 경우 상대적으로 빠른 다른 작업을 먼저 보여줄 수 있다.
비동기식 렌더링의 단점
하나의 상태에 대해 여러가지 UI를 보게 될 수도 있다.
A작업이 완료되었는데 B,C가 완료되지 않아 사용자에게 혼란을 줄 수 있다.
중요한점은 리액트의 렌더링은 상위컴포넌트가 변경되었다면 하위 컴포넌트도 무조건 리렌더링이 일어나게 된다. 이를 해결하기 위해서는 적절히 memo를 활용하여 커밋단계를 생략하게 할 수 있다.
memo를 사용해도 렌더단계는 수행되지 않는다. 렌더단계 수행 이후 props가 변경되지 않으면 렌더링이 생략될 뿐이다.
리액트에서는 useMemo,useCallback , memo 컴포넌트를 제공한다.
그럼 이 최적화를 위한 메모이제이션 기법은 언제 사용하는 것이 좋을까?
렌더링이 자주 일어나는 컴포넌트
렌더링이 일어날 것 같은 컴포넌트에 몽땅 추가
무거운 연산의 기준??
함수의 실행속도??
렌더링 비용 vs 메모이제이션 비용
모든 컴포넌트를 메모이제이션?!
위와같이 메모이제이션 최적화에 관한 논쟁은 리액트 커뮤니티에서 꾸준히 이어졌다.
주장1) 꼭 필요한 곳에만 메모이제이션을 추가하자.
function sum(a,b){
return a+b
}
위와같이 극단적으로 간단한 함수가 있다고 생각해보자.
Q) 함수의 결과를 메모이제이션하기 vs 매번 새로운 계산을 하기
물론 위 예시는 극단적이지만 대부분 가벼운 작업 대부분은 매번 작업을 수행하는 것이 오히려 더 빠른 경우도 많다.
또 메모이제이션은 분명 비용이 든다.
값을 비교하고 렌더링 할지말지 계산
결과물을 저장해두었다 꺼내와야한다는 비용
따라서 섣부른 최적화(premature optimization)는 문제가 될 수 있고 경계해야 한다.
만약 이런 비교,렌더링이 문제가 되었다면 리액트는 모든 컴포넌트를 이미 PureComponent로 만들었을 것이다. 혹은 모든 컴포넌트를 memo로 감싸거나,
리액트의 아브라모프는 이런 트윗을 남긴적이 있다고 한다.
아무데서나 memo를 쓰지 말것.
왜 모든 컴포넌트에 memo()를 기본값으로 사용하지 않나요?
스스로에게 물어보세요 ? 왜 lodash의 모든 함수에 memoize()를 사용하지 않나요? 그게더 빠르지 않나요? 성능을 확인해볼 필요가 있지 않을까요?
또한 리액트 공식문서에도 useMemo관련 내용이 있다.
https://react.dev/reference/react/useMemo
또한 리액트의가 캐시결과를 저장하려고 하겠지만 캐시가 무효화되는 경우또한 있을 수 있다. 미리 개발자가 렌더링이 많이 될 것 같은 부분을 예상하고 메모이제이션하기 보다는 애플리케이션을 어느정도 제작 이후 개발자도구,useEffect등으로 렌더링이 되는 부분을 확인하고 최적화 하는 것이 옳다.
주장 2) 렌더링 과정은 비싸다. 모조이 메모이제이션 해야한다.
우선 양쪽 주장에서 공통으로 깔고가는 전제가 있다. 일부 컴포넌트에서 메모이제이션 하는것이 분명 성능에 도움이 된다는 것.
1. memo를 일부 컴포넌트에만 적용
2. memo를 모든 컴포넌트에 적용
당연히 1번 방법이 이상적인 방법이란것은 알고 있다. 하지만 리액트 APP 규모가 커지고 협업이 이루어질수록 이 기조를 유지하기는 어렵다. 또한 개발자들이 실무에서 최적화,성능향상에 쏟을 시간은 그렇게 많지 않다. 따라서 memo를 우선 감싸고 생각해보는게 좋다 라는 의견이 있다.
그렇다면 굳이 memo로 안감싸도 되는 컴포넌트에 memo를 감싸서 역으로 생기는 비용을 생각해보자.
해당 비용은 props에 대한 얕은 비교가 발생하면서 생기는 비용이다. 메모이제이션을 위해서는 CPU,메모리를 사용하여 이전 렌더링 결과물을 저장하고 리렌더링 할필요가 없다면 이전 작업을 쓰는 것이다.
그런데 해당 방법은 기본적인 리액트의 재조정 알고리즘과 똑같고 어차피 리액트는 렌더링 결과를 다음 렌더링과 구별하기 위해서 저장해둬야한다.
자, 이제 memo를 사용하지 않았을때 생기는 문제를 보자.
1. 렌더링 비용
2. 컴포넌트 내부의 복잡한 로직 실행
3. 1-2번 과정이 자식 컴포넌트에서 반복시행
4. 리액트가 구트리-신규 트리를 비교
그렇다면 memo를 사용하였을때의 문제는?
props에 대한 얕은 비교를 하며 생기는 비용
이런 상황때문에 memo를 사용안할 이유가 없다는 주장이 있다.
useMemo와 useCallback
useMemo와 useCallback을 사용하여 의존성 배열을 비교하고 값을 재계산하는 과정 vs 값과 함수를 매번 재생성 하는 과정중 무엇이 저렴한지 매번 계산해야하는데, 그렇다면 이과정또한 메모이제이션을 무조건 적용하는것을 고려해볼만 하다.
또한 리렌더링이 발생할때 메모이제이션과 같은 조치가 없다면 모든 객체가 재생성되기 때문에 이 값을 useEffect와 같은 의존성 배열에 쓰면 문제가 생길 수 있다.
import React, { useEffect, useState } from 'react'
function useMath(number: number) {
const [double, setDouble] = useState(0)
const [triple, setTriple] = useState(0)
useEffect(() => {
setDouble(number * 2)
setTriple(number * 3)
}, [number])
return { double, triple }
}
const App = () => {
const [counter, setCounter] = useStatE(0)
const value = useMath(10)
useEffect(() => {
console.log(value.double, value.triple)
}, [value])
function handleClick() {
setCounter(counter + 1)
}
return (
<>
<h1>{counter}</h1>
<button onClick={handleClick}>+</button>
</>
)
}
/* STYLE */
export default App
위 예시에서 console.log가 계속 출력되는것을 확인할 수 있다.
App의 호출 -> useMath의 호출 -> 객체내부의 값은 같지만 참조가 변함
useMemo를 활용하면 이 문제를 해결할 수 있다.
function useMath(number: number) {
const [double, setDouble] = useState(0)
const [triple, setTriple] = useState(0)
useEffect(() => {
setDouble(number * 2)
setTriple(number * 3)
}, [number])
return useMemo(()=>({ double, triple }),[double,triple])
}
정리
양쪽 의견 모두 메모이제이션이 최적화에 도움이 된다는건 동의한다. 다만 이를 얼마나 적용하는지에 대한 관점이 다를 뿐이다. 책의 저자는 이렇게 설명하고 있다.
시간여유가 있고 리액트에 대한 깊은 공부를 하고있다면 1번방법처럼 섣부른 메모이제이션을 지양한느것이 좋다.
현업에서 리액트를 사용하고 있지만 시간적여유가 없다면 의심스러운곳에 모두 적용해보는 것이 괜찮을 수 있다.
props에 대한 얕은비교보다 리액트 컴포넌트를 다시 계산하는 비용이 매우 비싸기 때문이다.
리액트의 렌더링 , 메모이제이션에 대해 알아보았는데 너무 유익한 정보였던 것 같다. 앞으로 프로젝트에도 도입해 봐야겠다.
'FrontEnd > Deep Dive' 카테고리의 다른 글
[React] Deep Dive 모던 리액트(9) useMemo,useCallback,useRef (0) | 2023.12.22 |
---|---|
[React] Deep Dive 모던 리액트(8) useState & useEffect (1) | 2023.12.21 |
[React] Deep Dive 모던 리액트(6) 클래스형&함수형 컴포넌트 (0) | 2023.12.19 |
[React] Deep Dive 모던 리액트(5) 가상 DOM & 리액트 파이버 (1) | 2023.12.18 |
[React] Deep Dive 모던 리액트(4) JSX (1) | 2023.12.18 |