렌더링이란 화면에 특정한 요소를 그려내는 것을 의미하며, 브라우저에서 렌더링이란 DOM요소를 계산하고 그려내는 것을 의미한다. HTML과 CSS를 통해서 만들어지고 계산된 DOM과 CSSOM은 결합되어, 위치를 계산하고, 최종적으로 브라우저에 그려지게된다. 그리고 브라우저에서 제공하는 DOM API를 JS를 통해 호출하면서 브라우저에 그려진 화면을 변화시킨다.
하지만 Vanila JS를 이용해서 DOM에 직접 접근하고 수정하는 것(명령형)
방식으로는, 애플리케이션의 규모가 커지면 커질수록 프로젝트를 관리하기 힘들어진다. 그래서 개발자들은 애플리케이션에서 보여주고 싶은 핵심 UI를 선언
하기만 하면 실제로 DOM을 조작해서 UI를 그려내고, 변화시키는 일은 라이브러리나 프레임워크가 대신 해주는 방식을 찾게 된다.
이런 니즈에 맞춰서 React
, Vue
, Angular
등의 라이브러리, 프레임워크가 등장하게 되고 그 중에서 React
가 현재는 가장 많이 사용되고 있는 것이다. 실제로 React 공식문서를 보면 가장 첫번째 장점으로 선언형
을 내세우고 있다.
이처럼, React는 선언형
으로 실제 렌더링 과정은 React에서 대신 처리해주고, 개발자는 UI를 설계하는 부분만 집중하게 해준다. 하지만 때로는 React 내부에서 처리해주는 렌더링을 최적화 해야 되는 상황이 발생
한다. 이러한 상황에서는 React 내부에서 렌더링이 언제 발생하는지, 어떤 과정을 거쳐서 이루어지는지를 이해하고 있어야 각 과정에서 렌더링을 최적화 할 수 있게 된다.
선언형 프로그래밍 vs 명령형 프로그래밍
명령형 프로그래밍
은 어떤 일이 수행되어야 하는지, 그리고 그 일이 어떻게 수행되어야 하는지를 명시하는 방식이다. 이는 프로그램의 상태를 변경하면서 단계별로 애플리케이션의 상태를 변경한다. for문을 사용하여 배열의 모든 요소를 순회하는 것이 예시라고 할 수 있다.
선언형 프로그래밍
은 어떤 일이 수행되어야 하는지를 명시하며, 어떻게 수행되어야 하는지에 대해서는 신경 쓰지 않는다. 이 패러다임은 함수형 프로그래밍에서 흔히 볼 수 있으며, JS의 map이나 filter 같은 고차 함수가 선언형 프로그래밍의 예시라고 할 수 있다.
// 명령형 프로그래밍의 예
let doubledArray = [];
let array = [1, 2, 3, 4, 5];
for (let i = 0; i < array.length; i++) {
doubledArray.push(array[i] * 2);
}
console.log(doubledArray); // [2, 4, 6, 8, 10]
// 선언형 프로그래밍의 예
let array = [1, 2, 3, 4, 5];
let doubledArray = array.map(num => num * 2);
console.log(doubledArray); // [2, 4, 6, 8, 10]
React에서 rerendering은 언제 발생할까? React에서는 왜 state를 사용할까?
React에서 state를 사용하는 이유는 UI
와 상태(state)
를 연동시키기 위해서이다. 근본적으로 UI는 어떠한 데이터가 있고 그것을 보기 편한 형태로 표현한 것이다. 리액트는 이를 이해하고 UI와 연동
되어야 하고, 변할 가능성이 있는 데이터
들을 state라는 형태로 사용할 수 있게 만들었다. 그리고 데이터가 변경되었을 때 UI가 그에 맞춰서 변화하기 위해서 state를 변경시키는 방법을 제한시키고(setState), 이 함수가 호출 될 때 마다 리렌더링이 되도록 설계하였다.
이러한 이유로, 리액트에서 리렌더링이 발생하는 시점은 state가 변했을 때
이다. 특정 컴포넌트의 state가 변한다면, 해당 컴포넌트와 해당 컴포넌트의 하위에 있는 모든 컴포넌트들은 리렌더링이 발생
하게 된다.
즉, state가 변하면 해당 컴포넌트를 포함한 하위 컴포넌트들은 모두 리렌더링 된다.
라는 명확한 멘탈 모델을 이해하고 있는 것이 리액트를 이용해서 애플리케이션을 설계하고, 최적화하는데 가장 기본이 되는 사항이다.
좀 더 자세히 들어가서, 리액트에서 렌더링
이란 한 단어로 표현되는 행위 속에서는 어떤 일이 발생할까?
앞서서, 리액트는 state가 변화했을 때
리렌더링을 발생시킨다고 했다. 이 과정을 좀 더 들어가면, state가 변화되고 최종적으로 브라우저상의 UI에 반영되기까지 각 컴포넌트에서는 크게 4단계를 거치게 된다.
기존 컴포넌트의 UI를 재사용할 지 확인한다.
함수 컴포넌트: 컴포넌트 함수를 호출한다 / Class 컴포넌트: render
메소드를 호출한다.
2의 결과를 통해서 새로운 VirtualDOM을 생성한다.
이전의 VirtualDOM과 새로운 VirtualDOM을 비교해서 실제 변경된 부분만 DOM에 적용한다.
먼저 4번의 과정을 왜 하는지, 근본적으로 VirtualDOM
을 왜 사용하는지에 대해 알아보자.
브라우저는 근본적으로 화면을 보여주기 위해서 HTML
, CSS
, JavaScript
를 다운로드 받고 브라우저 렌더링 과정을 처리해서 화면에 픽셀 형태로 그려낸다. 그리고 이 과정을 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이 수행되는 횟수를 최적화 하기 위해
서 VirtualDOM
을 사용하는 것이다.
UI를 변화하기 위해서는 많은 DOM 조작이 필요하다. 한번의 DOM조작마다 CRP가 수행될 것이고 이는 곧 브라우저에게 많은 연산을 요구하게 되고, 성능을 저하시키는 요인이 될 수 있다. 그래서 리액트는 이를 해결하고자 VirtualDOM
이란 개념을 도입한 것이다.
리액트에서는 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의 형태를 비교적 차이가 적은 형태로 만들어지도록 하는 것이다. 예를 들어 UI를 바꾸기 위해서 <div>
tag를 <span>
태그로 변환시키는 것 보다는 <div className="block" />
을 <div className="inline">
으로 변환시키는 것이 VirtualDOM끼리 비교했을 때 차이가 적은 형태로 만들어지도록 하는 것입니다.
이중에서 1. 기존 컴포넌트의 UI를 재사용할 지 확인한다.
에 해당하는 부분을 알아보자.
앞서 말했듯이 리액트는 state가 변할 경우 해당 컴포넌트와 하위의 컴포넌트들을 모두 리렌더링 한다.
그런데 state가 변한 컴포넌트의 경우 당연히 UI의 변화가 있을 것이기에 리렌더링을 해야 하지만, 하위 컴포넌트의 경우에는 경우에는 props가 변화하지 않았다면
해당 컴포넌트의 UI가 변화하지 않았을 수도 있을
것이다. 이런 경우에는 굳이 새롭게 컴포넌트 함수를 호출할 필요 없이 이전에 저장되어 있던 결과를 그대로 사용하는 것이 효율적이다.
하지만 UI가 실질적으로 변화되었는지 안되었는지를 매번 리액트가 렌더링 과정에서 일일이 모든 컴포넌트 트리를 순회하면서 검사하는 것은 비효율적이다. 따라서 리액트에서는 개발자에게 이 컴포넌트가 리렌더링이 되어야 할지 아닐지에 대한 여부를 표현할 수 있는 React.memo
함수를 제공하고 이를 통해 기존의 컴포넌트의 UI를 재사용할 지 판단하는 방법
을 채택하였다.
const MyComponent = React.memo(function MyComponent(props) {
/* render using props */
});
React.memo는 HOC(Higher Order Component)이다.
HOC(Higher Order Component)란?
HOC란 컴포넌트를 인자로 받아서, 컴포넌트를 리턴하는 컴포넌트이며, 재사용 가능한 컴포넌트 로직을 분리하고 추상화할 수 있다.
function HOC(Component) {
/* do something */
return <Component />
}
React.memo로 감싸진 컴포넌트의 경우에는 상위 컴포넌트가 리렌더링 될 경우 무조건 리렌더링 되는 것이 아니라 컴포넌트의 이전의 props와 다음 렌더링 때 사용될 props를 비교해서 차이가 있을 경우에만
리렌더링을 수행한다. 만약 차이가 없다면 리렌더링을 수행하지 않고 기존의 렌더링 결과를 재사용
한다. 이를 통해 컴포넌트에서 불필요하게 리렌더링이 되는 경우를 막을 수 있다.
이때 중요하게 생각해야 할 것은 props를 비교하는 방식
이다. React.memo는 기본적으로 props의 변화를 이전 prop와 새로운 prop를 각각 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할 경우 리렌더링이 일어난다.
얕은 비교 vs 깊은 비교
let obj1 = { a: 1, b: 2 };
let obj2 = obj1;
console.log(obj1 === obj2); // true
let obj1 = { a: 1, b: 2 };
let obj2 = { a: 1, b: 2 };
console.log(JSON.stringify(obj1) === JSON.stringify(obj2)); // true
앞서 React.memo는 기본적으로 props의 변화를 이전 props와 새로운 props를 shallow compare
해서 판단한다고 했다. props를 shallow compare한다는 의미는 아래와 같다.
props는 객체 형태로 표현된다. 그리고 props 객체는 매 렌더링마다 새롭게 생성
된다. 따라서 props 객체 자체를 비교하는 것은 의미가 없다.
그렇다면 비교해야 하는 것은 props객체 안의 각 property들이다. 따라서 리액트는 props 객체 안의 각 property들을 Object.is(===)
연산자를 통해서 비교한다. 이 중 하나라도 false가 나올 경우 props가 변경되었다고 판단하고 리렌더링을 수행한다.
<Component name="foo" hello="world" object={{first:1, second:2}} array={[1,2,3]} />
<Component name="bar" hello="world" object={{first:1, second:2}} array={[1,2,3]} />
const areEqual = (prevProps, nextProps) => {
if(prevProps.name !== nextProps.name) return false;
if(prevProps.hello !== nextProps.hello) return false;
if(prevProps.object !== nextProps.object) return false;
if(prevProps.array !== nextProps.array) return false;
return true;
}
이러한 동작과 데이터 타입에 대해서 제대로 이해하지 않으면 React.memo를 잘못 활용하는 상황이 발생한다.
Object.is vs ===
Object.is
Object.is는 ===와 매우 유사한 동작을 하지만, NaN과 +0 및 -0과 같은 특수한 경우에 다르게 작동한다.
===
일치 비교 연산자는 일반적으로 두 값을 비교할 때 사용되며, 이는 두 값이 같고, 같은 타입인 경우에만 true를 반환한다.
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true
console.log(-0 === +0); // true
console.log(Object.is(-0, +0)); // false
즉, Object.is는 NaN을 NaN과 동일하게 취급하고, +0과 -0을 서로 다르게 취급한다. 이는 ECMAScript의 SameValue 알고리즘에 따른 것이다.
대부분의 경우에는 === 비교 연산자를 사용하면 충분하며, NaN 또는 +0과 -0 등의 특수한 경우에 Object.is를 사용할 수 있다.
Memoization은 특정한 값을 저장해뒀다가, 이후에 해당 값이 필요할 때 새롭게 계산해서 사용하는게 아니라 저장해둔 값을 활용하는 기술을 의미한다.
함수 컴포넌트는 근본적으로 함수이다. 그리고 리액트는 매 렌더링마다 함수 컴포넌트를 다시 호출
한다. 함수는 기본적으로 이전 호출과 새로운 호출간에 값을 공유할 수 없다
. 만약 특정한 함수 호출 내에서 만들어진 변수를 다음 함수 호출에도 사용하고 싶다면 그 값을 함수 외부의 특정한 공간에 저장해뒀다가 다음 호출 때 명시적으로 다시 꺼내와야 한다.
이것을 직접 구현하는 것은 꽤나 번거로운 일이고, 특히 함수 컴포넌트에서 이를 구현하고 관리하는 것은 많은 노력이 드는 행위이다. 리액트에서는 함수 컴포넌트에서 값을 memoization 할 수 있도록 API를 제공해주고 있다.
useMemo는 리액트에서 값
을 memoization 할 수 있도록 해주는 함수이다.
// useMemo(callbackFunction, deps]
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
useMemo는 두가지 인자를 받는다. 첫번째 인자
는 콜백함수
이며, 이 함수에서 리턴하는 값이 메모된다. 두번째 인자
는 의존성 배열
이다. 메모이제이션을 할 때 주의해야 할 점은 만약 새로운 값을 만들어서 사용해야 하는 상황임에도 불구하고 이전의 결과를 그대로 활용해버리면 버그가 발생할 수 있다는 점이다.
위의 예시에서 a, b
라는 두가지 변수를 이용해서 메모이제이션 하기 위한 값을 계산하고 있다. 그런데 만약 a, b
라는 값이 변경되었는데 이전의 값을 그대로 활용해버리면 의도한 결과와 다른 결과가 나오게 될 것이다.
이런 상황을 방지하기 위해서 useMemo에서는 의존성 배열을 인자로 받아, 의존성 배열에 있는 값 중 하나라도 이전 렌더링과 비교했을 때 변경되었다면
메모된 값을 활용하는 것이 아니라 새로운 값을 다시 계산한다.
useCallback은 useMemo를 조금 더 편리하게 사용할 수 있도록 만든 React Hook이다.
일반적인 값들은 useMemo를 통해서 메모하기 편리하다. 하지만 함수의 경우에는 useMemo를 사용해서 메모하게 되면 콜백함수에서 또다른 함수를 리턴하는 형태가 되게 된다. 이는 동작상에는 아무런 이상이 없지만 코드 스타일에 따라 문법적으로 다소 보기가 불편해지는 단점이 있다. 따라서 이러한 동작을 간소화한 useCallback이란 함수를 만들어서 제공해주고 있다.
const memorizedFunction = useMemo(() => () => console.log("Hello World"), []);
const memorizedFunction = useCallback(() => console.log("Hello World"), []);
메모이제이션
, 개념만 보았을 때는 굉장히 효율적이고 사용하기만 하면 최적화가 이루어질 것 같은 느낌이 들기도 하지만 명확한 목적없이 무작정 메모이제이션을 사용하는 것은 오히려 비효율적이다. 메모이제이션을 하기 전 아래의 사항을 생각해봐야 한다.
새로운 값을 만드는 것과 어딘가에 이전의 값을 저장해두고 메모이제이션 함수를 호출하고 의존성을 비교해서 가져올지 말지 여부를 판단하는 것 중 어떤 것이 비용이 더 적게 들까?
위의 문장에 대한 정답은 상황에 따라 다르다. 만약 새로운 값을 만드는 과정이 복잡하다면 메모이제이션을 사용하는 것이 더 효율적일 수 있다. 하지만 새로운 값을 만드는 과정이 복잡하지 않다면 메모이제이션을 사용하는 것은 오히려 비용이 더 많이 들수도 있다. 컴퓨터 자원(memory)의 측면뿐만 아니라 메모이제이션을 쓰면서 코드의 복잡도가 올라간다는 개발적인 측면의 비용도 무시할 수 없다.
이처럼 메모이제이션은 무조건 사용하는것이 좋은게 아니라, 필요성을 분석하고 필요하다고 판단되는 순간에만 사용해야 한다. 리액트에서 메모이제이션이 필요하다고 판단할 수 있는 요인은 아래 두가지 이다.
새로운 값을 만드는 연산이 복잡하다.
함수 컴포넌트의 이전 호출과, 다음 호출 간 사용하는 값의 동일성을 보장하고 싶다
.
1번의 경우에는 만약 10000개의 요소를 가진 배열이 있다고 생각하면 이 배열을 매번 생성하는 것 보다는 메모해서 활용하는 것이 효율적일 것이다.
2번의 경우에는 함수 컴포넌트의 호출 간 값들의 동일성을 보장하기 위해서이다. 그렇다면 왜 동일성을 보장해야 할까? 그 이유는 바로 React.memo
와 연동해서 사용하기 위해서이다.
앞서 memo의 잘못된 활용 예시에서 props로 전달되는 객체의 동일성이 보장되지 않아 실제 객체의 내용은 똑같아도 shallow compare
를 통해서 다른 객체라고 판단되어서 매번 리렌더링이 실행되는 상황
을 확인했다. 이런 상황에서 전달되는 객체의 동일성을 보장하기 위해서 메모이제이션을 활용할 수 있다.
메모이제이션 된 객체는 새롭게 만들어진 것이 아니라 이전의 객체를 그대로 활용
하는 것이기에 shallow compare
에서 동일함을 보장받을 수 있다.
최적화는 개발자에게 굉장히 흥미로운 주제이다. 일반적인 기능 개발을 하는 것 보다 최적화를 하는 것이 더 어려워보이고, 더 많은 지식이 필요하다고 생각되고, 아무나 할 수 없을 것 같다는 인상을 준다.
그리고 이러한 요소들은 도전정신이 있는 개발자에게 매력적인 포인트가 되어서 프로젝트를 최적화 하고 싶다는 욕구를 생기게 하고 본인을 인정받고 싶은 개발자들은 야심차게 기존의 프로젝트에 최적화를 시도하기도 한다.
하지만, 대부분의 상황에서 기대한대로 👍 와 같은 반응보다는 😳 또는 🤬 와 같은 반응을 주위 개발자들이 보일 확률이 높다.
항상 명심해야 할 사항이 있습니다. 최적화는 공짜가 아니다.
최적화를 하기 위해서는 최적화를 위한 코드가 프로젝트에 추가되어야 할 수 있고, 이는 프로젝트의 복잡도를 비교적 증가시키게 된다. 그리고 최적화를 하기 위한 개발자의 시간과 노력 또한 투입되게 될 것이다. 이처럼 최적화는 꽤나 비싼 비용을 투자해야 하는 작업이다.
현업에서의, 실제 필드에서의 개발자는 자기만족을 위해서 개발을 하는 사람이 아니다. 현업에 있는 개발자는 개발을 통해서 가치를 창출해내야 한다. 만약 개발자가 하는 개발이 아무런 가치도 창출해내지 못한다면 절대 좋은 평가를 받을 수 없다. 자기의 도전정신, 탐구욕을 채우기 위한 개발은 사이드 프로젝트등을 통해서 수행해야 하지 실제 내가 현업에서 가치를 창출해낼 것을 기대받는 상황에서 하는 것이 아니다.
따라서 최적화를 해야 하는 시기는 이 최적화가 명확히 가치를 창출해낼 수 있을 것이라고 기대되는 상황 즉, 현재의 프로젝트에 성능적인 이슈
가 발생했거나, 발생할 가능성이 있고
이를 해결해야 될 필요성
이 있는 상황에서 수행하는 것이다. 만약 내가 최적화를 하고 싶다면 현재 상황을 분석해서 최적화를 해야 하는 이유를 정리하고 이를 관련된 사람들에게 알리고 최적화의 필요성에 대한 공감대가 형성시키고 난 후 최적화를 수행해야 할 것이다.