Angular ChangeDetection의 이해와 OnPush 전략
ChangeDetection ? Zone.js ?
앵귤러는 컴포넌트의 상태변화를 자동으로 감지해 뷰를 다시 랜더링하는 로직을 가지고 있다.
상태변화란, 컴포넌트의 프로퍼티가(모델) 변화하는것인데, 최초 컴포넌트를 생성하는 시점 이후 상태변화를 일으킬 가능성이 있는 경우는 많지 않다.
- DOM 이벤트(click, mouse move 등)
- Timer관련 함수(setTimeout, setInterval)등의 tick이벤트
- Ajax통신/ Promise등
이러한 비동기 처리는 컴포넍트의 상태를 변화시킬 가능성이 있고, Angular에서는 zone.js가 이들을 프록시로 재정의여 대체하는데(프록시로 랩핑) 이런 개념을 몽키패치라고 한다.
모델을 변화시킬 수 있는 비동기 처리가 호출되면, zone.js는 패치를 통해 호출을 후킹하고, 변화가 감지되면 Digest loop을 실행하여 모델의 변화를 뷰에 반영한다(랜더링).
컴포넌트 트리와 상태 공유
컴포넌트는 Stateful과 Stateless가 있다.
Stateful 컴포넌트는 어플리케이션의 상태 정보를 저장/변경할 수 있는 컴포넌트이고, Stateless 컴포넌트는 상태정보를 참조하여 화면에 출력할 뿐, 직접 변경하지 않는다. Stateful 컴포넌트는 Side Effect를 발생시킬 여지가 있기 때문에 어플리케이션의 복잡도를 증가시키기 때문에, 최대한 Stateful 컴포넌트를 줄이는것이 중요하다.
컴포넌트는 보통 부모-자식-손자의 관계로 분리되어 트리를 형성하는데, 이 때 상태를 공유하는 가장 기본적인 방법은 @Input() 데코레이터를 이용해 부모->자식->손자 에게 상태를 전달하는것이다. 그런데 이 때, 위에서 언급한것처럼 Stateful 컴포넌트를 최대한 줄여, 부모만이 Stateful컴포넌트로 만들고 자식, 손자 컴포넌트에 단방향으로 상태를 전달한다.
이를 구현하면 아래와 같은 코드가 될 것이다.
// code
// 부모(root) 컴포넌트
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `<h2>부모 id : {{data.id}}</h2>
<app-child-component [data]="data"></app-child-component>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent implements DoCheck{
data = {
id: 1
}
}
// 자식 컴포넌트
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-child-component',
template: `<h2>자식 id : {{data.id}}</h2>
<app-child-child-component [data]="data"></app-child-child-component>`,
})
export class ChildComponentComponent {
@Input() data: any;
}
// 손자 컴포넌트
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-child-child-component',
template: `<h3>손자 id: {{data.id}}</h3>
<button (click)="click()">손자버튼</button>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildChildComponentComponent {
@Input() data: any;
}
만약 자식 component에서 상태가 변화에 관련된 이벤트를 발생시킨다면? @Output 데코레이터로 부모 컴포넌트로 이벤트를 전달해 부모 컴포넌트에서 상태값을 변화시키고, 다시 이 상태를 자식이 받도록 생성해야한다.
ngDoCheck()
위의 기본적인 컴포넌트 트리에서, zone.js에서 비동기 이벤트를 감지하면 컴포넌트 트리의 최상단 컴포넌트부터 이벤트가 발생한 컴포넌트가 속한 subTree로 ChangeDetection(CD)이 진행된다.
컴포넌트가 DoCheck 인터페이스의 ngDoCheck() 를 구현하면, 해당 컴포넌트에 CD 발생시 ngDoCheck()가 호출된다.
// 부모(root) 컴포넌트
import { Component, DoCheck } from '@angular/core';
@Component({
selector: 'app-root',
template: `<h2>부모 id : {{data.id}}</h2>
<!-- DOM의 click이벤트는 zone.js에 감지된다. -->
<button (click)="click()">부모버튼</button>
<app-child-component [data]="data"></app-child-component>`,
})
export class AppComponent implements DoCheck{
data = {
id: 1
}
ngDoCheck(): void {
console.log("부모 doCheck()");
}
// DOM의 click이벤트 바인딩
click() {
console.log('click()');
this.data.id++;
}
}
// 자식 컴포넌트
export class ChildComponentComponent implements DoCheck {
...
ngDoCheck(): void {
console.log(`자식 doCheck() , id : ${this.data.id}`);
}
...
}
// 손자 컴포넌트
export class ChildChildComponentComponent implements DoCheck {
...
ngDoCheck(): void {
console.log('손자 doCheck()');
}
...
}
부모 컴포넌트에서 click()발생시 아래와 같은 로그를 확인할 수 있다.
ChangeDetectionStrategy
Angular의 CD의 기본전략은 하위 컴포넌트에 영향이 있든 없든, subTree의 leaf노드까지 CD를 실행하는 것인데, 이는 당연히 성능에 좋지 않을것이다. 이를 위해 ChangeDetectionStrategy.OnPush라는 전략이 존재한다.
OnPush전략은 해당 컴포넌트에 바인딩된 Input값의 reference의 변화가 없을 경우 하위 컴포넌트에 대해 CD를 실행하지 않고, 자신은 뷰를 다시 랜더링하지 않는 전략이다.
Javascript에서 자료형은 mutable, immutable 두 종류로 나뉘는데, immutable은 primitive 타입의 자료와 같이, 값이 변할 수 없는 값이고, mutable은 객체와 같이 reference가 그대로이면서 내부의 값이 변경될 수 있는 자료형이다.
OnPush전략에서, @Input으로 공유되는 상태가 immutable한 자료형의 경우 변화가 있다면 무조건 CD가 이뤄질 것이다.
하지만 객체와 같은 mutable한 자료형이라면 내부값의 변화는 인지하지 않으므로 랜더링을 다시하지도, CD를 하위 컴포넌트로 전파하지도 않게 된다.
코드로 이를 검증해보자.
우선, 자식-손자 컴포넌트의 CD전략을 OnPush로 바꾸고, 부모 컴포넌트에서 DOM의 click이벤트 발생시 바인딩된 click()함수로 this.data.id++로 상태의 값만 변경한다.
// 자식 컴포넌트
@Component({
...
changeDetection: ChangeDetectionStrategy.OnPush
})
// 손자 컴포넌트
@Component({
...
changeDetection: ChangeDetectionStrategy.OnPush
})
부모 컴포넌트에서 버튼 클릭으로 click이벤트가 발생하면 화면과 콘솔은 아래와 같다.
1. 자식 컴포넌트까지 CD가 이뤄졌다.
2. 자식컴포넌트에서 id를 찍어보면 값이 2로 뜨는데 뷰는 바뀌지 않았다
(@Input() 데코레이터로 받는 프로퍼티는, 공유되는 모든 컴포넌트가 같은 주소를 참조하기때문에 값을 찍으면 잘 나온다)
참조를 변경하면 어떻게 될까?
// 부모(root) 컴포넌트
...
click() {
console.log('click()');
this.data = { id: this.data.id + 1 }
}
}
예상했던대로 모든 OnPush전략이라도 CD가 전파되고 랜더링도 잘 바뀌었다.
결론
앵귤러에서 CD는 실제 상태를 변화시키지 않는 모든 이벤트에 대해서도 CD가 이뤄지므로 OnPush전략을 쓰는것은 성능개선에 무척 도움이 될 것으로 판단된다. 그러나 무심코 상태의 값만 변화시키고, 이를 하위 컴포넌트에 @Input()으로 전달하여 공유했다간 하위 컴포넌트에서 갱신된 상태로 랜더링되지 않는 문제가 발생할 수 있겠다.
따라서 CD 전략을 OnPush 로 불필요한 CD을 줄여 성능을 높이고, 상태값을 바꾸는 방식은 참조를 바꾸는(immutable한 방식으로) 방식으로 안전하게 이뤄져야 할 것이다.
참고
https://yeoulcoding.tistory.com/117
https://codingpark.tistory.com/60
https://yceffort.kr/2020/07/change-detection-in-angular-react