1️⃣ 리액트 컴포넌트의 Props 란

Props 는 Property의 약자로 리액트에서는 상위 컴포넌트가 하위 컴포넌트에 값을 전달할때 사용하는 옵션을 일컫습니다.

상위 컴포넌트가 하위 컴포넌트에게 전달하기 때문에 단방향 데이터 흐름을 갖는다는 특성을 갖고 있습니다.

뿐만 아니라 부모 컴포넌트에서 넘겨준 값을 자식컴포넌트에서 받을때는 하나의 Object 형태로 받게 되며, 각 내부 값은 부모 컴포넌트에서만 수정이 가능하다는 특성을 갖고 있습니다.


2️⃣ 자바스크립트 의 Object 란

자바스크립트는 *객체(Object) 기반 프로그래밍 언어로 자바스크립트를 구성하는 대부분의 것들이 객체 로 이루어져있습니다.

원시형값(number, string, boolean…) 을 제외한 나머지 값**(함수, 배열, 객체, Set, Map, 정규 표현식) 은 모두 객체 구조**를 갖습니다.

객체(*Object) 와 원시타입의 근본적인 차이 중 하나는 참조에 의해 저장되고 복사된다는 점입니다.

특정 변수에 원시타입 값을 할당시 해당 값 자체가 변수에 저장이 되는 한편, 객체의 경우에는 해당 객체가 저장되어있는 참조 값(=메모리 주소 )이 저장됩니다.


const primitive = 1 // 1이 그대로 변수에 저장(할당) 됩니다.
const object = { name: "홍길동" } // 생성된 객체가 저장(할당) 되어있는 메모리상의 주소가 저장됩니다.

3️⃣ 리액트 리랜더링 시점

리액트의 랜더링이란 리액트 라이브러리가 작성된 코드를 기반으로 만들어둔 가상 돔(Virtual Dom) 과 실제 돔(Real Dom) 을 서로 비교하여 달라진 부분이 발견하고 해당 변경사항을 실제 돔에 반영하는 과정을 말합니다.

가상돔과 리얼돔을 비교하는 과정을 render phase

변경된 부분을 리얼돔에 적용하는 과정을 commit phase 라고 부릅니다. 😊

리랜더링(*re rendering) 과정은 조금 다른데요, 이미 초기 랜더링을 통해 화면이 그려진 상태에서 특정한 조건에 의해 화면의 일부분 혹은 전체를 새로 그리는 과정을 의미합니다.

이러한 리랜더링의 조건을 살펴보면 아래와 같은 조건들이 있습니다.

  • 컴포넌트의 내부 상태(*State) 가 변경되었을 때

  • 자식 컴포넌트가 부모로 부터 받은 Props의 값이 변경되었을 때

  • 부모 컴포넌트가 리랜더링이 되는 경우

여기서 부모 컴포넌트가 리랜더링이 되는 경우에는 자식 컴포넌트의 변화 유무 상관없이 모든 자식 컴포넌트들 또한 리랜더링 작업이 이루어집니다. 단, 자식 컴포넌트에 어떠한 최적화 작업이 없었다는 가정하에 말이죠.

물론, 리 랜더링이 된다고 모든 컴포넌트들이 새롭게 제작되어 화면에 반영되는것은 아닙니다.

앞서 말씀드린것처럼 랜더링과정은 리액트 라이브러리가 가상돔과 리얼돔을 비교하여 달라진 부분만 반영을 하기 때문에 리랜더링 과정에서 자식 컴포넌트가 코드 비교과정까지에는 참여될지라도 화면에 반영하는 과정까지는 가지 않을수도 있습니다.

화면에 반영하는 과정에서 매우 큰 연산이 이루어지기 때문에 비교연산을 하더라도 새롭게 그리는것보다 연산비용이 절약이 되기 때문에 위와같은 방식으로 리액트는 작업을 진행합니다.


🤔 리액트 컴포넌트 Props & Object 문제

앞서서 리액트의 Props 와 자바스크립트의 Object 그리고 리액트 랜더링에 대한 설명을 한 이유는 이 세가지 모두가 리액트 랜더링 최적화와 관련이 있기 때문입니다.

간혹 리액트 컴포넌트를 만들어 사용하다보면 부모 컴포넌트로부터 객체형(배열, 함수, Map, Set, 정규 표현식) 을 자식 컴포넌트의 Props로 내려줄때가 종종 생깁니다.

이때 아래 코드처럼 해당 값들을 별도의 변수로 만드는것보다 Props로 곧바로 전달해주는 즉 inline 방식이 편리하여 해당 방식으로 값이 전달되곤 하는데요, 이러한 방식은 리액트 리랜더링 관점에서 좋지않은 습관이 될 수 있습니다.


const Container = () => {
  return (
    <Child
      // inline 방식으로 Object 값을 전달
      info={
        {
          name: "홍길동",
          age: 28,
          hobby: "soccer"
        }
      }
      // inline 방식으로 익명함수 전달
      onClick={
        () => console.log("익명함수") 
      }
    >
  )
}

위와 같은 코드 작성 방식이 왜 좋지 않은 습관이될까요?

그 이유는 앞서 설명드린 객체의 참조 특성때문인데요, 아래에서 더 자세하게 살펴보겠습니다.

객체는 특정변수에 저장(할당)될 당시 값 자체가 아닌 해당 객체가 저장된 메모리 주소가 저장된다고 말씀드렸습니다. 그리고 리액트 컴포넌트는 부모 컴포넌트가 넘겨준 Props 가 변경되었을때 리랜더링이 이루어진다고도 말씀을 드렸습니다.

특정변수에 할당되지 않은 객체형 값을 익명함수 익명객체라고도 부르는데요, 만약 우리가 위의 예제 코드처럼 컴포넌트의 Props 로 익명객체 값을 넘겨주게 되면 실제로는 해당 객체의 참조값(=메모리 주소)이 자식 컴포넌트로 넘겨주게 됩니다.

문제는 해당 익명객체는 넘겨줄 당시에 생성되기 때문에 매 리랜더시 해당 익명객체가 새로이 생성되며, 이로인해 참조값(=메모리 주소)또한 새로운 값으로 바뀌게 됩니다.

이를 상황으로 설명해보겠습니다.

이번 문서에서는 React.memo, useCallback, useMemo 등 memoization 을 통한 최적화 과정에 대한 설명을 다루진 않습니다.


1. 첫번째 상황

부모 컴포넌트가 자식 컴포넌트에게 익명 객체를 Props로 전달했습니다. 이후 자식 컴포넌트 내부의 state가 변경이 되어 리랜더링이 되는 상황이었습니다. 개발자는 이러한 리랜더링을 방지하기 위해서 React.memo 로 Memoization 하여 최적화를 하려고 하였는데요, 하지만 여전히 상태가 변경될때 마다 리랜더링이 되고 있는 상황이었습니다.

위에서 다룬 예제에서는 자식 컴포넌트의 리랜더링을 최적화 하기 위해서 React.memo 를 사용하였는데요, React.memo 는 해당 컴포넌트의 Props를 메모리상에 올려두고 해당 컴포넌트가 리랜더링 되어야 하는 상황에 메모리상에 올려둔 Props와 현재 해당 컴포넌트가 들고 있는 Props를 서로 비교하여 리랜더링을 시킬지 아니면 생략할지를 결정하게 됩니다.

이때 위 예제에서는 익명 객체를 넘겨주었는데요, 이렇게 되면 객체 내부 값들을 동일하다 할지라도 매 리랜더시마다 새로운 참조값(=메모리 주소) 를 갖기때문에 리액트 입장에서는 달라진 코드다 라고 인식을 하게 됩니다. 이 때문에 제일 연산작업이 큰 화면에 그리는 과정까지도 참여하게 되는것이죠.


2. 두번째 상황

부모 컴포넌트가 자식 컴포넌트에게 익명 객체를 Props로 전달했습니다. 이후 부모 컴포넌트가 재 랜더링이 이루어졌고 이로인해 자식 컴포넌트도 리랜더 과정에 참여하게 되었습니다.

이때 부모 컴포넌트가 넘겨준 익명 객체 값은 변한것이 없었는데요, 그럼에도 불구하고 화면에 새롭게 그려지는 과정까지 진행하게 되었습니다.

개발자는 고민 끝에 Props로 전달한 익명 객체를 변수에 할당 후 다시 Props로 전달 하도록 수정후 재 실행해 봤습니다. 하지만 결과는 이전과 동일하게 자식컴포넌트가 화면에 새롭게 반영이 되었습니다.

이 상황에서 핵심은 리랜더과정에 참여하게 되더라도 가상 돔(Virtual Dom) 과 실제 돔(Real Dom) 을 서로 비교하는 과정에서 달라진점이 없다면 실제 화면에 새롭게 그리는 과정은 생략된다는 것입니다.

하지만 어째서인지 위 상황에서는 달라진게 없었음에도 화면에 새롭게 반영이 되었습니다. 심지어 익명객체를 Props로 전달하지 않았음에도 결과는 동일했습니다.

그 이유는 부모 컴포넌트가 랜더링되는 과정에 있습니다. 부모 컴포넌트가 리랜더링이 되면서 자식컴포넌트에게 전달할 객체값이 할당된 변수를 새로이 만들게 되었는데요. 이때 새로이 만들어진 객체값은 새로운 참조값(=메모리 주소)를 갖게 되었고 이 때문에 자식 컴포넌트 또한 화면에 새롭게 그려지게 된것입니다.



🚩 리액트 컴포넌트 Props 최적화

1. 첫번째 상황

첫번째 상황같은 문제를 피하는 방법은 생각보다 간단합니다.

바로 리액트 컴포넌트의 Props로 익명객체를 넘겨주지 않는 것인데요, 익명객체를 넘겨주는 대신에 부모 컴포넌트에서 특정 변수에 해당 객체를 할당하고 해당 변수를 프롭스로 전달해 주는 방법입니다.

위 방법을 토대로 이전 예제코드를 리팩토링해보면 아래와 같습니다.


const Container = () => {
  const info = {
    name: "홍길동",
    age: 28,
    hobby: "soccer"
  }
  
  const handleOnClick = () => console.log("익명함수")
  
  return (
    <Child
      info={ info }
      onClick={ handleOnClick }
    >
  )
}

2. 두번째 상황

두번째 상황을 해결하는 방법은 여러가지가 있습니다.

가장 간단한 방법은 객체형태의 값을 Props로 전달하지 않는것이겠지만, 실제로 개발을 하다보면 자식 컴포넌트에게 넘겨주어야 할 값이 많기 때문에 실질적인 솔루션이 되지는 못할것 같습니다.

물론, 불필요하게 값을 많이 넘기고 있는 상황이었다면, 필요한 값만 넘겨주는 방식으로 도움이 될것 같아요.

여기서 근본적인 문제는 부모가 넘겨주는 객체 값이 내부값의 변화유무 상관없이 달라지고 있다는 것입니다.

이러한 문제를 해결하기 위해서 리액트에서는 useMemo, useCallback 등 객체형 값들을 랜더유무와 상관없이 일정하게 유지할수 있도록 돕는 훅을 제공하고 있습니다.

이러한 훅들은 객체값을 메모리에 올려두고 사용합니다. 이때 메모리에 올려둔 값들은 useEffect 처럼 dependency 배열의 값에 따라 객체값을 새로이 생성할지 말지 결정합니다. 이러한 최적화 방식을 Memoization 이라고 하는데요, 이러한 처리가된 객체를 자식 컴포넌트에게 전달해줌으로서 객체값이 변경되었을때만 화면에 반영되도록 할 수 있습니다.


🤔 Memoization 은 만능인가

뭔가 해답법만 봤을때는 부모 컴포넌트에서 모든 객체값에 Memoization 처리를 해야만 할것 같습니다. 하지만, Memoization은 메모리 공간을 지불하여 최적화 하는 방법이기 때문에 마냥 공짜는 아닙니다.

결국 앞서서 우리가 고민한 최적화 방법들은 컴퓨팅 연산을 최소화 시키는 방식입니다. 그렇기 때문에 상황에 따라 Memoization 처리를 하는것이 좋을수도 있고, 리랜더링 후 화면에 반영되도 그대로 두는것이 좋을때도 있습니다.

만약 자식 컴포넌트의 크기가 크고 내부 로직이 복잡하여 화면에 다시 그리는 연산이 크다고 판단이 되었다면 useMemo 나 useCallback 등을 이용해 재랜더링이 안되도록 막아주는것이 좋을수도 있습니다. 반면에 자식 컴포넌트가 모달처럼 간단한 크기를 갖는다면 해당 컴포넌트 랜더링 최적화를 위해 메모리를 지불하는것은 오히려 손해 일수도 있습니다. 왜냐하면, 이러한 Memoization 또한 특정한 기능을 수행하는 함수이고 이를 사용한다는 것은 최적화를 위한 연산또한 총 연산에 포함됨을 의미하기 때문입니다.

실제로 어떠한 개발자분의 실험에 따르면 애플리케이션의 복잡도에 따라 useMemo 를 썼을때 랜더링 성능이 달라진다는 결과가 있었습니다.

애플리케이션이 복잡할수록 성능개선에 도움이 되었으며, 복잡도가 매우 적은 경우에는 오히려 성능개선에 방해가 되었다는 결과가 였어요.



참고자료: https://github.com/yeonjuan/dev-blog/blob/master/JavaScript/should-you-really-use-usememo.md


👏 결론

작성중입니다…