[React] 부모, 자식 컴포넌트 리렌더링 관계 (props,state)
리액트는 보통 부모 -> 자식 방향의 단방향 정보전달의 구조를 이루고 있다.
따라서 자식 컴포넌트에서 정보가 변하지 않고 부모 컴포넌트에서만 값이 변해도 자식 컴포넌트가 같이 렌더링 되는 불필요한 과정이 있다.
이를 해결하기 위해서 memo, useMemo, useCallback 등을 사용할 수 있는데 실제로 리랜더링을 얼마나 막아줄 수 있는지와 props로 객체가 전달되었을 경우에 대한 이해를 여러가지 케이스들을 실험해 보며 정리해 보자.
1. 세팅
먼저 간단한 프로젝트를 CRA를 활용해서 만들어 보았다!
npx create-react-app react-render --template typescript
components라는 폴더를 만들고 간단하게 부모 컴포넌트, 자식 컴포넌트를 만들어준다.
//App.tsx
import React from "react";
import logo from "./logo.svg";
import "./App.css";
import Parent from "./components/Parent";
import Child from "./components/Child";
function App() {
return <Parent></Parent>;
}
export default App;
//components/Parent.tsx
import Child from "./Child";
const Parent = () => {
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트
<Child></Child>
</div>
);
};
/* STYLE */
export default Parent;
// components/Child.tsx
const Child = () => {
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트
</div>
);
};
/* STYLE */
export default Child;
오늘 알아 볼 주제의 핵심은 리랜더링이기 때문에 리액트 18이상 부터 지원하는 개발자 도구 옵션중 하나인 Profiler를 통해서 확인할 것이다!
Profiler의 좌측상단에 보이는 도돌이표 ( 녹화버튼 바로 오른쪽 ) 을 누르면 새로고침 이후의 랜더링 상태를 볼 수 있다.
현재 아무 기능을 넣지 않았으므로 1회 랜더링이 이루어지며 모든 컴포넌트인 App, Parent, Child가 최초 랜더링 된 상태를 확인할 수 있다.
CASE 1 : 자식에 상태가 있는 경우
자식 컴포넌트에 useState를 활용해서 상태를 만든 이후, 간단한 버튼을 통해 리랜더링이 되도록 해보자.
import { useState } from "react";
// components/Child.tsx
const Child = () => {
const [cnt, setCnt] = useState(0);
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트, CNT : {cnt}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid green",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setCnt((v) => v + 1)}
>
+++
</button>
</div>
);
};
/* STYLE */
export default Child;
이제 도돌이표를 누르고 + 버튼을 두어번 정도 누른 후, 빨간색 버튼을 눌러서 리랜더링이 어떻게 일어났는지 확인해보자.
결과는 아래와 같이 나오게 된다.
예상한 대로 자식컴포넌트의 상태만 변화되었기 때문에 Child 컴포넌트만 리랜더링 된것을 확인할 수 있다.
CASE 2 : 부모의 상태가 변한 경우
이번엔 부모 컴포넌트에 상태를 하나 만들어보자. 부모 컴포넌트에 상태를 추가했지만 해당 상태는 자식 컴포넌트에 영향을 주지 않는 상태로 설정하였다.
//components/Parent.tsx
import { useState } from "react";
import Child from "./Child";
const Parent = () => {
const [parentCnt, setParentCnt] = useState(0);
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트 parentCnt : {parentCnt}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setParentCnt((v) => v + 1)}
>
+++
</button>
<Child></Child>
</div>
);
};
/* STYLE */
export default Parent;
해당 상태에서 자식컴포넌트 CNT, 부모컴포넌트 CNT를 쓴 순서대로 한번씩 눌러보자.
부모의 상태가 자식 컴포넌트에서 사용되지 않음에도 부모 컴포넌트의 상태가 변경될 때 자식컴포넌트가 리랜더링 되는 것을 확인할 수 있다.
CASE 3 : memo 사용
위 경우 자식컴포넌트의 리랜더링을 막기 위해 자식컴포넌트를 memo로 감싸는 방법이 있을 수 있다.
import { memo, useState } from "react";
// components/Child.tsx
const Child = memo(() => {
const [cnt, setCnt] = useState(0);
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트, CNT : {cnt}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid green",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setCnt((v) => v + 1)}
>
+++
</button>
</div>
);
});
/* STYLE */
export default Child;
Child 컴포넌트를 memo로 감싸주었기 때문에 부모의 상태를 변경해도 부모만 리랜더링이 일어나게 된다.
CASE 4 : 부모 상태가 자식 컴포넌트에 영향을 주는 경우
자식 컴포넌트가 부모로부터 props를 받아서 보여주는 경우면 어떨까?
import { memo, useState } from "react";
// components/Child.tsx
const Child = memo(({ infoFromParent }: { infoFromParent: number }) => {
const [cnt, setCnt] = useState(0);
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트
<p>
childCnt : {cnt}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid green",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setCnt((v) => v + 1)}
>
childCnt ++
</button>
</p>
<p>infoFromParent : {infoFromParent}</p>
</div>
);
});
/* STYLE */
export default Child;
//components/Parent.tsx
import { useState } from "react";
import Child from "./Child";
const Parent = () => {
const [parentCnt, setParentCnt] = useState(0);
const [infoFromParent, setInfoFromParent] = useState(0);
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트 parentCnt : {parentCnt}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setParentCnt((v) => v + 1)}
>
parentCnt+
</button>
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setInfoFromParent((v) => v + 1)}
>
infoFromParent ++
</button>
<Child infoFromParent={infoFromParent}></Child>
</div>
);
};
/* STYLE */
export default Parent;
memo로 감싸져 있음에도 자식 컴포넌트에서 사용하기 때문에 리랜더링이 되는 것을 확인할 수 있다.
CASE 5 : Props가 객체인 경우 ( 상태 자체가 객체 )
이번에는 부모 -> 자식으로 넘겨주는 props가 객체라고 생각해보자
{
parentValue : number;
childValue : number;
}
위와 같은 타입의 props를 전달해주긴 하지만 자식 컴포넌트에서는 childValue속성만 사용하는 상태이다. 이 경우에 자식 컴포넌트에서 사용하지 않는 parentValue값만 바뀐다면 자식 컴포넌트는 리랜더링이 될까?
자식 컴포넌트는 이전과 같이 memo로 감싸져 있으며, infoFromParent라는 객체 안에서 사용하지 않는 parentValue 값이 바뀐 경우를 테스트 해보자.
//components/Parent.tsx
import { useState } from "react";
import Child from "./Child";
const Parent = () => {
const [parentCnt, setParentCnt] = useState(0);
const [infoFromParent, setInfoFromParent] = useState({
parentValue: 0,
childValue: 0,
});
const plusParentValue = () => {
setInfoFromParent((v) => {
return {
...v,
parentValue: v.parentValue + 1,
};
});
};
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트 parentCnt : {parentCnt}
<p>infoFromParent.parentValue : {infoFromParent.parentValue}</p>
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setParentCnt((v) => v + 1)}
>
parentCnt+
</button>
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={plusParentValue}
>
infoFromParent.parentValue ++
</button>
<Child infoFromParent={infoFromParent}></Child>
</div>
);
};
/* STYLE */
export default Parent;
import { memo, useState } from "react";
// components/Child.tsx
interface Props {
parentValue: number;
childValue: number;
}
const Child = memo(({ infoFromParent }: { infoFromParent: Props }) => {
const [cnt, setCnt] = useState(0);
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트
<p>
childCnt : {cnt}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid green",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setCnt((v) => v + 1)}
>
childCnt ++
</button>
</p>
<p>infoFromParent.childValue : {infoFromParent.childValue}</p>
</div>
);
});
/* STYLE */
export default Child;
결과는 자식 컴포넌트에서 사용되지 않는 값임에도 불구하고 객체 자체가 변경되었기 때문에 변화라고 감지하고 리랜더링이 일어나게 된다.
이는 결과적으로는 자식 컴포넌트에서 사용되지 않는 값이 변경되었음에도 랜더링을 막지 못했다고 볼 수 있다.
CASE 6 : Props가 객체인 경우 ( 객체 안의 값이 상태 )
이번는 CASE 5와 유사하지만 객체 안의 값들이 상태라고 생각해보자.
객체가 props로 전달되는 것은 똑같지만 객체 안의 parentValue, childValue가 각각의 상태로 정의된 상태에서 전달되고 있다.
이전과 마찬가지로 paretValue는 자식에선 사용되지 않는다.
//components/Parent.tsx
import { useState } from "react";
import Child from "./Child";
const Parent = () => {
const [parentCnt, setParentCnt] = useState(0);
const [parentValue, setParentValue] = useState(0);
const [childValue] = useState(0);
const plusParentValue = () => {
setParentValue((v) => v + 1);
};
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트 parentCnt : {parentCnt}
<p>parentValue : {parentValue}</p>
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setParentCnt((v) => v + 1)}
>
parentCnt+
</button>
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={plusParentValue}
>
parentValue ++
</button>
<Child infoFromParent={{ parentValue, childValue }}></Child>
</div>
);
};
/* STYLE */
export default Parent;
이 경우 memo로 감싸진 Child는 paretValue의 변화에 리랜더링이 될까?
해당 케이스 역시 리랜더링이 일어난다.
리액트는 값을 비교하기 위해서 Object.is() 를 사용한다. 즉 객체를 비교하는 경우 얕은비교 ( 참조값을 비교) 하게 된다.
따라서 안의 값의 변동 유무와는 상관없이 참조값이 다르면 다르다고 본다.
위 케이스의 경우 참조값이 달라지기 때문에 parentValue, childValue와는 전혀 상관없이 리랜더링이 일어나게 된다.
해결방법 ( 정리 )
즉, 정리하자면 react.memo를 감싸주더라도 넘겨주는 값이 객체이면 리랜더링 방지 역할을 제대로 못할 수 있다.
이에대한 해결방법은 아래와 같다.
첫번째로는 우선 컴포넌트 구조를 짜는 경우 child에서만 사용되는 props를 전달하는 것이 당연히 좋을 것이다. 애초에 안쓰이는 props를 전달하지 않는것이 중요하다.
불필요하게 props를 전달해야 하는 경우에는 react memo의 두번째 인자를 활용할 수 있다. memo의 두번째 인자로 객체 비교하는 방법을 내가 지정할 수 있는데, 이를 활용하면 올바르게 만들 수 있다.
import { memo, useState } from "react";
// components/Child.tsx
interface Props {
parentValue: number;
childValue: number;
}
const Child = memo(
({ infoFromParent }: { infoFromParent: Props }) => {
const [cnt, setCnt] = useState(0);
return (
<div
style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}
>
자식 컴포넌트
<p>
childCnt : {cnt}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid green",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setCnt((v) => v + 1)}
>
childCnt ++
</button>
</p>
<p>infoFromParent.childValue : {infoFromParent.childValue}</p>
</div>
);
},
(v1, v2) => v1.infoFromParent.childValue === v2.infoFromParent.childValue
);
/* STYLE */
export default Child;
위는 억지로 infoFromParent.childValue만 비교하도록 만들어본 방법이다 ( 좋은 방법은 아니다 )
비교를 childValue만 하도록 변경했기 때문에 리랜더링이 되지 않은 것을 확인할 수 있다.
CASE 7 : Props가 함수인 경우
이번에는 Props로 함수가 들어가는 경우를 생각해보자.
func1, func2 내용은 같지만 함수 의 참조는 다른 함수 2개를 준비했다.
//components/Parent.tsx
import { useState } from "react";
import Child from "./Child";
const Parent = () => {
const [flg, setFlg] = useState(true);
const func1 = {
func: () => console.log("같은함수"),
};
const func2 = {
func: () => console.log("같은함수"),
};
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트 flg : {flg}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setFlg((v) => !v)}
>
toggle
</button>
<Child func={flg ? func1 : func2}></Child>
</div>
);
};
/* STYLE */
export default Parent;
import { memo, useEffect } from "react";
// components/Child.tsx
const Child = memo(({ func }: any) => {
useEffect(() => {
func.func();
});
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트
</div>
);
});
/* STYLE */
export default Child;
자바스크립트는 모든 것이 객체이기 때문에 함수또한 객체이다. 즉 내용이 같아도 다르게 비교될 수 있다.
CASE 7 : Props가 함수인 경우 - memo의 2번째 인자 이용
그렇다고 아래와 같이 memo의 두번째인자에 JSON.stringfy를 이용하는 것은 정말 위험할 수 있다.
import { memo, useEffect } from "react";
// components/Child.tsx
const compare = (a: any, b: any) => {
const prev = JSON.stringify(a);
const next = JSON.stringify(b);
return prev === next;
};
const Child = memo(({ func }: any) => {
useEffect(() => {
func.func();
});
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트
</div>
);
}, compare);
/* STYLE */
export default Child;
실제로 위 코드를 적용시켜보면 자식 컴포넌트가 리랜더링 되지는 않는다.
하지만 이는 JSON.stringify에 함수를 인자로 넣을 시 무조건 아래와 같은 빈 값으로 바뀌기 때문이며 이 방식을 사용하면 다른 함수가 들어오는 경우에도 리랜더링이 막히게 된다.
{}
CASE 7 : Props가 함수인 경우
그렇다면 값만 다른 함수가 아닌 아래처럼 아예 같은 함수를 계속 인자로 넣으면 어떻게 될까?
//components/Parent.tsx
import { useState } from "react";
import Child from "./Child";
const Parent = () => {
const [flg, setFlg] = useState(true);
const func1 = {
func: () => console.log("같은함수"),
};
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트 flg : {flg}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setFlg((v) => !v)}
>
toggle
</button>
<Child func={func1}></Child>
</div>
);
};
/* STYLE */
export default Parent;
리액트는 컴포넌트가 불러오는 경우마다 func1을 새롭게 생성하기 때문에 참조가 달라지게 되고 결국 자식의 리랜더링이 발생하게 된다.
CASE 8 : Props가 함수인 경우 - 객체에 안쌓인 경우
좀 더 직관적인 예시를 위해서 함수 자체를 넘겨보자.
//components/Parent.tsx
import { useCallback, useState } from "react";
import Child from "./Child";
const Parent = () => {
const [flg, setFlg] = useState(true);
const notChangeFunc = () => console.log("함수");
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트 flg : {flg}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setFlg((v) => !v)}
>
toggle
</button>
<Child func={notChangeFunc}></Child>
</div>
);
};
/* STYLE */
export default Parent;
import { memo, useEffect } from "react";
// components/Child.tsx
const Child = memo(({ func }: any) => {
useEffect(() => {
func();
});
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트
</div>
);
});
/* STYLE */
export default Child;
즉 react memo로 감쌌음에도 함수가 props로 전달되는 경우에는 같은 함수를 전달하더라도 막을 수 없다.
CASE 9 : Props가 함수인 경우 Parent-useCallback, Child-memo
//components/Parent.tsx
import { useCallback, useState } from "react";
import Child from "./Child";
const Parent = () => {
const [flg, setFlg] = useState(true);
const notChangeFunc = useCallback(() => console.log("함수"), []);
return (
<div style={{ border: "1px solid blue", margin: "4rem", padding: "4rem" }}>
부모 컴포넌트 flg : {flg}
<button
style={{
background: "white",
marginLeft: "2rem",
border: "2px solid blue",
borderRadius: "20px",
cursor: "pointer",
}}
onClick={() => setFlg((v) => !v)}
>
toggle
</button>
<Child func={notChangeFunc}></Child>
</div>
);
};
/* STYLE */
export default Parent;
즉 위처럼 적용을 한다면 useCallback이 함수의 참조가 변경되는걸 막아주기 때문에 리랜더링이 방지가 된다.
CASE 10 : Props가 함수인 경우 Parent-useCallback, Child- non memo
import { useEffect } from "react";
// components/Child.tsx
const Child = ({ func }: any) => {
useEffect(() => {
func();
});
return (
<div style={{ border: "1px solid green", margin: "4rem", padding: "4rem" }}>
자식 컴포넌트
</div>
);
};
/* STYLE */
export default Child;
만약 react.memo를 사용하지 않는다면 부모의 상태가 변화된 것이기 때문에 함께 변하게 된다.
즉, 객체 ( 함수도 객체다! ) 를 props로 넘길 때 memo가 이를 보호하게 해 주려면 충분한 생각을 해줘야 한다. memo만 감싼다고 리랜더링이 알아서 방지되지는 않는다.