내가 현재 참여하고 있는 프로젝트인 pic.me 의 회원가입 기능을 리팩토링하면서 여러 가지 이유로 기존에 사용하던 React-hook-form을 들어내고, 직접 구현하게 되었다.
이 과정에서 마주한 문제점은 바로 "리렌더링"이었다.
React-hook-form에서는 register과 getValue를 이용해서 여러 state를 선언하지 않고도 상태를 관리하기가 용이했다.
하지만 직접 구현을 하다보니 여러 state가 생기게 되었고 각 state에서 유효성 검사를 실시하면서 (특히나 기획 요구 상 onChange에서 유효성 검사를 실시하게 되면서) 많은 리렌더링이 발생하게 되었다.
이를 조금이라도 개선하기 위해서 렌더링 최적화 방법에 대해 공부하게 되었고, 공부한 내용을 정리해보려고 한다.
함수를 호출하는 것!
function App() { const handleClick = () => { //로직 생략 } return <h1 onClick={handleClick}>렌더링은 함수를 호출하는 것입니다.</h1>; }
렌더링이란, App 컴포넌트가 실행이 되고 내부 로직이 실행이 되고, return문을 통해 element가 반환이 되는 과정이다.
렌더링 과정을 살짝 보자면,
function Parent() {
const [valueForFirstChild, setValueForFirstChild] = useState(null);
const handleClick = () => {}
useEffect(()=>{
setTimeout(()=>{
setValueForFirstChild('changedValue');
},3000)
})
return (
<>
<FirstChild value={valueOfFirstChild}>
<SecondChild onClick={handleClick}>
</>
)
}
useEffect에 의해 state의 값이 변경되면 Parent 컴포넌트가 리렌더링된다.(리렌더링은 함수가 실행되는 것이기 때문에)
또한 Parent 컴포넌트의 return 문을 실행하면서 FirstChild, SecondChild 컴포넌트도 리렌더링이 된다.
컴포넌트가 리렌더링 되는 조건은 두 가지가 있다.
FirstChild : 변경된 state값(valueForFirstChild)을 props로 받기 때문에 리렌더링
SecondChild : Parent 컴포넌트가 리렌더링이 될 때마다 handleClick 함수가 재생성이 되고, 이에 따라 참조값이 달라진 함수를 props로 넘겨받기 때문에 리렌더링 (props가 변경되었을 때에 해당 )
여기서 잠깐!
FirstChild에는 변경된 state 값을 전달해주었기 때문에 리렌더링이 되는 것이 당연하지만,
SecondChild 입장에서는 변화한 것이 없는데 ParentChild에 의해 불필요한 리렌더링이 발생한다.
function FirstChild({value}){
return <div>{value}</div>
}
function SecondChild({onClick}){
return (
<div onClick={onClick}>
{Array.from({length:1000}).map((_,idx) =>(
<GrandChild key={idx} order={idx+1}/>
))}
</div>
)
}
SecondChild 컴포넌트가 불필요하게 리렌더링이 되면, SecondChild 내부의 1000개의 GrandChild도 리렌더링이 되어 굉장히 많은 리렌더링이 발생하게 된다.
SecondChild의 불필요한 리렌더링을 막아주려면 어떻게 해야할까?
Parent 컴포넌트가 리렌더링이 될 때마다 handleClick의 참조값이 변하지 않도록 처리를 해주면 될 것이다.
어떻게?
함수를 메모이제이션 해주는 훅. 메모이제이션이란, 기존에 수행한 연산의 결과값을 어딘가에 저장해두고 필요할 때 재사용하는 것을 말한다.
function Parent() {
const [valueForFirstChild, setValueForFirstChild] = useState(null);
const handleClick = useCallback(() => {});
...
}
이렇게 handleClick을 useCallback으로 감싸주면, 의존성 배열의 값에 변화가 생기지 않는 한 handleClick의 참조값이 변하지 않아 SecondChild가 리렌더링되지 않을 것이라고 예상할 수 있다.
위와 같이 handleClick을 useCallback으로 감싸주어도 SecondChild와 그의 자식인 GrandChild는 계속 리렌더링되는 것을 확인할 수 있다.
왜?
Parent 컴포넌트를 Babel로 컴파일해보면 알 수 있는데, 부모컴포넌트를 실행하면 이에 따라 React.createElement로 FirstChild, SecondChild 엘리먼트가 생성되어 반환이 되기 때문에 어쩔 수 없이 리렌더링이 되는 것이다.
useCallback은 Render Phase는 실행이 되지만 함수의 참조값을 같게 해주어 props에 이전과 같게 해주었기에 SecondChild에서 commit phase가 일어나지 않게 해준다.
따라서 최적화에 효과가 있다고 말할 수 있다.
컴포넌트 전체에 대하여 메모이제이션을 진행한다.
얕은 비교 / 깊은 비교얕은 비교
기본 타입 : 값 비교
참조 타입 (ex. 객체, 배열) : 참조값 비교
const obj1 = {a : 1, b : 2};
const ob2 = {a : 1, b : 2};
console.log(obj1 === obj2); // false
깊은 비교
function SecondChild({onClick}){
return (
<div onClick={onClick}>
{Array.from({length:1000}).map((_,idx) =>(
<GrandChild key={idx} order={idx+1}/>
))}
</div>
)
}
export default React.memo(SecondChild);
SecondChild 컴포넌트가 Render Phase에 들어가기 전에 props로 들어온 onClick의 참조값이 이전과 같은 지 비교하고, useCallback이 적용되어 있어 참조값이 같다면 메모이제이션 해두었던 컴포넌트를 그대로 사용해 SecondChild의 리렌더링 과정이 생략된다.
(즉, React.memo와 useCallback을 함께 사용하여 리렌더링을 막을 수 있다.)
이에 따라 GrandChild 컴포넌트도 리렌더링 되지 않는다.
props가 객체라면 useCallback을 적용할 수도 없기 때문에 매번 같은 객체가 들어오더라도 참조값이 계속 변화할 것이다.
따라서 React.memo를 사용해도 리렌더링을 막을 수 없다.
이러한 상황에서 SecondChild의 리렌더링을 막을 수 있는 다른 방법이 없을까?
함수 자체에 대하여 메모이제이션을 진행하는 useCallback과 달리, useMemo는 값에 대한 메모이제이션을 진행한다.
function Parent() {
const [valueForFirstChild, setValueForFirstChild] = useState(null);
const item = {
name : "React",
isGood : true
}
//처음 마운트 될 때 들어온 그 값을 메모이제이션
const memoization = useMemo(()=> item, []);
useEffect(()=>{
setTimeout(()=>{
setValueForFirstChild('changedValue');
},3000)
})
return (
<>
<FirstChild value={valueOfFirstChild}>
<SecondChild onClick={memoization}>
</>
)
}
이렇게 되면 SecondChild의 props로 계속 참조값이 같은 인자가 들어가기 때문에, React.memo(SecondChild)가 의도한대로 동작해 SecondChild의 리렌더링을 막아줄 수 있다.
Do not optimize rendering prematurely, do it when needed
React.Memo, useCallback, useMemo 모두 내부적으로 특정한 동작을 실행시켜주는 함수이기 때문에 사용하는 것이 모두 비용이다.
예를 들어 항상 props로 다른 값이 들어가는데 useMemo를 쓴다거나, 리렌더링이 자주 되는 컴포넌트라고 해서 내부 함수를 무조건 useCallback으로 감싸주는 경우도 있을 수 있다.
이러한 경우에는 최적화를 사용하기 전보다 최적화를 사용하고 난 후가 더 웹사이트 성능이 안 좋아질 수 있다.
앞서 내가 React-hook-form을 들어내며 마주한 리렌더링 문제도, 의존성 배열로
언제 최적화를 사용해야하는지는 항상 깊게 고민해봐야하는 문제이다.
const printMenu useCallback(() => {
//food가 업데이트 될 때만 console.log(...)가 갱신된다.
console.log(`[ Today's Dinner Menu : ${food} ]`);
}, [food]);
useEffect(() => { state가 업데이트 될 때만 이 부분이 실행된다 }, [state]);