원문: https://www.developerway.com/posts/react-elements-children-parents by Nadia Makarevich
리액트 엘리먼트가 무엇인지 살펴보고, 리액트의 다양한 부모-자식 관계에 대해 탐구해보고, 리렌더에 어떤 영향을 미치는지 알아봅시다.
리액트 컴포지션(합성)에 관한 이전 글에서, 상태가 무거운 컴포넌트를 직접 렌더링하는 대신 다른 컴포넌트를 자식 컴포넌트에 전달해서 성능을 향상시키는 방법을 예시로 들었는데요. 이 글에서 받은 질문으로 인해 리액트의 작동 방식에 대해 의문을 가지게 되었습니다. 자식 컴포넌트는 자식이 아니고, 부모 컴포넌트는 부모가 아니며, 메모이제이션은 제 역할을 하지 않고, 인생은 무의미하고, 리렌더링이 우리의 인생을 컨트롤하며 그것을 막을 수 있는 것은 아무 것도 없습니다. (스포일러: 저는 여기서 승리를 거두었습니다 😅) 더 자세히 설명해보겠습니다.
자식 컴포넌트 패턴과 작은 미스터리
패턴 자체는 다음과 같습니다. 자주 상태가 변경되는 컴포넌트가 있다고 가정해볼게요. 예를 들어 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>
);
};
ChildComponent
는 MovingComponent
의 부모 컴포넌트인 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>
)
}
왜일까요? 여전히 ChildComponent
는 SomeOutsideComponent
에 "속해" 있고, 이 예제에서 볼 수 있듯 SomeOutsideComponent
는 리렌더링되지 않습니다.
미스터리 3: React.memo의 작동 방식
외부 컴포넌트인 SomeOutsideComponent
에 상태를 넣고 React.memo
를 사용해서 자식들이 리렌더되는 것을 방지하면 어떨까요? "정상적인" 부모-자식 컴포넌트 관계라면, MovingComponent
를 React.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를 리렌더링)
그리고 아래 코드에서 Child
를 React.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
는 리렌더를 유발합니다. MovingComponent
는 SomeOutsideComponent
의 자식이고, 메모이제이션되어있지 않습니다. 따라서 부모가 리렌더될 때 같이 리렌더됩니다. MovingComponent
가 리렌더될 때, children 함수를 호출하게 되죠. 이 함수는 물론 메모이제이션되어있지만, 리턴값은 그렇지 않습니다. 매 호출 시마다 <ChildComponent />
를 호출해 새 정의 객체를 만들 것이고, ChildComponent
는 리렌더될 것입니다.
이런 플로우는 ChildComponent
가 리렌더되는 것을 방지하기 위해서는 두 가지 방법이 있다는 것을 의미합니다. 지금처럼 useCallback
을 사용해 함수 자체를 메모이제이션하고, 거기에 더해 MovingComponent
도 React.memo
로 감싸는 것이죠. 그러면 MovingComponent
도 리렌더되지 않고, 이것은 children 함수가 절대로 다시 호출되지 않을 것임을 의미합니다. ChildComponent
의 정의 또한 업데이트되지 않을 것입니다.
혹은, 그냥 useCallback
메모이제이션을 지우고 ChildComponent
를 React.memo
에 감싸면 됩니다. MovingComponent
는 리렌더링되고, children 함수도 호출되지만, 리턴값은 메모이제이션되어 ChildComponent
는 리렌더되지 않을 것입니다.
두 가지 방식 모두 잘 작동합니다. 예제
오늘은 여기까지입니다. 이 작은 미스터리들를 즐기셨길 바라며, 다음 번에 컴포넌트를 작성할 때 렌더링을 완전히 통제할 수 있기를 바랍니다 ✌🏼
'Web > Frontend' 카테고리의 다른 글
[라이브러리] 뚝딱뚝딱 UI 라이브러리 만드는 일기 (4) | 2024.06.04 |
---|---|
[Next.js] 1시간만에 OpenAI로 영어 회화 도우미 만들기 (3) | 2024.04.07 |
[Next.js] API route와 firebase로 인증과 글 쓰기 구현하기 (0) | 2024.01.21 |
[Web] 초보 웹 프론트엔드 개발자가 백엔드와 통신할 때 오류가 난다면? (3) | 2023.11.22 |
[Next.js] AWS Polly API를 사용해 TTS 서비스 만들기 (2) | 2023.11.15 |
댓글