React) children prop에 대한 고찰(feat. 렌더링 최적화)

2ast·2022년 9월 11일
86

React

목록 보기
1/3

Part 1. 일단, children이 뭔지부터 알아보자!

React에서 children이란?

React는 JSX라는 문법을 채택하여 사용하고 있다. JSX는 html과 유사한 문법으로 꺽쇠 사이에 타입과 속성을 부여해 프로젝트 구조를 짜는 목적으로 사용된다. 이러한 컴포넌트들 사이에는 포함관계를 설정할 수 있는데, 이때 상위 컴포넌트를 parent component, 하위 컴포넌트를 child component라고 부른다. 그리고 부모-자식 관계가 설정되면 부모 컴포넌트 내부에서는 children prop을 통해 자식 컴포넌트 정보에 접근할 수 있다.

<ParentComponent>
	<ChildComponent/>
</ParantComponent>
const ParantComponent =(props) =>{
	return <>
    	{props.children}
    </>
}

정리하자면

  • 부모 컴포넌트 내부에서는 자식 컴포넌트 정보에 접근할 수 있는데, 바로 이때 사용되는 것이 children prop이다.

children prop은 언제 사용되는 걸까?

그렇다면 이 children prop을 언제 어떻게 활용할 수 있을까? 여러가지 케이스가 있을 수 있지만 대표적인 것은 바로 렌더링 최적화에 사용할 수 있다는 점이다. 아래 경우를 생각해 보자

import React,{useState} from 'react';

const ChildComponent = () =>{
	console.log("ChildComponent is rendering!");
	return <div>Hello World!</div>
}

const ParentComponent = () =>{
	console.log("ParentComponent is rendering!");
    const [toggle, setToggle] = useState(false);
	return <>
    	<ChildComponent/>
        <button onClick={()=>{setToggle(!toggle)}}>
        	re-render
        </button>
    </>
}

const Container =() =>{
	return <div>
    	<ParentComponent/>
    </div>
}

Container > ParentComponent > ChildComponent 구조로 프로젝트가 구성되어 있다. 이 상황에서 만약 re-render 버튼을 눌러 ParentComponent의 리렌더링을 유발하면 어떻게 될까? 당연히 ParentComponent가 리렌더 되는 순간 ChildComponent도 함께 리렌더 될 것이다. 실제로 콘솔을 살펴보면 두 컴포넌트가 모두 렌더링 되고 있음을 확인할 수 있다.

console

그런데 사실 이 현상은 아주 비효율적이다. ParentComponent의 toggle state 변경은 ChildComponent와는 무관함에도 무의미하게 ChildComponent가 리렌더링 되는 것이기 때문이다. 따라서 이 문제를 해결하기 위해 React.memo를 사용하기도 한다.

const ChildMemo = React.memo(ChildComponent)

const ParentComponent = () =>{
	console.log("ParentComponent is rendering!");
    const [toggle, setToggle] = useState(false);
	return <div>
    	<ChildMemo/>
        <button onClick={()=>{setToggle(!toggle)}}>
        	re-render
        </button>
    </div>
}

console

하지만 굳이 React.memo를 사용하지 않고도 렌더링 최적화를 달성할 수 있다. 바로 이번 글의 주제인 children prop을 활용하는 것이다. 아래 코드를 보자

import React,{useState} from 'react';

const ChildComponent = () =>{
	console.log("ChildComponent is rendering!");
	return <div>Hello World!</div>
}

const ParentComponent = ({children}) =>{
	console.log("ParentComponent is rendering!");
    const [toggle, setToggle] = useState(false);
	return <div>
 		{children}
        <button onClick={()=>{setToggle(!toggle)}}>
        	re-render
        </button>
    </div>
}

const Container =() =>{
	return <div>
    	<ParentComponent>
        	<ChildComponent/>
        </ParentComponent>
    </div>
}

console

아까와는 다르게 ParentComponent 내부에 직접 ChildComponent를 배치하는 대신 children prop을 통해 간접적으로 ChildComponent를 return하고 있음을 볼 수 있다. 이 상태에서 아까와 동일하게 re-render 버튼을 눌러 ParentComponent의 리렌더를 유발하게 하게 되면 어떤 결과가 나오게 될까? 놀랍게도 콘솔에는 "ParentComponent is rendering!"이라는 문구만 찍히게 된다. React.memo와 같이 다른 조치를 취하지 않았음에도 ChildComponent가 렌더링 되지 않고 최적화를 달성하게 된 것이다.

정리하자면

  • children prop을 사용하면 React.memo 등의 도구를 사용하지 않고도 구조적으로 렌더링 최적화를 달성할 수 있다.

Part 2. children, 생각보다 쉽지 않은 녀석일지도?

이유를 알 수 없는 렌더링 최적화 효과

오케이, children이 무엇인지 알았고, children을 사용하면 구조적으로 렌더링 최적화에 도움이 된다는 것도 알았다. 하지만, 정작 왜 이런 현상이 발생하는지는 선뜻 이해하기 어렵다. Parent 내부에서 Child를 직접 return하는 것과 prop으로 받아서 return 하는 것에 도대체 무슨 차이가 있는 걸까?

const Parent = ({value}) =>{
	reutrn <div>{value}</div>
}
const Container = () =>{
	return <div><Parent value={100}/></div>
}
const Parent = () =>{
	reutrn <div>100</div>
}
const Container = () =>{
	return <div><Parent/></div>
}

위 두 케이스는 동일한 코드를 표현만 달리한 것이며, 실제로 전혀 차이가 없음을 쉽게 예상할 수 있다. 이제 이번 주제인 children을 생각해보자. value라는 prop으로 100이라는 값을 받아오느냐, children이라는 prop으로 Child Component를 받아오느냐의 차이일뿐이다. 그런데 도대체 왜 Parent 컴포넌트가 리렌더될 때 Child는 렌더링 되지 않는 것일까? 왜 아래의 두 케이스는 같은 결과를 내지 않는 것일까?

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

const Container = () =>{
	return <Parent>
      <Child/>
    </Parent>
}
const Parent = () =>{
	return <div><Child/></div>
}

React.memo를 무효화하는 children prop?

children prop에 의해 일어나는 알 수 없는 현상은 이 뿐만이 아니다. 작은 프로젝트를 생성해 여러가지 실험을 하는 중에 한가지 현상을 발견했는데, 바로 children을 prop으로 갖는 컴포넌트에는 React.memo가 적용되지 않고 항상 렌더링 된다는 사실이었다. 백문이불여일견. 아래 예시 코드를 보자

import React,{useState} from 'react';

const ParentComponent = ({children}) =>{
	console.log("ParentComponent is rendering!")
	return <div>
    	{children}
    </div>
}

const ParentMemo = React.memo(ParentComponent)

const Container = () =>{
	console.log("Container is rendering!")
	const [toggle,setToggle] = useState(false)
	return (
    	<>
    		<ParentMemo>
    			<div>Hello World!</div>
	    	</ParentMemo>
    	    <button onClick={()=>{setToggle(!toggle)}}>
            	re-redner!
            </button>
        </>
    )
}

프로젝트의 구조는 최상위에 Container 컴포넌트가 있고, 그 하위에 ParentComponent를 React.memo로 감싼 ParentMemo가 존재한다. 마지막으로 ParentMemo는 "Hello World!"를 화면에 보여주는 div태그를 child로 두고 있다. 여기에 리렌더링이 될 때마다 어떤 컴포넌트가 렌더링되고 있는지 확인하기 위해 console.log 코드를 삽입했으며, 렌더링을 유발하기 위해 Container에 re-redner 버튼을 두었다.

이 상황에서 re-render 버튼을 누르면 어떤 일이 벌어질까? 일반적으로 ParentMemo는 렌더링되지 않고 콘솔에는 "Container is rednering!" 문구만이 찍힐 것이라고 예상하기 쉽다. React.memo는 컴포넌트의 prop이 변경되지 않는 한 다시 렌더링되지 않도록 제어하는 역할을 하고, ParentMemo의 prop이라고는 children으로 존재하는 div 태그 밖에 없기 때문이다. div 태그는 toggle state와는 무관하며, 당연히 re-render 버튼을 누르기 전과 달라질리 만무하다. 그러니 "ParentComponent is rendering!"이 콘솔에 찍힐 일은 없는 것이다. 그러나 결과적으로 그 예상은 처참히 깨졌다. 그리고 그로부터 시작된 고찰이 이 글의 계기가 되었다.

console

Part 3. children, 별거 아닌거였을 지도?

실마리를 발견했다.

알듯말듯 애매한 children의 미스테리를 알아보기 위해 구글링을 시도했고, 하나의 트위터 글을 발견했다.

출처:https://twitter.com/aweary/status/1230594484347396097

Brandon Dail이라는 분이 올린 트윗이었는데, 내용을 요약하자면 children prop은 매 렌더링마다 값이 달라지므로 React.memo가 동작하지 않는다는 것이었다. 그렇다. 이는 알 수 없는 모종의 이유로 발생하는 버그같은 것이 아니었다. 명백한 원인과 결과가 있었고, 내 소양이 부족해서 마치 버그처럼 보였을 뿐이었다. 나는 이제 선택의 기로에 놓였다. '아 그렇구나! 그럼 이제부터 children이 있는 컴포넌트에는 React.memo를 쓰지 말아야겠다!' 하고 다시 하던 일로 돌아가는 것과 '도대체 children prop이 정확히 뭐길래 React.memo를 무효화하고, 또 ParentComponent가 리렌더링 될 때 왜 children은 렌더링되지 않는지 한번 알아봐야겠어' 라며 더 깊게 조사를 해보는 선택지였다. 나는 후자를 선택했고 조금 더 구글링을 해보기로 했다.

구글링 결과 몇개의 글을 발견할 수 있었고, 그중에서 children의 미스터리에 대해 깊게 고찰한 하나의 글을 발견했다. 결과적으로 이 하나의 아티클을 통해 내 대부분의 궁금증을 해결할 수 있었고, 사실상 지금 쓰고 있는 이 글도 대부분 해당 아티클의 내용 중 일부를 풀어서 설명하고 추가적인 실험 몇가지를 덧붙인 내용이 될 예정이다. 그야말로 Special thanks to!
(Thanks to: https://www.developerway.com/posts/react-elements-children-parents)

기본에 충실하면 해답이 보인다.

children 미스테리에 대한 궁금증을 해결하려면 기초적인 부분부터 하나씩 짚고 넘어갈 필요가 있다. 가장 먼저 알아볼 부분은 'react에서 사용하는 JSX상의 표현은 어떤 의미를 갖고 있을까?'에 대한 것이다.

<Child/>

react에서 위 코드가 실행될 때 정확히 무슨일이 일어나는 걸까? 사실 JSX의 각 태그는 html을 닮았을 뿐 전부 react element를 반환하는 javascript 코드이다. 즉, 아래 코드와 완전히 같은 표현이라고 볼 수 있다. (첫번째 argument는 타입이며, 두번째, 세번째 argument는 각각 porps와 children이다.)

React.createElement(Child,null,null)

React.creatElement는 주어진 arguments를 기반으로 react element를 새롭게 생성해 반환하고, 이렇게 생성된 react element들은 화면에 어떻게 나타내어질지 정보를 담고 있는 object형태로 존재한다. 그 후 function component의 return문에 배치되어지면(class component의 경우 render 함수에 인자로 주어졌을 경우) 그때서야 react가 element의 정보를 해석하여 화면에 그려주게 되는 것이다. 이 플로우에서 가장 중요한 부분은 react element들은 단지 화면을 어떻게 그려야하는지 정보를 담고 있는 object에 불과하다는 점이다. react에 의해 해석되기 전에는 어떠한 것도 실행할 수 없는, 단지 정보의 집합체일 뿐이다.
또 한가지 중요한 점은 react element는 immutable하다는 점이다. 이들은 말 그대로 '상수'다. 따라서 element의 내용이 변경된다는 것은 React.createElement를 통해 변경된 정보를 담아 새로운 object를 반환한다는 것을 의미한다.

정리하자면

  • JSX상의 태그 표현은 사실상 React.createElement라는 javascript 코드의 다른 형태였고, 새로운 react element를 생성해 반환하는 역할을 한다.
  • react element는 단지 화면 정보를 담고 있는 object에 불과하다.
  • react element는 값이 변하지 않는 상수이며, element의 변경은 곧 새로운 element를 생성한다는 것을 의미한다. (re-render = re-create)

React.memo 무효화의 비밀

자, 이제 children을 가지는 컴포넌트에서 React.memo가 왜 동작하지 않는지 알아내기 위한 모든 준비가 끝났다. React.memo란 무엇인가? props가 변하지 않는 한 component의 렌더링을 방지하는 역할을 수행한다. 반대로 말하면 React.memo가 무효화되었다는 것은 props에 변화가 생겼다는 것을 의미한다. 즉, 매 렌더링 마다 children prop은 다른 값으로 변경되고 있었던 것이다. 왜냐하면 JSX에 표현되어지는 react component들은 모두 React.createElemet라는 javascript 함수의 다른 표현이기 때문이다! React.createElement는 매 렌더링 때마다 새로운 react element를 생성해 반환하는데 이 react element는 object 형태로 존재한다. 즉, 결과적으로 children의 데이터 자체는 변경되지 않더라도 새롭게 react element를 반환할 때 object의 참조값이 변경되기 때문에 React.memo는 props가 변경되었다고 인식하여 매번 렌더링하게 되는 것이다!

console.log(<Child/> === <Child/>) //false

정리하자면

  • 매 렌더링마다 react element들은 새롭게 생성되고, 그로인해 object의 참조값이 변경된다.
  • children으로 전달되는 react element 또한 매번 새롭게 생성되기 때문에 prop이 변경되었다고 판단하여 memo가 렌더링을 방지해주지 못하는 것이다.

Parent가 re-render될 때 children은 렌더링 되지 않는 이유

children의 또다른 미스테리는 바로 props.children은 왜 Parent가 렌더링 될 때 리렌더되지 않느냐 하는 점이다. 이 특성 덕분에 렌더링 최적화에 사용될 수 있다는 메리트가 있지만 정작 왜 이런 현상이 일어나는지는 설명하기 어려웠다. 하지만 우리는 이제 이제 그 이유를 설명할 수 있게 되었다. 결론부터 말하자면 react에서 children이란 말 그대로 prop일 뿐이며, ParentComponent에 children 속성을 주는 것과 완전히 동일한 표현이기 때문이다. 즉, 아래 두 표현은 완전히 동일한 의미를 갖고 있다.

<ParentComponent>
	<ChildComponent/>
</ParentComponent>


<ParentComponent children={<ChildConponent/>}/>

이 사실을 기억하며 앞서 사용했던 예시를 조금 수정해보았다.

import React,{useState} from 'react';

const Child () =>{
	console.log("ChildComponent is rendering!");
	return <div>Hello World!</div>
}

const Parent = ({children}) =>{
	console.log("ParentComponent is rendering!");
    const [toggle, setToggle] useState(false);
	return <div>
 		{children}
        <button onClick={()=>{setToggle(!toggle)}}>
        	re-render
        </button>
    </div>
}

const Container =() =>{
	return <div>
    	<Parent children={<Child/>}/>
    </div>
}

코드를 보면 Parent는 children으로 Child를 받고 있다. 정확히 말하면 화면 정보를 담고 있는 object 형태의 react element를 받고 있는 것이고, 해당 element의 출처는 React.createElement 함수의 반환값이다. 즉 Container component가 렌더링 될 때 React.createElement(Child,null,null)을 실행하여 그 반환값을 Parent에 children으로 넘겨주고 있는 있는 것이다. 쉽게 말해서 React.createElement는 object를 반환하는 함수, children은 object 그 자체이다.

대략 이런 느낌이지 않을까? (feat. 함수 사이의 상수)

const sayHi = () =>{
	return "Hi"
}
const Hi = sayHi()
const App = () =>{
	return <>
    	<div>{sayHi()}</div>
      	<div>{Hi}</div>
      	<div>{sayHi()}</div>
    </>
}

React.createElement(Child,null,null)이 실행되는 것은 Container가 렌더링되며 Parent에 props을 넘겨줄 때 뿐이므로, 이 상황에서 Parent가 리렌더링 된다고 해도 이전 렌더링에서 전달받은 children 값을 그대로 사용할 뿐이다. 즉, Parent의 children은 애초에 object 형태인 상수로 전달받았기 때문에 렌더링 이전과 비교해서 값이 달라질리 없고, 따라서 re-render 되지도 않는 것이다.

기억해보자

  • react element는 값이 변하지 않는 상수이며, element 내용의 변경은 곧 새로운 element를 생성한다는 것을 의미한다. (re-render = re-create)

마지막으로 Parent가 re-render 될 때 정말로 props로 받은 값들이 갱신되지 않고 이전 렌더링에 받은 값을 그대로 사용하는지 알아보기 위해 random 함수를 사용해 간단한 실험을 설계해 보았다.

import React,{useState} from 'react';


const ParentComponent = ({valeu}) =>{
    const [toggle, setToggle] useState(false);
    
    console.log(value)
    
	return <div>
        <button onClick={()=>{setToggle(!toggle)}}>
        	re-render
        </button>
    </div>
}

const Container =() =>{
	const randomNumber = () =>{
    	return Math.random()
    }
	return <div>
    	<ParentComponent value={randomNumber()}/>
    </div>
}

console

re-render 버튼을 몇번을 눌러도 동일한 랜덤 값이 출력되고 있음을 확인할 수 있다.

정리하자면

  • React.createElement는 매번 새로운 object를 반환하는 함수이며, children은 그 결과 반환된 object이다.
  • children prop은 말 그대로 prop이고, 한번 전달된 prop은 상위 컴포넌트가 리렌더 되지 않는한 갱신되지 않고 유지된다.
  • 이전 렌더 시점과 비교해서 react element가 달라지지 않았다면 그 내용이 변경되지 않았다 판단, 렌더링되지 않는다.

Part 4. 결론, children에 대한 의견

children은 언제 쓰는게 좋을까?

지금까지 알아본 바에 의하면 children prop은 React.memo를 사용하지 않고도 Child component의 불필요한 렌더링을 방지해주는 효과가 있지만, 그와 동시에 Parent component의 불필요한 렌더링을 유발할 수 있다는 단점도 존재하는 양날의 검이라고 할 수 있다. 그렇다면 어떤 기준으로 children을 사용하는게 좋을까? 나도 아직 react를 공부하기 시작한지 얼마 되지 않은 입장이기에 명확한 답을 내릴 수는 없지만 지금까지 공부한 내용을 바탕으로 몇가지 상황을 생각할 수는 있을 것 같다.

Container가 빈번하게 렌더링 되지 않는 조건일 때

ParentComponent를 감싸고 있는 Container가 빈번하게 re-rendering 되는 환경이라면 ChildComponent를 children으로 받아와서 렌더링하기보다는 ParentComponent 내부에서 import하여 직접 리턴하고 ParentComponent는 React.memo로 감싸는 것을 추천한다.
ChildComponent를 children으로 받아오게 되면 Container가 리렌더 될 때마다 ParentComponent가 불가피하게 함께 렌더링될 수 밖에 없기 때문이다. 따라서 children 형태의 구조를 지양하고 ParentComponent 내부에서 직접 ChildComponent를 리턴하도록 하는 것이 최적화 관점에서 더욱 적절할 수 있다.
다만 이 의견은 어디까지나 절대적인 최적화 관점일 뿐이고, 언제나 이렇게 하는 것이 옳은 것만은 아니다. 복잡한 로직이나 무거운 데이터 요청이 없는 단순한 화면에 최적화 코드를 적용한다고 해도 react 성능에 기여하는 바는 체감하기 어려운 수준이기 때문이다. 이런 때에는 오로지 성능의 관점으로 구조를 설계하기 보다 가독성과 재사용성, 유지보수 관점에서 어떤 방법을 취해야하는지 고민하는 것이 더욱 적절하다고 할 수 있다.

반복적으로 쓰이는 디자인 레이아웃을 제작할 때

프론트 개발을 하다보면 반복적으로 비슷한 형태의 디자인 레이아웃을 짜야하는 경우가 있을 수 있다. 이럴 경우 Layout 컴포넌트를 제작하고 그 children으로 원하는 콘텐츠를 넣는 방식으로 구성하는 것이 코드의 재사용성을 극대화하여 더욱 생산성과 가독성을 높일 수 있다.
또한 이런식으로 구성하면 부가적인 이점을 얻을 수 있는데 바로 불필요한 prop drilling 방지다. Child가 Parent의 children으로 넘겨지는 상황을 상정해봤을 때, 사실상 보여지는 구조만을 놓고 보면 Child 또한 Container의 child 컴포넌트로 간주할 수 있다. 즉 Container에서 Child로 prop을 넘기기 위해 Parent를 거쳐갈 필요가 없기 때문에 귀찮은 과정은 생략하고 코드의 가독성이 증가하는 메리트가 있다.

const Container =() =>{
	const [state,setState] = useState({})

	return <>
    	<LayoutComponent>
        	<ContentsComponent state = {state} setState={setState}/>
        </LayoutComponent>
    </>
}

마무으리

useMemo, useCallback을 비롯한 hooks와 React.memo를 가지고 렌더링 최적화와 관련된 몇가지 실험을 하던 중 최적화를 달성하는 다른 방법으로 children을 사용하는 방법을 알게 됐다. React.memo 또한 memoization을 사용하는 만큼 메모리 부담이 있다는 점을 감안하면 children을 사용해 구조적으로 렌더링 최적화를 할 수 있다는 점은 큰 메리트로 다가왔다. 때문에 처음에는 가능한 모든 컴포넌트를 children 형태로 변경해볼까 생각하며 몇가지 실험을 해보던게 이렇게 장문의 글로 재탄생하게 될줄은 상상도 못했다.
children이라는게 처음에는 아주 간단한 개념이라고만 생각했는데 조금만 깊게 파고드니 마냥 간단하기만한 주제는 아니었다. 그래도 덕분에 react 동작에 대해 기초부터 자세히 알아볼 수 있는 기회가 되어서 아주아주 만족하고 있다. 이 글이 첫 게시물인데, 앞으로도 자주 포스팅할 수 있기를!

react를 공부한지 얼마 지나지 않은 초심자이니 만큼 내용에 오류가 있거나 잘못 설명한 부분이 있을 수 있으니 내용에 대한 지적은 언제나 환영입니다.

profile
React-Native 개발블로그

14개의 댓글

comment-user-thumbnail
2023년 3월 7일

어려운 개념인데 덕분에 이해 잘했습니다!!

1개의 답글
comment-user-thumbnail
2023년 4월 2일

이 개념이 잘 이해가 안갔는데 작동 로직을 잘 정리해주셔서 지금 프로젝트에 어떤 걸 써야하는지 알게됐어요!! 감사합니다!!

1개의 답글
comment-user-thumbnail
2023년 5월 25일

여태까지 children을 깊이있게 생각하지 않았던 거 같은데 덕분에 새로운 사실을 알고 갑니다~👍

1개의 답글
comment-user-thumbnail
2023년 10월 26일

안녕하세요. memo와 children에 대한 글을 찾다 우연찮게 글을 읽었습니다. 블로그에 댓글을 단적이 한 번도 없었는데, 글의 퀄리티를 보니 달지 않을 수가 없었습니다. 좋은 양질의 글을 작성해주셔서 감사합니다!

1개의 답글
comment-user-thumbnail
2023년 11월 24일

서버 컴포넌트 관련 글을 읽다가 여기까지 넘어왔네요. 좋은 글 공유해주셔서 감사합니다!

1개의 답글
comment-user-thumbnail
2024년 3월 6일

감사합니다

1개의 답글
comment-user-thumbnail
2024년 3월 16일

react memo와 children에 대한 상관관계가 궁금했는데 이해가 잘 되었어요!! 멋진 글 감사합니다

1개의 답글