렌더링 & 리렌더링?
보통 컴퓨터 프로그램이나 웹 애플리케이션에서 데이터와 코드를 이용하여 화면을 그리거나 출력하는 과정을 랜더링이라 한다.
일반적으로 렌더링은 화면에 바로 나타나는 것처럼 표현되지만, React에서는 컴포넌트에게 현재 Props와 State에 기반하여 UI에서 어떻게 구성할지를 알려달라고 요청하는 작업이다.
이 포스팅 전 알고 있던 랜더링 과정은 아래와 같다.
- HTML을 파싱하여 DOM Tree를 만든다.
- CSSOM Tree 생성한다.
- 합쳐서 Render Tree를 만든 후 렌더 트리의 각 요소 위치와 크기를 계산하는 layout과정이 진행된다.
- 다음 레이어 별로 실제 그리기 작업을 수행하는 painting 과정이 이루어진다.
- 마지막으로 레이어를 합성해 화면에 출력하는 composition 과정 이 진행된다.
이 과정을 아래에서 더 깊게 알아 보자.
리엑트의 랜더링 과정은 3가지다.
- Trigger - 렌더링을 유발 ( 총의 방아쇠를 당기는 것과 같다 )
- Rendering (Render Phase) - 컴포넌트에 렌더링 할것들을 가상돔에 올린다
- Commit (Commit Phase) - 가상돔을 DOM에 적용하고 라이프 사이클을 실행하는 단계 (실제 paint 되는것은 아니다. 실제 그려지는것은 Commit 단계가 끝나면 브라우저에서 실제 DOM을 업데이트하여 화면이 그려진다고 한다.)
이 3가지 과정이지만 초기 렌더링과 리렌더링으로 구분하여 알아보겠다.
초기 렌더링
- Trigger
- 리액트의 컴포넌트들이 그려지기 위해서는, 사용자가 어떤 버튼을 클릭 또는 사이트를 방문할때 처럼 촉매가 필요하다.
- 어느 유저가 내가 만든 리엑트 페이지에 처음 방문했다면, 리소스를 서버에 요청하고 앱이 실행된다. 이때 Entry파일에서
ReactDom의 render() 메소드를 호출하고 루트 컴포넌트를 화면에 그린다.
ReactDOM.render()는 React 18 버전에서는 동작하지 않고 createRoot 를 사용해야 한다고 한다.
- Render
- Trigger 에서
render() 메소드가 호출되면 리액트는 createElement() 로 HTML 요소를 생성한다.
- 리액트는 root로 부터 시작해 모든 자식 요소를 재귀적으로 처리하여 트리의 구성 요소들을 파악한다. (* 컴포넌트 호출 시 리턴값이 컴포넌트면 그 컴포넌트를 또 호출하는 것을 반복)
- 이 과정에서 JSX는
React.createElement() 함수로 JSX를 리엑트 요소로 변환한다.
- 이떄 재귀적으로 생성된 리액트 요소들은 UI의 구조를 나타내는 객체 (가상 DOM)로 유지된다.
- 차례대로 모두 훑었다면, 완성된 가상 DOM 트리가 그려지는 것이다.
- Commit
- 렌더 단계에서 파악한 DOM 노드들을 실제 DOM에 적용한다.
리렌더링
- Trigger
- Trigger의 조건은 state로 저장된 값이 변경되는 것이다.
- 이때
Object.is() 메서드를 사용하여 state 값이 (===) 정확히 동일한지를 확인하고 불변성을 지키고 있는지 확인한다.
- 여기서 화면이 변경되는것이 아니다.
- 상태 업데이트 함수(setState)가 호출되면 리액트는 상태 업데이트 함수를 큐에 입력합니다. ( 입력된 큐는 Rendering 과정에서 활용되어 순차적으로 렌더링 작업을 수행한다. )
- 즉, 상태 변경 함수가 실행된다면 해당 setState를 Queue에 순차적으로 등록하는것이고, 이때는 그냥 불변성을 지키고 대체되었는지만 확인하는것이다. 이 과정을 통해 React가 데이터가 변경되었다는것을 알게 하는것이다.
- Rendering
- 여기서 DOM과 그려지기 전 DOM을 비교해 달라진 부분을 찾아 계산하는 과정이 Reconcilliataion(재조정)과정을 거친다. 이때 Diffing이라는 알고리즘이 있는데 이 알고리즘이 하는 일은 아래와 같다.
- React Element의 타입(JSX 태그 종류) 비교
- 타입이 동일할 경우 속성(attribute) 비교
- key 값 비교
- 재귀적으로 자식 Element 비교
- 처음 랜더링때 가상 DOM을 그리는데, 그 다음 상태가 변경되어 트리거가 발생하면, 새로운 가상 DOM을 그린다. 그리고 효율을 위해 Diffing 알고리즘을 통해 어떤 부분을 업데이트할지 결정하며, 굳이 리렌더링이 필요없는 부분을 제외하여, 새로운 가상 DOM 트리를 만든다.
Render phase에서 새로 생성된 새로운 가상 DOM을 커밋 단계에서 사용한다.
- Commit
Render Phase (Rendering)에서 재조정된 가상 DOM을 DOM에 적용하고 라이프사이클을 실행하는 단계다. 여기서도 DOM에 마운트 될 뿐 paint 된다는 것이 아니다.
- 항상 일관적인 화면 업데이트를 위해 동기적으로 실행된다. 동기적으로 실행된다는 것은, 콜 스택을 비우지 않고, DOM 조작을 Batching 처리 한다는 것이다.
- 리엑트는 최대한 랜더링을 적게 발생시키기 위해 작업을 한번에 묶어서 일괄처리(Batching)를 한다.
- Trigger에서 setState 함수가 Queue에 순차적으로 들어가고, Reconcilliataion의 Diffing 알고리즘으로 어떤 DOM이 변경되어야하는지 알고, 변경되어야 할 DOM의 state 값을 Queue에 들어간 함수를 실행하고 계산한다.
- 성능향상을 위해 여러 상태 업데이트를 단일 리랜더링으로 그룹화하는 것을 Batching이라 한다.
- layout, painting 과정은 요소가 변경 되거나 레이아웃이 변경됐을 때 다시 실행되는 과정인데, 비용이 많이 발생하는 작업이다. 리엑트가 setState를 호출할 때마다 리렌더링이 일어나면 repaint, reflow가 계속해서 일어나기 때문에 Batching을 통해 한번만 일어나도록 한 것이다.
- layout이 다시 일어날때 reflow, paint가 다시 일어나는것을 repaint 라한다.
- 비용 크기 reflow > repaint
- 이 Commit 단계가 끝나면, 브라우저에서 실제 DOM을 업데이트하며 화면을 그려준다.
- 즉, 새로 생성,수정 또는 삭제된 가상 DOM 노드를 새로운 컴포넌트 트리와 동기화하는 과정이 발생한다.
- 클래스 라이브 사이클 메소드 다이어그램도 참고하면 좋을
요약
- 유저가 페이지에 접속하면
Trigger가 발생하여 Render Phase 와 Commit Phase 을 거쳐 처음 랜더링이 된다.
- 그 후 state 가 변경 되면 이것으로 리렌더링이
Trigger 되며, Render Phase 에서 신규, 수정, 삭제 된 가상 DOM을 생성하고, Commit Phase 에서 생성된 가상 DOM을 실제 DOM에 적용한다. 그 다음 브라우저가 실제 DOM을 업데이트하며 화면을 그려준다.
위에서 대략적으로 리렌더링이 되는 경우와 과정을 이해했지만 아래에 조금 더 예제로 들어가 리렌더링 되는 경우를 알아보자.
1. state 변경 (props)
- 최상위 App - CountResultComponent를 랜더링 하는 CountComponent 총 3가지로 이루어진 예시다.
- 리엑트에서 모든 상태값은 특별한 컴포넌트 인스턴스에 있고, 위 예시에서는 CountComponent에 count라는 하나의 state만 존재한다.
- 상태가 업데이트되면 CountComponent가 리렌더링 되며, CountResultComponent 또한 부모요소인 CountComponent에 의해 랜더링 되기에 리렌더링이 발생한다.
- 이때 App자체는 리랜더링 되지 않는다. 그 안의 CountComponent가 리렌더링 될 뿐이다.
- 이를 통해 알 수 있듯, 리엑트의 모든 상태 변경이 App까지 강제로 리렌더링 시킨다고 오해하는 경우가 있지만, 실제 리렌더링되는 요소는 그 상태를 소유한 컴포넌트와 그 자식 컴포넌트 뿐이다.
자세히 알아보자
유저가 버튼을 클릭하면 count 의 상태가 0에서 1로 변한다. 그렇다면 이 과정이 어떻게 UI에 영향을 미칠까?
리엑트는 CountComponent와 CountResultComponent 컴포넌트를 위해 코드를 재 실행되고, 우리가 원하는 새로운 DOM을 아래와 같이 생성한다.
각 렌더링은 스냅샷이라고 생각하면 된다. 첫 랜더링에서 사진이 찍혔고, count가 변하면서 상태가 0 에서 1로 변하여 리랜더링 됐을때 사진이 찍힌다. 이때 두 사진을 비교하여 변경된 부분만 리랜더링 되는 것이다. 따라서 이 예제에서는 이 상태값을 가지고 있는 두 컴포넌트가 리렌더링 될 뿐 App은 리렌더링 되지 않는것이다.
즉, 본문 초기에 알아봤던 Render Phase 에서 state가 변경된 컴포넌트만 가상돔에 그리고 Commit Phase 에서 DOM에 적용 되는것이며. 핵심은 상태 변경이 UI에 어떤 영향을 미칠지를 생각해보고 상태변경에 영향을 받는 모든 구성 요소를 다시 렌더링 한다는 것이다.
→ 상태값과 관련있는 컴포넌트들만 리렌더링 되는것이다.
2. 부모 컴포넌트 리렌더링
App 에 있던 Footer를 CountComponent에 넣은 예제다.
그렇다면 이때는 어떻게 변경 될까?
컴포넌트가 리렌더링될 때, 해당 컴포넌트에 종속된 모든 컴포넌트를 순서대로 모두 다시 스케치 하기 때문에 새로운 스냅샷이 생기는것으로, 종속되어 있는 모든 컴포넌트가 다시 리렌더링 된다.
3. Context API 사용 시에 값 변경될 경우
보통 Context API는 UserContext 선언과 UserProvider 함수와 같은 형식으로 생성하고, App에서와 같이 사용한다.
이때 UserProvider가 GreetUser와 Footer를 감싸고 있는데, 이때는 어떤것이 리렌더링 될까?
이땐 React.useContext(UserContext)를 호출하여 사용하는 GreetUser만 리렌더링 되며, Footer는 리렌더링 되지 않는다.
여기까지 렌더링과 리렌더링을 알아보았다.
리렌더링 과정은 비용이 많이 소모되는 작업으로 비용을 줄여 효율적인 코드를 만드는 것이 개발자의 숙명이 아닐까 생각한다.
따라서 다음 포스팅은 리렌더링을 효율적으로 관리하는 방법에 대하여 알아보겠다.
참고한 곳