메모..

Jinux·2022년 9월 12일
0

원티드 프리온보딩 프론트엔드 과정을 진행하며 얻은 인사이트를 기록한 내용입니다!

그저 리액트에서 렌더링 최적화를 하고싶었습니다..

렌더링?

렌더링 최적화를 하기 위해선 렌더링이 무엇인지 알아야하는 것이 인지상정. 렌더링을 알아보겠습니다.

렌더링이란 화면에 특정한 요소를 그리는 것을 말합니다. 브라우저에서는 DOM 요소를 그려내기위해 몇 단계를 거치는 데요. 먼저 HTML, CSS, JavaScript를 다운로드 받고 HTML과 CSS를 통해 만들어지고 계산된 DOM과 CSSOM을 결합해 Render Tree 라는 것을 만들고 위치를 계산하고, 최종적으로 브라우저에 그립니다. 이 과정을 Critical Rendering Path(CRP)라고 부릅니다.

CRP

  1. HTML을 파싱하여 DOM 생성
  2. CSS을 파싱하여 CSSOM 생성
  3. DOM과 CSSOM을 결합하여 Render Tree
  4. Render Tree와 Viewport와 width를 통해서 각 요소들의 위치와 크기를 계산 (Layout)
  5. Render Tree상의 요소들을 실제 Pixel로 그림 (Paint)

이후 DOM 또는 CSSOM이 수정될 때 마다 위의 과정을 반복합니다. 위 과정중에서도 Layout, Paint 과정이 특히나 많은 계산을 필요로 하죠.

리액트에서 CRP

리액트는 이 CRP과정을 최소화 하기 위해 Virtual DOM이라는 가상의 객체를 사용합니다. Virtual DOM을 이용해 이전의 Virtual DOM과 새로운 Virtual DOM을 비교하여 변경된 부분만을 렌더링 하는것이죠.

기존의 Virtual DOM과 새로운 Virtaul DOM을 비교하여 렌더링하는 부분은 리액트 내부적으로 수행하고 있어 리액트를 사용하는 개발자가 직접 최적화할 여지는 없습니다.

그렇다면 그냥 손을 떼고 있으면 될까요? 아닙니다. 개발자는 Virtual DOM이 생성하기 이전 단계를 최적화하면 됩니다.

즉, 기존 컴포넌트의 UI가 재사용할 지 확인하면 되는 것이죠.

React.memo

다들 아시다시피 리액트는 state가 변할 경우 해당 컴포넌트와 그 하위의 컴포넌트들을 모두 리렌더링 합니다. 하위 컴포넌트의 경우 props가 변화하지 않았따면 UI가 변화하지 않았을 것 입니다. 굳이 새롭게 컴포넌트 함수를 호출할 필요없이 재활용하면 된다는 이야기죠.

이 UI가 실질적으로 변화했는지 리액트가 모든 렌더링 과정에서 검사할 순 없기 때문에 개발자에게 React.memo함수를 제공해주고 판단할 수 있게 해준 것 입니다.

React.memo

HOC

function HOC(Component){
  // do something
  return <Component />
}

React.memo는 HOC(Higher Order Component)라고 합니다. HOC는 컴포넌트를 인자로 받아서 컴포넌트를 리턴하는 컴포넌트입니다.

React.memo는 기본적으로 이전 props와 다음 렌더링 때 사용되 props를 비교해서 차이가 있을 경우에만 리렌더링을 수행합니다. 만약 차이가 없다면 기존의 렌더링 결과를 재사용하겠죠?

기본적인 props비교는 shallow compare를 합니다. 만약 이를 원치않고 직접 비교하는 로직을 넣고자 한다면 두번째 인자로 넘겨주면 됩니다.

shallow compare (얕은 비교)

  • 숫자, 문자열, Boolean과 같은 원시 자료형은 값을 비교한다.
  • 배열, 객체 등 참조 자료형은 그 안의 값 혹은 attribute를 비교하지 않고, 그들의 레퍼런스를 비교한다.
    즉, 배열과 객체를 직접 수정한 뒤 setState로 적용하여도 같은 참조 위치를 가지고 있기 때문에 값의 변화를 감지하지 못한다.
function AComponent(props){
  // render 
}

function areEqual(prev, next){
  // return something boolean (true or false)
  // true 일경우 이전 결과를 재사용
  // false 일 경우 리렌더링
}

export default React.memo(AComponent, areEqual);

불변성

앞서 알아본 바와 같이 배열과 객체같은 Object는 참조형 타입입니다. 그렇다면 이 Object의 변화는 어떻게 감지할 수 있을까요? 먼저 불변성이란 것을 이해해야 합니다.

기본적으로 원시형 타입은 모두 불변합니다.

let name = "jinux";
name = "jinwoo";

위 코드에서 name에 할당된 "jinux"란 string을 "jinwoo"라는 string으로 변경한 것이 아닙니다. "jinwoo"라는 string을 만들고 name에 할당된 값 자체를 교체한 것이죠.

참조형의 경우는 어떨까요?

const jinux = {name:"jinwoo", gender:"male"};

jinux.name = "something";

참조형의 경우는 가변합니다. 어떤 형태로든 변경할 수 있죠.

가변성은 메모리를 절약하면서 유연하게 사용할 수 있지만 때때로 결과를 예상하기 힘들고 비교가 어렵다는 단점이 있습니다.

자바스크립트의 비교연산자는 메모리 주소를 통해 일치 여부를 판단합니다. 원시형 타입의 경우 메모리 주소가 달라져서 비교연산자가 용이하지만 객체의 경우 내용물의 비교는 어렵죠. 또 같더라도 메모리 주소가 다르다면 동일하지 않다고 결과가 나옵니다.

const A = {name:"jinux"};
const B = A;
B.name = "jinwoo";

// 같은 메모리를 가르킨다.
console.log(A === B) // true;
// 같은 메모리를 가르키기 때문에 수정된다.
console.log(A) // {name: 'jinwoo'}

const C = {name: "same?"};
const D = {name: "same?"};

// 내용은 같을지라도 메모리가 다르다.
console.log(C === D); // false;

이런 동작으로 인해서 내용을 비교하려면 모든 property를 순회하며 비교해야 합니다. 만약 내부에 객체가 존재한다면 또 순회해야 하기 때문에 복잡도는 기하급수적으로 늘어납니다.

이처럼 객체간의 비교가 힘들고 과거에 비해 메모리 용량이 늘어남에 따라 객체를 불변하게 사용하는 방법을 사용하는 추세입니다.

const prev = {...};
const next = {...prev, ...수정할 property};

console.log(prev === next); // false

React.memo의 올바른 활용

React.memo는 기본적으로 shallow compare를 한다고 하였습니다. 그 의미를 알아봅시다.

props는 객체 형태로 표현됩니다. 각 props는 매 렌더링마다 새롭게 생성이 되어 그 자체를 비교하는 것은 의미가 없습니다. 앞서 알아보았듯이 어차피 다를테니깐요.

바로 비교해야하는 것은 props 객체안의 property들입니다.

<Component name="foo" hello="world" />

<Component name="bar" hello="world" />

const areEqual = (prevProps, nextProps) => {
	if(prevProps.name !== nextProps.name) return false;
	if(prevProps.hello !== nextProps.hello) return false;

	return true;
}

Memorization

컴포넌트를 기억해서 비교해 리렌더링을 하는 것은 알겠습니다. 이것을 특정한 값에도 적용할 수 있지않을까요? 이런 테크닉을 Memorization이라고 합니다.

리액트는 매 렌더링마다 함수 컴포넌트를 다시 호출합니다. 함수는 기본적으로 이전 호출과 새로운 호출의 값을 공유할 수 없죠. 비교하려면 어떤 공간에 저장해두고 있다가 가져와야 할텐데 이는 번거로울 것 같습니다.

다행히 리액트에선 이를 도와주는 api를 제공해주고 있습니다.

useMemo

값을 "memorizing"해주는 함수입니다.

const memo = useMemo(()=> somethingCompute(a,b), [a,b]);

useMemo는 두가지 인자를 받습니다. 첫 번째 인자는 콜백함수, 두 번째 인자는 의존성 배열이죠. 의존성 배열의 값중 하나라도 달라진다면 새로운 값으로 계산합니다.

useCallback

useMemo를 사용하며 함수를 memorizing하게 되면

const memorizedFunction = useMemo(() => () => console.log("Hello World"), []);

이런 형태가 됩니다. 보기 불편한데요. 이 때 useCallback을 사용해주면

const memorizedFunction = useCallback(() => console.log("Hello World"), []);

깔끔하게 변하는 것을 볼 수 있습니다.

마무리

memorizing.. 멋져보입니다. 하지만 고려해야할 점이 분명히 존재한다는 것을 알아야 한다고 생각됩니다.

새로운 값 vs 이전 값 저장과 비교하기

정답은 상황마다 다를 것 입니다. 만약 새로운 값을 만드는 과정이 복잡하다면 후자가 그게 아니라면 전자가 또, memorizing하면서 코드의 복잡도가 올라가는 것도 생각할만하죠.

리액트에서 메모라이징이 필요하다고 판단할 수 있는 요인은 두가지라고 합니다.
1. 새로운 값을 만드는 연산이 복잡하다.
2. 함수 컴포넌트의 이전 호출과, 다음 호출 간 사용하는 값의 동일성을 보장하고 싶다.

그 중 2번의 경우의 이유가 무엇일까요? 바로 REact.memo와 연동해서 사용하기 위함입니다.

props로 전달되는 객체의 내용이 같더라도 shallow compare를 통해 다른 객체로 판단되며 매번 렌더링 되는 것을 막을 수 있는 것이죠.

0개의 댓글