Angular에서 리스트를 렌더링할 때, 배열의 데이터가 변경되면 Angular는 DOM 전체를 다시 렌더링할 수 있습니다. 이는 성능 저하의 원인이 될 수 있습니다. 이 문제를 해결하기 위해 Angular는 각 아이템을 고유하게 식별하여 변경된 부분만 업데이트하는 메커니즘을 제공합니다.
Angular v17부터 도입된 새로운 Control Flow 문법인 @for는 이 문제를 매우 간단하게 해결합니다. track 키워드를 사용하여 각 아이템을 추적할 고유한 프로퍼티를 직접 지정할 수 있습니다.
<ul>
@for (item of data(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
track item.id 구문은 trackBy 함수의 필요성을 완전히 없애줍니다. 새로운 프로젝트나 최신 Angular 버전을 사용한다면, 항상 @for와 track을 사용하는 것이 가장 좋은 방법입니다.
ngFor에 연결된 배열의 값이 변경될 때 refresh가 발생하여 rendering이 일어나는데, 이를 방지하기 위해 trackBy를 사용합니다.
즉, trackBy를 사용하면 값이 변경되어도 rendering이 되지 않으므로 성능을 향상 시킬 수 있습니다.
하지만 레거시 프로젝트나 특정 라이브러리 호환성 문제로 여전히 ngFor를 사용해야 하는 경우가 있습니다. ngFor에서 성능을 최적화하려면 trackBy 프로퍼티와 함께 추적 함수(trackByFn)를 사용해야 합니다.
{% raw %}
<!-- app.component.html -->
<div *ngFor="let item of data; trackBy: trackByFn"></div>
{% endraw %}
// app.component.ts
...
trackByFn = (item): number => item.id
trackBy의 단점은 위의 코드에서 보듯 ngFor문이 사용될 때마다 component에 매번 trackByFn 함수를 만들어주어야 한다는 것입니다.
이 불편함 때문에 개발자들이 종종 trackBy를 고의 또는 실수로 누락하는 경우가 있으며 이는 제품의 성능에 영향을 미칩니다.
목표인 trackBy Directive를 만들기 위해 먼저 trackBy가 어떻게 동작하는지 원리를 알아봅시다.
trackBy를 좀 더 자세히 파악하기 위해 ng-template 으로 변경해보겠습니다.
아래의 두 코드는 동일한 결과를 갖습니다.
{% raw %}
<div *ngFor="let item of data; trackBy: trackByFn"></div>
<ng-template ngFor let-item [ngForOf]="data" [ngForTrackBy]="trackByFn"></ng-template>
{% endraw %}
ng-template에서 보면 알 수 있듯이 trackBy의 원래 이름은 ngForTrackBy이며 ngFor directive의 전달인자로 볼 수 있습니다.
어떻게 ngForTrackBy를 directive화 할 수 있을까요? 일반적인 방법으로 생각해보면 다음과 같을 것 입니다.
{% raw %}
<div *ngFor="let item of data" [trackByFn]="'id'"></div>
{% endraw %}
이를 ng-template에서 풀어보면 다음과 같이 됩니다.
{% raw %}
<ng-template ngFor let-item [ngForOf]="data">
<div [ngForTrackByFn]="'id'"></div>
</ng-template>
{% endraw %}
즉, 일반적인 방법으로 directive로 만들경우 ngFor directive의 전달인자가 아닌 별도의 directive로 인식되므로 ngFor에 관여할 수 없게 되므로 우리가 원하는 기능을 구현할 수 없습니다.
그러면 어떻게 ngFor에 접근할 수 있을까요?
이를 구현하기 위해 @Host 함수를 소개합니다.
@Host는 Angular의 내장된 기능으로 @Self와 유사하게 연관된 DI를 가져올 수 있습니다.
차이가 있다면, @Self는 component와 연관된 DI를 가져오는 반면 component의 template과 viewProviders에 연관된 DI 를 가져옵니다.
즉, 우리는 @Host를 사용하여 ngFor를 가져오고 ngFor에 속한 ngForTrackBy 함수에 우리가 원하는 필드 값을 주입해야 합니다.
이 때, @Host에 NgForOf를 지정하여 ngFor에 접근할 수 있도록 할 예정이며, 코드는 아래와 같습니다.
// ng-for-track-by-field.directive.ts
import { NgForOf } from '@angular/common';
import { Directive, Input, inject } from '@angular/core';
@Directive({
// ngFor와 함께 사용될 때만 활성화되도록 셀렉터 구성
selector: '[ngFor][ngForTrackByField]',
standalone: true,
})
export class NgForTrackByFieldDirective<T extends Record<string, any>> {
@Input('ngForTrackByField')
public field!: keyof T;
// host 요소에 있는 NgForOf 인스턴스를 주입받습니다.
private ngFor = inject(NgForOf<T>, { host: true });
constructor() {
// NgForOf 디렉티브의 trackBy 함수를 동적으로 설정합니다.
this.ngFor.ngForTrackBy = (index: number, item: T) => {
// field가 지정되었다면 해당 프로퍼티 값을, 아니면 아이템 자체를 반환합니다.
return this.field ? item[this.field] : item;
};
}
}
위의 코드에서 보면 ngForTrackByField는 ngFor를 호스트로 두는 directive이며, 호출되었을 때 ngFor의 ngForTrackBy함수에 원하는 값을 주입하고 실행시키는 역할을 합니다.
이제 template에서는 trackByField를 통해 간단히 trackBy를 구현할 수 있습니다.
이제 템플릿에서 trackByFn 함수 없이 간단하게 trackBy를 구현할 수 있습니다.
// app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; // *ngFor를 위함
import { NgForTrackByFieldDirective } from './ng-for-track-by-field.directive';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, NgForTrackByFieldDirective], // 디렉티브 import
template: `
<h3>*ngFor with Custom trackBy Directive</h3>
<div *ngFor="let item of data(); ngForTrackByField: 'id'">
{{ item.name }}
</div>
`,
})
export class AppComponent {
data = signal([ { id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' } ]);
// 더 이상 trackByFn 함수가 필요 없습니다!
}