React, 어떻게 작동하는걸까?

쿠우·2023년 6월 10일
0

리액트

목록 보기
1/5
post-thumbnail

이번 시간은 가상돔과 리액트 랜더링 동작 원리에 대해 알아보겠습니다. 일반적인 브라우저 랜더링 원리를 바탕으로 리액트 고유 특성에 대해 알아보려고 하는데요, 물론 해당 내용을 몰라도 코드를 짤 수 있습니다.

하지만 내부 동작을 알게 되면 코드를 짤때 성능을 고려하고 디버깅할 때 도움이 됩니다. 알고 쓰는 것과 모르고 사용하는것은 큰 차이가 있으니까요:) 이번 기회로 평소 사용하던 리액트를 보다 깊게 이해하고 좀더 친해지는 계기가 되면 좋겠습니다.

virtual dom

수시로 바뀌는 데이터, 어떻게 변화를 감지할까요?

1. 일반적인 브라우저 랜더링 과정

  1. DOM(Document Object Model) tree 생성
  • dom tree 형태

랜더 엔진이 브라우저가 html을 이해하고 처리할수 있도록 html을 파싱하여 위와 같이 dom 노드(Element)로 구성된 dom tree 생성

  1. CSSOM(CSS Object Model) tree 생성
    html과 마찬가지로 같은 과정을 반복하여 cssom tree 생성

  2. Render tree 생성
    dom, cssom을 결합하여 랜더 트리가 만들어짐

  3. Layout 작업
    스크린에서의 각 요소들을 어느 위치에 배치할지 좌표가 결정됨
    ex) div → LayoutRect x = 183 / y = 148 / width = 402 / height = 116

  4. Paint
    실제 화면에 그리는 작업 실행
    ( repaint: 데이터가 변경되어 재결합된 랜더 트리를 바탕으로 다시 페인트 작업 수행 )

2. virtual dom(가상돔)이 나오게 된 배경

노드에 변화가 발생하면 위의 5단계를 전부 재실행하게 되는데 이는 불필요한 과정.

최근 SPA(Single Page Application)를 주로 사용하는 추세로 dom tree를 즉각적으로 변경할 일이 많아지게 됨.

이때 전체 페이지를 서버에서 매번 보내주는 방법이 아닌, 브라우저에서 js가 관리하므로 효율적인 dom 조작 방법이 필요하게 됨. dom은 일반적으로 아주 무거움. 일부분을 바꾸기 위해서 dom 트리 전체를 건드리는건 비효율적으로, virtual dom이 효율적인 방법으로 조작할 수 있도록 해줌.

  • SPA를 구현하는 가장 대표적인 방법 “CSR(Client Side Rendering)”
    : 사용자는 웹사이트에 접속할때 서버에 정보를 요청하고, 서버는 정보를 유저에게 전달
    이때 단일 html 혹은 리액트 js 큰 규모의 파일, 그밖의 static 파일들이 전달되고 사이트와 유저의 상호작용은 서버의 도움없이 작동

3. virtual dom이란

실제 dom의 복사본 개념. 실제 dom과 같은 속성을 지녔지만 실제 dom이 가진 api는 없음.
데이터가 변경되면 virtual dom에 랜더링이 되고 실제 dom과 비교하여 변경된 부분만을 실제 dom에 반영. 이로써 실제 dom의 전체를 변경하지 않고 필요한 부분만 변경하여 효율적으로 업데이트 가능.

virtual dom은 js 객체로, 실제 랜더링되지 않고 메모리 상에서만 동작하여 연산 비용을 아낄수 있음. 실제 랜더링 되지 않고 랜더링 최적화가 가능.

이러한 가상돔에 대한 지식을 바탕으로 랜더링을 알아봅시다 :)

rendering

랜더링이 뭘까요?

변하는 값을 사용자 화면에 보여주는 랜더링, 어떻게 가능할까요?

랜더링 ? "현재 props 및 상태를 기반으로 리액트가 컴포넌트에게 UI 영역이 어떻게 보이길 원하는지 설명을 요청하는 프로세스"

랜더링 프로세스

  1. 리액트는 업데이트가 되어 변화가 있는 모든 컴포넌트를 찾기 위해 컴포넌트 트리의 루트(시작부분)에서부터 아래로 순회
  2. 플래그가 지정된 각 컴포넌트에 대해 FunctionComponent(props) (for 함수 컴포넌트) / classComponentInstance.render() (for 클래스 컴포넌트)를 호출하고 렌더 출력을 저장
[JSX]

Let bs = [
	{ id: 0,
];

<h1>{user.name.toUpperCase()}</h1>
<ul>
	<li></li>
	<li></li>
</ul>
  1. JSX 구문으로 작성된 컴포넌트 렌더 출력은 js가 컴파일되고 배포가 준비될 때 React.createElement() 호출로 변환
이름: 홍 -> 김 바꾸고 싶을때 위의 jsx가 아래와 같이 컴파일 됨!

React.createElement(‘h1’, {id: name, className: ‘bs’},)
React.createElement(‘ul’, null, React.createElement(‘li’, {}, bs[0],
  1. 컴포넌트 트리 전체에서 렌더 출력(상기 2번)을 수집하고 리액트는 새로운 객체 트리(virtual dom)와 비교하여 실제 dom에 원하는 출력이 반영되도록 적용해야할 모든 변경 사항 목록들을 수집
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]가 바뀜.

- 4번에서의 비교 및 계산 프로세스? "재조정(reconciliation)"

  1. 랜더 단계 : 컴포넌트 랜더링 & 변경 사항 계산

  2. 커밋 단계 : 랜더 단계에서 계산된 변경 사항을 dom에 적용, 요청된 dom 요소 및 컴포넌트 인스턴스를 가리키도록 모든 참조를 적절히 업데이트 (?)

  3. componentDidMount 및 componentDidUpdate 클래스 라이프 사이클 메서드와 useLayoutEffect 훅을 동기적으로 실행 (?)

  4. Passive Effects(패시브 이펙트) 단계 : 리액트는 짧은 시간 제한을 설정하고 이 시간이 만료되면 useEffect 훅 실행
    ( 동시 랜더링 기능 (ex. useTransaction) : 브라우저가 이벤트를 처리할 수 있도록 랜더링 단계에서 작업 일시 중지)


    ** 주의 해야할 점 : 랜더링 =/= dom 업데이트

    다음의 경우, 가시적인 변화 없이 컴포넌트가 랜더링 가능

    1) 컴포넌트가 이전과 동일한 렌더 출력을 반환하여 변화가 없는 경우
    2) 동시 랜더링 시, 다른 업데이트로 인해 현재 진행 중인 자업이 무효화될 경우 렌더 출력 미수행

리액트는 어떤 방식으로 랜더링할까요?

랜더링 큐에 랜더링 등록

첫 랜더링 후에 리액트가 리랜더링을 큐에 등록하도록 하는 방법

  • 함수 컴포넌트
    - useState / useReduer
    ("큐(Queue)" ? 가장 먼저 삽입된 항목을 제거하는 자료구조. 선입선출 First In First Out(FIFO) 방식.
    반대로는 stack이 있으며 후입선출, Last In First Out(LIFO) 방식.)

컴포넌트의 하위 순환

상위 컴포넌트가 랜더링될 때 해당 컴포넌트의 모든 하위 컴포넌트를 순환

** 랜더링은 나쁜 것이 아닌, 리액트가 실제로 dom을 변경해야 하는지 여부를 체크는 방법!

리액트 렌더링 규칙

기본 규칙 ? "순수하며 side effect가 없어야 한다!"

  • 수행해서는 안되는 랜더 로직
    1) 기존 변수 및 객체 변경 불가
    2) Math.random() 혹은 Date.now()와 같은 임의의 값 생성 불가
    3) 네트워크 요청 불가
    4) 상태 업데이트를 큐에 추가 불가

  • 수행해야 하는 랜더 로직
    1) 랜더링 중 새로 생성된 객체 변경
    2) 오류 발생
    3) 캐시된 값과 같이 생성되기 전인 데이터에 대한 "지연 초기화"

다양한 유형의 업데이를 가능하게 해주는 fiber

리액트의 화이버는 리액트의 핵심 알고리즘을 재구성한 재조정(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 값을 알수 없기에 위와 같이 작동순서가 비동기적으로 실행됨.

랜더링 동작의 edge case

virtual dom과 비교하여 실제 dom에 원하는 출력이 반영되도록 비교 계산하는 재조정(reconciliation) 과정 중 변경 사항을 dom에 업데이트 하는 커밋 단계의 메서드에는 몇가지 엣지 케이스들이 존재.

- componentDidMount
- componentDidUpdate
- useLayoutEffect

해당 메서드들을 통해 랜더링 후에 브라우저가 페인트하기 전 아래의 추가적인 로직 실행 가능.

1) 불완전한 데이터가 있는 컴포넌트를 렌더링
2) ref를 사용하여 실제 dom 노드 크기를 측정
3) 측정된 dom 노드 크기를 바탕으로 컴포넌트의 일부 상태를 설정
4) 업데이트된 데이터로 즉시 리랜더링

랜더링 성능 개선

효육적인 랜더링을 위해서는 '낭비'를 줄이는 것이 중요.
컴포넌트 props나 상태의 변화가 없어 랜더링 출력이 동일한 경우 랜더링 작업을 안전히 건너뛸 수 있어야 함.

일반적인 소프트웨어 성능 향상에는 두가지 접근 방식이 있음.

  1. 동일한 작업을 보다 빠르게 수행하는 것
  2. 더 적게 수행하는 것

리액트 랜더링 최적화에서는 주로 위와 같이 컴포넌트 랜더링을 적절히 건너뛰어 작업량을 줄이는게 중요함!

컴포넌트 랜더링 최적화 기법

리액트에는 컴포넌트 랜더링 생략을 가능하게 하는 3가지 api 존재, 아래 3가지 주된 메서드

  1. "higher order component" 형태로 내장된 React.memo()
    : 사용자의 컴포넌트 타입을 인자로 전달받고 래핑된 새 컴포넌트를 반환(?). 래퍼 컴포넌트의 기본 동작은 props가 변경됐는지 확인하고 변경되지 않은 경우 그렇지 않은 경우 리랜더링되지 않도록 함.

    ex) const MemoizedChildComponent = React.memo(ChildComponent)
    : memo로 메모이즈 되고 변경이 일어나지 않은 것으로 간주하여 ChildComponent 렌더링스킵
  2. React.Component.shouldComponentUpdate
    : 랜더링 초기 단계에 호출되는 선택적 클래스 컴포넌트 생명 주기 메서드

  • 값이 boolean으로, false인 경우 컴포넌트 랜더링 생략
  • 해당값을 계산하는데 사용할 로직을 포함할 수 있으며, 가장 일반적인 방법은 컴포넌트 props와 상태를 이전과 비교하여 변화가 없으면 false를 반환
  1. React.PureComponent

(모두 "얕은 비교"를 사용 → 오브젝트의 참조를 비교하는 방법으로, 오브젝트 전체값를 비교하는 것이 아니기에 보다 가볍고 빠른 효율적인 비교방법.
ex) props.someValue !== prevProps.someValue)

이외)
4. 출력에 props.children을 포함하는 경우 컴포넌트가 상태를 업데이트해도 해당 요소는 동일(?)
5. useMemo()로 요소를 감싸면 종속성이 변경될 때까지 동일하게 유지됨(?)

props 참조가 랜더링 최적화에 미치는 영향

리액트는 컴포넌트의 props가 변경되지 않더라도 해당되는 컴포넌트를 모두 리랜더링 함. 동일한 props를 전달하는지와 관계없이 전부 랜더링 실행.

메모이제이션을 남발하게 되면?

memo로 최적화하는 방법은 컴포넌트가 동일한 props로 리랜더링 하는 경우가 많고, 리랜더링 로직의 비용이 큰 경우에만 유용함. 이외의 경우에도 memo를 사용하는 경우가 많은데 큰 문제가 되는건 아니지만 가독성이 떨어짐.

따라서 불필요한 메모이제이션을 지양할 필요가 있음.

불변성(Immutability)과 리랜더링

"불변성"? 값이나 상태를 변경할 수 없는 것을 의미.

예시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 (새로운 참조값)

위와 같이 값이 대체되는 것이 아니라 변수에 메모리 영역이 (재)할당됨을 통해, 불변성이란 "메모리 영역에서 값이 변하지 않는 것"임을 알 수 있음.

  • 리액트 상태 업데이트가 불변성을 지켜야하는 두 가지 이유
  1. 변한 내용과 위치에 따라 랜더링할 것으로 예상한 컴포넌트가 랜더링 안될 수 있음
    : 사이드 이팩트를 방지하기 위함. 리액트 어디선가 원본 데이터를 사용한다면 원본데이터를 변경할 경우 해당 부분에서 문제가 될 수 있음.

  2. 데이터가 실제로 업데이트된 시기와 원인에 혼란을 일으킴
    : 데이터가 참조타입의 경우 얕은 비교를 통해 상태 업데이트 시 데이터의 이전 참조값과 현재 참조값을 비교하여 상태 변화를 감지함. 불변성을 지키지 않고 원본 데이터를 변경할 경우 참조값 비교가 불가능해져 상태 업데이트 과정에서 혼란을 일으킬수 있음.

랜더링 성능을 측정하는 방법

React DevTools Profiler로 랜더링되는 컴포넌트 확인 가능. 불필요한 랜더링 컴포넌트를 찾고 원인을 파악하여 React.memo()로 감싸거나 상위 컴포넌트가 전달하는 props를 메모하도록 수정.

  • 데브툴즈 > profiler 창을 열고 녹화하면 다음과 같이 랜더링 기록, 분석됨!

컨텍스트(context)와 랜더링 동작

리액트 버전 16부터 사용 가능한 context api는 특정값을 하위 컴포넌트 트리 전반에서 사용할 수 있도록 해주는 메커니즘. context는 상위 컴포넌트에서 하위 컴포넌트에 데이터를 props로 전달하지 않고도 특정 값을 읽을 수 있게 해줌. 즉, 컴포넌트들이 데이터(state)를 좀더 쉽게 공유할 수 있음.

  1. context의 장점 "props drilling 방지"

    : 부모 이상의 조상 컴포넌트의 데이터를 알아야 할 경우 props 전달을 받을 때 부모가 몰라도 되는 props를 전달받게 되는 경우가 있음. 즉, 해당 데이터를 사용하지 않는 컴포넌트들에게까지 prop을 전달하는 것은 비효율적. 전역에서 데이터를 읽을 수 있는 context는 해당 문제를 해결해줌.

  2. 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>;
    }
  3. context 업데이트 동작 원리
    : ParentComponent 컴포넌트가 랜더링 될때마다 리액트는 변경이 있는지 확인. 하위 컴포넌트를 순회하며 MyContext를 사용하는 컴포넌트를 찾음. provider가 있는 컴포넌트(ParentComponent)에 변화가 있을 경우, MyContext를 사용하는 컴포넌트 전부 리랜더링.

상태 업데이트 & context & 랜더링

기본적으로 context provider를 랜더링하는 상위 컴포넌트의 상태 업데이트는 모든 하위 컴포넌트가 리랜더링 되도록 함.

context에서 랜더링 최적화를 위해선 React.memo 사용 필요!

React-Redux와 랜더링

redux 역시 context와 비슷한 전역으로 데이터를 전달할 수 있도록 하는 라이브리러.

  • 사용법
  1. createStore 생성
    const store = createStore(rootReducer);
  2. combineReducer로 여러 slice를 결합하여 하나의 root reducer 생성
  3. rrot reducer에 여러 reducer 등록

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

profile
기록하며 J가 되고싶은 ENTP 🐣

0개의 댓글