React Element, children과 parents의 여러 관계, 그리고 이들이 리렌더에 미치는 영향을 깊이 있게 파헤칩니다.
React에서 Element란 무엇인지 알아보고, children과 parents간의 다양한 관계, 그리고 이들이 리렌더에 어떤 영향을 미치는 지를 탐구합니다.
이전 React 컴포지션 관련 글에서, 상태 변화가 많은 컴포넌트의 성능을 개선하기 위해 자식 컴포넌트를 직접 렌더하지 않고 children으로 넘기는 방법을 다룬 적이 있습니다. 이 글에 관한 질문을 받고 React의 작동 원리를 더 깊이 파헤쳤고, 그 과정에서 React에 대한 내 모든 지식에 의문이 들 정도였습니다. children은 진짜 자식이 아니고, 부모도 부모가 아니고, memoization도 기대만큼 잘 안 먹히고, 인생이 허무하고, 리렌더가 세상을 지배하며 막을 수 없는 것처럼 느껴졌죠(하지만 해답은 찾았습니다 😅).
궁금해지셨나요? 😉 설명해드릴게요.
패턴을 한 번 볼게요. 어떤 컴포넌트가 자주 상태를 바꾼다고 해봅시다. 예를 들어, onMouseMove에서 상태가 바뀝니다:
jsxconst 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> ); };
React에서는 상태가 바뀌면 자신과 자식이 모두 리렌더됩니다. 즉, 마우스를 움직일 때마다 MovingComponent가 리렌더되고, ChildComponent(혹 덩치가 큰)도 리렌더되죠. 이게 잦아지면 앱 성능이 떨어질 수 있습니다.
이를 막는, 즉 memo 외의 방법 중 하나는 ChildComponent를 직접 렌더하지 말고 children으로 받아오는 겁니다.
jsxconst 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> ); };
합성은 다음과 같이 할 수 있습니다:
jsxconst SomeOutsideComponent = () => { return ( <MovingComponent> <ChildComponent /> </MovingComponent> ); };
이렇게 되면 ChildComponent는 SomeOutsideComponent에 "소속"되고, MovingComponent의 상태 변화와 무관하게 리렌더가 일어나지 않습니다. 예시 codesandbox도 확인해보세요.
여전히 div 안에 렌더되는 것(즉, <div style={{ left: state.x, top: state.y }}>)인데 이 div도 매번 리렌더됩니다. 그럼에도 children이 왜 리렌더되지 않을까요? 🤔
children을 렌더 함수로 넘기면(컴포넌트간 데이터 교환에 많이 쓰는 패턴) ChildComponent가 다시 리렌더됩니다. 심지어 그 함수에서 받은 데이터를 쓰지 않아도요!
jsxconst MovingComponent = ({ children }) => { ... return ( <div ...> {/* children을 함수로 넘김; data는 변하지 않음 */} {children({ data: 'something' })} </div> ); }; const SomeOutsideComponent = () => { return ( <MovingComponent> {() => <ChildComponent />} </MovingComponent> ); };
이 경우엔 ChildComponent가 MovingComponent의 상태가 변할 때마다 다시 렌더됩니다. 예시 codesandbox에서 확인해보세요.
바깥 컴포넌트(SomeOutsideComponent)에 상태를 추가하고, React.memo로 감싸봐도 children으로 받은 ChildComponent는 여전히 리렌더됩니다!
jsxconst MovingComponentMemo = React.memo(MovingComponent); const SomeOutsideComponent = () => { const [state, setState] = useState(); return ( <MovingComponentMemo> <ChildComponent /> </MovingComponentMemo> ); }
반면에, ChildComponent 단독으로 memo를 씌우면 부모는 리렌더되더라도 자식은 안됩니다:
jsxconst ChildComponentMemo = React.memo(ChildComponent); const SomeOutsideComponent = () => { const [state, setState] = useState(); return ( <MovingComponent> <ChildComponentMemo /> </MovingComponent> ); }
예시 codesandbox 확인!
children을 함수로 전달하고 그 함수를 useCallback으로 래핑해서 memoize 해봤지만 ChildComponent의 리렌더엔 아무 영향이 없습니다 😬
jsxconst SomeOutsideComponent = () => { const [state, setState] = useState(); const child = useCallback(() => <ChildComponent />, []); return ( <MovingComponent> {child} </MovingComponent> ); }
예시 codesandbox에서 확인하세요.
정답을 고민해보시겠어요? 아니면 바로 이어서 아래로 확인을... 👇
먼저, children은 도대체 뭘까요?
jsxconst Parent = ({ children }) => { return <>{children}</>; }; <Parent> <Child /> </Parent>;
정답은 매우 단순합니다. 그냥 props에 불과합니다. 아래처럼 써도 결과는 같죠:
jsxconst Parent = (props) => { return <>{props.children}</>; };
children 패턴은 사실상 문법 설탕에 가깝죠. 명시적으로 children 프로퍼티에 넣어도 동일합니다.
jsx<Parent children={<Child />} />
props이기 때문에 Element, 함수, 컴포넌트를 다 넘길 수 있습니다. 즉, 렌더 함수 패턴도 아래처럼 쓸 수 있습니다:
jsx<Parent children={() => <Child />} /> <Parent> {() => <Child />} </Parent> const Parent = ({ children }) => { return <>{children()}</> }
혹은(비추천이지만) 이런 식도 가능합니다:
jsx<Parent children={Child} />; const Parent = ({ children: Child }) => { return <>{<Child />}</>; };
관련 caveat는 React component as prop: the right way™️에서 자세히 다뤘습니다.
따라서 1번 미스터리에 대한 단초는 이미 잡았습니다. 즉, "children으로 넘긴 컴포넌트는 props로만 존재하기 때문에 리렌더되지 않는다".
두 번째로 중요한 건, 아래처럼 할 때 실제로 무슨 일이 일어날까요?
jsxconst child = <Child />;
많은 사람들이 이 순간 Child가 렌더링 프로세스에 진입한다고 생각하지만 사실은 아닙니다.
<Child />는 "Element"(요소)라고 부릅니다. 이것은 단순히 React.createElement를 위한 문법 설탕일 뿐이고, 실상은 단순한 객체입니다. 그리고 이 객체는 언젠가 렌더 트리에 실제로 들어갈 때에만 의미를 가집니다.
예를 들어 이렇게 해봅시다:
jsxconst Parent = () => { const child = <Child />; return <div />; };
이 경우 child는 단순히 객체일 뿐 그 이상도 이하도 아닙니다.
혹은 그냥 문법 설탕 없이 호출도 가능합니다:
jsxconst Parent = () => { const child = React.createElement(Child, null, null); return <div />; };
예시 codesandbox 참고.
실제로 저 child가 렌더되는 것은, return의 결과로 돌려보낼 때(함수형 컴포넌트에선 실제로 화면에 그려질 때) 그리고 오직 부모인 Parent가 렌더를 통과한 이후에만 Child의 렌더가 트리거됩니다.
jsxconst Parent = () => { const child = <Child />; return <div>{child}</div>; };
Element(요소)는 immutable(불변) 객체입니다. 요소를 바꾸거나 컴포넌트의 리렌더를 트리거하려면 객체 자체를 새로 만들어야 합니다. 이게 바로 리렌더의 본질입니다:
jsxconst Parent = () => { const child = <Child />; return <div>{child}</div>; };
Parent가 리렌더되면 child는 무에서 새로 재생성됩니다. 이는 객체이기 때문에 아주 빠릅니다. React 입장에서는 child가 새 Element이지만 타입/위치가 같으므로 기존 컴포넌트에 업데이트만 반영(실제 Child 리렌더)합니다.
memoization이 이 원리로 작동하죠. Child를 React.memo로 감싸면:
jsxconst ChildMemo = React.memo(Child); const Parent = () => { const child = <ChildMemo />; return <div>{child}</div>; };
혹은 useMemo로 반환값 자체를 메모이즈 하면,
jsxconst Parent = () => { const child = useMemo(() => <Child />, []); return <div>{child}</div>; };
객체가 새로 안 만들어지므로 React는 바꿀 게 없다고 판단, Child를 스킵하게 됩니다.
더 깊이 파고들고 싶으면 공식 문서를 참고하세요:
이제 위 개념을 바탕으로 모든 미스터리를 풀 수 있습니다. 아래 세 요점을 기억하세요:
const child = <Child />를 선언하면 Element(컴포넌트 정의 객체)만 만들어지며, 렌더가 이 시점에 일어나지 않습니다. 이 객체는 불변입니다.jsxconst MovingComponent = ({ children }) => { const [state, setState] = useState(); return ( <div style={{ left: state.x, top: state.y }}> {/* 이 children은 상태와 무관하게 리렌더되지 않음 */} {children} </div> ); }; const SomeOutsideComponent = () => ( <MovingComponent> <ChildComponent /> </MovingComponent> );
children은 SomeOutsideComponent에서 한번만 만들어진 <ChildComponent /> element일 뿐입니다. MovingComponent가 자체 상태로 몇번을 리렌더되어도, children prop 그 자체는 바뀌지 않으므로, ChildComponent는 리렌더되지 않습니다.
jsxconst MovingComponent = ({ children }) => { const [state, setState] = useState(); return ( <div> {/* 리렌더 때마다 재호출됨! */} {children()} </div> ); }; const SomeOutsideComponent = () => ( <MovingComponent> {() => <ChildComponent />} </MovingComponent> );
이 경우 children은 함수이고, MovingComponent가 리렌더될 때마다 children()를 호출합니다. 매번 <ChildComponent />를 새로 만들기 때문에, ChildComponent도 매번 리렌더됩니다.
jsxconst MovingComponentMemo = React.memo(MovingComponent); const SomeOutsideComponent = () => { const [state, setState] = useState(); return ( <MovingComponentMemo> <ChildComponent /> </MovingComponentMemo> ); }
props는 그냥 객체이기 때문에, <ChildComponent />도 매번 새로 만들어집니다. React.memo는 props가 바뀌었는지 "얕은 비교"를 하는데, element 객체는 새로 만들어졌으니 바뀐걸로 판단. 그래서 부모, 자식 둘 다 리렌더됩니다.
반면, 자식만 memo하면 아래와 같이 작동합니다:
jsxconst ChildComponentMemo = React.memo(ChildComponent); const SomeOutsideComponent = () => { const [state, setState] = useState(); return ( <MovingComponent> <ChildComponentMemo /> </MovingComponent> ); }
MovingComponent는 계속 리렌더되어도, <ChildComponentMemo />의 정의는 변하지 않으므로 ChildComponentMemo는 스킵됩니다. 예시 codesandbox로 확인해보세요.
jsxconst SomeOutsideComponent = () => { const [state, setState] = useState(); const child = useCallback(() => <ChildComponent />, []); return <MovingComponent>{child}</MovingComponent>; };
children을 함수로 memoize했지만, 실제로는 함수의 return 값(element)는 memo되지 않으니, 호출될 때마다 <ChildComponent />가 새 객체가 되어 리렌더가 일어납니다.
완벽하게 막으려면 두 가지 방법이 있습니다: (1) children 함수를 memoize하고 MovingComponent까지 memo로 감싼다. 그러면 MovingComponent가 리렌더가 안 되니 함수도 호출 안 됩니다. (2) 아니면 children 함수 내에서 반환하는 자식 컴포넌트를 memoize한다. MovingComponent 리렌더가 반복돼도 자식은 memo 체크로 리렌더 안 됩니다.
오늘은 여기까지! 이 작은 미스터리 덕분에 다음에 컴포넌트 설계할 때 누가 언제 리렌더되는지 완벽히 제어하실 수 있길 바랍니다. ✌🏼