렌더링이란?
리액트에서 렌더링이란 화면에 특정한 요소를 그려내는 것을 의미합니다. 브러우저에서 렌더링이란 DOM 요소를 계산하고 그려내는 것을 의미합니다. HTML과 CSS를 통해서 만들어지고 계산된 DOM과 CSSOM은 렌터 트리를 형성하고 최종적으로 브라우저에 그려지게 됩니다. 그리고 우리는 브라우저에서 제공하는 DOM API를 Javascirpt를 통해 호출하면서 그려진 화면을 변화시킵니다.
하지만 Vanilla Javascript를 이용하여 DOM에 직접 접근하고 수정하는 것(명령형), 그리고 이를 최적화하는 것은 애플리케이션의 규모가 커지면 커질수록 관리하기가 힘들어집니다. 그래서 애플리케이션에서 보여주고 싶은 핵심 UI를 "선언"하기만 하면 실제로 DOM을 조작해서 UI를 그래내고, 변화시키는 일은 라이브러리나 프레임워크가 대신해주는 방식을 찾게 됩니다.(선언적 개발)
React에서 리렌더링이 되는 시점
리액트에서 state를 사용하는 이유는 UI와 상태(state)를 연동시키기 위해서입니다. 근본적으로 UI는 어떠한 데이터가 있고 그것을 보기 편한 형태로 표현한 것입니다. 리액트는 이를 이해하고 UI와 연동되어야 하고, 변할 여지가 있는 데이터들을 state라는 형태로 사용할 수 있게 해주었습니다. 그리고 데이터가 변경되었을 때 UI가 그에 맞춰서 변화하기 위해서 state를 변경시키는 방법을 제한시키고(setState), 이 함수가 호출 될 때마다 리렌더링이 되도록 설계하였습니다.
이런 이유로 인해서 리액트에서 리렌더링이 발생하는 시점은 state가 변했을 때입니다. 특정 컴포넌트의 state가 변한다면, 해당 컴포넌트와 해당 컴포넌트의 하위에 있는 모든 컴포넌트들은 리렌더링이 발생하게 됩니다.
React의 렌더링 과정
- 기존 컴포넌트의 UI를 재사용할 지 확인한다.
- 함수 컴포넌트: 컴포넌트 함수를 호출한다 / 클래스 컴포넌트 : render를 호출한다.
- 2의 결과를 통해서 새로운 VirtualDOM을 생성한다.
- 이전의 VirtualDOM과 새로운 VirtualDOM을 비교해서 실제 변경된 부분만 DOM에 적용한다.
먼저 4번의 과정을 왜 하는지(Virtual DOM의 사용 이유)에 대해 알아보겠습니다.
브라우저는 화면을 보여주기 위해서 HTML, CSS , JS를 다운로드하고 그를 처리해서 화면에 픽셀 형태로 그려냅니다. 이 과정을 CRP(Critical Rendering Path)라고 부릅니다.
Critical Rendering Path는 기본적으로 아래의 과정을 수행합니다.
- HTML을 파싱해서 DOM을 만든다.
- CSS를 파싱해서 CSSOM을 만든다.
- DOM과 CSSOM을 결합해서 Render Tree를 만든다.
- Render Tree와 Viewport의 width를 통해서 각 요소들의 위치와 크기를 계산한다.(Layout)
- 지금까지 계산된 정보를 이용해 Render Tree상의 요소들을 실제 Pixel로 그려낸다. (Paint)
이후 DOM또는 CSSOM이 수정될 때마다 위의 과정을 반복합니다. 따라서 이 과정을 최적화하는 것이 퍼포먼스상 중요한 포인트입니다. 그런데 위 과정 중에서 Layout, Paint 과정은 특히나 많은 계산을 필요로 하는 부분입니다. 따라서 리액트는 이 CRP이 수행되는 횟수를 최적화하기 위해 Virtual DOM을 사용하는 것입니다.
UI를 변화하기 위해서는 많은 DOM의 조작이 필요합니다. 하나하나의 DOM조작마다 CRP가 수행될 것이고 이는 곧 브라우저에게 많은 연산을 요구하게 되고, 퍼포먼스를 저하시키는 요인이 될 수 있습니다. 리액트는 이를 해결하고자 Virtual DOM이란 개념을 도입한 것입니다.
리액트에서는 UI의 변화가 발생하면 변화에 필요한 DOM조작들을 매번 바로 실제 DOM에 적용하는 것이 아니라, VirtualDOM이란 리액트가 관리하고 있는 DOM과 유사한 객체형태로 만들어냅니다. 그리고 이전의 VirtualDOM과 새로운 VirtualDOM을 비교해서 실제로 변화가 필요한 DOM요소들을 찾아냅니다. 그다음에 한 번에 해당 DOM요소들을 조작합니다.
이 처리를 통해서 브라우저에서 수행되는 CRP의 빈도를 줄일 수 있고 이게 VIrtualDOM을 이용해서 리액트가 수행하는 최적화입니다. 즉, "4.이전의 VirtualDOM과 새로운 VirtualDOM을 비교해서 실제 변경된 부분만 DOM에 적용한다."에 해당하는 최적화는 리액트 내부적으로 수행하고 있다는 의미이고 이 부분은 리액트를 사용하는 개발자입장에서는 따로 최적화를 수행할 여지가 없습니다.
리액트를 사용하는 개발자가 할 수 있는 최적화는
- 1. 기존 컴포넌트의 UI를 재사용할 지 확인한다.
- 3. 2의 결과를 통해서 새로운 VirtualDOM을 생성한다.
위 두부분에 해당하는 최적화입니다. "1. 기존 컴포넌트의 UI를 재사용할지 확인한다."의 경우에는 만약 리렌더링 될 컴포넌트의 UI가 이전의 UI와 동일하다고 판단되는 경우 새롭게 컴포넌트 함수를 호출하지 않고 이전의 결괏값을 그대로 사용하도록 함으로써 최적화를 수행할 수 있습니다.
또한 "3. 2의 결과를 통해서 새로운 VirtualDOM을 생성한다." 의 경우는 컴포넌트 함수가 호출되면서 만들어질 VirtualDOM의 형태를 비교적 차이가 적은 형태로 만들어지도록 하는 것입니다.
이 중에서 "1. 기존 컴포넌트의 UI를 재사용할지 확인한다."에 해당하는 부분을 집중적으로 파악해 보도록 하겠습니다.
React.memo
리액트는 state가 변할 경우 해당 컴포넌트와 하위의 컴포넌트들을 모두 리렌더링 합니다. 그런데 state가 변한 컴포넌트의 경우 당연히 UI의 변화가 있을 것이기에 리렌더링을 해야 하지만, 하위 컴포넌트의 경우에는 경우에는 props가 변화하지 않았다면 해당 컴포넌트가 UI가 변화하지 않았을 수도 있을 것입니다. 이런 경우에는 굳이 새롭게 컴포넌트 함수를 호출할 필요 없이 이전에 저장되어 있던 결과를 그대로 사용하는 것이 효율적입니다.
하지만 UI가 실질적으로 변화되었는지 안되었는지를 매번 리액트가 렌더링 과정에서 일일이 모든 컴포넌트 트리를 순회하면서 검사하는 것은 비효율적입니다. 따라서 리액트에서는 개발자에게 이 컴포넌트가 리렌더링이 되어야 할지 아닐지에 대한 여부를 표현할 수 있는 React.memo 함수를 제공하고 이를 통해 기존의 컴포넌트의 UI를 재사용할지 판단하는 방법을 채택했습니다.
React.memo는 HOC(Higher Order Component)입니다.
HOC란 컴포넌트를 인자로 받아서, 컴포넌트를 리턴하는 컴포넌트입니다.
function HOC(Component) {
/* do something */
return <Component />
}
React.memo로 감싸진 컴포넌트의 경우에는 상위 컴포넌트가 리렌더링 될 경우 무조건 리렌더링 되는 것이 아니라 컴포넌트의 이전의 Props와 다음 렌더링 때 사용될 Props를 비교해서 차이가 있을 경우에만 리렌더링을 수행합니다. 만약 차이가 없다면 리렌더링을 수행하지 않고 기존의 렌더링 결과를 재사용합니다. 이를 통해 컴포넌트에서 불필요하게 리렌더링이 되는 경우를 막을 수 있습니다.
이때 중요하게 생각해야 할 것은 props를 비교하는 방식입니다. React.memo는 기본적으로 props의 변화를 이전 props와 새로운 props를 "shallow compare" 해서 판단합니다. 만약 이 기본적인 비교 로직을 사용하지 않고 비교를 판단하는 로직을 직접 작성하고 싶을 경우를 대비해서 React.memo는 변화를 판단하는 함수를 인자로 받을 수 있도록 설정해뒀습니다.
function MyComponent(props) {
/* render using props */
}
function areEqual(prevProps, nextProps) {
/*
true를 return할 경우 이전 결과를 재사용
false를 return할 경우 리렌더링을 수행
*/
}
export default React.memo(MyComponent, areEqual);
React.memo의 두 번째 인자로 함수를 전달할 경우 해당 함수의 인자로는 이전의 props와 새로운 props가 순서대로 인자로 전달되며, 이 함수의 return 값이 true일 경우 이전 결과를 재사용하고, false를 return 할 경우 리렌더링을 수행합니다.
Memoization
Memoization은 특정한 값을 저장해 뒀다가, 이후에 해당 값이 필요할 때 새롭게 계산해서 사용하는 게 아니라 저장해 둔 값을 활용하는 테크닉을 의미합니다.
함수 컴포넌트는 근본적으로 함수입니다. 그리고 리액트는 매 렌더링마다 함수 컴포넌트를 다시 호출합니다. 함수는 기본적으로 이전 호출과 새로운 호출 간에 값을 공유할 수 없습니다. 만약 특정한 함수 호출 내에서 만들어진 변수를 다음 함수 호출에도 사용하고 싶다면 그 값을 함수 외부의 특정한 공간에 저장해 뒀다가 다음 호출 때 명시적으로 다시 꺼내와야 합니다. 이것을 직접 구현하는 것은 꽤나 번거로운 일이고, 특히 함수 컴포넌트에서 이를 구현하고 관리하는 것은 많은 노력이 드는 행위입니다.
1. useMemo
useMemo는 리액트에서 값을 memoization 할 수 있도록 해주는 함수입니다.
// useMemo(callbackFunction, deps]
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
useMemo는 두 가지 인자를 받습니다. 첫 번째 인자는 콜백함수이며 이 함수에서 리턴하는 값이 메모됩니다. 두 번째 인자는 의존성 배열입니다. 메모이제이션을 할 때 주의해야 할 점은 만약 새로운 값을 만들어서 사용해야 하는 상황임에도 불구하고 이전의 결과를 그대로 활용해 버리면 버그가 발생할 수 있다는 점입니다.
위의 예시에서 a, b라는 두가지 변수를 이용해서 메모이제이션 하기 위한 값을 계산하고 있습니다. 그런데 만약 a, b 라는 값이 변경되었는데 이전의 값을 그대로 활용해 버리면 의도한 결과와 다른 결과가 나오게 될 것입니다.
이런 상황을 방지하기 위해서 useMemo에서는 의존성 배열을 인자로 받아, 의존성 배열에 있는 값 중 하나라도 이전 렌더링과 비교했을 때 변경되었다면 메모된 값을 활용하는 것이 아니라 새로운 값을 다시 계산합니다.
2. useCallback
useCallback은 useMemo를 조금 더 편리하게 사용할 수 있도록 만든 버전입니다.
일반적인 값들은 useMemo를 통해서 메모하기 편리합니다. 하지만 함수의 경우에는 useMemo를 사용해서 메모하게 되면 콜백함수에서 또 다른 함수를 리턴하는 형태가 되게 됩니다. 이는 동작상에는 아무런 이상이 없지만 코드 스타일에 따라 문법적으로 다소 보기가 불편해지는 단점이 있습니다. 따라서 이러한 동작을 간소화한 useCallback이란 함수를 만들어서 제공해주고 있습니다.
const memorizedFunction = useMemo(() => () => console.log("Hello World"), []);
const memorizedFunction = useCallback(() => console.log("Hello World"), []);
3. 언제 memoization을 해야 할까?
메모이제이션, 개념만 보았을 때는 굉장히 효율적이고 사용하기만 하면 최적화가 이루어질 것 같은 느낌이 들기도 합니다. 하지만 명확한 목적 없이 무작정 메모이제이션을 사용하는 것은 오히려 비효율적입니다.
리액트에서 제공하는 메모이제이션 함수를 호출해서 특정한 값을 저장해 둘 수 있기에 얼핏 쓰기만 하면 효율이 올라갈 것 같습니다. 그런데 메모이제이션을 하기 전 아래의 사항을 생각해봐야 합니다
새로운 값을 만드는 것과 어딘가에 이전의 값을 저장해 두고 메모이제이션 함수를 호출하고 의존성을 비교해서 가져올지 말지 여부를 판단하는 것 중 어떤 것이 비용이 더 적게 들까?
위의 문장에 대한 정답은 상황에 따라 다릅니다. 만약 새로운 값을 만드는 과정이 복잡하다면 메모이제이션을 사용하는 것이 더 효율적일 수 있습니다. 하지만 새로운 값을 만드는 과정이 복잡하지 않다면 메모이제이션을 사용하는 것은 오히려 비용이 더 많이 들수도 있습니다. 컴퓨터 자원의 측면뿐만 아니라 메모이제이션을 쓰면서 코드의 복잡도가 올라간다는 개발적인 측면의 비용도 무시할 수 없습니다.
이처럼 메모이제이션은 무조건 사용하는 것이 좋은 게 아니라, 필요성을 분석하고 필요하다고 판단되는 순간에만 사용해야 합니다. 리액트에서 메모이제이션이 필요하다고 판단할 수 있는 요인은 아래 두 가지입니다.
- 새로운 값을 만드는 연산이 복잡하다.
- 함수 컴포넌트의 이전 호출과, 다음 호출 간 사용하는 값의 동일성을 보장하고 싶다.
언제 최적화를 해야 할까?
최적화는 개발자에게 굉장히 매혹적인 주제입니다. 뭔가 일반적인 기능 개발을 하는 것보다 최적화를 하는 것이 더 어려워 보이고, 더 많은 지식이 필요하다고 생각되고, 아무나 할 수 없을 것 같다는 인상을 줍니다.
항상 명심해야 할 사항이 있습니다. “최적화는 공짜가 아닙니다” 최적화를 하기 위해서는 최적화를 위한 코드가 프로젝트에 추가되어야 할 수 있고, 이는 프로젝트의 복잡도를 비교적 증가시키게 됩니다. 그리고 최적화를 하기 위한 개발자의 시간과 노력 또한 투입되게 될 것입니다. 이처럼 최적화는 꽤나 비싼 비용을 투자해야 하는 작업입니다.
최적화를 해야 하는 시기는 이 최적화가 명확히 가치를 창출해 낼 수 있을 것이라고 기대되는 상황 즉, 현재의 프로젝트에 성능적인 이슈가 발생했거나, 발생할 가능성이 있고 이를 해결해야 될 필요성이 있는 상황에서 수행하는 것입니다. 만약 내가 최적화를 하고 싶다면 현재 상황을 분석해서 최적화를 해야 하는 이유를 정리하고 이를 관련된 사람들에게 알리고 최적화의 필요성에 대한 공감대가 형성시키고 난 후 최적화를 수행해야 합니다.
출처 - 원티드 프론트엔드 프리온보딩 인턴쉽 과정
'Javascript > React' 카테고리의 다른 글
[React] Props Drilling의 문제점과 해결방법 (0) | 2023.04.13 |
---|---|
[React] useRef Hook의 이해와 활용 (0) | 2023.03.30 |
[React] React(Typescript)에 ESLint/Prettier/Airbnb Rule 적용하기 + Husky (2) | 2023.03.02 |
[React] ESLint와 Prettier, Git Hook을 이용한 협업 (0) | 2023.02.27 |
[React] React Router 사용하기 (0) | 2023.02.23 |