Change Detection 중심 Angular 최적화 방법

코인원

들어가며

Angular를 포함한 많은 Front End Framework는 Browser상에서 구현하기 힘든 복잡한 로직들을 쉽게 구현합니다. 만약 Framework 없이 개발을 한다고 가정해볼까요? Framework를 사용했을 때 신경쓰지 않았던 부분들을 하나하나 살펴봐야하고 개발 시간이 더 늘어날 것입니다. 이렇게 저를 포함한 많은 프론트엔드 개발자 분들은 편안함에 익숙해져있습니다. ‘프레임워크가 알아서 해주겠지.’라고 생각하고 넘어가는 것들이 있죠. 그 중 하나가 바로 성능 최적화입니다.

출처 : Flickr

예를 들어, React 같은 경우 vdom을 사용하면 그냥 DOM을 건드리는 것보다 평균적으로 빠르다고 React 문서에도 적혀 있으며 많은 컨퍼런스에서 이야기합니다. 그러나 아래 GIF와 같이, 가장 빠른 프론트엔드 프레임워크는 ‘NO’ 프레임워크라는 것을 알 수 있습니다.

물론 소프트웨어를 만들 때 성능 말고도 중요한 것은 많습니다. XSS를 Framework단에서 알아서 핸들링 해주는 것과 같은 보안, 많은 개발자들이 서로 공감할 수 있는 커뮤니티, 개발할 때의 즐거움, 발전하게 하는 개발환경 등등 중요한 요소가 많다고 생각합니다.

이러한 이유로 프레임워크가 상대적으로 속도가 느리더라도 큰 규모의 회사들이 전부 사용하는 것이죠. 그래도 성능은 어느 개발 환경에서도 무시할 수 없는 요소입니다.

이번 글에서는 코인원에서 사용하고 있는 FrontEnd Framework인 Angular 환경에서 Change Detection의 성능 최적화 방법을 알아보도록 하겠습니다. Change Detection에서 성능적으로 문제가 될 수 있는 부분은 두 가지가 있습니다.

1) Change Detection이 불필요하게 많이 실행되는 부분 2) Change Detection 한번 실행될 때 사용되는 CPU 부분

위 두 가지 케이스를 최적화하고 Change Detection으로 발생하는성능 저하를 전체적으로 최적화해보겠습니다.

최적화 방법 1 : ChangeDetection 횟수 최소화

Change Detection은 Framework 별로 불리는 방식이 다르지만 (React에서는 Reconciliation이라고 합니다.) Angular뿐 아니라 대부분의 Front End Framework에서 성능적으로 항상 문제인 DOM manipulation 최소화를 위해 사용하는 방식입니다.

Change Detection을 한 줄로 요약하자면 ‘DOM을 업데이트할지 체크하는 것’이라고 할 수 있습니다.

위 한 줄 요약 문장에서 ‘체크’라는 단어를 강조해서 다시 요약하겠습니다.

“Change Detection은 DOM을 Update 하는 역할을 하는 것이 아니고 오로지 Update 할지 체크만 하는 것입니다. (강조: 체크, 체크, 체크)”

사실 체크만 하는 과정인데, 과연 최적화가 필요할지 생각하기도 했습니다. 그러나 이 ‘체크'라는 과정은 정말 빈번하게 발생합니다.

이 섹션에서는 간단한 예제를 통해 최적화가 되어있지 않을 경우, Change Detection이 얼마나 많이 발생하는지 살펴보고 최적화를 하겠습니다.

간단한 예제로 아래의 `parent` 컴포넌트를 구현했습니다.

`parent` 컴포넌트는 Angular가 Change Detection을 실행할 때마다 콘솔에 `0 Parent ChangeDetection` 이라고 로그를 찍어 주고 있습니다.

또한 클릭 이벤트가 바인딩된 버튼이 하나 있고 (하지만 아무 기능을 하지 않습니다) 자식 컴포넌트로 `child-one`과 `child-two` 컴포넌트를 가지고 있습니다.

(`’’.log(0, ‘Parent ChangeDetection’)`과 `’’.noop()`이 이상해 보일 수 있는데요, 이 부분은 Angular Template에서 window를 직접적으로 접근할 수 없지만 String Prototype은 접근을 할 수 있기에 String Prototype에 Template에서 직접 쓸 기능을 정의했습니다. 그 이유는 Component에 method를 정의하지 않고 Template에만 집중하기 위해서입니다.)

`child-one` 컴포넌트와 `child-two` 컴포넌트는 다음과 같이 구현했습니다.

위 두 Child 컴포넌트도 Parent 컴포넌트와 같이 Change Detection이 발생할 시, 콘솔에 로그를 찍어 주며 클릭이벤트가 바인딩된 버튼을 가지고 있습니다. 또한 어느 컴포넌트에서 Change Detection이 일어났는지 구분 가능하도록 컴포넌트 별로 서로 다른 로그를 찍어 주도록 구현했습니다. 이제 위 코드를 아주 간단한 스타일과 함께 브라우저에서 실행시키면 다음과 같이 보입니다.

옆에 구현된 그림에서 한번 짚고 넘어갈 것은 `child-one` 컴포넌트와 `child-two` 컴포넌트는 `parent` 컴포넌트의 자식으로 들어가는 것과 `child-one` 컴포넌트와 `child-two` 컴포넌트는 서로 형제 컴포넌트라는 것을 강조하고 이제 위처럼 간단하게 구현된 컴포넌트들의 Change Detection 주기로 일어나는 성능적인 문제점을 보도록 하겠습니다.

위 그림에서 보이는 바와 같이 앱이 처음 실행될 때 Change Detection은 컴포넌트 별로 두 번씩 돌아가고 있습니다. 상식적으로 생각해보면 처음 컴포넌트가 render 될 때 어떤 값들이 있나 체크를 한번 하는 것이 옳은 것이라고 볼 수 있습니다. 하지만 Angular에서는 기본적으로 하나의 컴포넌트에서 Change Detection이 돌면 아무 상관이 없는 컴포넌트들도 Change Detection을 돌립니다.

아마도 AngularJS (Angular 1) 시절 디자이너도 쉽게 개발할 수 있게 Black Magic이라 홍보하던 것을 그대로 이어받아, 처음 Angular를 접하시는 분들이 쉽게 Angular를 사용할 수 있게 하기 위한 배려가 아니었나 싶습니다. 이런 전체적인 Change Detection은 위 처음 앱이 실행될 때가 아닌 아래처럼 Event가 생겼을 때도 발생합니다.

위 그림처럼 이벤트가 어느 컴포넌트에서 발생했는지와 상관없이 모든 컴포넌트의 Change Detection이 실행되는 것을 볼 수 있습니다.특히 이상하게 생각되는 두 가지 부분이 있습니다.

첫 번째로, Child 컴포넌트가 아닌 상위의 Parent Component의 이벤트가 발생을 하더라도 Event Bubbling과 아무 상관없는 Child 컴포넌트의 Change Detection이 돌아간다는 것입니다.

두 번째로, ChildOne 컴포넌트에서 Event가 발생했는데 형제 컴포넌트인 ChildTwo 컴포넌트의 Change Detection이 돌아가는 것입니다.

위 두가지 경우는 정말 말도 안 되는 경우라 생각합니다. Angular도 그렇게 생각했는지 위 두개의 경우는 아주 쉽게 해결할 수 있는 옵션을 제공하고 있습니다. 이름하여 Change Detection Strategy!

Change Detection Strategy

위에서 말씀드린 것처럼, Angular에서는 Change Detection이 말도 안 되는 상황에 실행되는 것을 쉽게 바꿀 수 있게 인터페이스를 제공하고 있습니다. 그 인터페이스 사용방법은 모든 컴포넌트 decorator의 `changeDetection` prop에 아래처럼 `ChangeDetectionStrategy.OnPush`로 값을 지정해주면 됩니다.

Angular에서는 `ChangeDetectionStrategy`의 값들로 `OnPush`와 `Default`가 있습니다. `changeDetection` 옵션을 컴포넌트 데코레이터에 설정을 안 해줄 경우 기본 값은 `ChangeDetectionStrategy.Default`로 자동 설정됩니다. 우선 ChangeDetectionStrategy를 OnPush로 바꾸고 아래와 같이 다시 한번 앱을 실행하겠습니다.

위와 같이,; 앱을 실행 시 Default Change Detection Strategy일 때 컴포넌트별로 2번씩 실행되던 Change Detection이 onPush Change Detection Strategy로 바꾸자 올바르게 컴포넌트 별로 한 번씩만 실행되는 것을 볼 수 있습니다. 그렇다면 onPush Change Detection Strategy를 사용한 상태에서 Event는 어떻게 핸들링하는지 보도록 하겠습니다.

onPush Change Detection Strategy를 사용을 하니 Parent 컴포넌트에서 이벤트가 발생했을 시 오로지 자기 자신의 Change Detection만 돌아갑니다. 자식 컴포넌트에서 이벤트가 발생하면 자기 자신의 Change Detection과 Parent 컴포넌트의 Change Detection이 실행되는 것을 볼 수 있습니다.

다행히도 onPush Change Detection Strategy는 하나의 컴포넌트에서 이벤트가 발생하면 존재하는 모든 컴포넌트의 Change Detection을 실행시키는 말도 안 되는 행동은 하지 않지만 아직 부족한 부분이 있는 것 같습니다. Change Detection이 돌아가는 이유는 유저들에게 보여주는 View가 바뀌었을 거 같을 때 돌아가야 한다고 생각합니다.

지금 같은 경우 Event가 발생했고 심지어 그 Event가 아무런 행동도 하지 않고 있는데, Change Detection을 이벤트가 발생한 컴포넌트 포함 그 위의 모든 컴포넌트에 실행을 시킨다는 것은 비효율적이라 생각합니다.

일단 onPush Change Detection Strategy는 무조건 사용하는 게 옳다고 생각합니다. onPush가 아닌 Default Change Detection Strategy를 사용할 경우 보신 바와 같이 하나의 컴포넌트에서 발생하는 이펙트들이 아무 관계없는 모든 컴포넌트에 영향을 주는 것은 정말 말도 안 되는 행동이기 때문입니다. 그래서 많은 글에서도 Change Detection을 일단 onPush로 하는 것을 권장하고 있습니다. onPush Change Detection인 상황에서도 위 Event를 포함 최적화해야 하는 부분들도 많이 있기에 onPush CD stategy로 바꾼 지금부터가 최적화 시작입니다.

일단 지금 까지 긴 글이 있었는데 결국에는 최적화를 하기 위해 무엇보다 첫 번째로 해야 하는 것은 간단하게 “ChangeDetectionStrategy.onPush를 사용하세요”입니다. 이제부터는 모든 컴포넌트에 ChangeDetectionStrategy.onPush을 반영했다는 가정하에 최적화 방법을 하나하나 보도록 하겠습니다.

이벤트로 인한 불필요한 Change Detection 고치기

위 예제에서 보신 바와 같이 onPush Change Detection Strategy를 사용후 Angular에서 제공하는 `(click)` property로 event binding을 하면 Click Event 발생시 현 컴포넌트를 포함 상위 컴포넌트의 Change Detection들이 실행됩니다.

그 이유는 Angular가 제공해주는 `(click)` Property로 binding을 하면 Zone이라는 곳에서 로직을 돌리게 되고 그 Zone에서 로직이 돌아갈 시 Angular가 Change Detection이 필요할 것 같은 Component들의 Change Detection을 돌리기 때문입니다.

이것을 우회하기 위해 Angular Zone 밖에서 Event를 바인딩해주면 Angular는 Event가 바인딩되어 있다는 것을 모르게 되며 그렇게 될 시 불필요한 Change Detection 실행은 안하게 됩니다. 이런 상황을 제현하기 위해 Angular Zone 밖에서 Event Binding 하는 것을 아래의 ChildOne 컴포넌트에 구현해 보았습니다.

코드에서 보는 바와 같이, Angular에서 재공 하는 `(click)` 이벤트 바인딩 인터페이스를 사용하지 않고 RxJS에서 제공하는 `fromEvent` operator를 사용하여 이벤트를 바인딩하면 Angular는 이벤트가 바인딩되어 있는지 알 수가 없게 됩니다.

즉 Angular Zone에 `(click)`이 바인딩돼있다는 것을 모르게 하는 효과를 가지게 됩니다.

위 코드 수정후 아래와 같이 앱을 구동해 본 결과 기존의 ChildOne Button을 클릭 시 ChildOne과 Parent 컴포넌트에서 돌던 Change Detection이 더 이상 돌지 않는 것을 볼 수 있습니다.

이제 상식적으로 이해가 안 되던 (또는 초보자 배려를 위한) 불필요한 ChangeDetection은 없앤 것 같습니다. 다음 최적화로 넘어가기 전에 이렇게 Angular Zone 밖에서 Event 바인딩을 하게 될 시 주의해야 되는 부분을 보도록 하겠습니다. 아래 코드를 보시면 CHILD ONE BUTTON을 클릭 시 `count` property가 1씩 증가할 것 같은 코드를 볼 수 있습니다;

위 코드를 실행해보면 ChildOne Component 코드는 Event가 생성되어도 Change Detection은 실행되지 않기에 component property인 count 값은 업데이트되어 console log에서 확인할 수 있지만, Template에 바인딩되는 count값은 업데이트되지 않는 것을 아래에서 볼 수 있습니다.

Change Detection은 이제 event의 생성으로 실행되지 않습니다. 불필요한 Change Detection은 없앴지만, template에 값이 바인딩될 때는 Change Detection을 실행시켜줄 필요가 있습니다. 이런 상황을 대비하여 Angular에서는 `async` pipe을 제공해 주고 있습니다.

`async` pipe와 Change Detection

`async` pipe은 template에서 사용하는 것으로 컴퍼넌트의 Observable 값에 subscribe하여 그 Observable이 값을 쏴줄때마다 Change Detection을 실행시킵니다.

`async` pipe 자체도 성능적 문제가 있다는 것을 아래 섹션에서 보겠지만, 우선 `async` pipe의 행동을 보도록 하겠습니다. 아래 코드는 위 예제에서 template이 제대로 업데이트 안 되는 상황을 Observable과 async pipe를 사용하여 작동되도록 수정한 코드입니다.

위 코드를 보시면 template에 `async` pipe를 쓰고 있는 것을 볼 수 있습니다. 또한 기존에 number type이었던 `count` property가 BehaviorSubject인 `count$`로 변경이 되었고 `count$` 의 값은 `count$.next(value)`로 업데이트됩니다.

이렇게 `async` pipe를 사용하여 Subscribe 되어 있는 Observable이 값을 쏘게 되면 (예: `count$.next(value)`) Angular는 현제 컴포넌트와 모든 상위 컴포넌트들에 MarkForCheck를 하게 됩니다.

결국 현재 컴포넌트의 Change Detection과 모든 상위 컴포넌트의 Change Detection이 아래와 같이 실행되는 효과를 가지게 됩니다.

보시는 바와 같이 ChildComponent에서 `count$.next()`를 하는 순간 ChildComponent와 그 상위 컴포넌트인 ParentComponent의 Change Detection이 돌아가는 것을 볼 수 있습니다. async pipe는 상위 컴포넌트가 없거나 상위 컴포넌트의 Change Detection이 실행되어도 성능적으로 문제가 되지 않을 때 편의상 쓰면 좋다는 결론을 내릴 수 있습니다.

결국 성능적으로 최고의 행동은 ChildOne 컴포넌트의 값이 변했을 때 오로지 ChildOne 컴포넌트의 Change Detection만 실행되는 행동이라 할 수 있습니다. 값이 변한 컴포넌트의 Change Detection만 돌리는 제가 아는 방법은 오로지 한 가지입니다. 바로 ChangeDetectorRef의 detectChanges 메서드!

ChangeDetectorRef의 detectChanges Method로 가능한 효율적 수동 Change Detection

아래 코드는 위 `async` pipe의 문제인 ChildOne Component의 변화에 불필요하게 Parent Component의 Change Detection까지 실행되는 것을 detectChanges Method를 사용하여 수정한 코드입니다.

위 코드에서 바뀐 점은 기존의 문제가 되었던 `async` pipe을 없애고 number type의 count 프로퍼티를 사용하고 있다는 것과 `ChangeDetectorRef`를 주입하여 `detectChanges` 메서드를 count 프로퍼티 값을 변경 후 불러주고 있는 것입니다. 위 코드를 아래와 같이 실행시켜 보았습니다.

ChildOne의 count 값이 바뀐 후 ChangeDetectorRef의 detectChanges 메서드를 부르면 ChildOne의 Change Detection만 실행되고 기존에 문제가 됐던 Parent 컴포넌트의 Change Detection이 실행되지 않는 것을 볼 수 있습니다.

이렇게 하여 하나의 컴포넌트 안에서 Event와 local state change의 변화로 일어나는 Change Detection의 횟수를 최적화해 보았습니다.

마지막으로 다룰 Change Detection 횟수 최적화는 컴포넌트 안에서 일어나는 행동으로 인한 Change Detection이 아닌 Parent Component가 Child Component에게 값을 전달할 때 불려지는 Change Detection의 문제점과 최적화의 대하여 알아보겠습니다.

Props로 Primitive 값을 받았을때 실행되는 Change Detection

컴포넌트의 Change Detection은 위에서 보셨던 경우들 말고도 돌아가는 경우가 하나 더 있습니다. 바로 Parent로부터 기존에 받았던 reference 값이 지금 받아온 reference 값과 다를 때입니다. 아래 예제와 함께 설명해 보겠습니다.

위 왼쪽에 있는 코드는 Parent 컴포넌트로 2초마다 `countFromParent` 라는 프로퍼티에 0의 값을 지정해 주고 Parent의 Change Detection을 실행시킵니다. 그리고 이 `countFromParent` 의 값은 2초마다 ChildOne 컴포넌트의 `count` Input (이하 props 라 하겠습니다)로 넘겨집니다. Angular 에서는 props 로 넘어오는 값이 같은지 다른지 strict equality (`===`)를 사용해 결정합니다.

지금 같은 경우 ChildOne의 `count` Input (또는 props)으로 받아오는 값은 항상 0 이며 `0 === 0` 은 true 이기에 받아오는 값이 달라지지 않는 것을 Angular는 확인하고 ChildOne의 Change Detection은 아래와 같이 실행되지 않습니다.

위 그림처럼 `countFromParent` 의 값이 ChildOne의 `count` props로 2초마다 넘겨지고 있지만 항상 같은 값인 0을 넘겨받기에 ChildOne 컴포넌트의 Change Detection은 실행되지 않고 Parent 컴포넌트의 Change Detection이 실행되는 것을 볼 수 있습니다.

반대로 Parent Component에서 항상 다른 값을 ChildOne 컴포넌트로 넘겨주면 아래와 같이 Child Component의 Change Detection도 실행되는 것을 볼 수 있습니다.

(변경된 코드는 Parent Component에서 `this.countFromParent = 0` 부분을 `this.countFromParent = this.countFromParent + 1` 밖에 없어 코드 설명은 생략하겠습니다.)

위 그림에서 보시는 바와 같이 ChildOne의 `count` props로 받아오는 reference 값이 기존에 받아왔던 reference값과 다르기 때문에 ChildOne의 Change Detection도 실행되는 것을 볼 수 있습니다.

위 예제 같은 경우 별문제 없이 필요한 경우에 Change Detection이 실행되고 있습니다. 그 이유는 JavaScript에서 number와 같은 primitive는 pass by value이기 때문입니다. 문제는 props로 받아오는 값이 number나 string 같은 primitive data structure가 아닌 Object나 Array 같은 complex data structure일 때 생기게 됩니다.

Props로 Complex Data Structure를 받았을때 실행되는 Change Detection

설명을 위해 Complex Data Structure중 하나인 JavaScript Object를 ChildOne 컴퍼넌트 Props로 넘기는 예제를 아래와 같이 만들었습니다.

위 왼쪽 Parent 컴포넌트의 `countFromParent` 는 더 이상 number type이 아닌 Object type입니다. 또한 timer안에서 바뀌는 값(`this.countFromParent = { value: 0 }`)은 모양이 기존의 값과 똑같지만 reference value는 완전히 다른 새로운 Object입니다.

ChildOne 컴포넌트는 이 Object를 2초마다 받게 되고 값이 같은지 다른지 strict equality (===)로 체크를 합니다. Object와 같은 Complex Data Structure는 pass by reference이기 때문에 `{value: 0} === {value: 0}` 는 `false`의 값을 가지게 되고 Angular는 값이 바뀐 것으로 간주하여 ChildOne 컴포넌트의 Change Detection은 아래와 같이 매번 실행되는 것을 볼 수 있습니다.

위에서 보시는 바와 같이, 결국 template에 사용되는 받아온 Object (`{value: 0}`) 안의 value property의 값은 변하지 않았지만 ChildOne의 Change Detection이 실행되는 것을 볼 수 있습니다. 위 같이 컴포넌트로 받아오는 props가 Object이나 Array일 때 모양은 같아도 reference가 다를 경우 실행되는 Change Detection을 최소화하는 방법은 두 가지가 있다고 생각합니다.

첫째, Immutable.js나 Immer와 같은 Structural Sharing을 하는 Immutable Data Structure를 사용하는 것

둘째, Input으로 들어오는 값의 “다름”의 기준을 strict equality (===)가 아닌 다른 방식의 수동 체크를 사용하는 것

두 번째 방법은 native DOM을 건드리는 3rd Party Library를 사용할 때 유용한 방법이지만 복잡하여 스킵하겠습니다.

Immutable 라이브러리로 핸들링하는 Complex Data Structure

위에서 보이던 문제의 해결 방법으로 Immutable.js나 Immer같은 Structural Sharing / Immutable 라이브러리를 사용하는 방법이 있습니다. 그중 요즘 트렌드인 immer를 사용하여 아래처럼 구현해 보았습니다.

위 왼쪽의 Parent 컴포넌트는 `produce`라는 immer library에서 제공하는 함수를 사용하였습니다. 보통 Immutable 하게 개발을 하다 보면 비효율적으로 Object나 Array를 복사해야 되는 경우가 있습니다. 이런 경우를 효율적으로 바꾸기 위해 보통 Immutable Library들은 Structural Sharing이라는 방법을 사용합니다.

Structural Sharing은 이론적으로는 아주 간단합니다. 기존에 있던 Object의 값들을 최대한 제사용 하여 복사할 필요 없는 상황을 만드는 것 이 Structural Sharing의 궁극적인 목표입니다. 위 예제를 토대로 말씀드리면, `this.countFromParent`라는 JavaScript Object를 사용하여 `produce`라는 함수는 필요시에만 새로운 Object을 생성하여 return 하고 새로운 Object를 만들 필요가 없다면 인자로 받은 Object (위 예제의 `this.countFromParent`)를 그대로 return 합니다.

지금 같은 경우 `draft.value = 0`부분을 보시면 기존의 `this.countFromParent`라는 Object의 값이 이미 {value: 0}이기 때문에 기존의 Object에 변화를 주지 않습니다. 이렇게 변화가 없는 경우 새로운 Object을 만들지 않고 기존에 있던 Object을 그대로 넘겨줍니다. 즉 `produce`함수로 나온 Object는 기존에 있던 `this.countFromParent` Object와 reference가 같은 값을 return 합니다.

그리하여 ChildOne 컴포넌트가 `count` Input으로 받아오는 Object가 변했는지 {value: 0} === {value: 0}로 체크를 할시 같은 메모리에 위치한 Object이기 때문에 true의 값을 가지게 되고 ChildOne의 Change Detection은 아래와 같이 실행되지 않습니다.

위 그림처럼 Parent 컴포넌트의 Change Detection만 실행이 되고 ChildOne의 Change Detection은 불필요하게 실행되지 않는 것을 볼 수 있습니다.

물론 값이 변했을 때는 Change Detection이 돌아야 되니 immer가 저희가 원하는 대로 작동하는지 체크할 겸 Object에 매번 다른 `value` property값을 아래처럼 넣어 줘 봤습니다.

위 코드에서 달라진 점은 Parent의 `timer`안에서 `draft.value`를 increment 해주는 게 다이고 ChildOne 컴퍼너너트는 달라진 것은 없습니다. 위 코드를 아래와 같이 실행해보겠습니다.

위 그림처럼 immer Library는 Object의 값이 바뀔 시 새로운 Object를 생성해 주며 ChildOne 컴퍼넌트는 새로운 Object를 받아 Change Detection을 실행해주는 것을 볼 수 있습니다.

개인적인 의견으로 복잡한 Data Structure를 다루는 개발이라면 Immutable.js나 immer의 사용은 성능적인 면에서 봤을 때 필수라 생각합니다.

이렇게 하여 Change Detection 횟수 최소화를 시킴으로써 성능 최적화하는 방법을 보았습니다. 위 예제에서 Change Detection이 실행될 때 그리 많이 CPU를 사용하지 않는 (사용자 입장에서 봤을 때) console에 log만 했었습니다.

하지만 Change Detection이 돌아갈 때 간단한 console의 log가 아닌 CPU를 엄청나게 많이 쓰는 로직이 돌아간다면 앱이 잠시 멈추는 등 사용자가 느낄 수 있을 정도의 영향을 줄 수 있습니다.

다음 섹션은 이런 Change Detection시 무거운 로직이 돌아가는 경우 최적화하는 방법에 대하여 알아보겠습니다.

최적화 방법 2: ChangeDetection 무게 최소화.

위 섹션에서 Change Detection 횟수를 최소화하는 방식으로 최적화를 해보았습니다. 이번 섹션에서는 Change Detection이 실행될 때 불필요한 CPU 사용을 최적화하는 방법을 보도록 하겠습니다. 설명을 위해 아래 예제를 만들어 보았습니다.

위 왼쪽 Parent 컴포넌트의 button을 클릭 시 Parent 컴퍼넌트는 ChildOne 컴포넌트에 Object를 전달합니다. Object의 값은 의미가 없으며 오로지 ChildOne 컴포넌트의 Change Detection을 실행시키기 위함입니다.즉, Parent 컴포넌트의 Button을 클릭 하면 ChildOne 컴포넌트의 Change Detection이 실행됩니다.

ChildOne 컴포넌트의 template을 보시면 `progress` 태그가 있습니다.

이 `progress` 태그는 애니메이션이 반영되어 있으며 서비스를 사용하는 유저가 앱의 성능적 문제로 인해 UI가 멈추는 것을 보여주기 위해 존재합니다.

ChildOne 컴포넌트의 메서드로 `heavyCalc` 라는 메서드가 있습니다. 이 `heavyCalc` 메서드는 template에 바인딩되어 있으며 Change Detection이 실행될 때마다 불리게 됩니다.

`heavyCalc` 메서드 안의 로직을 보면 for 문을 인자로 받은 숫자만큼 돌리고 난 후 인자를 반환한다는 것을 볼 수 있습니다. for문을 많이 돌려 CPU를 많이 쓰는 상황을 재현한 것입니다. 위 코드를 실행시키면 다음과 같이 실행됨을 볼 수 있습니다.

위 보시는 바와 같이 처음 앱이 실행될 때 `heavyCalc` 가 실행되어 rendering을 블록 하는 것을 볼 수 있습니다.

또한 Parent 컴포넌트 버튼을 클릭할 때마다 UI thread를 블록 하여 progress bar가 멈추는 것을 볼 수 있습니다. UI thread가 블록 되면 브라우저의 scroll과 같은 기능들도 멈추게 됩니다. 이제 위에서 보이는 문제점들을 고쳐 최적화를 해보도록 하겠습니다.

Memoize기능을 사용한 부분을 최적화

위에서 보이는 문제점은 Change Detection이 돌아갈 때마다 CPU를 많이 쓰는 로직이 돌아간다는 것입니다.

그렇다면 memoize와 같은 방법으로 `heavyCalculation` method를 memoize 한다면 처음에는 느리겠지만 추후 함수 인자가 같다면 CPU를 많이 사용하는 로직을 돌릴 필요 없이 기존에 반환한 값을 그대로 반환하면 됩니다.

여기서 주의하여야 할 점은 이 `heavyCalculation`이라는 함수가 pure 함수여야 한다는 점 입니다. 즉 인자가 같으면 반환되는 값도 같아야 된다는 것 입니다. Angular에서 제공하는 pipe는 이런 부분적 memoize기능을 사용하고 있습니다. 다음은 pipe를 만들어 CPU intensive 한 로직을 꼭 필요할 때만 실행되도록 구현해 보았습니다.

위와 같이 ChildOne 컴포넌트에 있던 `heavyCalculation` 메서드를 따로 pipe로 구현하여 template에 바인딩(`{{ 6000 | heavyCalculation }}`) 하였습니다.

이렇게 할 경우 heavyCalculation Pipe에 있는 `transform` 메서드의 인자가 기존에 실행된 값과 같다면 그 안의 로직들 (for 문 등)이 실행되지 않고 넘어가게 됩니다. 위 코드를 아래와 같이 실행해 보았습니다.

위 그림에서 보이듯 처음 앱 실행될 시 rendering이 블록 되는 것은 해결되지 않았습니다. 하지만 Parent button을 클릭할 때 멈추던 것은 해결이 된 것을 볼 수 있습니다.

지금과 같은 경우, Parent Button을 클릭하면 ChildOne 컴포넌트의 Change Detection은 실행되지만 heavyCalculation pipe의 transform 메서드가 받은 인자의 값은 변하지 않았기 때문에 heavyCalculation안의 로직들은 다시 실행되지 않는 것을 볼 수 있습니다.

결국 문제는 무슨 방법을 쓰던 최소한 처음 rendering 할 때 heavyCalculation은 첫 값을 인자로 받아 돌아가는 상황은 꼭 있고 다른 값을 인자로 받는 경우로 인해 heavyCalculation이 돌아가는 상황은 빈번하다는 것입니다.

그래서 memoize와 같은 기능도 좋지만 결국은 UI thread를 block 하는 로직은 최소한 한 번은 돌아간다는 것은 그 어떤 방법을 써도 변함이 없다고 가정할 수 있습니다. 이렇게 heavyCalculation안의 로직을 한 번쯤은 꼭 실행해야 된다면 UI thread를 block하지 않고 실행을 하면 문제는 해결됩니다.

CPU intensive 로직을 Web Worker Thread로

많은 분들이 브라우저 JavaScript환경은 single thread라고 알고 있지만 엄밀히 따진다면 UI thread (사용자의 input을 받고 사용자에게 output을 보여주는 thread)만 Single thread이고 계산을 위한 또는 Caching을 위한 thread는 따로 생성할 수 있습니다.

위 예제의 문제를 해결하기 위해 UI thread에 영향을 주지 않는 Web Worker를 아래와 같이 구현해 보았습니다.

위 왼쪽 코드는 ChildOne 컴포넌트로 주목해야 되는 부분은 ngOnInit부분입니다. ngOnInit은 ChildOne 컴포넌트가 처음 생성이 되는 시기에 새로운 Web Worker thread를 한번 생성합니다. 이렇게 처음 생성된 Web Worker thread를 ChildOne 컴포넌트의 `worker` property에 저장하여 언제든 재사용할 수 있도록 구현하였습니다.

ChildOne의 `heavyCalculation`이라는 method가 template에 바인딩되어 있음으로 Change Detection이 실행될 때마다 `heavyCalculation`이라는 method는 실행이 되고 `heavyCalculation` 메서드는 인자로 받아오는 값을 Web Worker로 보내고 난 후 바로 `heavyCalculationResult` property를 반환해줍니다.

즉, template에 `heavyCalculation`을 실행시키는 부분은 `heavyCalculationResult`라는 property의 값으로 기다림 없이 바로 바인딩 되게 됩니다. Main thread로 부터 `loopCount`의 값을 받은 Web Worker (위 오른쪽 코드 ChildOne Worker)는 `heavyCalculation` 로직을 실행 시킵니다.

위 오른쪽에 있는 ChildOne Worker 로직을 보시면 `memoLoopCount`라는 HashMap에 loopCount라는 키가 있는지 확인을 하고 만약에 없다면 CPU를 많이 쓰는 로직을 돌립니다. 이때 이 CPU intensive 로직은 UI thread에 영향을 안주는 별계의 thread에서 실행된다는 게 중요한 포인트입니다.

CPU intensive 로직을 실행시키고 나온 값을 `memoLooopCount`에 저장하여 다음에 똑같은 인자를 받아올 시 불필요하게 CPU intesive로직을 실행하지 않고 반환을 해줄 수 있게 구현하였습니다.

계산 결과 값을 찾은 ChildOne Worker는 값을 다시 Main UI thread (ChildOne 컴포넌트)에 넘겨줍니다. 이때 값을 받아오는 로직은 위 오른쪽 ChildOne 컴포넌트의 `this.worker.onmessage` 부분입니다.

무한 Change Detection loop을 방지하기 위해 `this.worker.onmessage`로 받아온 ChildOne Web Worker의 결과 값을 기존에 받아온 결과 값과 같은지 체크를 한 후 다를 시에만 Change Detection을 다시 돌리게 구현되어있습니다.

이렇게 구현을 하면 아래에서 보이는 바와 같이 rendering은 물론 UI thread는 단 한순간도 블록 되지 않는 것을 확인할 수 있습니다.

위 그림에서 보이는 바와 같이 CPU Intensive 계산은 web worker에서 완료를 하는 것을 console log에서 볼 수 있습니다. 추가적으로 개선할 것이 있다면 `heavyCalculation`을 실행시키는 순간 Spinner와 같은 계산 중이라는 UI를 넣어 주면 위에서 6000이라는 숫자가 갑자기 나타나는 것을 더 자연스럽게 표현할 수 있을 것 같습니다.

Web Worker는 지금 것 Front End Framework에서 가장 Under utilized 된 기능인 것 같습니다. Web Worker를 쓰면서 “redux환경에서 rootReducer 로직을 webworker에서 실행시키면 어떨까?”와 같은 생각이 들 정도로 WebWorker를 잘 사용한다면 그 아무리 CPU intensive 한 로직이라도 핸들링할 수 있을 것 같다 생각 들었습니다.

허나 Web Worker를 처음 생성하는데 걸리는 시간은 40ms정도가 되고 Web Worker로 넘어가는 보통 Front End 개발자가 쓰는 data structure들은 전부 복사가 되서 넘어간다는것등 고려해야 하는게 조금 있기는 합니다. (참고: https://hacks.mozilla.org/2015/07/how-fast-are-web-workers/).

(아마도 다음 블로그는 React에서 Web Worker를 최대한 활용하기가 되지 않을까 싶습니다. )

마무리하며

이렇게 하여 Angular의 Change Detection 부분의 최적화하는 방법을 알아보았습니다. Change Detection이 기본 세팅에서는 얼마나 자주 돌아가는지 보았고 이것을 최소화시켜 보았습니다. Change Detection이 실행될 때 CPU intensive 한 로직은 Web Worker로 뺌으로 인하여 유저들 입장에서 봤을 때 더 최적화되어 보이게 구현도 해봤습니다.

끝으로 제 생각을 말씀드리자면 최적화는 하면 할수록 더욱 진행하고 싶은 부분입니다. 최적화는 끝이 없는 것이기에 MVP를 구현하고 어느 정도 유저들이 불편 없이 사용할 수 있게 완성하여, 배포 후 문제가 될 시 하는 게 옳다고 생각합니다. 또한 진행하면서 데이터를 측정하시는 것을 추천드립니다.

처음부터 최적화를 하시지 말고, 마지막에 필요할 때 진행하는 것과 정확한 최적화 측정을 위한 Profiler 사용을 추천드리며 이 글을 마치겠습니다.

고승훈, Frontend Engineer

기업문화 엿볼 때, 더팀스

로그인

/