티스토리 뷰

Frontend/react.js

[React JS] 리액트의 렌더링

blueprint-12 2022. 9. 23. 22:24

※해당 글은 원문이 따로 있습니다. 공부용으로 타이핑한 것이니 자세한 내용은 원문을 참고해주세요!

 

리액트에서 렌더링은 DOM을 업데이트하는 것과 같은 것이 아니고, 컴포넌트는 어떠한 가시적인 변경이 없이도 컴포넌트가 렌더링될 수 있다는 것이다.

리액트가 컴포넌트를 렌더링하는 경우를 살펴보면

  • 컴포넌트는 이전과 같은 렌더링 결과물을 리턴해서, 아무런 변화가 일어나지 않을 수 있다.
  • Concurrent Mode*에서는, 리액트는 컴포넌트를 렌더링하는 작업을 여러번 할 수 있지만, 다른 업데이트로 인해 현재 작업이 무효화되면 매번 렌더링 결과물을 버린다. 
이번에 리액트 18에서 나온 Concurrent Mode의 경우, 브라우저가 이벤트를 처리할 수 있도록 렌더링 단계에서 작업을 일시 중지 할 수 있다. 리액트는 해당 작업을 나중에 다시시작하거나, 버리거나, 다시 계산할 수 있다. 렌더링이 패스가 된 이후에도, 리액트는 커밋단계를 한단계 동기적으로 실행한다.

렌더링 순서를 만드는 법

최초 렌더링이 끝난 이후에, 리액트가 리렌더링을 queueing하는 방법에는 여러가지가 있다. 

클래스 컴포넌트

  • this.setState()
  • this.forceUpdate()

함수형 컴포넌트

  • useState()의 setter
  • useReducer()의 dispatches

기타

  • ReactDOM.render()를 호출하는 것 (forceUpdate와 동일) (리액트 18에서는 사라짐)

일반적인 렌더링 동작

리액트의 기본적인 동작은 부모 컴포넌트가 렌더링되면, 리액트는 모든 자식 컴포넌트를 순차적으로 리렌더링한다.

예를 들어, A>B>C>D 순서의 컴포넌트 트리가 있고, B에 카운터를 올리는 버튼이있고 이를 클릭한 상황, 

  1. B의 setState()가 호출되어, B의 리렌더링이 렌더링 큐로 들어간다.
  2. 리액트는 트리 최상단에서부터 렌더링 패스를 시작한다.
  3. A는 업데이트가 필요하다고 체크되어있지 않으므로 지나간다.
  4. B는 업데이트가 필요한 컴포넌트로 체크되어 있으므로, B를 리렌더링한다. B는 C를 리턴한다.
  5. C는 원래 업데이트가 필요한 것으로 간주되어 있지 않았다. 그러나, 부모인 B컴포가 렌더링되었으므로, 리액트는 하위 컴포넌트인 C를 렌더링한다. C는 D를 리턴한다. 
  6. D도 마찬가지로 렌더링이 필요하다고 체크되어있지 않지만 C가 렌더링된 관계로 D도 렌더링한다. 

즉, 컴포넌트를 렌더링하는 작업은, 기본적으로 하위에 있는 모든 컴포넌트 또한 렌더링하게 된다.

또한, 

일반적인 렌더링의 경우, 리액트는 props 가 변경되어 있는지 신경쓰지 않는다. 부모 컴포넌트가 렌더링되어 있기 때문에, 자식 컴포넌트도 무조건 리렌더링된다. 

 

리액트 렌더링 규칙

리액트 렌더링의 중요한 규칙 중 하나는 렌더링은 '순수'해야하고 '부수작용'이 없어야 한다는 것이다.

근데 이는 매우 복잡하고 어렵다. 왜냐하면 대다수의 부수 작용이 왜 일어났는지 뚜렷하지 못하고, 어떤 것도 망가뜨리지 않기 때문이다. 예를 들어, 엄밀히 말하면 console.log()도 부수작업을 야기하지만, 그 어떤 것도 망가뜨리지 않는다. 

props가 변경되는 것은 명백한 부수효과이며, 이는 무언가를 망가뜨릴 수 있다. 렌더링 중간에 ajax호출 또한 부수효과를 일으키고, 이는 요청의 종류에 따라서 명백하게 앱에 예기치 못한 결과를 야기할 수 있다. 

 

Rules of React

위의 글은 렌더링을 포함한 다양한 리액트 라이프 사이클 메소드의 동작과, 어떤 동작이 순수한지 혹은 안전한 지를 나타내고 있으며 요약하자면

 

렌더링 로직이 할 수 없는 것 

  • 존재하는 변수나 객체를 변경해서는 안된다.
  • Math.random(), Date.now()와 같은 랜덤 값을 생성할 수 없다.
  • 네트워크 요청을 할 수 없다.
  • state를 업데이트

렌더링 로직은

  • 렌더링 도중에 새롭게 만들어진 객체를 변경
  • 에러 던지기
  • 아직 만들어지지 않은 데이터를 lazy 초기화 하는 일 (캐시 같은)

등이 가능하다. 

 

컴포넌트 메타데이터와 파이버

리액트는 애플리케이션에 존재하는 모든 현재 컴포넌트 인스턴스를 추적하는 내부 데이터 구조를 가지고 있다. 이 데이터 구조의 핵심적인 부분은, 다음과 같은 메타데이터 필드를 포함하고 있는 Fiber라고 불리는 객체이다.

 React는 컴포넌트 트리에 대한 추가 정보를 포함하기 위해 fibers라는 내부 객체를 사용한다.
  • 컴포넌트 트리의 특정 시점에서 렌더링 해야하는 컴포넌트 타입의 유형
  • 이 컴포넌트와 관련된 prop, state의 상태
  • 부모, 형제, 자식 컴포넌트에 대한 포인터
  • 리액트가 렌더링 프로세스를 추적하는데 사용되는 기타 메타데이터

*리액트 17의 fiber 타입은 여기에서 볼 수 있다.

 

렌더링 패스 동안, 리액트는 fiber 객체의 트리를 순회하고, 새로운 렌더링 결과를 계산한 결과로 나온 업데이트 된 트리를 생성한다. 

fiber 객체는 실제 컴포넌트 prop과 state값을 저장하고 있다. 컴포넌트에서 props와 state의 값을 꺼내서 쓴다는 것은, 사실 리액트는 이러한 값을 fiber 객체에 있는 것으로 전달해준다. 

클래스형 컴포넌트의 경우, 리액트는 컴포넌트를 렌더링하기 직전에 componentInstance.props = newProps를 통해서 복사본을 저장해준다. this.props가 존재한다는 것은, 리액트 내부 데이터 구조의 참조를 복사해 두었다는 의미이다. 

즉, 컴포넌트라는 것은 fiber 객체를 보여주는 일종의 외관이라고 볼 수 있다. 

 

부모 컴포넌트라 렌더링되어 자식 컴포넌트가 주어진다면, 리액트는 fiber객체를 만들어 이 컴포넌트의 인스턴스를 추적한다. 클래스 컴포넌트의 경우, const instance = new YourComponentType(props)가 호출되고 새로운 컴포넌트 인스턴스를 fiber객체에 저장한다. 함수형 컴포넌트의 경우에는, YourComponentType(props)를 호출한다. 

 

컴포넌트 타입과 재조정(Reconciliation)

리액트는 기존 컴포넌트 트리와 DOM구조를 가능한 많이 재사용함으로써 리렌더링의 효율성을 추구한다. 동일한 유형의 컴포넌트, 또는 HTML 노드를 트리의 동일한 위치에 렌더링하도록 리액트에 요청하게 되면, 리액트는 해당 컴포넌트 또는 HTML 노드를 만드는 대신에 해당 업데이트만 적용한다. 즉, 리액트에 해당 컴포넌트 타입을 같은 위치에 렌더링하도록 계속 요청이 있다면, 리액트는 계속 컴포넌트의 인스턴스를 유지한다는 뜻이다. 

클래스 컴포넌트의 경우, 실제 컴포넌트의 실제 인스턴스와 동일한 인스턴스를 사용한다. 함수형 컴포넌트는, 클래스와 같은 느낌의 인스턴스는 없지만, <MyFunctionCompenent/> 가 보여지고 활성화 상태로 유지되고 있다는 관점에서 인스턴스를 나타내는 것으로 볼 수 있다. 

 

렌더링 배치와 타이밍

기본적으로, setState()를 호출하는 것은 리액트가 새로운 렌더링 패스를 시작한다는 뜻이다. 이는 동기적으로 실행되어 리턴된다. 이에 추가적으로, 리액트는 렌더링 배치* 형태의 최적화를 자동으로 실행한다. 

렌더링 배치란? setState()에 대한 여러 호출로 인해 하나의 렌더 패스가 대기열에 저장되어 실행되는 것, 일반적으로 약간 지연된다.
React가 더 나은 성능을 위해 여러개의 state 업데이트를 하나의 리렌더링으로 묶는 것을 의미한다.
React는 16ms 동안 변경된 상태 값들을 하나로 묶는다. (16ms 단위로 배치를 진행한다.)  

🙋‍♂️그렇다면 state 업데이트는 비동기적일 수 있다는 사실은 뭘까?

- 정확히는 setState가 비동기적이다. 

특히, 리액트는 리액트 이벤트 핸들러에서 발생하는 상태 업데이트를 자동으로 일괄적으로 처리한다.

리액트의 이벤트 핸들러는, 일반적인 리액트 어플리케이션에서 매우 큰 부분을 차지하기 때문에, 이는 주어진 앱의 대부분의 상태 업데이트가 실제로 일괄적으로 처리된다는 것을 의미한다. 

하나의 페이지나 컴포넌트 내에도 수많은 상태값이 존재한다. 만약 이 상태 하나하나가 바뀔 때마다 화면을 리렌더링 한다면 문제가 생길수도 있다. 때문에 리액트는 성능의 향상을 위해서 setState를 연속 호출하면 배치 처리하여 한 번에 렌더링하도록 하였다. 아무리 많은 setState가 연속적으로 사용되었어도 배치 처리에 의해서 한 번의 렌더링으로 최신 상태를 유지하는 것이다.

+
useState를 동기적으로 처리하려면 2가지 방법이 있다. 
첫 번째로는, useEffeect의 의존성 배열을 이용하는 것이다. 두 번째로는, setState의 인자로 함수를 집어넣는 것이다.

 

리액트는 이벤트 핸들러를 instability_batchedUpdates 라고 하는 내부 함수로 래핑하여 이벤트 핸들러를 렌더링 한다. 

리액트 18에서 소개된 Concurrent 모드에서는, 리액트는 모든 업데이트를 배치로 실행한다.
리액트 18에서 배치 작업이 많이 달라졌으니, 살펴보는 것이 좋다.  keyword: Automatic Batching

 

커밋 단계의 라이프사이클 메소드에는 componentDidMount, componentDidUpdate, useLayoutEffect 와 같은 몇 가지 추가적인 엣지 케이스가 존재한다. 이는 주로 브라우저가 페인팅을 하기 전에 렌더링 후 추가 로직을 수행할 수 있도록 하기 위해 존재한다. 일반적인 사용 사례는 아래와 같다. 

  • 불완전한 일부 데이터로 컴포넌트를 최초 렌더링
  • 커밋 단계 라이프 사이클에서, DOM 노드의 실제 크기를 ref 를 통해 측정하고자 할 때
  • 해당 측정을 기준으로 일부 컴포넌트의 상태 설정
  • 업데이트된 데이터를 기준으로 즉시 렌더링

위의 사용 사례에서, 초기의 부분 렌더링된 UI가 사용자에게 절대로 표시되지 않도록 하고, 최종 UI만 나타날 수 있게 한다. 브라우저는 수정중인 DOM구조를 다시 계산한다.(?)자바스크립트는 여전히 실행중이고, 이벤트 루프를 차단하는 동안에는 실제로 아무것도 페인팅하지 않는다. 그러므로, div.innerHTML = 'a', div.innerHTML = 'b'와 같은 작업을 수행하면 a는 나타나지 않고 b만 나타날 것이다. (해당 div의 마지막 내용만 적용된 것)

 

이 때문에 리액트는 항상 커밋 단계 라이프사이클에서 렌더링을 동기로 실행한다. 이렇게하면 부분적인 렌더링을 무시하고 최종 단계의 렌더링 내용만 화면에 표시할 수 있다. 

 

마지막으로, 모든 useEffect 콜백이 완료되면 useEffect 콜백의 상태 업데이트가 대기열에 저장되고, Passive Effect 단계가 끝나면 플러시된다. 

 

 렌더 동작의 엣지 케이스

리액트에서 개발중인 <StrictMode > 태그 내부에서는 컴포넌트를 이중으로 렌더링한다. 즉, 렌더링 로직이 실행되는 횟수가 커밋된 렌더링 패스의 횟수와 동일하지 않으며, 렌더링을 수행하는 동안 console.log() 문에 의존하여 발생한 렌더링의 수를 셀 수 없다. 

대신 React DevTools Profiler를 사용하여 추적을 캡처하고, 전체적으로 커밋된 렌더링 갯수를 세거나, useEffect 훅 또는 componentDidMount componentDidUpdate 라이프 사이클에 로깅을 추가하는 방법을 사용해야 한다. 이렇게 하면 실제로 렌더링 패스를 완료하고 이를 커밋한 경우에만 로그가 찍힌다. 

 

정상적인 상황에서는 절대로 실제 렌더링 로직에서 상태 업데이트를 대기열에 넣어서는 안된다. 즉, 클릭이 발생할 때 setState() 를 호출하는 콜백을 사용하는 것은 괜찮지만, 실제 렌더링 동작의 일부로 setState()를 호출하는 것은 안된다. 

 

⚠한 가지 예외가 있는데, 함수 컴포넌트는 렌더링하는 동안 setState()를 직접 호출할 수 있지만, 이는 조건부로 수행되고 컴포넌트가 렌더링될 때마다 실행되지 않는다. 이것은 클래스 컴포넌트의 getDerivedStateFromProps 와 동등하게 작동한다. 렌더링하는 동안 함수 컴포넌트가 상태 업데이트를 대기열에 밀어 넣어두려면, 리액트는 즉시 상태 업데이트를 적용하고 해당 컴포넌트 중 하나를 동기화하여 다시 렌더링한 후 계속 진행한다. 컴포넌트가 상태 업데이트를 무한하게 queueing하고 리액트가 다시 렌더링을 하도록 강제하는 경우, 리액트는 최대 50회까지만 실행한 후에 이 무한반복을 끊어내고 오류를 발생시킨다. 이 기법은 useEffect 내부에 setState() 호출과 리렌더링을 하지 않고 props값을 기준으로 state의 값을 강제로 업데이트할 때 사용할 수 있다. 

 

function ScrollView({ row }) {
  const [isScrollingDown, setIsScrollingDown] = useState(false)
  const [prevRow, setPrevRow] = useState(null)

  // 조건부로 prop 값을 기준으로 바로 state를 업데이트 때릴 수 있음
  if (row !== prevRow) {
    setIsScrollingDown(prevRow !== null && row > prevRow)
    setPrevRow(row)
  }

  return `Scrolling down: ${isScrollingDown}`
}

 

렌더링 성능 향상시키기 

렌더링은 리액트의 동작 방식에서 일반적으로 예상할 수 있는 부분이지만, 렌더링 작업이 때때로 낭비될 수 있다는 것도 사실이다. 컴포넌트의 렌더링 출력이 변경되지 않았고, DOM의 해당 부분을 업데이트할 필요가 없다면 해당 컴포넌트를 렌더링 하는것은 정말 시간낭비이다. 

 

리액트 컴포넌트 렌더링 결과물은 항상 현재 props와 state의 상태를 기반으로 결정되어야 한다. 

따라서 props와 state가 변경되지 않았음을 미리 알고 있다면 렌더링 결과물은 동일할 것이고, 이 컴포넌트에 대한 변경이 필요하지 않고 렌더링 작업을 건너 뛸 수 있다. 

 

일반적으로  소프트웨어 성능을 개선하는 건 두 가지 접근법이 존재한다. 

  1. 동일한 작업을 가능한 더 빨리 수행하는 것
  2. 더 적게 작업하는 것

🤸‍♀️리액트에서 렌더링을 최적화하는 것은 주로 컴포넌트 렌더링을 적절하게 건너 뛰어 작업량을 줄이는 것이다.

 

리액트 컴포넌트, 엘리먼트, 인스턴스 정리 참고 자료

 

[리액트] 리액트 컴포넌트, 엘리먼트, 인스턴스

리액트에서의 컴포너트, 엘리먼트, 인스턴스가 무엇인지 알아보자.

velog.io

 

 

리액트의 렌더링은 어떻게 일어나는가? 원문 

 

Home

yceffort

yceffort.kr

 

댓글