프론트엔드 작업을 하다보면 기능 구현만을 신경쓰다 보면 제일 중요하다고 볼 수 있는 성능에 큰 신경을 쓰지 못하는 경우가 꽤 있다.
성능에 큰 영향을 미치는 요소 중 하나가 불필요한 렌더링이다.
불필요한 렌더링을 최소화하는 것은 프론트엔드 개발자가 극복해야하는 문제 중 하나이다.
리액트의 렌더링 과정
우선 렌더링 최적화를 이루기 위해 렌더링이 이루어지는 과정을 알아볼 필요가 있다.
렌더링이란 리액트가 컴포넌트에게 현재의 props와 state를 기반으로 UI가 어떻게 생겼으면 좋겠는지 설명하도록 요청하는 프로세스라고 나와있다.
리액트 작업에서는 주로 함수형 컴포넌트를 사용하여 작업을 하게 된다.
export default function Component(){
...
return <div>컴포넌트 코드</div>
}
위는 우리가 자주 사용하는 패턴이다.
함수형 컴포넌트에서는 렌더링 과정에서 해당 함수를 실행하여 JSX를 반환하고, 이 JSX는 React element로 변환되게 된다.
위의 코드는
return React.createElement('h1', null, 'React')
와 동일하다.
결국 함수형 컴포넌트에서 반환하는 것은 바로 위의 React Element이다.
const element = createElement(type, props, ...children)
다음은 createElement 메소드의 정의이다.
결국 함수형 컴포넌트의 실행으로 나온 JSX는 위의 createElement 메소드 정의에 맞게 변환되고, createElement를 통해 UI를 설명할 수 있는 자바스크립트 객체를 만들게 된다.
Reconcilation(재조정)
위와 같은 컴포넌트 트리 구조가 있다고 가정해보자.
빨간색은 상태가 변경되어 업데이트가 필요한 컴포넌트를 나타낸다.
우선 A 컴포넌트부터 render pass를 시작하는데, 업데이트가 필요 없으므로 건너뛴다.
B 컴포넌트는 업데이트 되었으므로 렌더링 하게 된다. 이 때 중요한 점은 부모 컴포넌트가 업데이트 되면 해당 컴포넌트의 자식 컴포넌트도 재귀적으로 렌더링 된다는 점이다.
즉, 위 그림에서 B 컴포넌트가 렌더링 됨에 따라 재귀적으로 C,D 컴포넌트도 렌더링 되게 된다.
모든 과정이 끝나고 DOM에 컴포넌트 업데이트를 반영해야 한다.
그런데 C,D 컴포넌트는 렌더링 되긴 했지만, 똑같은 렌더링 결과물을 반환하므로 DOM에 반영할 필요가 없다.
이를 위해 Virtual DOM이 사용된다.
Virtual DOM은 React element들을 자바스크립트 객체 형태로 나타낸 것이라고 할 수 있다.
위에서 렌더링 과정의 결과물은 결국 React.createElement 메소드가 만들어내는 자바스크립트 객체라고 하였다.
즉 렌더링을 실행하면 이전 렌더링을 수행했을 때 반환된 자바스크립트 객체와, 최근 렌더링 수행시의 자바스크립트 객체가 있을 것이다.
최근 렌더링 수행이 마무리되면 두 객체를 비교하여(diffing) 결과물이 다르게 나온 부분에 해당하는 컴포넌트만 DOM에 업데이트 해주게 된다.
이렇게 이전과 최신 버전의 두 Virtual DOM을 비교하여 달라진 부분만 DOM에 반영되게 함으로써 DOM의 조작을 최소화 하는 과정을 Reconcilation 즉, 재조정이라고 한다.
Render Phase & Commit Phase
결국 렌더링은 위의 내용을 쪼개어 Render Phase + Commit Phase 라고 나타낼 수 있다.
Render Phase는 컴포넌트를 렌더링 하고, Virtual DOM을 비교 및 계산하는 모든 과정을 말하며,
Commit Phase는 위의 결과물을 DOM에 직접 적용하는 단계를 말한다.
공식문서에 따르면 Commit 단계는 매우 빨리 일어나지만, Render 단계는 느릴 수도 있다고 경고하고 있다.
위에서 봤듯이 실제 Commit 단계에 반영이 안되더라도 부모 컴포넌트가 렌더링이 일어나면 자동으로 자식 컴포넌트는 모두 렌더링이 일어나며, 이는 성능에 크고 작은 영향들을 미칠 수 있다는 것이다.
렌더링이 trigger 되는 경우
크게 3가지로 렌더링이 발생하는 경우를 나눌 수 있다.
- 부모에게 전달받은 props가 변경될 때
- 부모 컴포넌트가 렌더링 될 때
- 자신의 state가 변경될 때
3은 해당 컴포넌트에서 사용하고 있는 useState를 통해 관리하고 있는 변수 값이 setter를 통해 변경되었을 때나 context가 변할 때를 말한다.
위의 상황에서 Render Phase 과정에서 불필요한 렌더링이 발생할 수 있다.
자식 컴포넌트에서의 불필요한 렌더링 과정을 막을 수 있는 방법에 대해 알아보자.
React.memo
React.memo는 Memoization 기법으로 동작하며, HOC(고차 컴포넌트) 이다.
HOC는 컴포넌트를 인자로 받아 새로운 컴포넌트를 반환해내는 함수를 말한다.
function MyComponent(props){
return /*컴포넌트 렌더링 코드*/
}
export default React.memo(MyComponent)
주로 위의 패턴으로 사용하게 된다.
React.memo는 props의 변화에 대해서만 적용되게 된다.
만약 부모 컴포넌트가 렌더링 되었어도 전달되는 props 값이 이전과 변함이 없으면 렌더링을 trigger하지 않는다.
다만 props 변경은 없어도 해당 컴포넌트 안에서 구현한 state가 변경될 때에는 렌더링을 막지 못한다.
간단한 예제를 봐보자.
내가 하고 있는 프로젝트에는 장소 검색 기능이 있고, X 버튼을 누르면 지금까지 입력한 텍스트가 초기화 되는 기능이 있다.
...
const handleTextDeleteClick = () => {
setSearchText("");
};
...
<SearchTextDelete onClick={handleTextDeleteClick} />
위처럼 SearchTextDelete 컴포넌트를 누르면 useState로 관리하고 있는 검색 텍스트를 ""로 되돌리는 함수가 실행되는 간단한 코드이다.
interface SearchTextDeleteProps {
onClick?: () => void;
}
export default function SearchTextDelete({ onClick }: SearchTextDeleteProps) {
return (
<div className="flex justify-center items-center bg-line-gray-3 rounded-full w-[2.4rem] h-[2.4rem]">
<CloseGrayIcon onClick={onClick} />
</div>
);
}
SearchTextDelete 컴포넌트의 코드는 위와 같다.
이제 Chrome에서 react-devtools 도구를 활용하여 해당 기능을 실행했을 때 렌더링 되는 컴포넌트를 확인해보자.
위처럼 SearchTextDelete 컴포넌트도 다시 렌더링 되는 것을 확인할 수 있다.
이유는 SearchTextDelete 컴포넌트가 자식 컴포넌트로 있는 컴포넌트 안에서 사용하고 있는 text state가 변경되었기 때문에 이 컴포넌트가 렌더링 되고, 리액트 렌더링 규칙에 따라 자식 컴포넌트로 있는 SearchTextDelete 컴포넌트도 렌더링 되는 것이다.
그럼 SearchTextDelete 컴포넌트를 React.memo로 감싸보자.
interface SearchTextDeleteProps {
onClick?: () => void;
}
function SearchTextDelete({ onClick }: SearchTextDeleteProps) {
return (
<div className="flex justify-center items-center bg-line-gray-3 rounded-full w-[2.4rem] h-[2.4rem]">
<CloseGrayIcon onClick={onClick} />
</div>
);
}
export default React.memo(SearchTextDelete);
위처럼 React.memo로 감싸주면 props로 전달되고 있는 것은 onClick밖에 없고, 해당 함수를 건드리지는 않았으므로 렌더링 되지 않아야 할 것 같다.
이제 한번 결과를 봐보자.
예상과 달리 React.memo를 활용해주어도 위처럼 달라진 것 없이 똑같이 렌더링 되는 것을 확인할 수 있다.
그럼 onClick prop이 변경되었다는 뜻인데..??
이 현상을 해결하기 위해 필요한 훅이 useCallback이다.
useCallback
리액트에서 렌더링에 일어날 시 함수에 일어나는 일에 주목할 필요가 있다.
부모 컴포넌트에서 자식 컴포넌트로 위처럼 함수를 prop 으로 넘겨주고 있는 상황에 주목하자.
이 때 부모 컴포넌트에서 렌더링이 일어나면, 부모 컴포넌트에서 사용되고 있는 함수는 그때마다 재생성되게 된다.
즉, 부모 컴포넌트에서 렌더링이 일어나면 자식 컴포넌트에서 prop으로 받는 함수는 그때마다 다른 참조 변수를 받게 되므로 자식 컴포넌트에서의 React.memo는 효과가 없어지게 되는 것이다.
함수를 memoization 하는 useCallback 훅을 사용하면 렌더링 때마다 일어나는 함수의 재생성을 막을 수 있다.
const memoizedCallback = useCallback(callback, dependencies)
위는 useCallback의 사용 방법이다.
useCallback을 적용하면 callback으로 전달된 함수는 렌더링 때마다 재생성 되지 않고 동일한 함수 인스턴스를 유지할 수 있게 된다.
다만, 배열 형태의 dependencies에 해당하는 값들이 변경될 때에만 callback을 재생성하게 된다.
dependencies가 빈 배열이면 매 렌더링마다 동일한 callback을 사용하게 된다.
즉, 위에서는 SearchTextDelete 컴포넌트를 클릭하여 handleTextDeleteClick 함수를 호출하면 text state가 변경되고, handleTextDeleteClick 함수가 재생성되어서 SearchTextDelete에 새로운 참조 변수가 전달되게 되어 마찬가지로 렌더링이 일어나게 되는 것이다.
한번 handleTextDeleteClick 함수에 useCallback 훅을 적용해보자.
const handleTextDeleteClick = useCallback(() => {
setSearchText("");
}, []);
위처럼 매 렌더링 마다 같은 callback을 사용할 수 있도록 설정한 뒤 결과를 봐보자.
위처럼 SearchTextDelete 컴포넌트의 렌더링은 trigger되지 않는 것을 확인할 수 있다!!
이처럼 React.memo와 useCallback을 동시에 사용하여 불필요한 렌더링을 막을 수 있다.
useMemo
마지막으로 소개할 훅은 useMemo이다.
React.memo는 HOC 이므로 함수형, 클래스형 컴포넌트에서 모두 사용 가능하지만, useMemo는 훅이므로 함수형 컴포넌트 내에서만 사용할 수 있다.
해당 훅은 특정 값을 memoization 할 때 사용할 수 있다.
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
위는 useMemo훅의 사용방법이다.
useCallback훅과 사용법이 매우 유사하다.
배열 형태의 dependencies 값들이 변경될 때만 첫번째 인자로 주어진 함수가 다시 실행되고, 그 외의 상황에서는 memoize된 첫번째 인자로 주어진 함수가 실행된 후의 결과값을 그대로 사용할 수 있다.
그럼 useCallback과 명확한 차이가 뭘까?
사실 useCallback(fn, deps) 는 useMemo(() => fn, deps) 과 동일하다.
useCallback은 첫번째 인자의 fn이 재생성되는 것을 막아주는 반면, useMemo는 첫번째 인자로 전달되는 함수를 실행하여 해당 함수 실행의 결과 값을 memoize하는 데 사용하면 된다.
역효과
그럼 불필요한 모든 렌더링을 위의 HOC와 훅들을 사용해서 막는 것이 과연 옳은 것일까??
답은 아니다.
오히려 위의 것들을 무분별하게 사용하면 성능을 좋게하기 위해 사용한 방법들이 역효과를 불러올 수도 있다.
memoization 과정 자체가 메모리를 사용하는 것이기 때문에 제한된 자원을 사용하게 되는 것이다.
또한 dependencies도 확인하는 과정도 추가되므로 light한 계산에서의 memoization은 오히려 역효과를 불러올 수 있는 것이다.
사실 어느정도로 불필요한 렌더링을 막는 것에는 정답이 없다.
위의 내용을 잘 인지해놓고 memoization 과정 자체에서 드는 비용과 불필요한 렌더링으로 인한 성능 저하를 잘 저울질 하여 알맞게 사용하는 것이 프론트엔드 개발자의 과제가 아닐까 싶다!
※ 번외: children prop에 대한 고민
렌더링 최적화에 관한 공부 도중, children prop이 예상과 다르게 동작하는 현상을 발견하였다.
1. children을 prop으로 받는 컴포넌트에서는 React.memo를 사용해도 렌더링이 막아지지 않음.
이는 결국 렌더링 될 때마다 children prop이 새롭게 만들어지게 있다는 뜻이였다. 대체 왤까??
답은 사실 위에 나와있다.
위에서 JSX는 사실상 React.createElement 메소드의 다른 형태였고, 새로운 React element를 생성해 반환한다고 언급한 바 있다.
const Container = () =>{
return (
<div>
<Parent>
<Child />
</Parent>
</div>
);
}
const Parent = ({children}) =>{
return <div>{children}</div>
}
위와 같은 코드가 있다고 가정해보자.
Container가 렌더링 될 때
React.createElement(Child,null,null)
위 메소드가 실행되게 되고, 그 결과 새롭게 React element를 반환할 때 object의 참조값이 변경되기 때문에 children의 데이터 자체는 변경되지 않더라도 Parent에서는 props가 변경되었다고 인식하여 React.memo 처리를 해도 매번 렌더링하게 되는 것이다.
2. 부모 컴포넌트가 렌더링 될 때 children은 렌더링 되지 않음
위에서 부모 컴포넌트가 렌더링 되면, 모든 자식 컴포넌트들은 재귀적으로 렌더링 된다고 했었다.
그런데 부모 컴포넌트로 children을 감싼 형태에서 부모 컴포넌트가 렌더링 될 때 children은 렌더링 되지 않았다. 이유가 뭘까?
사실 리액트에서 children은 그저 prop일 뿐이다.
const Container = () =>{
return (
<div>
<Parent>
<Child />
</Parent>
</div>
);
}
const Container = () =>{
return (
<div>
<Parent children={<Child />} />
</div>
);
}
위에서 두개의 표현은 완전히 같은 의미라는 것이다.
위에서 React.createElement(Child,null,null)이 실행되는 것은 Container가 렌더링 되어 Parent에 children prop을 넘겨줄 때 뿐이다.
이 상황에서 Parent가 렌더링 된다고 해도 이전 렌더링에서 전달받은 children 값을 그대로 사용할 뿐이다.
즉, Parent의 children은 애초에 자바스크립트 객체 형태인 상수로 전달받았기 때문에 렌더링 이전과 비교해서 값이 달라질리 없고, 따라서 Child 컴포넌트는 Parent가 렌더링 되어도 렌더링 되지 않는다.
이처럼 아무생각없이 안일하게 사용하였던 children도 사실 이런 비밀이 숨겨져 있었고, 이를 렌더링을 관리하는데도 사용할 수 있겠구나 생각을 했다.
역시 배움에는 끝이 없고, 더 높은 단계로 나아가기 위해서는 기초적인 부분들이 매우 중요하다고 다시 한번 느낄 수 있었다.
참고
리액트 렌더링에 대한 이해
리액트의 렌더링을 렌더 단계와 커밋 단계로 구분하여 전체적인 렌더링 프로세스에 대해 이해해봅니다.
velog.io
https://velog.io/@hyunjine/Thinking-in-React#reconciliation%EC%9E%AC%EC%A1%B0%EC%A0%95
Thinking in React
소프트웨어 마에스트로 컨퍼런스 발표(22.09.29)
velog.io
https://cocoder16.tistory.com/36
리액트 렌더링 최적화하는 8가지 방법과 고찰
서론 이 글은 함수형 컴포넌트, 클래스형 컴포넌트 상관없이 공통적으로 적용되는 렌더링 최적화 이야기와 hooks를 사용하는 함수형 컴포넌트에서 구체적으로 어떤 기능들을 사용해 렌더링 최적
cocoder16.tistory.com
[짤막글] useCallback을 사용해보자
안녕하세요. 지난 포스팅에 이어서 react rendering 최적화를 위한 hook, useCallback에 대해 살펴보도록 하겠습니다:)
velog.io
React) children prop에 대한 고찰(feat. 렌더링 최적화)
Part 1. 일단, children이 뭔지부터 알아보자! React에서 children이란? React는 JSX라는 문법을 채택하여 사용하고 있다. JSX는 html과 유사한 문법으로 꺽쇠 사이에 태그와 속성을 부여해 프로젝트 구조를
velog.io
'FrontEnd > React' 카테고리의 다른 글
HTTP 캐시 다루기 (2) | 2024.06.13 |
---|---|
아토믹 디자인(Atomic Design) 도입하기 (0) | 2024.05.08 |
Context API 에서 Recoil로 마이그레이션 일지 (3) | 2024.04.06 |
React 에서 Context API 효율적으로 사용하기 (0) | 2024.04.04 |
프론트엔드(React) 클린 코드로 나아가기 (0) | 2024.03.29 |