리액트에서는 최적화는 컴포넌트의 리랜더링을 줄이는 과정이라고해도 과언이 아니다. useCallback
과 useMemo
그리고 React.memo
를 사용하여 메모이제이션 기법으로 리랜더링을 줄이게 된다.
리액트의 렌더링은 Render Phase
와 Commit Phase
크게 두가지 로 나뉜다.
state
나 props
가 변하면 해당 컴포넌트를 불러서 React.createElement
를 동작시켜 컴포넌트를 실행시킨다. 이과정을 리랜더링이라고 한다.
해당 리랜더링 과정을 통해 생성된 새로운 virtual DOM
을 만들게 된다.
이전에 만들어진 virtual DOM
과 현재 만든 virtual DOM
을 비교하여 실제 Real DOM에 반영
할 목록들을 확인한다.
render phase
에서 확인했던 변경이 필요한 목록들을 실제 Real DOM에 적용
한다.
변경이 필요한 부분이 없다면 commit phase는 스킵
된다.
이해를 돕기 위해 다음과 같은 예시코드를 준비했다
//부모 컴포넌트
const Parent = ()=>{
const [value, setValue] = useState(null);
const handleClick = () =>{};
useEffect(()=>{
setTimeout(()=>{
setValue("changeValue");
},3000);
},[])
return(
<>
<FirstChild value={value}/>
<SecondChild onClick={handleClick}/>
</>
)
}
// 첫번째 자식 컴포넌트
const FirstChild = ({value})=>{
return <div>{value}</div>
}
// 두번째 자식 컴포넌트
const SecondChild = ({ onClick })=>{
<div onClick={onclick}>
{Array.from({length:1000}).map((_,idx)=>(
<GrandChild key={idx+1) order={idx}/>. //오래걸리는 작업의 예시
))}
</div>
}
React의 리랜더링은 조건은 props
와 state
의 변경에 있다. Parent
의 value의 값이 변경되면서 Parent
는 리랜더링이 되고 그로인해 Firstchild
도 리랜더링된다. 여기서 주의할 점은 SecondChild
의 props
값도 변경된다는 것이다 Parent가 리랜더링 되면서 handleClick
은 재정의
되고 이전과는다른 handleClick
함수가 생성되면서 자연스럽게 SecondChild
의 props
는 변경되게 된다. SecondChild
는 불필요하게 리랜더링 작업을 수행하게 된다.
이 불필요한 과정을 최적화 할 순 없을까?
//부모 컴포넌트
const Parent = ()=>{
const [value, setValue] = useState(null);
const handleClick = useCallback(()=>{},[]);
useEffect(()=>{
setTimeout(()=>{
setValue("changeValue");
},3000);
},[])
return(
<>
<FirstChild value={value}/>
<SecondChild onClick={handleClick}/>
</>
)
}
useCallback
을 사용해서 handleClick
을 메모이제이션 하였다. 의존성 배열이 변경되지 않는다면 handleClick
의 참조값은 변경되지 않는다. 때문에 리랜더링의 조건중 하나인 props를 동일하게 만들었기 때문이다.
그렇다면 이제 리랜더링은 발생하지 않을까? 안타깝게도 여전히 리랜더링이 발생한다.
왜냐하면 Parent가 리랜더링되면서 React.createElement
를 사용해 자식컴포넌트들을 재생성하기 때문이다. 그렇다고 useCallback
을 사용하는게 아주의미없는것은 아니다. SecondChild
는 리랜더링 되지만 render phase
의 재조정과정
에서 이전 virtual DOM과 변경된 점이 없기 때문에 commit phase를 생략
할 수 있다!
그치만 여전히 render phase에서 불필요하게 리랜더링 되고 있다..
이때 React.memo
를 사용하게 된다. React.memo
를 사용한 컴포넌트는 props
와 state
가 변하지 않는다면 컴포넌트의 리랜더링을 막고 메모이제이션한 컴포넌트를 사용하여 render phase를 생략
할 수 있다. !
// 두번째 자식 컴포넌트
const SecondChild = ({ onClick })=>(
<div onClick={onclick}>
{Array.from({length:1000}).map((_,idx)=>(
<GrandChild key={idx+1) order={idx}/>. //오래걸리는 작업의 예시
))}
</div>
)
export default React.memo(SecondChild); //리액트 메모의 사용
React.memo가 props를 이전과 비교할 때는 얕은비교를 사용한다. 얕은 비교란 원시타입은 값을 비교하고, 참조타입은 참조값이 같은지를 비교한다.
이제 React.memo
를 통해 컴포넌트 전체를 메모이제이션 해주었다. React.memo
는 props가 변경되지 않으면 해당 컴포넌트를 이전에 랜더링 했을 때 생성된 가상 DOM을 재사용하여 리랜더링
을 방지한다.
이전에 useCallback
을 사용해 handleClick
을 재사용하여 props의 변경을 방지했기 때문에 Parent 컴포넌트가 리랜더링이 되어서 SecondChild는 더이상 리랜더링이 되지 않는다!
//부모 컴포넌트
const Parent = ()=>{
const [value, setValue] = useState(null);
const item = {
name : "이순신",
age : 45
}
useEffect(()=>{
setTimeout(()=>{
setValue("changeValue");
},3000);
},[])
return(
<>
<FirstChild value={value}/>
<SecondChild item={item}/>
</>
)
}
// 두번째 자식 컴포넌트
const SecondChild = ({ item })=>(
<div>이름 : {item.name}, 나이 : {item.age}</div>
)
export default React.memo(SecondChild); //리액트 메모의 사용
다음 코드에서 Parent는 3초후에 리랜더링되고 item 변수또한 재정의된다. item은 객체로 참조타입이기 때문에 React는 이전과 다른 객체로 판단하고 SecondChild props가 변경된것으로 판단하여 SecondChild 리랜더링 하게된다. 기껏한 React.memo가 소용없게 되버렸다.
이런경우에는 값을 저장하는 useMemo를 사용할 수 있다. 의존성 배열이 변경되지 않는다면 useMemo는 메모이제이션한 값을 반환한다.
//부모 컴포넌트
const Parent = ()=>{
const [value, setValue] = useState(null);
const item = {
name : "이순신",
age : 45
}
const memoizationItem = useMemo(()=> item, []);
useEffect(()=>{
setTimeout(()=>{
setValue("changeValue");
},3000);
},[])
return(
<>
<FirstChild value={value}/>
<SecondChild item={memoizationItem}/>
</>
)
}
이로써 SecondChild의 item의 참조값은 유지되고 props는 변경되지 않아 리랜더링 되지 않고, 메모이제이션한 컴포넌트를 재사용할 수 있다.
해당 함수들도 결국 코드이고 메모이제이션을 하기위한 작업이 필요하다. 때문에 props나 state가 자주 변경되는 컴포넌트에 사용하면 오히려 메모리의 낭비를 초례할 수 있다.
기본적으로 코드를 잘 작성해놓고 최적화 도구를 사용하도록하자
const Component = () =>{
const forceUpdate = useForceUpdate(); // 강제 리랜더링하는 함수
return(
<>
<button onClick={forceUpdate}> force</button>
<Consoler value="fixedValue"/>
</>
)
}
force라는 버튼을 누르면 강제로 리랜더링이 동작하는 컴포넌트이다. 여기서 Consoler의 value값은 변경되지 않았지만 버튼을 눌러 Component를 리랜더링하게 되면 React.createElement가 자동으로 내부 컴포넌트들을 리랜더링하게 된다.
이 코드를 근본적을 개선할 수 있는 방법은 무엇일까?
const Component = ({children}) =>{
const forceUpdate = useForceUpdate();
return(
<>
<button onClick={forceUpdate}> force</button>
{children}
</>
)
}
function App(){
<div>
<Component>
<Consoler value="fixedValue"/>
</Component>
</div>
}
다음과 같이 사용하면 Consoler는 Component 컴포넌트 안에 있는 것이 아니기 때문에 React.createElement로 리랜더링을 하지 않는다.
이처럼 메모이제이션을 사용하기 이전에 근본적으로 최적화를 먼저 해야한다.
미리 최적화 하지 말자, 필요할 때 하자