
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를 받았
