React 렌더링 이해 및 최적화 (With Hook)

MinuKang
Vingle Tech Blog
Published in
12 min readJul 30, 2019

안녕하세요. 빙글 프론트엔드 개발팀에서 훅잽이를 맡고 있는 강민우입니다. React에서 Hook이 릴리즈된 이후로 너무 잘 쓰고 있으며, 모든 컴포넌트를 Hook으로 대체하고자하는 야망을 가지고 있습니다.

이 글을 통해 React의 렌더링 과정과 이를 최적화하는 과정을 알아보고, 잘못된 시나리오로 인하여 최적화가 쓸모없어지는 케이스들을 준비해보았습니다. 그리고 이를 개선하면서, Hook으로는 이렇게 쉽게 할 수 있다! 를 여러분께 소개하려고 합니다. 잘 읽어주시고 피드백은 언제나 환영합니다!

React가 렌더링을 실행하는 과정

React 컴포넌트가 렌더링을 수행하는 시점은 다음과 같습니다.

  1. Props가 변경되었을 때
  2. State가 변경되었을 때
  3. forceUpdate() 를 실행하였을 때
  4. 부모 컴포넌트가 렌더링되었을 때

1~3번의 과정을 통해 컴포넌트가 렌더링될 때, 자식 컴포넌트 또한 같은 과정으로 렌더링이 진행됩니다. 하지만 컴포넌트에서 렌더링 결과에 전혀 영향을 미치지 않는 변경사항이 발생하게 된다면, 불필요한 렌더링이 발생 하므로 성능 손실이 발생합니다. 이는 렌더링에서 수행하는 로직이 많을 수록, 많은 컴포넌트를 출력할 수록 손실은 배가 됩니다.

부모 컴포넌트에서 상태를 변경하였지만, 아무런 영향을 받지 않는 자식 컴포넌트가 렌더링을 수행하는 것을 콘솔을 통해 확인할 수 있습니다

이럴 때 사용하는 것이 shouldComponentUpdate 이라는 라이프사이클 메서드입니다. 이 라이프사이클 메소드를 정의하면 컴포넌트 렌더링을 제어할 수 있게 되므로, 데이터가 변경되어 렌더링이 필요한 경우에만 렌더링 작업을 수행할 수 있습니다.

shouldComponentUpdate를 사용하여 변경이 필요한 데이터를 검증

shouldComponentUpdate 메서드에서는 인자를 통해 변경되는 속성(Props)과 상태(State)를 수신할 수 있으며, 참을 반환하는 경우 렌더링을 수행하고, 거짓이면 갱신을 위한 렌더링을 수행하지 않습니다.

예외로 forceUpdate()shouldComponentUpdate을 무시하고 렌더링을 강행합니다.

자세한 내용은 레퍼런스를 참고하세요: https://reactjs.org/docs/react-component.html#shouldcomponentupdate

React.PureComponent

React.PureComponent를 상속하여 shallowEqual에 한하여 렌더링 보장

만일, 변경되는 데이터가 모두 원시값이거나 각각의 불변성을 보장할 수 있다면 React.PureComponent를 사용할 수 있습니다. React.ComponentshouldComponentUpdate가 미리 장착되어 있는 컴포넌트라고 생각하면 됩니다. 불변성을 강조한 이유는 shallow equal, 얕은 비교를 통해 컴포넌트의 렌더링을 결정하기 때문에 넘겨받는 Props, 사용하는 State를 잘 확인한 후 써주면 좋습니다.

불필요한 렌더링의 시나리오

상위의 컴포넌트에서 변화가 일어난다면(예를 들어, Redux로 구현된 컴포넌트의 경우), 하위 컴포넌트에 렌더링을 명령하게 됩니다. 따라서shouldComponentUpdatePureComponent 등을 사용하지 않고 큰 앱을 개발하게된다면, 사소한 변경에 모든 컴포넌트가 렌더링을 행하기 때문에 성능저하를 일으킬 수 있습니다.

SCU(ShouldComponentUpdate)를 구현하여, 변화가 필요한 컴포넌트에만 렌더링을 하는 것을 보여주는 다이어그램 (출처 : https://reactjs.org/docs/optimizing-performance.html)

그래서 불변성을 보장할 수 있다면 PureComponent를 사용하여 개발할 것을 권장합니다. 그런데 우리는 이 불변성을 지키는 것을 모르고 지나치는 상황이 발생합니다. 흔히 발생하는 시나리오를 가정해보겠습니다.

배열, 객체 리터럴의 경우

우리는 처음 React 배울 때 ‘불변성’에 대한 예제로 배열과 객체를 다룰 때 새로 생성하여 만드는 것을 배웁니다. 이유는 객체(배열도 이하 객체로 통일합니다)는 불변인 원시 데이터(문자열, 숫자, 불리언)과 달리 변경이 가능한(Mutable) 데이터이기 때문에 그렇습니다.

{ a: 10 } === { a: 10 } // false

그래서 객체와 객체의 동일(equal)을 증명하기 위해서 모든 내부에 있는 불변 데이터가 동일하다는 것을 입증해야합니다.

그런데 제가 위에서 언급했죠, PureComponent를 구현할 때는 불변성을 지켜야한다고 말이죠. 하지만 직접 객체 리터럴을 생성할 경우 불변성을 해치게 됩니다. 흔히 다음과 같은 경우처럼 말이죠.

Props로 배열(객체)를 직접 넘겨주는 경우

<FileUploadInput /> 컴포넌트에 deny prop에 배열을 넘겨주었습니다. 만일 <FileUploadInput /> 컴포넌트가 PureComponent로 이루어져있을 경우, 쓸 때 없이 렌더링 할 때 마다 얕은 평가를 진행하게 됩니다. 어차피 거짓평가일텐데 낭비가 되는 것이죠.

함수의 경우

함수도 객체와 마찬가지로 불변데이터가 아닙니다. 더군다나 객체와 달리 동일한 함수임을 증명하려면 상당히 지저분한 방법을 사용하게 됩니다. 따라서 함수는 리터럴로 생성하지 마세요. 다음과 같이 말입니다.

onUpload prop에 함수 리터럴을 만들어서 넘겨주는 경우

Function.prototype.toString() 을 사용하여 shouldComponentUpdate를 구현하여 함수의 내용이 동일하지 않다는 전재하에 함수가 동일함을 증명할 수 있습니다. fast-deep-equal 패키지는 다음과 같이 함수의 동일함을 증명합니다.

if (a.toString !== Object.prototype.toString)
return a.toString() === b.toString();

https://github.com/epoberezkin/fast-deep-equal

ReactElement의 경우

가장 놓치기 쉬우면서 가장 비교하기 번거로운 친구입니다. Props로 사용하는 children은 우리가 사용하기 편하라고 마크업 구조로 작성하지만 결국에는 Props이기 때문에 렌더링 변경시의 비교대상이 됩니다.

단순한 children일 뿐이지만, 이는 내부적으로 불필요한 렌더링을 진행한다.

이 경우 비교하기 위해서는 동일한 컴포넌트를 사용하는지, 그리고 내부적으로 모든 프로퍼티(Props, State, Ref)가 동일한지 비교해야합니다. 따라서 사용할 수 있는 Props 중에서는 동일함을 비교하는 비용이 높습니다.

fast-deep-equal 패키지를 포크하여 React의 Element를 비교해주는 react-fast-compare 패키지를 사용하면 동일한 ReactElement인지를 판단할 수 있습니다.

https://github.com/FormidableLabs/react-fast-compare

자 그럼 여태까지 극단적이지만, 매우 흔히 볼 수 있는 시나리오들을 살펴봤습니다. 만일 우리가 위 데이터들을 사용하면서 렌더링의 영향을 받지 않도록 할려면 어떻게 해야할까요? 다음 섹션에 준비해보았습니다.

시나리오 극복하기

변하지 않는 상수 데이터의 경우

상태에 전혀 영향을 받지 않는 상수 데이터의 경우 이를 극복하는 방법은 쉽습니다. 렌더링 단계에서 생성하는 것이 아닌, 정적으로 생성 하면 됩니다.

고정된 값(배열, ReactElement)을 따로 분리

위 예제에서 정적 데이터로 분리할 수 있는 값들을 따로 상수처럼 사용하는 모습입니다.

즉각적인 반응이 필요하지 않은 데이터의 경우

거의 대부분 이벤트/데이터 핸들러가 이에 해당됩니다. 따로 클래스 속성으로 구현하면 됩니다.

함수를 따로 프로퍼티로 만들어서 넘겨주는 방식

참고로 위와 같이 화살표 함수로 구현한다면 따로 this를 바인딩할 필요가 없습니다.

즉각적인 반응이 필요한 데이터의 경우

여기서 부터는 렌더링 내에서 제어가 필요한 데이터들 입니다. 다루기 까다로워지기 시작하죠. 메모이제이션(memoization)라는 개념이 등장하기 시작합니다.

메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. 동적 계획법의 핵심이 되는 기술이다. 메모아이제이션이라고도 한다. (출처: https://ko.wikipedia.org/wiki/%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98)

즉, 아무리 복잡한 계산이라도 전달인자만 동일하다면 빠르게 값을 가져올 수 있는 것이죠. lodashmemoize()를 통해 다음과 같이 구현 할 수 있습니다.

memoize를 통해 구현된 함수는 ‘1번’만 실행하고 결과 값을 캐쉬하게 된다.

이를 이용하여 변경이 필요한 데이터만 메모이제이션으로 구현하여 불변성을 보장하여 불필요한 렌더링의 영향을 받지 않게 할 수 있습니다.

파일 업로드 여부에 따라 적절한 children을 내보내주기

위 예제에서 파일 업로드 여부를 불리언 값으로 평가하여 참/거짓에만 결과를 가져오도록 합니다. 이제 <FileUploadInput /> 컴포넌트의 Props는 불변한 데이터임을 보장할 수 있게 되었습니다.

Hook을 사용해보실래요?

이 아티클의 본 목적입니다. Hook을 사용하여 위와 같은 시나리오들을 더욱 더 간결하고, 쉽게 대응할 수 있습니다. 또한 lodashfast-deep-equal 등이 필요없구요 (진짜로요!)

만일 Hook의 개념을 잘 모르신다면 위 아티클을 봐주시면 좋을 것 같습니다.

Hook에서 PureComponent, shouldComponentUpdate 사용하기

React.memo() 를 통해 PureComponent 혹은 shouldComponentUpdate를 구현할 수 있다.

React.memo() 를 통해 함수형 컴포넌트의 렌더링을 제어할 수 있습니다. 단순히 컴포넌트를 넣으면 PureComponent가 되고, 두 번째 인자로 현재 Props와 미래의 Props를 비교하여 shouldComponentUpdate 처럼 렌더링을 직접 제어할 수 있습니다.

Hook에서 memoization 사용하기

React Hook에서는 memoization을 자체적으로 지원하고 있습니다. 대표적으로 useMemo() 가 있습니다. useMemo() 를 이용하여 Props를 넘겨줄 때 간편하게 사용할 수 있습니다.

Hook을 사용하여 우리 예제를 수정해보았다.

useCallback()useMemo()의 핸들러 버전으로 값이 아닌 lodash/memoize 처럼 메모이제이션되는 함수를 가져온다고 생각하시면 됩니다. 차이점은 메모이제이션의 값이 변경되는 시점은 함수 인자가 아닌 의존성(dependencies)를 배열 형태로 받아서 판단을 합니다.

메모이제이션의 변경시점을 두번째 인자인 의존성 배열을 통해서 판단을 한다.

useMemo()useCallback()의 두번째 인자로 의존성 값들을 배열로 받아오는데, 여기에 불변한 값들을 넘겨주어 해당 값이 변경되면 감지를 하여 새로운 값(혹은 함수)을 만듭니다.

의존성 배열을 빈 값으로 보내면 어떻게 될까?

주의 할 점은 의존성 배열을 빈값으로 보내게되면 아무리 props.message 값이 변경되어도 값은 그대로 유지하게 됩니다. 즉, 절대 변하지 않는 값이 됩니다.

이를 이용하여 불변한 값을 제어하여 렌더링을 쉽게 제어할 수 있습니다.

이렇게까지 해야합니까?

작성하고 있는 컴포넌트의 성격에 맞게 전략을 세우는 것이 중요합니다. 만일 작성하고 있는 컴포넌트가 로직이 별로 없고 단순히 출력 용도의 컴포넌트라면 이러한 최적화들은 이른 최적화가 될 가능성이 높습니다.

하지만 상위에서 1초마다 갱신하는 등 지속적으로 업데이트가 된다던지, 하위에 컴포넌트가 많이 있다던지, 이러한 경우는 유의해서 작성하는 것이 좋습니다.

그리고 PureComponentReact.memo() 등을 사용하여 컴포넌트의 렌더링을 최적화했다고 하지만 만일 불변성이 보장되지 않은 값들이나 children과 같은 ReactElement를 받아와서 사용한다면 최적화가 의미가 없는 결과가 되는 것입니다. 어차피 매 렌더링마다 출력되는 것인데 쓸때없이 비교만 하는 것이 됩니다.

하지만 모르고 쓰는것하고 알고 쓰는것은 다르겠죠? 이제부터라도 고려하면 되는 것입니다. 저희도 잘못 사용한 컴포넌트들을 지속적으로 개선하고 있답니다.

--

--