💕
후원
본문 바로가기
Web/Frontend

[React] [번역] 리액트 엘리먼트, 자식 컴포넌트, 부모 컴포넌트 그리고 리렌더링에 관한 미스터리

by r4bb1t 2024. 6. 22.
반응형

원문: https://www.developerway.com/posts/react-elements-children-parents by Nadia Makarevich

 

The mystery of React Element, children, parents and re-renders

Looking into what is React Element, exploring various children vs parents relationship in React, and how they affect re-renders

www.developerway.com


리액트 엘리먼트가 무엇인지 살펴보고, 리액트의 다양한 부모-자식 관계에 대해 탐구해보고, 리렌더에 어떤 영향을 미치는지 알아봅시다.

리액트 컴포지션(합성)에 관한 이전 글에서, 상태가 무거운 컴포넌트를 직접 렌더링하는 대신 다른 컴포넌트를 자식 컴포넌트에 전달해서 성능을 향상시키는 방법을 예시로 들었는데요. 이 글에서 받은 질문으로 인해 리액트의 작동 방식에 대해 의문을 가지게 되었습니다. 자식 컴포넌트는 자식이 아니고, 부모 컴포넌트는 부모가 아니며, 메모이제이션은 제 역할을 하지 않고, 인생은 무의미하고, 리렌더링이 우리의 인생을 컨트롤하며 그것을 막을 수 있는 것은 아무 것도 없습니다. (스포일러: 저는 여기서 승리를 거두었습니다 😅) 더 자세히 설명해보겠습니다.

자식 컴포넌트 패턴과 작은 미스터리

패턴 자체는 다음과 같습니다. 자주 상태가 변경되는 컴포넌트가 있다고 가정해볼게요. 예를 들어 onMouseMove 콜백에서 상태가 업데이트되는 상황을 생각해봅시다.

const MovingComponent = () => {
  const [state, setState] = useState({ x: 100, y: 100 });

  return (
    <div
      // 이 컴포넌트 안에서 마우스가 움직이면 상태를 업데이트합니다.
      onMouseMove={(e) => setState({ x: e.clientX - 20, y: e.clientY - 20 })}
      // 이 컴포넌트는 마우스 움직임에 따라 움직일 거예요.
      style={{ left: state.x, top: state.y }}
    >
      <ChildComponent />
    </div>
  );
};

컴포넌트는 상태가 업데이트되면 자신과 자신의 모든 자식을 리렌더링합니다. 모든 마우스 이동마다 MovingComponent의 상태가 업데이트되면서, ChildComponent도 리렌더링되겠죠. 이 때 ChildComponent가 무겁다면, 잦은 리렌더링으로 인해 앱의 성능 문제가 발생할 수도 있습니다. 이 문제를 해결하기 위해서는 React.memo 외에도 ChildComponent를 외부로 빼서 children으로 전달하는 방법을 사용할 수 있습니다.

const MovingComponent = ({ children }) => {
  const [state, setState] = useState({ x: 100, y: 100 });

  return (
    <div onMouseMove={(e) => setState({ x: e.clientX - 20, y: e.clientY - 20 })} style={{ left: state.x, top: state.y }}>
      // children은 이제 리렌더되지 않습니다.
      {children}
    </div>
  );
};

그리고 두 컴포넌트를 합성합니다:

const SomeOutsideComponent = () => {
  return (
    <MovingComponent>
      <ChildComponent />
    </MovingComponent>
  );
};

ChildComponentMovingComponent의 부모 컴포넌트인 SomeOutsideComponent에 속합니다. 그리고 MovingComponent의 상태 변화에 영향을 받지 않죠. 결과적으로 마우스가 움직이더라도 ChildComponent는 리렌더되지 않습니다. 각 예제를 확인해보세요.

미스터리 1: ChildComponent는 여전히 MovingComponent의 자식이다.

ChildComponent는 여전히 마우스를 움직이면 리렌더되는 <div style={{ left: state.x, top: state.y }}> 안에서 렌더링되죠. 이 div는 리렌더링되는 부모잖아요. 왜 ChildComponent는 여기서 리렌더링되지 않는 걸까요?

미스터리 2: render function으로써의 자식

만약 자식 컴포넌트를 render function으로 전달한다면(컴포넌트 간의 데이터 공유를 위한 일반적인 패턴이죠) ChildComponent는 변경되는 상태에 의존하지 않아도 다시 리렌더링되기 시작할 것입니다.

const MovingComponent = ({ children }) => {
  ...
  return (
    <div ...// 아까와 같은 onMouseMove 콜백
    >
      // render function으로 데이터와 함께 전달된 자식 컴포넌트
      // 이 데이터는 상태에 의존하지 않습니다.
      {children({ data: 'something' })}
    </div>
  );
};

const SomeOutsideComponent = () => {
  return (
    <MovingComponent>
      // ChildComponent는 MovingComponent의 상태가 변경되면 리렌더됩니다.
      // MovingComponent에서 전달된 데이터를 사용하지 않더라도 말이죠.
      {() => <ChildComponent />}
    </MovingComponent>
  )
}

왜일까요? 여전히 ChildComponentSomeOutsideComponent에 "속해" 있고, 이 예제에서 볼 수 있듯 SomeOutsideComponent는 리렌더링되지 않습니다.

미스터리 3: React.memo의 작동 방식

외부 컴포넌트인 SomeOutsideComponent에 상태를 넣고 React.memo를 사용해서 자식들이 리렌더되는 것을 방지하면 어떨까요? "정상적인" 부모-자식 컴포넌트 관계라면, MovingComponentReact.memo로 감싸면 됩니다. 하지만, ChildComponent가 children으로 전달되었을 경우에는 MovingComponent가 메모이제이션되어도 ChildComponent는 리렌더됩니다.

// 리렌더링 방지를 위해 MovingComponent를 memo로 감쌉니다.
const MovingComponentMemo = React.memo(MovingComponent);

const SomeOutsideComponent = () => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();

  return (
    <MovingComponentMemo>
      <!-- ChildComponent는 SomeOutsideComponent가 리렌더되면 여전히 같이 리렌더됩니다. -->
      <ChildComponent />
    </MovingComponentMemo>
  )
}

그런데 이번에는 MovingComponent를 메모하지 않고 ChildComponent를 메모하면 해결됩니다.

// 리렌더링 방지를 위해 ChildComponent를 memo로 감쌉니다.
const ChildComponentMemo = React.memo(ChildComponent);

const SomeOutsideComponent = () => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();

  return (
    <MovingComponent>
      <!-- ChildComponent는 MovingComponent가 메모되지 않았는데도 리렌더되지 않습니다. -->
      <ChildComponentMemo />
    </MovingComponent>
  )
}

예제

미스터리 4: useCallback의 작동 방식

한편 ChildComponent를 render function으로 넘겨 보겠습니다. 그리고 그 함수를 useCallback으로 감싸서 리렌더링을 방지할 수 있을까요?

const SomeOutsideComponent = () => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();

  // ChildComponent의 리렌더를 방지하기 위해 useCallback으로 감싸 봅니다. 원하는 대로 작동하지 않습니다.
  const child = useCallback(() => <ChildComponent />, []);

  return (
    <MovingComponent>
      <!-- 메모된 render function이지만 리렌더링이 방지되지 않습니다. -->
      {child}
    </MovingComponent>
  )
}

이 미스터리들을 해결할 수 있나요? 답지를 지금 보고 싶다면 몇 가지 키 콘셉트들을 먼저 살펴봐야 합니다.

그래서 리액트에서 "children"이 뭔데?

const Parent = ({ children }) => {
  return <>{children}</>;
};

<Parent>
  <Child />
</Parent>;

답은 간단합니다. 그냥 prop이에요. 우리가 props.children을 통해 접근할 수 있다는 사실이 그걸 증명하죠.

const Parent = (props) => {
  return <>{props.children}</>;
};

우리가 사용하는 합성 패턴들은 그저 문법적 설탕에 지나지 않습니다. 아래 코드처럼 자식을 prop으로 직접적으로 전달해도 작동은 정확히 같을 거예요.

<Parent children={<Child />} />

다른 prop들과 마찬가지로 우리는 엘리먼트, 함수, 컴포넌트를 전달할 수 있습니다. children에 render function을 넣을 수 있는 것도 마찬가지입니다. 아래 코드처럼 할 수 있죠.

// prop으로 넘기기
<Parent children={() => <Child />} />

// 일반적인 구문
<Parent>
  {() => <Child />}
</Parent>

// 구현
const Parent = ({ children }) => {
  return <>{children()}</>
}

혹은

<Parent children={Child} />;

const Parent = ({ children: Child }) => {
  return <>{<Child />}</>;
};

물론 마지막 것은 하지 말아야 합니다. (모든 팀원들이 싫어할 거예요.) 이런 패턴들과 작동 방식, 리렌더와 관련된 자세한 것들을 알고 싶다면 이 글을 참고하세요.

여기서, "자식으로 전달된 컴포넌트들은 그저 prop에 불과하므로 리렌더링되지 않는다"는 사실은 미스터리 1번에 대한 답이 될 수 있습니다.

그럼 리액트 엘리먼트는 뭔데?

두 번째로 이해해야 할 중요한 것은 아래 코드에서 정확히 무슨 일이 일어나고 있는지 이해하는 것입니다.

const child = <Child />;

자주 위 코드에서 컴포넌트가 렌더링되고, Child 컴포넌트의 렌더링 사이클이 시작되는 시점이라는 오해를 하곤 합니다.

하지만 <Child/>는 "Element"입니다. 이건 그냥 React.createElement 함수가 리턴하는 객체에 대한 문법적 설탕에 불과해요. 이 객체는 이 엘리먼트가 렌더 트리에 있을 때 어떻게 나타날지에 대한 설명을 담고 있는 것일 뿐 그 이상의 의미가 없습니다.

기본적으로 이렇게 작성하면:

const Parent = () => {
  // 그냥 객체
  const child = <Child />;

  return <div />;
};

child는 그냥 가만히 있는 객체를 표현하는 상수가 될 뿐입니다.

직접 함수를 호출해서 아래와 같은 문법적 설탕으로 대체할 수도 있죠.

const Parent = () => {
  // <Child />와 정확히 똑같습니다.
  const child = React.createElement(Child, null, null);

  return <div />;
};

예제

리턴에 실제로 포함시킬 때만(함수형 컴포넌트에서 렌더와 같음), 그리고 부모 컴포넌트가 렌더링된 후에만 자식 컴포넌트의 실제 렌더링이 트리거됩니다.

const Parent = () => {
  // Child의 렌더링은 Parent가 리렌더될 때 트리거됩니다.
  // return 안에 포함되어 있기 때문입니다.
  const child = <Child />;

  return <div>{child}</div>;
};

엘리먼트 업데이트

엘리먼트는 불변 객체입니다. 엘리먼트를 업데이트하는, 그리고 정확하게 컴포넌트를 리렌더하는 유일한 방법은 그 객체를 스스로 재생성하는 것 뿐입니다. 이것이 리렌더링이 일어날 때 React에서 벌어지는 일입니다.

const Parent = () => {
  // child를 정의하는 객체는 재생성될 것입니다.
  // Child 컴포넌트는 Parent 컴포넌트가 리렌더될때 리렌더됩니다.
  const child = <Child />;

  return <div>{child}</div>;
};

만약 Parent 컴포넌트가 리렌더되면 child 상수는 처음부터 다시 만들어집니다. (그냥 객체이기 때문에 비용이 저렴합니다.) Child는 React적 관점에서 볼 때 새로운 엘리먼트지만(우리가 객체를 다시 만들었기 때문에) 정확히 동일한 위치에, 동일한 타입을 가지고 있으므로 React는 기존 컴포넌트를 새 데이터로 그저 업데이트 할 것입니다. (이미 있는 Child를 리렌더링)

그리고 아래 코드에서 ChildReact.memo로 감싸면 메모이제이션이 제대로 작동하게 됩니다.

const ChildMemo = React.memo(Child);

const Parent = () => {
  const child = <ChildMemo />;

  return <div>{child}</div>;
};

아니면 함수의 결괏값을 메모이제이션해도 됩니다.

const Parent = () => {
  const child = useMemo(() => <Child />, []);

  return <div>{child}</div>;
};

Element를 정의하는 객체가 다시 생성되지 않을 것이고, 리액트는 이것이 업데이트가 필요하지 않을 거라고 판단할 것입니다. 따라서 컴포넌트도 리렌더되지 않습니다.

공식 문서들에 더 자세히 적혀있으니 참고해보세요: Rendering Elements, React Without JSX, React Components, Elements, and Instances

미스터리 해결

이제 위의 사항들을 살펴보았으니, 상기한 미스터리들을 쉽게 해결할 수 있습니다. 키포인트들을 기억해봅시다.

우리가 const child = <Child />라고 작성하면, 우리는 그냥 엘리먼트를 만든 것입니다. 이 엘리먼트는 그냥 컴포넌트의 정의에 해당하는 객체일 뿐이며, 컴포넌트를 실제로 렌더링하는 것은 아닙니다. 이 정의는 불변 객체이고, 이 정의에서 만들어지는 컴포넌트는 실제 렌더 트리에 포함될 때에만 렌더링됩니다. 함수형 컴포넌트라면 컴포넌트에서 리턴될 때에만 렌더링됩니다. 그리고 이 정의 객체를 재생성하는 것이 컴포넌트를 리렌더링하게 만드는 거죠.

미스터리 1: 왜 prop으로 전달된 자식 컴포넌트는 리렌더되지 않을까?

const MovingComponent = ({ children }) => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();
  return (
    <div
      // ...
      style={{ left: state.x, top: state.y }}
    >
      <!-- 상태가 변해도 아래 children은 리렌더되지 않아요 -->
      {children}
    </div>
  );
};

const SomeOutsideComponent = () => {
  return (
    <MovingComponent>
      <ChildComponent />
    </MovingComponent>
  )
}

"children"은 SomeOutsideComponent에서 생성된 <ChildComponent /> 엘리먼트입니다. MovingComponent가 상태 변경으로 인해 리렌더되어도 props는 그대로입니다. 따라서 props로 전달된 Element(정의 객체)는 재생성되지 않고, 따라서 컴포넌트도 리렌더되지 않습니다.

미스터리 2: children이 render function으로 전달되면 왜 리렌더될까?

const MovingComponent = ({ children }) => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();
  return (
    <div ///...
    >
      <!-- 상태가 변경되면 리렌더됩니다 -->
      {children()}
    </div>
  );
};

const SomeOutsideComponent = () => {
  return (
    <MovingComponent>
      {() => <ChildComponent />}
    </MovingComponent>
  )
}

이 경우 "children"은 함수죠. 그리고 Element(정의 객체)는 이 함수의 결괏값입니다. 우리는 이 함수를 MovingComponent 안에서 실행합니다. 즉 리렌더될 때마다 이 함수를 실행하게 되죠. 따라서 모든 리렌더마다 새로운 정의 객체인 <ChildComponent />가 생성되고, 컴포넌트가 리렌더됩니다.

미스터리 3: 왜 부모 컴포넌트를 React.memo로 감싸도 바깥쪽에서 주입된 자식 컴포넌트가 리렌더될까? 그리고 왜 자식 컴포넌트를 React.memo로 감싸면 부모 컴포넌트를 감싸지 않아도 리렌더가 방지될까?

// 리렌더링 방지를 위해 MovingComponent를 memo로 감쌉니다.
const MovingComponentMemo = React.memo(MovingComponent);

const SomeOutsideComponent = () => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();

  return (
    <MovingComponentMemo>
      <!-- ChildComponent는 SomeOutsideComponent가 리렌더되면 여전히 같이 리렌더됩니다. -->
      <ChildComponent />
    </MovingComponentMemo>
  )
}

children이 단순히 props라는 것을 기억하세요. 위의 코드를 조금 더 명확하게 쓰면 아래와 같습니다.

const SomeOutsideComponent = () => {
  // ...
  return <MovingComponentMemo children={<ChildComponent />} />;
};

우리는 여기서 MovingComponentMemo만을 메모이제이션하지만, 여전히 Element를 받는 children prop을 가지고 있어요. 우리는 이 객체를 매 리렌더마다 재생성할 것이며, 메모이제이션된 컴포넌트는 props를 체크하고, 이 객체가 새로운 것인지 확인할 것입니다. 따라서 MovingComponentMemo는 여전히 리렌더됩니다. ChildComponent의 정의가 재생성되기 때문에, 이 컴포넌트도 리렌더됩니다.

반대로 ChildComponent를 메모이제이션해봅시다.

// 리렌더링 방지를 위해 ChildComponent를 memo로 감쌉니다.
const ChildComponentMemo = React.memo(ChildComponent);

const SomeOutsideComponent = () => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();

  return (
    <MovingComponent>
      <!-- ChildComponent는 MovingComponent가 메모되지 않았는데도 리렌더되지 않습니다. -->
      <ChildComponentMemo />
    </MovingComponent>
  )
}

이 경우 MovingComponent는 여전히 children을 props로 가지고 있지만, ChildComponentMemo는 메모이제이션되어 있습니다. 따라서 이 엘리먼트는 리렌더 시에도 보존됩니다. MovingComponent는 그 자체로는 메모이제이션되어있지 않아 리렌더되지만, 리액트가 children을 체크할 때에는 ChildComponentMemo의 정의가 여전히 같은 것이라고 판단하고 넘어갈 것입니다. 따라서 리렌더가 일어나지 않습니다.

예제

미스터리 4: children을 함수로 전달할 때, 이 함수를 메모이제이션 하는 것은 왜 동작하지 않을까?

const SomeOutsideComponent = () => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();

  // ChildComponent의 리렌더를 방지하기 위해 useCallback으로 감싸 봅니다. 원하는 대로 작동하지 않습니다.
  const child = useCallback(() => <ChildComponent />, []);

  return (
    <MovingComponent>
      {child}
    </MovingComponent>
  )
}

이해하기 쉽게 children을 prop으로 다시 작성해보겠습니다.

const SomeOutsideComponent = () => {
  // 리렌더를 트리거하는 상태
  const [state, setState] = useState();

  // ChildComponent는 여전히 리렌더됩니다.
  const child = useCallback(() => <ChildComponent />, []);

  return <MovingComponent children={child} />;
};

SomeOutsideComponent는 리렌더를 유발합니다. MovingComponentSomeOutsideComponent의 자식이고, 메모이제이션되어있지 않습니다. 따라서 부모가 리렌더될 때 같이 리렌더됩니다. MovingComponent가 리렌더될 때, children 함수를 호출하게 되죠. 이 함수는 물론 메모이제이션되어있지만, 리턴값은 그렇지 않습니다. 매 호출 시마다 <ChildComponent />를 호출해 새 정의 객체를 만들 것이고, ChildComponent는 리렌더될 것입니다.

이런 플로우는 ChildComponent가 리렌더되는 것을 방지하기 위해서는 두 가지 방법이 있다는 것을 의미합니다. 지금처럼 useCallback을 사용해 함수 자체를 메모이제이션하고, 거기에 더해 MovingComponentReact.memo로 감싸는 것이죠. 그러면 MovingComponent도 리렌더되지 않고, 이것은 children 함수가 절대로 다시 호출되지 않을 것임을 의미합니다. ChildComponent의 정의 또한 업데이트되지 않을 것입니다.

혹은, 그냥 useCallback 메모이제이션을 지우고 ChildComponentReact.memo에 감싸면 됩니다. MovingComponent는 리렌더링되고, children 함수도 호출되지만, 리턴값은 메모이제이션되어 ChildComponent는 리렌더되지 않을 것입니다.

두 가지 방식 모두 잘 작동합니다. 예제

오늘은 여기까지입니다. 이 작은 미스터리들를 즐기셨길 바라며, 다음 번에 컴포넌트를 작성할 때 렌더링을 완전히 통제할 수 있기를 바랍니다 ✌🏼

반응형

댓글