이번 시간은 가상돔과 리액트 랜더링 동작 원리에 대해 알아보겠습니다. 일반적인 브라우저 랜더링 원리를 바탕으로 리액트 고유 특성에 대해 알아보려고 하는데요, 물론 해당 내용을 몰라도 코드를 짤 수 있습니다.
하지만 내부 동작을 알게 되면 코드를 짤때 성능을 고려하고 디버깅할 때 도움이 됩니다. 알고 쓰는 것과 모르고 사용하는것은 큰 차이가 있으니까요:) 이번 기회로 평소 사용하던 리액트를 보다 깊게 이해하고 좀더 친해지는 계기가 되면 좋겠습니다.
수시로 바뀌는 데이터, 어떻게 변화를 감지할까요?
랜더 엔진이 브라우저가 html을 이해하고 처리할수 있도록 html을 파싱하여 위와 같이 dom 노드(Element)로 구성된 dom tree 생성
CSSOM(CSS Object Model) tree 생성
html과 마찬가지로 같은 과정을 반복하여 cssom tree 생성
Render tree 생성
dom, cssom을 결합하여 랜더 트리가 만들어짐
Layout 작업
스크린에서의 각 요소들을 어느 위치에 배치할지 좌표가 결정됨
ex) div → LayoutRect x = 183 / y = 148 / width = 402 / height = 116
Paint
실제 화면에 그리는 작업 실행
( repaint: 데이터가 변경되어 재결합된 랜더 트리를 바탕으로 다시 페인트 작업 수행 )
노드에 변화가 발생하면 위의 5단계를 전부 재실행하게 되는데 이는 불필요한 과정.
최근 SPA(Single Page Application)를 주로 사용하는 추세로 dom tree를 즉각적으로 변경할 일이 많아지게 됨.
이때 전체 페이지를 서버에서 매번 보내주는 방법이 아닌, 브라우저에서 js가 관리하므로 효율적인 dom 조작 방법이 필요하게 됨. dom은 일반적으로 아주 무거움. 일부분을 바꾸기 위해서 dom 트리 전체를 건드리는건 비효율적으로, virtual dom이 효율적인 방법으로 조작할 수 있도록 해줌.
실제 dom의 복사본 개념. 실제 dom과 같은 속성을 지녔지만 실제 dom이 가진 api는 없음.
데이터가 변경되면 virtual dom에 랜더링이 되고 실제 dom과 비교하여 변경된 부분만을 실제 dom에 반영. 이로써 실제 dom의 전체를 변경하지 않고 필요한 부분만 변경하여 효율적으로 업데이트 가능.
virtual dom은 js 객체로, 실제 랜더링되지 않고 메모리 상에서만 동작하여 연산 비용을 아낄수 있음. 실제 랜더링 되지 않고 랜더링 최적화가 가능.
이러한 가상돔에 대한 지식을 바탕으로 랜더링을 알아봅시다 :)
변하는 값을 사용자 화면에 보여주는 랜더링, 어떻게 가능할까요?
[JSX]
Let bs = [
{ id: 0,
];
<h1>{user.name.toUpperCase()}</h1>
<ul>
<li></li>
<li></li>
</ul>
이름: 홍 -> 김 바꾸고 싶을때 위의 jsx가 아래와 같이 컴파일 됨!
React.createElement(‘h1’, {id: name, className: ‘bs’},)
React.createElement(‘ul’, null, React.createElement(‘li’, {}, bs[0],
xxx()
<xxx>{user.name.toUpperCase()}</xxx>
<ul>
<li></li>
<li></li>
</ul>
xxx가 사용되는 곳에서 변화가 있을 때 xxx()가 실행되도록 리액트가 알아서 처리해줌!
<xxx>{user.name.toUpperCase()}</xxx> 적었을 때 자동으로 등록이 된 것!
이 구조를 실행 컨텍스트에 올려놓음.
Virtual dom은 실행 컨텍스트에 올라가서 bs[0] 참조값을 가지고 있음. 해당 값이 바꾸면 eventListener가 xxx()를 불러옴.
스트링으로 주면 bs[0]가 바뀜.
랜더 단계 : 컴포넌트 랜더링 & 변경 사항 계산
커밋 단계 : 랜더 단계에서 계산된 변경 사항을 dom에 적용, 요청된 dom 요소 및 컴포넌트 인스턴스를 가리키도록 모든 참조를 적절히 업데이트 (?)
componentDidMount 및 componentDidUpdate 클래스 라이프 사이클 메서드와 useLayoutEffect 훅을 동기적으로 실행 (?)
Passive Effects(패시브 이펙트) 단계 : 리액트는 짧은 시간 제한을 설정하고 이 시간이 만료되면 useEffect 훅 실행
( 동시 랜더링 기능 (ex. useTransaction) : 브라우저가 이벤트를 처리할 수 있도록 랜더링 단계에서 작업 일시 중지)
** 주의 해야할 점 : 랜더링 =/= dom 업데이트
다음의 경우, 가시적인 변화 없이 컴포넌트가 랜더링 가능
1) 컴포넌트가 이전과 동일한 렌더 출력을 반환하여 변화가 없는 경우
2) 동시 랜더링 시, 다른 업데이트로 인해 현재 진행 중인 자업이 무효화될 경우 렌더 출력 미수행
첫 랜더링 후에 리액트가 리랜더링을 큐에 등록하도록 하는 방법
상위 컴포넌트가 랜더링될 때 해당 컴포넌트의 모든 하위 컴포넌트를 순환
** 랜더링은 나쁜 것이 아닌, 리액트가 실제로 dom을 변경해야 하는지 여부를 체크는 방법!
기본 규칙 ? "순수하며 side effect가 없어야 한다!"
수행해서는 안되는 랜더 로직
1) 기존 변수 및 객체 변경 불가
2) Math.random() 혹은 Date.now()와 같은 임의의 값 생성 불가
3) 네트워크 요청 불가
4) 상태 업데이트를 큐에 추가 불가
수행해야 하는 랜더 로직
1) 랜더링 중 새로 생성된 객체 변경
2) 오류 발생
3) 캐시된 값과 같이 생성되기 전인 데이터에 대한 "지연 초기화"
리액트의 화이버는 리액트의 핵심 알고리즘을 재구성한 재조정(Reconciliation) 엔진으로 역할은 애니메이션, 레이아웃, 제스처, 중단 또는 재사용 기능과 같은 영역에 대한 적합성을 높이고 다양한 유형의 업데이트에 우선 순위를 지정하는 것.
리액트는 비동기적으로 상태를 업데이트.
예를 들어 setState를 활용하여 1씩 증가하는 handleClick 함수가 있다고 가정해봄. 해당 handleClick 함수는 "closure".
( closure(클로저)란? 자신이 선언될 당시의 Scope Chain에서 알 수 있었던 변수들 중 언젠가 자신이 실행될 때 사용할 변수들만을 기억하여 참조를 유지시키는 함수 )
function Plus() {
const [plus, setPlus] = useState(0);
const handleClick = () => {
setPlus(plus + 1); // 작동순서 2
console.log(plus); // 작동순서 1
};
}
handleClick 함수는 가장 나중에 정의되어 해당 랜더 단계에서의 plus만 기억. setPlus()를 호출하게 되면 다음 단계의 랜더 패스가 큐에 추가되어 기존과 다른 새로운 plus와 handleClick 함수가 추가됨. 이때 기존의 handleClick 함수는 새로운 plus 값을 알수 없기에 위와 같이 작동순서가 비동기적으로 실행됨.
virtual dom과 비교하여 실제 dom에 원하는 출력이 반영되도록 비교 계산하는 재조정(reconciliation) 과정 중 변경 사항을 dom에 업데이트 하는 커밋 단계의 메서드에는 몇가지 엣지 케이스들이 존재.
- componentDidMount
- componentDidUpdate
- useLayoutEffect
해당 메서드들을 통해 랜더링 후에 브라우저가 페인트하기 전 아래의 추가적인 로직 실행 가능.
1) 불완전한 데이터가 있는 컴포넌트를 렌더링
2) ref를 사용하여 실제 dom 노드 크기를 측정
3) 측정된 dom 노드 크기를 바탕으로 컴포넌트의 일부 상태를 설정
4) 업데이트된 데이터로 즉시 리랜더링
효육적인 랜더링을 위해서는 '낭비'를 줄이는 것이 중요.
컴포넌트 props나 상태의 변화가 없어 랜더링 출력이 동일한 경우 랜더링 작업을 안전히 건너뛸 수 있어야 함.
일반적인 소프트웨어 성능 향상에는 두가지 접근 방식이 있음.
리액트 랜더링 최적화에서는 주로 위와 같이 컴포넌트 랜더링을 적절히 건너뛰어 작업량을 줄이는게 중요함!
리액트에는 컴포넌트 랜더링 생략을 가능하게 하는 3가지 api 존재, 아래 3가지 주된 메서드
"higher order component" 형태로 내장된 React.memo()
: 사용자의 컴포넌트 타입을 인자로 전달받고 래핑된 새 컴포넌트를 반환(?). 래퍼 컴포넌트의 기본 동작은 props가 변경됐는지 확인하고 변경되지 않은 경우 그렇지 않은 경우 리랜더링되지 않도록 함.
ex) const MemoizedChildComponent = React.memo(ChildComponent)
: memo로 메모이즈 되고 변경이 일어나지 않은 것으로 간주하여 ChildComponent 렌더링스킵
React.Component.shouldComponentUpdate
: 랜더링 초기 단계에 호출되는 선택적 클래스 컴포넌트 생명 주기 메서드
(모두 "얕은 비교"를 사용 → 오브젝트의 참조를 비교하는 방법으로, 오브젝트 전체값를 비교하는 것이 아니기에 보다 가볍고 빠른 효율적인 비교방법.
ex) props.someValue !== prevProps.someValue)
이외)
4. 출력에 props.children을 포함하는 경우 컴포넌트가 상태를 업데이트해도 해당 요소는 동일(?)
5. useMemo()로 요소를 감싸면 종속성이 변경될 때까지 동일하게 유지됨(?)
리액트는 컴포넌트의 props가 변경되지 않더라도 해당되는 컴포넌트를 모두 리랜더링 함. 동일한 props를 전달하는지와 관계없이 전부 랜더링 실행.
memo로 최적화하는 방법은 컴포넌트가 동일한 props로 리랜더링 하는 경우가 많고, 리랜더링 로직의 비용이 큰 경우에만 유용함. 이외의 경우에도 memo를 사용하는 경우가 많은데 큰 문제가 되는건 아니지만 가독성이 떨어짐.
따라서 불필요한 메모이제이션을 지양할 필요가 있음.
"불변성"? 값이나 상태를 변경할 수 없는 것을 의미.
예시1) 아래의 변수 string은 'data1'에서 'data2'로 대체한 것이 아닌, 메모리1('data1'이 담김)에서 메모리2('data2'가 담김)로 새로운 영역을 재할당 한 것.
let string = 'data1';
string = 'data2';
예시2) 오브젝트 참조 타입의 경우, 새로운 참조값을 가진 오브젝트를 재할당
let array = [1, 2, 3, 4]; // 메모리1
array.push(5); // 메모리1
array = [1, 2, 3, 4, 5]; // 메모리2 (새로운 참조값)
위와 같이 값이 대체되는 것이 아니라 변수에 메모리 영역이 (재)할당됨을 통해, 불변성이란 "메모리 영역에서 값이 변하지 않는 것"임을 알 수 있음.
변한 내용과 위치에 따라 랜더링할 것으로 예상한 컴포넌트가 랜더링 안될 수 있음
: 사이드 이팩트를 방지하기 위함. 리액트 어디선가 원본 데이터를 사용한다면 원본데이터를 변경할 경우 해당 부분에서 문제가 될 수 있음.
데이터가 실제로 업데이트된 시기와 원인에 혼란을 일으킴
: 데이터가 참조타입의 경우 얕은 비교를 통해 상태 업데이트 시 데이터의 이전 참조값과 현재 참조값을 비교하여 상태 변화를 감지함. 불변성을 지키지 않고 원본 데이터를 변경할 경우 참조값 비교가 불가능해져 상태 업데이트 과정에서 혼란을 일으킬수 있음.
React DevTools Profiler로 랜더링되는 컴포넌트 확인 가능. 불필요한 랜더링 컴포넌트를 찾고 원인을 파악하여 React.memo()로 감싸거나 상위 컴포넌트가 전달하는 props를 메모하도록 수정.
리액트 버전 16부터 사용 가능한 context api는 특정값을 하위 컴포넌트 트리 전반에서 사용할 수 있도록 해주는 메커니즘. context는 상위 컴포넌트에서 하위 컴포넌트에 데이터를 props로 전달하지 않고도 특정 값을 읽을 수 있게 해줌. 즉, 컴포넌트들이 데이터(state)를 좀더 쉽게 공유할 수 있음.
context의 장점 "props drilling 방지"
: 부모 이상의 조상 컴포넌트의 데이터를 알아야 할 경우 props 전달을 받을 때 부모가 몰라도 되는 props를 전달받게 되는 경우가 있음. 즉, 해당 데이터를 사용하지 않는 컴포넌트들에게까지 prop을 전달하는 것은 비효율적. 전역에서 데이터를 읽을 수 있는 context는 해당 문제를 해결해줌.
context 사용 방법
1) <MyContext.Provider value={데이터}>과 같이 value를 props로 전달
function ParentComponent() {
const [a, setA] = useState('1');
const [b, setB] = useState('2');
const contextValue = { a, b };
return (
<MyContext.Provider value={contextValue}>
<ChildComponent />
</MyContext.Provider>
);
2) 함수 컴포넌트에서 useContext hook 호출
function ChildComponent() {
const value = useContext(MyContext);
return <div>{value.a}</div>;
}
context 업데이트 동작 원리
: ParentComponent 컴포넌트가 랜더링 될때마다 리액트는 변경이 있는지 확인. 하위 컴포넌트를 순회하며 MyContext를 사용하는 컴포넌트를 찾음. provider가 있는 컴포넌트(ParentComponent)에 변화가 있을 경우, MyContext를 사용하는 컴포넌트 전부 리랜더링.
기본적으로 context provider를 랜더링하는 상위 컴포넌트의 상태 업데이트는 모든 하위 컴포넌트가 리랜더링 되도록 함.
context에서 랜더링 최적화를 위해선 React.memo 사용 필요!
redux 역시 context와 비슷한 전역으로 데이터를 전달할 수 있도록 하는 라이브리러.
react-redux 내부적으로 context를 활용하다?
→ React-Redux는 컨텍스트를 사용하여 현재 상태 값이 아닌 "Redux 저장소 인스턴스"(?)를 전달. 즉, 항상 동일한 컨텍스트 값을 <ReactReduxContext.Provider>에 전달.
(하기와 같은 의미인지는 혼동;;)
상기 이미지 내용 참조 : https://velog.io/@cada/React-Redux-vs-Context-API
redux 저장소를 구독하여 최신 상태를 읽고 값을 비교하여 해당 저장소에 저장된 전역 상태를 가져옴. 따라서, 어느 컴포넌트에서나 저장소에서의 상태값 랜더링 가능.
이번 시간에는 가상돔을 활용하여 리액트가 어떻게 데이터 변화를 감지하고 화면에 랜더링하는지 작동 원리에 대해 알아보았습니다.
작동 원리를 이해하며 리액트 강의 시간에 배웠던 얕은복사 및 불변성 지키기와 같은 개념이 리액트 작동의 근간이 된다는 점이 새롭게 다가왔던거 같아요.
처음 작성하는 블로그 첫번째 글로 아래 참조 문서들을 참고하며 기술 글을 많이 읽게 되었는데요, 앞으로 배워 나가야할 내용이 너무나도 많다는 점을 다시 한번 깨달았습니다.
첫술에 배부르지 않지만 차근차근 꾸준히 공부하여 리액트 전문가가 될 수 있도록 노력해야겠습니다 :)
https://makasti.tistory.com/92
https://velog.io/@superlipbalm/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior#%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0
https://www.youtube.com/watch?v=8qAYnwEgAVs
https://velog.io/@jangws/React-Fiber
https://velog.io/@eunnbi/useState%EC%99%80-%ED%81%B4%EB%A1%9C%EC%A0%80
https://hsp0418.tistory.com/171
https://www.freecodecamp.org/korean/news/cobojareul-wihan-riaegteu-context-wanbyeog-gaideu-2021/
https://yceffort.kr/2022/04/deep-dive-in-react-rendering
https://velog.io/@rayong/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A6%AC%EB%8D%95%EC%8A%A4-React-Redux#4-3-%EA%B0%81%EA%B0%81%EC%9D%98-reducer-%EB%A5%BC-%ED%95%A9%EC%B9%98%EB%8A%94-rootreducer-%EC%98%88%EC%8B%9C
https://velog.io/@cada/React-Redux-vs-Context-API