teklog

Fluent React 1 - 컴포넌트 설계 패턴

2024/02/19

n°44

category : React

img


최근에 출간된 Fluent React를 읽고 나서 인상 깊었던 내용을 정리해보고자 한다. 이전에 'Learning React'는 React 18과 관련된 최신 내용이 부족해 아쉬웠지만, 이 책을 통해 그러한 부분을 완전히 보완할 수 있었다.


가령 책에서는 그동안 궁금했던 React Fiber의 상세한 설명과 서버 컴포넌트에 대한 깊이 있는 내용을 다룬다. React 18의 기본 개념부터 효율적인 컴포넌트 설계 패턴에 이르기까지 다양한 주제를 쉽고 상세하게 설명한다. 책을 독해하면서 특히 인상 깊었던 부분과 평소 궁금했던 주제에 대해 주관적인 해석을 덧붙여 메모한다.


이번 글에서는 리액트의 익숙한 개념들에 대해서 다루기 때문에, 기본적인 개념의 설명보다는 새롭게 알게된 사실 위주로 작성하였다.


Common Questions and Powerful Patterns 일반적인 질문과 강력한 패턴들


챕터5의 내용이다. 효율적인 컴포넌트 설계 패턴에 대해 알고자 하는 갈망이 있기에, 가장 먼저 살펴본다. 글이 길어질 것 같으니 먼저 앞의 항목들 중 기억할만한 내용을 정리해본다.



  • Memoization with React.memo
  • Moemoization with useMemo
  • Lazy Loading
  • useState vs useReducer


Memoization with React.memo


TLDR; React.memo



  • React.memo를 사용해 props에 변경이 없는 컴포넌트의 리렌더링을 방지할 수 있다
  • but props나 다른 state가 변경되어 리렌더링이 트리거 될 수 있다
  • non-scarla type, 값은 같아도 reference가 다른 것 때문에 object, array 형태의 값이 재생성되어 리렌더링을 방지할 수 없다
  • 부모 컴포넌트의 상태 변화로 인해 자식 컴포넌트가 전부 리렌더링될 때 자식 컴포넌트의 props에 useMemo, useCallback을 상황에 맞게 적용시킬 수 있다.
  • object, array ⇒ useMemo 사용하여 리렌더링 방지할 수 있다
  • function ⇒ useCallback 사용하여 리렌더링 방지할 수 있다

우리는 "재렌더링(re-render)"이 함수 컴포넌트를 재호출하는 것을 의미한다는 것을 알고 있습니다. React.memo로 감싸진 경우, 그 함수는 그것의 props가 변경되지 않는 한 조정(reconciliation) 도중에 다시 호출되지 않습니다. 함수형 컴포넌트를 메모이제이션함으로써, 우리는 불필요한 재렌더링을 방지할 수 있습니다 ... React는 props와 함께 함수 컴포넌트들을 재귀적으로 호출하여 vDOM 트리를 생성하고, 이 트리는 조정되는 두 개의 Fiber 트리의 기반이 됩니다. 때때로, 렌더링(즉, 컴포넌트 함수를 호출하는 것)은 함수 컴포넌트 내부의 강도 높은 계산 또는 DOM에 배치하거나 업데이트 효과를 적용할 때 강도 높은 계산으로 인해 오랜 시간이 걸릴 수 있습니다. 메모이제이션은 비용이 많이 드는 계산의 결과를 저장하고, 같은 입력이 함수에 전달되거나 같은 props가 컴포넌트에 전달될 때 그 결과를 반환함으로써 이를 피하는 방법입니다.


React.memo에 대한 익숙한 설명이다. memo는 컴포넌트 전체에서 props의 변경을 감지하고, 변경이 없을 시 메모아이즈된 컴포넌트를 보여준다는 내용이다. 추가적으로 알게된 사실은, 재조정reconciliation 동안 memo로 감싸진 컴포넌트가 호출되지 않는다는 점이다. 컴포넌트 함수의 재귀적인 호출 이후, vDOM 트리가 Firber 트리의 기반이 되어 reconciliation이 일어난다는 점은 새롭게 알게 되었다. (재조정과 관련해서 후속 글에서 더 깊이 살펴볼 예정이다.)



React에서 업데이트가 발생하면, 여러분의 컴포넌트는 이전 렌더링에서 반환된 vDOM의 결과와 비교됩니다. 이 결과가 다르다면—즉, 그것의 props가 변경되었다면—재조정자(reconciler)는 요소가 호스트 환경(보통 브라우저 DOM)에 이미 존재하는 경우 업데이트 효과를 실행하거나, 그렇지 않은 경우 배치 효과를 실행합니다. (중략) … React는 React.memo를 사용하여, 만약 그것의 props가 동일하게 유지된다면 우리가 우리의 컴포넌트들이 재렌더링되기를 원하지 않는다는 힌트를 그것의 재조정자(reconciler)에게 제공합니다. 이 함수는 단지 React에게 힌트를 제공합니다. 궁극적으로, React가 하는 일은 React에 달려 있습니다.


props의 변경 여부를 vDOM과 비교하고, 재조정 단계에서 리렌더링 여부를 결정한다는 내용. 렌더링될 필요가 없을 경우 재조정 단계에서 렌더링을 멈추고, 필요하다면 리액트가 렌더링 하도록 한다는 의미로 이해된다.


그렇다면 ‘재조정자’에서 어떤 일이 일어나는 걸까? 아래의 함수는 reconciliation에서 구현되는 memo로 감싸진 함수이다.






function updateMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes
): null | Fiber {
  if (current === null) {
    const type = Component.type;
    if (
      isSimpleFunctionComponent(type) &&
      Component.compare === null &&
      Component.defaultProps === undefined
    ) {
      let resolvedType = type;
      if (__DEV__) {
        resolvedType = resolveFunctionForHotReloading(type);
      }
      workInProgress.tag = SimpleMemoComponent;
      workInProgress.type = resolvedType;
      if (__DEV__) {
        validateFunctionComponentInDev(workInProgress, type);
      }
      return updateSimpleMemoComponent(
        current,
        workInProgress,
        resolvedType,
        nextProps,
        renderLanes
      );
    }
    if (__DEV__) {
      const innerPropTypes = type.propTypes;
      if (innerPropTypes) {
        checkPropTypes(
          innerPropTypes,
          nextProps, 
          "prop",
          getComponentNameFromType(type)
        );
      }
      if (Component.defaultProps !== undefined) {
        const componentName = getComponentNameFromType(type) || "Unknown";
        if (!didWarnAboutDefaultPropsOnFunctionComponent[componentName]) {
          console.error(
            componentName
          );
          didWarnAboutDefaultPropsOnFunctionComponent[componentName] = true;
        }
      }
    }
    const child = createFiberFromTypeAndProps(
      Component.type,
      null,
      nextProps,
      null,
      workInProgress,
      workInProgress.mode,
      renderLanes
    );
    child.ref = workInProgress.ref;
    child.return = workInProgress;
    workInProgress.child = child;
    return child;
  }
  if (__DEV__) {
   ....
  }
  const currentChild = ((current.child: any): Fiber);
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
    current,
    renderLanes
  );
  if (!hasScheduledUpdateOrContext) {
    const prevProps = currentChild.memoizedProps;
    let compare = Component.compare;
    compare = compare !== null ? compare : shallowEqual;
    if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }
  workInProgress.flags |= PerformedWork;
  const newChild = createWorkInProgress(currentChild, nextProps);
  newChild.ref = workInProgress.ref;
  newChild.return = workInProgress;
  workInProgress.child = newChild;
  return newChild;
}

memo는 이전 렌더링된 컴포넌트와 비교하여 작동한다. props의 변화가 없다면 동일한 컴포넌트를, 있다면 새 컴포넌트를 반환한다. 이 복잡한 함수는 memo가 reconciliation에서 이루어지는 단계를 설명한다.


1. 초기 검사


함수 updateMemoComponent는 현재 및 진행 중인 Fiber, 컴포넌트, 새로운 props, 그리고 렌더 레인(업데이트의 우선순위와 타이밍을 나타냄)을 포함한 여러 매개변수를 받습니다. 초기 검사(if (current === null))는 컴포넌트의 초기 렌더링 여부를 결정합니다.



  • current === null인 경우, 비교할 이전 렌더링이 없으므로 렌더링 되어야한다.
  • 컴포넌트를 렌더링하기 위해 새로운 Fiber가 생성되고 반환된다 (4번 단계)


2. 타입 및 빠른 경로 최적화


그런 다음 컴포넌트가 단순 함수 컴포넌트인지 그리고 Component.compareComponent.defaultProps를 검사하여 빠른 경로 업데이트가 가능한지 확인합니다. 이러한 조건이 충족되면, 진행 중인 Fiber의 태그를 SimpleMemoComponent로 설정하여, 더 효율적으로 업데이트할 수 있는 간단한 컴포넌트 타입을 나타냅니다.



  • 컴포넌트에 기본 props가 없고, 커스텀 비교 함수도 없는 단순 함수 컴포넌트인 경우, React는 이를 SimpleMemoComponent로 최적화한다.
  • 이를 통해 React는 컴포넌트가 오직 props에만 의존하며, 다른 것에는 의존하지 않는다고 가정할 수 있어 업데이트를 위한 빠른 경로를 사용할 수 있다.


3. 개발 모드 검사


개발 모드(__DEV__)에서는, 함수가 prop 타입을 검증하고 함수 컴포넌트의 defaultProps 같은 사용되지 않는 기능에 대해 경고하는 추가적인 검사를 수행합니다.



  • 리액트 개발 모드(__DEV__)에서는 defaultProps와 propTypes를 검사한다. 함수 컴포넌트에서 defaultProps의 사용은 개발 모드에서 경고를 발생시킨다. Prop 타입은 검증 목적으로 검사된다. (추후 리액트에서 DEV 모드의 prop 검증은 사라질 예정)


4. 새로운 Fiber 생성


초기 렌더링인 경우, createFiberFromTypeAndProps로 새로운 Fiber가 생성됩니다. 이 Fiber는 React 렌더러의 작업 단위를 나타냅니다. 참조를 설정하고 자식(새로운 Fiber)을 반환합니다.


5. 기존 Fiber 업데이트


컴포넌트가 업데이트되고 있을 때(current !== null), 비슷한 개발 모드 검사를 수행합니다. 그런 다음 얕은 비교(shallowEqual) 또는 제공된 사용자 정의 비교 함수를 사용하여 이전 props와 새로운 props를 비교하여 컴포넌트에 업데이트가 필요한지 확인합니다.



  • 이전 렌더링이 있을 경우, 비교 함수(Component.compare)가 false를 반환하는 경우에만 컴포넌트가 업데이트된다.
  • 이 비교 함수는 사용자가 정의한 함수일 수 있으며, 그렇지 않은 경우 얕은 검사(shallowEqual)를 기본값으로 사용한다.
  • 비교 함수가 새로운 props가 이전 props와 동일하고 ref가 같다고 판단하면, 컴포넌트는 재렌더링되지 않고, 함수는 렌더링 과정을 생략한다.


6. 업데이트에서 벗어나기


props가 동일하고 ref가 변경되지 않았다면, bailoutOnAlreadyFinishedWork를 사용하여 업데이트에서 벗어날 수 있습니다. 이는 이 컴포넌트에 대한 추가 렌더링 작업이 필요 없음을 의미합니다.



  • 예정된 업데이트 또는 컨텍스트 변경이 없는 경우(hasScheduledUpdateOrContext === false), 비교 함수가 오래된 props와 새로운 props를 동일하다고 판단하고 ref가 변경되지 않았다면, 함수는 bailoutOnAlreadyFinishedWork의 결과를 반환하여 재렌더링을 건너뜀


7. 진행 중인 Fiber 업데이트


업데이트가 필요한 경우, 함수는 진행 중인 Fiber에 PerformedWork 플래그를 설정하고 현재 자식을 기반으로 하지만 새로운 props를 가진 새로운 진행 중인 자식 Fiber를 생성합니다.



  • 컨텍스트 업데이트가 있는 경우 (hasScheduledUpdateOrContext === true ), props가 변경되지 않더라도 컴포넌트는 재렌더링.
  • memo 함수 안의 hasScheduledUpdateOrContext 부분에서 변화가 생길 시 리렌더링이 일어나게됨.
  • props의 변경 뿐만 아니라 상태 변경, 컨텍스트 변경, 그리고 예정된 업데이트 또한 재렌더링을 유발할 수 있음


하지만 React.memo는 함수 컴포넌트 전체를 memoize하는 것이기 때문에, 다음과 같은 경우에는 일반적으로 useMemo와 useCallback을 사용한다.



  • useMemo 케이스
  • 이 컴포넌트에서는 로컬 상태 count가 업데이트되어도 List는 리렌더링되지 않는다
function ParentComponent({ allFruits }) {
  const [count, setCount] = React.useState(0);

  const favoriteFruits = React.useMemo(
    () => allFruits.filter((fruit) => fruit.isFavorite),
    []
  );
// const favoriteFruits = allFruits.filter((fruit) => fruit.isFavorite);
// favoriteFruits가 다음과 같이 작성된다면 계속 리렌더링 될 것이다.

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <List items={favoriteFruits} />
    </div>
  );
}
  • useCallback 케이스
  • currentUser가 동일하다면 함수는 재작성되지 않는다.
  • 만일 콜백이 사용되지 않았다면 MemoizedAvatar는 계속 리렌더링 될 것이다.
const Parent = ({ currentUser }) => {
  const onAvatarChange = useCallback(
    (newAvatarUrl) => {
      updateUserModel({ avatarUrl: newAvatarUrl, id: currentUser.id });
    },
    [currentUser]
  );

  return (
    <MemoizedAvatar
      name="Tejas"
      url="<https://github.com/tejasq.png>"
      onChange={onAvatarChange}
    />
  );
};


useMemo, useCallback

리액트 문서의 다음 내용과 함께 확인하면 좋을 내용이 이어진다.


TLDR; useMemo, useCallback


  • 두 훅 모두 비용이 높은 컴포넌트를 최적화할 때 사용한다.
  • 두 훅 모두 적절한 상황에 사용하지 않으면 overhead로 오히려 성능 저하를 불러올 수 있다.
  • React.memo로 감싸진 자식 컴포넌트에 연결된 props에 사용하는 것이 적절하다.
  • useMemo : 스칼라 값(원시 값)에는 사용하지 않는다.
  • useCallback을 사용할 때는
  1. 부모 컴포넌트의 변경이 잦고
  2. 자식 컴포넌트가 비싸며
  3. 자식 컴포넌트가 React.memo로 감싸져있을 때
  • useCallback : 커스텀 컴포넌트가 아닌 기본 html 태그에 사용해서 사용상 이점이 없다.



모든 변수 선언을 컴포넌트 내에서 useMemo로 감싸는 것이 유혹적일 수 있지만, 이것이 항상 유익한 것은 아닙니다. useMemo는 특히 계산 비용이 많이 드는 작업을 메모이징하거나 객체와 배열에 대한 안정적인 참조를 유지하는 데 가치가 있습니다. 문자열, 숫자, 불리언과 같은 스칼라 값의 경우, useMemo를 사용하는 것은 일반적으로 필요하지 않습니다. 이는 이러한 스칼라 값이 자바스크립트에서 실제 값으로 전달되고 비교되기 때문이며, 참조에 의해서가 아닙니다. 따라서 스칼라 값을 설정하거나 비교할 때마다, 실제 값을 다루는 것이지 변경될 수 있는 메모리 위치에 대한 참조가 아닙니다.






const MyComponent = () => {
  const [birthYear, setBirthYear] = useState(1993);
  const today = useMemo(() => new Date(), []);
  const isAdult = today.getFullYear() - birthYear >= 18;

  return (
    <div>
      <label>
        Birth year:
        <input
          type="number"
          value={birthYear}
          onChange={(e) => setBirthYear(e.target.value)}
        />
      </label>
      {isAdult ? <h1>You are an adult!</h1> : <h1>You are a minor!</h1>}
    </div>
  );
};
이 예시는 더 큰 질문을 제기합니다: isAdult의 값을 useMemo로 감싸야 할까요? 만약 그렇게 한다면 어떤 일이 발생할까요? 답은 그렇게 하지 않아야 한다는 것입니다. 왜냐하면 **isAdult**는 메모리 할당 외에는 계산이 필요 없는 스칼라 값이기 때문입니다. 우리는 .getFullYear을 여러 번 호출하지만, JavaScript 엔진과 React 런타임이 성능을 대신 관리해 줄 것이라고 신뢰합니다. 이는 정렬, 필터링 또는 매핑과 같은 추가 계산이 없는 간단한 할당입니다.
이 경우, useMemo를 사용하는 것은 그 자체의 오버헤드 때문에 앱을 빠르게 하기보다는 오히려 느리게 할 가능성이 더 높습니다. 이 오버헤드에는 useMemo를 가져오기, 호출하기, 의존성을 전달하고, 그 의존성을 비교하여 값이 다시 계산되어야 하는지 확인하는 것 등이 포함됩니다. 이 모든 것은 런타임 복잡성을 가지며, 이는 앱에 도움이 되기보다는 해를 끼칠 수 있습니다. 대신, 우리는 할당하고 React가 필요할 때 자체 최적화를 통해 컴포넌트를 지능적으로 리렌더링하도록 신뢰합니다.


useCallback을 사용하는 경우





const MyComponent = () => {
  const [count, setCount] = useState(0);
  const doubledCount = useMemo(() => count * 2, [count]);
  const increment = useCallback(
    () => setCount((oldCount) => oldCount + 1),
    [setCount]
  );

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled count: {doubledCount}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};
여기에 useCallback을 사용해야 할까요? 대답은 아닙니다. 여기서 증가 함수를 메모이징하는 것은 아무런 이득이 없습니다. 왜냐하면 <button>은 브라우저 기본 요소이며 호출될 수 있는 React 함수 컴포넌트가 아니기 때문입니다. 또한, React가 렌더링을 계속 진행할 하위 컴포넌트도 없습니다.
React에서 useMemo와 useCallback 같은 메모이징 훅은 주로 불필요한 계산을 방지하고, 자식 컴포넌트에 props로 전달되는 객체나 함수가 변경되지 않았다는 것을 보장함으로써 자식 컴포넌트의 불필요한 리렌더링을 방지하기 위해 사용됩니다. 그러나, 이 경우와 같이 단순한 스칼라 값을 변경하는 로직이나, 브라우저 기본 요소를 조작하는 로직에는 그런 최적화가 필요 없습니다.


useCallback이 의미있는 경우:


useCallback이 특히 유용한 예는 부모 컴포넌트가 자주 리렌더링되고 해당 컴포넌트에서 콜백 함수를 자식 컴포넌트에 전달할 때입니다. 특히 그 자식 컴포넌트가 React.memo나 shouldComponentUpdate최적화된 경우 더욱 그렇습니다. 콜백 함수의 메모이제이션은 부모 컴포넌트가 리렌더링될 때 자식 컴포넌트가 불필요하게 리렌더링되지 않도록 보장합니다.


리액트 컴파일러 (React Forget)

리액트 문서에서도 설명된 다음 내용이다.


React Forget은 React 애플리케이션에서 메모이제이션을 자동화하는 것을 목표로 하는 새로운 도구 모음입니다. 이는 useMemo와 useCallback과 같은 훅을 불필요하게 만들 수 있습니다. 자동 메모이제이션 처리를 통해, React Forget은 컴포넌트의 리렌더링을 최적화하며, 사용자 경험(UX)과 개발자 경험(DX) 모두를 향상시킵니다. 이 자동화는 React의 리렌더링 동작을 객체 식별 변경에서 깊은 비교 없이 의미 있는 값 변경으로 전환함으로써 성능을 향상시킵니다. 2021년 React Conf에서 소개된 React Forget은 작성 시점에 아직 일반에 공개되지 않았지만, Meta에서 Facebook, Instagram 등에서 생산적으로 사용되고 있으며, 내부적으로 "기대를 초과했다"고 합니다.


Lazy loading

lazy loading에 대한 기본적인 내용의 설명이 이어진다. 리액트 문서와 중복되는 내용이어서 생략.



  • 자바스크립트 로드 async, defer처럼 컴포넌트를 dynamic import하여 성능을 개선할 수 있다.
  • Suspense 바운더리가 try catch 처럼 동작한다.
  • 따라서 pending 상태에 보여줄 Fallback UI를 필요로한다.
  • UI에서 보일 시에만 로드하도록 성능개선 가능.
  • 마찬가지로 Expensive 컴포넌트에 적용하여 성능 개선의 이점을 볼 수 있다.


useState Versus useReducer

이어서 useReducer 사용의 이점과 사용시 장황해지는 코드를 방지하기 위해 immer 라이브러리를 사용하는 것을 소개하고 있다. 마찬가지로 리액트 공식문서와 크게 다르지 않은 내용이어서 짧게 요약.


TLDR; useReducer의 유즈 케이스


  • state의 데이터 구조가 복잡할 때 사용하는 것이 좋다.
  • 상태를 업데이트하는 dispatch와 state를 분리할 수 있다.
  • 분리를 통 해 테스트의 용이성이 생긴다.
  • 코드가 장황해질 수 있다.


useReducer 사용의 이점 :


상태 업데이트의 로직을 컴포넌트에서 분리합니다. 그것의 리듀서 함수는 독립적으로 테스트될 수 있으며, 다른 컴포넌트에서 재사용될 수 있습니다. 이는 컴포넌트를 깨끗하고 간단하게 유지하고 단일 책임 원칙을 준수하는 훌륭한 방법입니다.
useReducer 사용의 이점은 컴포넌트의 로직을 더 명확하게 분리하고, 상태 관리를 좀 더 예측 가능하게 만들며, 코드 재사용성과 테스트 용이성을 증가시킨다는 점에 있습니다. useState가 제공하는 간결함과 직관성에도 불구하고, 애플리케이션의 특정 부분에서는 useReducer의 구조적인 이점이 더 큰 가치를 제공할 수 있습니다.


다음 글로 이어집니다.