안녕하세요. 꼬동입니다.
해당 글은 아래의 글을 번역한 글입니다.
약간 제이크 질렌할 닮으셨네여
런던에서 열린 AngularConnect 2017에서 쓰니는 "Purely Fast"라는 주제로 프레젠테이션을 했습니다.
비지니스 애플리케이션의 성능을 향상 시키는 것을 단계별로 보여줬더랬죠.
이 예에서는 Angular 및 AngularJS를 개발하면서 지난 몇 년간 마주한 문제들에 대한 얘기였습니다.
프레젠테이션 후 피드백을 받고, "Purely Fast"를 좀 더 디테일하게 설명하는 블로그 글을 적어보려고 왔습니다.
해당 글에서 immutable data structures와 OnPush change detection에 대해서 자세히 알아보려고 합니다.
요새 모던 SPA의 문제는 초기 load time입니다. 여기엔 네트워크로 전송되는 bytes 수를 줄이고, 네트워크 요청을 줄이는 것도 포함됩니다.
쓰니는 bundle size를 줄이는 법과 관련된 글을 적었더랬죠. 아래 두 개의 글입니다.
https://blog.mgechev.com/2016/06/26/tree-shaking-angular2-production-build-rollup-javascript/
https://blog.mgechev.com/2016/07/21/even-smaller-angular2-applications-closure-tree-shaking/
source-map-explorer와 같은 도구를 이용해 app 번들을 자세히 알고, 시간을 투자하는것은 좋은 아이디어이지만, 같은 방향성을 가진 다양한 방법들이 있습니다.
https://www.npmjs.com/package/source-map-explorer
예를들어, Google Closure Compiler 팀은 webpack 팀과 마찬가지로 지속적으로 dead code를 없애고, 최소화를 위해서 노력하고 있습니다.
Angular CLI 팀은 가장 효율적이고 캡슐화가 잘 된 빌드를 가능하게 만들면서, 양쪽에서 최고의 성능을 발휘하도록 합니다.
결국 우리가 할 수 있는 일은 최소화를 하기 위해서 이들 팀과 협력하거나 그들의 툴들을 우리 애플리케이션에 최적화 하는 방법이 될 것입니다. Lazy Loading 같은 것들이 있겠죠.
하지만, 우리 애플리케이션의 runtime performance는 전적으로 우리에게 달려있습니다.
더 얘기를 하기 전에 간단한 애플리케이션을 살펴보겠습니다.
요약
1. SPA의 문제는 초기 로딩
2. 수 많은 팀(webpack, google closure compiler, angular cli)들이 해당 문제를 해결하기 위해 노력 중, 우리는 이들이 만든 것을 잘 이용만 하면 됨
3. 근데, runtime performace는 우리가 알아서 해결해야함
예시로 들 샘플 애플리케이션을 한 번 알아봅시다.
위의 GIF가 저희가 최적화할 비즈니스 애플리케이션의 예입니다.
- Sales와 R&D, 두 개의 리스트 영역을 가지고 있습니다.
- 각 employee들은 이름을 가지고 있고, 숫자 값을 가지고 있습니다. 숫자값은 비즈니스 계산을 거친 값입니다. (표준편차 같은)
- 각 employee 마다 삭제 버튼을 가지고 있습니다.
- 각 부서의 리스트들은 새로 employee를 추가할 수 있는 text input을 가지고 있습니다. 우리가 새로운 item을 추가하면, employee의 숫자 값을 이러케 저러케 해가지고 숫자 값을 받아내고 스크린에 띄웁니다.
이게 끝입니다. 이제 구조를 살펴봅시다.
위의 사진은 애플리케이션 구조를 보여줍니다.
전체 애플리케이션을 감싸는 AppComponent와 EmployeeListComponent가 두 개의 인스턴스로 각각 다른 부분을 보여주고 있습니다.
EmployeeListComponent는 아래와 같습니다.
<h1 title="Department">{{ department }}</h1>
<mat-form-field>
<input placeholder="Enter name here" matInput type="text" [(ngModel)]="label" (keydown)="handleKey($event)">
</mat-form-field>
<mat-list>
<mat-list-item *ngFor="let item of data">
<h3 matLine title="Name">
{{ item.label }}
</h3>
<mat-chip-list>
<md-chip title="Score" class="mat-chip mat-primary mat-chip-selected" color="primary" selected="true">
{{ calculate(item.num) }}
</md-chip>
</mat-chip-list>
<i title="Delete" class="fa fa-trash-o" aria-hidden="true" (click)="remove.emit(item)">
</mat-list-item>
</mat-list>
첫 번째로, department 이름을 print하고 있습니다. (h3 태그)
그리고 우리는 ngModel을 머금은 material design form field를 선언했고, EmployeeListComponent에서의 label과 text input을 양방향 바인드를 사용하고 있습니다.
그 직후 각각의 employess의 이름과 연산된 숫자 정보를 보여주는 리스트가 있습니다.
template 안에 calculate 메소드를 직접 사용해서 보여주고 있습니다. calculate 메소드는 EmployeeListComponent 안에 존재합니다.
const fibonacci = (num: number): number => {
if (num === 1 || num ===2) {
return 1;
}
return fibonacci(num - 1) + fibonacci(num - 2);
};
@Component(...)
export class EmployeeListComponent {
@Input() data: EmployeeData[];
@Input() department: string;
@Output() remove = new EventEmitter<EmployeeData>();
@Output() add = new EventEmitter<string>();
label: string;
handleKey(event: any) {
if (event.keyCode === 13) {
this.add.emit(this.label);
this.label = '';
}
}
calculate(num: number) {
return fibonacci(num);
}
}
EmployeeListComponent의 설명을 드리자면
- 두 개의 @Input이 있습니다.
- data - 세부적인 부서의 employees의 리스트가 있습니다.
- department - 부서의 이름입니다.
- 두 개의 @output이 있습니다.
- remove - 리스트에서 employee를 제거할 때, trigger 합니다.
- add - 리스트에 employee를 추가할 때, trigger 합니다.
input들은 AppComponent로 부터 전달 받아집니다.
비즈니스 로직을 보면 피보나치 수열을 이용하는데, 재귀함수를 사용해서 구현하도록 되어있습니다.
사실 이런걸 저희 코드 안에 넣으면, 매번 계산을 진행해야하니 비효율적인 계산을 계속 하는 걸 알 수 있죠.
요약
1. 애플리케이션은 하나의 root component와 두 개의 child component들이 있다.
2. 각 child component(EmployeeListComponent)는 item list들이 존재한다. (item: label / num)
3. 각 list item들은 비효율적인 연산(피보나치 수열)이 존재한다.
이제 테스트를 해봅시다.
아래와 같이 타이핑을 한다고 했을 때,
chrome debug에서는 아래와 같이 결과가 뜹니다.
보시다시피 26초가 적혀있는데, 요새 어떤 누가 웹사이트 이용하는데, 26초를 기다릴까요.
보시면 대부분의 시간 분배가 Scripting에 쏟아져있는데, 이게 피보나치 수열 때문임을 알 수 있습니다.
두 개의 Child Component에서 공통되게 사용하기 때문에, 이는 2배의 연산이 필요함을 알게됩니다.
사실 모든 종업원의 수치를 연산을 해서 리스트에 보여주고 있지만, 여러번 이를 계산하고 있습니다.
이는 Angular의 Change Detection(이하 CD)이 위에서 등록한 각 이벤트 후에 발생되기 때문입니다.
일단 change detection이 트리거 되면, 템플릿의 모든 것들이 이전 값과 비교됩니다.
만약 변화가 있다면, Angular의 CD가 DOM을 최대한 효율적으로 업데이트를 합니다.
이 뜻은, 각 CD는 모든 Component의 모든 template을 재 연산하여 비교를 한다는 것입니다.
이것이 조금 무거운 연산을 template 안에서 하지 않는 것을 권장하는 이유입니다.
https://github.com/mgechev/angular-performance-checklist
사실 따지고보면, 저희의 두 개의 리스트들은 아무런 변화가 일어나지 않았습니다. 그렇기 때문에 Angular가 DOM을 업데이트를 할 이유가 전혀 없죠.
그렇기 때문에 어떻게 Angular가 두 department들의 리스트가 변경하지 않았을 때, 재 연산을 하지 않게 할 수 있을까요 ?
요약
1. 무거운 연산이 컴포넌트 안에 있고, 그 연산이 template에 의해 호출된다.
2. 컴포넌트 호출과 수많은 이벤트 (mousedown, mouseup 등)에 의해 해당 연산이 여러번 호출된다.
3. 여러번 호출되는 이유는 Angular CD는 모든 컴포넌트의 모든 템플릿을 재연산 하기 때문에, 무거운 연산이 여러번 호출된다.
이 문제에 대한 해결법은 CD 전략을 커스터마이징을 하는겁니다.
OnPush CD 전략은 정확하게 저희가 찾던 방법입니다.
EmployeeListComponent에 OnPush를 끼얹어서 angular가 CD 메커니즘이 해당 컴포넌트에서는 동작안하도록 만듭니다. input의 새로운 value가 들어오지 않는 한이요.
기능으로서의 Component
이를 설명하기 위해서 잠시 EmployeeListComponent가 함수라고 가정해봅시다.
또한 함수의 인자들이 EmployeeListComponent의 input이라고 가정하고, 함수의 반환값이 DOM에 렌더링 된다고 생각합시다.
function runChangeDetection() {
console.log('Detecting for changes');
}
function EmployeeListComponent(args) {
const shouldRun = Object.keys(args).reduce((a, i) => {
return a || args[i] !== EmployeeListComponent[i];
}, false);
Object.keys(args).forEach(i => {
EmployeeListComponent[i] = args[i];
});
if (shouldRun) {
runChangeDetection();
}
}
const f = EmployeeListComponent;
const data = [e1];
EmployeeListComponent를 함수로서 모델을 해보았습니다.
3가지 주의깊게 봐야하는 점이 있습니다.
const shouldRun = Object.keys(args).reduce((a, i) => {
return a || args[i] !== EmployeeListComponent[i];
}, false);
shuldRun의 경우 EmployeeListComponent와 전달 받은 args의 값을 비교하는데, i라는 같은 키를 두고 값이 다르면 true를 반환하면서, shouldRun 즉, 렌더를 하겠다로 둔다는 뜻입니다.
이 check는 OnPush CD 전략에서 변경 검출을 호출할까 말까하는 Angular에서 알고리즘 적으로 비슷합니다.
그 후 아래와 같은 코드가 있습니다.
Object.keys(args).forEach(i => {
EmployeeListComponent[i] = args[i];
});
EmployeeListComponent의 값에 넣어줍니다. 그 후 check합니다.
if (shouldRun) {
runChangeDetection();
}
여기서 CD가 일어납니다.
그리고, f라는 변수에 EmployeeListComponent를 집어넣었고, data는 e1이라는 employee의 single item을 가지고 있는 변수입니다.
만약, 저희가 f에 data를 넣고 실행을 하면 아래와 처럼됩니다. (Angular에서 input으로 넘겼다고 보시면 됩니다)
// 아래 코드는 runChangeDetection을 실행시킵니다.
f({ data: data });
CD는 trigger가 됩니다. 어찌됐건 초기값이 들어간거고, 초기값은 원래 undefined였으니까요.
Angular 안에서 input으로 넘겼다면, 당연히 잘 됐겠죠 ?
하지만 저희는 아래의 값을 넘겨보겠습니다.
data.push(e2);
f({ data: data });
저희가 data를 args로서 또 넘겼지만 CD는 실행되지 않습니다. 왜냐하면 참조값이 같기 때문이죠. 그렇기 때문에 아래처럼 넘겨야합니다.
f({ data: data.slice() });
새로운 data의 참조값이 넘어가면서 CD가 실행이됩니다.
Angular에서 어떻게 CD가 일어나는지 정확하게 설명해주는 부분입니다.
그 말은 즉슨, 이를 Angular Component로 수정할 수 있다는 뜻입니다. 기존에 작성한 EmployeeListComponent 처럼요.
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class EmployeeListComponent {
@Input() data: EmployeeData[];
@Input() depratment: string;
handleKey(event: any) {
if (event.keyCode === 13) {
this.add.emit(this.label);
this.label = '';
}
}
...
}
그리고 AppComponent는 아래와 같습니다.
@Component({
template: `
<sd-employee-list [data]="salesList" department="Sales" (add)="addToSales($event)">
`
})
export class AppComponent implements OnInit {
salesList: EmployeeData[];
addToSales(name: string) {
this.salesList = this.salesList
.unshift({ label: name, num: this.generator.generateNumber(NumRange) })
.slice();
}
...
}
이 방법은 유저가 Enter(keyCode === 13)를 누를 때마다, label이라는 값을 add output을 통해 emit 됩니다.
AppComponent는 이를 캐치해서 addToSales 메소드를 호출하게 되고 이는 salesList에 employee를 추가합니다.
이는 list를 완전히 복사하고, 새로운 참조값을 집어넣습니다.
이는 정확하게 동작하지만, 두 가지 이슈가 존재합니다.
- 느립니다. 우리가 employee를 넣을 때마다, 매번 전체 복사를 해야합니다. 또한, garbage collector가 사용하지 않는 메모리를 잡아서 삭제도 해야합니다.
- 많은 메모리가 필요합니다. 메모리 새 목록에 할당한 메모리는 목록의 크기만큼 필요합니다.
이 두 가지 이슈를 효율적으로 처리하기 위해서 immutable.js와 같은 immutable data 구조를 사용합니다.
요약
1. CD를 일으키기 위해서는 새로운 참조값을 넣어줘야한다.
2. 모든 배열 혹은 객체 등 데이터를 그대로 복사해서 넘기기엔 이슈가 존재한다.
3. 이를 효율적으로 처리할 immutable.js 혹은 immutable data 구조를 사용해야한다.
immutable.js는 immutable data 구조를 제공합니다.
두 개의 특징을 가지고 있는데요.
- 명백하게 immutable입니다. 그래서 만약 이러한 인스턴스의 데이터 구조를 변경하려고 할 때, 새로운 인스턴스를 받을 수 있습니다. (새로운 reference를 받는다는 뜻입니다)
- 새로운 데이터 구조를 제공하기 위해서 데이터 구조를 완전히 복사하지 않습니다. 대신에 복사하려는 데이터에서 최대한 재사용을 합니다.
아래 예시를 봅시다.
import { List } from 'immutable';
const data = List([a, b, c]);
console.log(data.toJS()); // [a, b, c]
const appended = data.push(d);
console.log(data.toJS()); // [a, b, c]
console.log(appended.toJS()); // [a, b, c, d]
위에 예시에서 볼 수 있듯이, List의 push 메소드를 사용했음에도, 우리는 연산이 적용된 새로운 list를 받았습니다. 즉, 기존 값이 변하지 않았습니다.
이제 우리는 immutable.js의 immutable data 구조를 이용해서, 저 위의 예시 (EmployeeListComponent의 예시)를 좀 더 효율적으로 만들 수 있을거 같습니다.
요약
1. Immutable.js를 이용해서 immutable data(이하 불변 데이터) 구조를 사용할 수 있다.
2. Immutable.js의 불변 데이터 구조를 사용하면, 기존의 값은 변하지 않는다.
3. Immutable.js의 불변 데이터 구조를 복사할 때, 최대한 기존의 데이터를 이용해서 복사하기에 효율적이다.
Immutable.js와 OnPush를 사용했을 때, 어떤 퍼포먼스가 초래하는지를 알아보기 위해서, sales department의 text input에 많은 문자들을 집어넣는 e2e 테스트를 준비했습니다.
구글 크롬에서 제공하는 benchpress를 사용했고, 시간을 측정했습니다.
최적화된 버전과 그렇지 않은 버전의 애플리케이션에다가 해당 e2e 테스트를 진행하였고, 아래와 같은 결과가 발생했습니다.
자 그러면 이제 서비스를 배포해도 될까요 !?
어,,, 아직도 느립니다. 위에서 볼 수 있듯이 calculate 메소드가 여전히 자주 호출되고 있습니다.
그렇다면 OnPush가 효과가 있었다는건 어떻게 알 수 있을까요 ?
보시면 Sales Component에서만 연산이 진행되는걸 알 수 있죠 ? 원래라면 R&D가 같이 연산이 진행됐거든요.
즉, 저희가 적용한 OnPush는 제대로 동작을 했다는 뜻입니다.
타이핑을 하는 동안 사실 salesList의 새로운 인스턴스를 받고 있지 않습니다. 근데, salesList의 CD가 동작하는 이유가 뭘까요 ?
답은 OnPush가 동작하는 방식에 있습니다. OnPush CD 전략을 사용하게 되면, 해당 Component는 Component 안에 모든 input 값이 새로운 값이 들어오거나 Component의 안에서 event가 일어나면 CD를 동작시킵니다.
요약
1. OnPush와 ImmutableJS를 쓰면 성능을 향상 시킬 수 있다.
2. OnPush 일어나는 환경은 다음과 같다.
- 템플릿 내부에서 사용되는 @Input()으로 새로운 값이 전달될 때
- 템플릿 내부의 네이티브 DOM 이벤트가 발생할 때
- 컴포넌트나 디렉티브에서 ChangeDetectorRef.markForCheck()를 호출할 때
추가적으로 성능 향상을 위해 리팩토링을 진행해봅시다.
지금하는 리팩토링은 불필요한 CD 실행을 줄여주고, 애플리케이션에서 관리 차원에서 더 효율적이게 구분을 지을 수 있습니다.
이 목적을 위해서 EmployeeListComponent를 나눠봅시다.
- NameInputComponent : 새로운 employee 이름을 적을 수 있는 칸을 두고, 원한다면 추가를 할 수 있습니다.
- ListComponent : 각각의 employees들의 리스트를 보여주고 숫자 값의 연산을 합니다.
이제 결과를 봅시다.
홀리몰리
엄청나게 빨라졌습니다.
이로서 유저는 훌륭한 경험을 할 수 있습니다.
요약
1. 구조적 리팩토링을 통해서 충분히 효율을 올릴 수 있다.
여기까지 immutable data 구조와 custom CD 전략을 통해 런타임 퍼포먼스를 향상 시킬 수 있는 Angular 애플리케이션을 살펴봤습니다.
두 개의 부서에 있는 Employee 리스트와 상당히 복잡한 연산 과정을 가진 샘플 비즈니스 애플리케이션에서 양방향 바인드에 의해 애플리케이션의 타이핑이 느려지는 것을 확인했고, 이를 효율적으로 만들기 위해서 OnPush 전략과 immutable data 구조를 이용해보았습니다.
OnPush 전략을 사용했을 때, 성능을 많이 향상 시킬 수 있었지만, Angular가 event를 감지할 때 마다, 다시 CD를 발생시키는 것을 알게되었고, 이를 수정하기 위해서 EmployeeListComponent를 분리를 했었습니다.
이로서 저희는 성능 좋은 애플리케이션을 사용할 수 있게되었습니다.
이상 Angular 더 빠르게 - On Push CD / Immutability였습니다. ^_^
'Web + APP > Angular' 카테고리의 다른 글
attr vs attr.name (0) | 2022.03.30 |
---|---|
Angular 더 빠르게 - On Push 두 번째 이야기 (0) | 2022.03.27 |
constructor과 ngOnInit의 차이는 ? (0) | 2022.03.15 |
NGRX - Selectors (0) | 2021.11.26 |
Normalizing State Shape (0) | 2021.11.08 |