Angular의 변경 감지 최적화

더 나은 사용자 환경을 위해 더 빠른 변경 감지를 구현합니다.

Angular는 데이터 모델의 변경사항이 앱의 뷰에 반영되도록 주기적으로 변경 감지 메커니즘을 실행합니다. 변경 감지는 수동으로 또는 비동기 이벤트(예: 사용자 상호작용 또는 XHR 완료)를 통해 트리거될 수 있습니다.

변경 감지는 강력한 도구이지만, 매우 자주 실행하면 많은 계산을 실행하고 기본 브라우저 스레드를 차단할 수 있습니다.

이 게시물에서는 애플리케이션의 일부를 건너뛰고 필요한 경우에만 변경 감지를 실행하여 변경 감지 메커니즘을 제어하고 최적화하는 방법을 알아봅니다.

Angular의 변경 감지 내부

Angular의 변경 감지 작동 방식을 이해하기 위해 샘플 앱을 살펴보겠습니다.

앱의 코드는 이 GitHub 저장소에서 확인할 수 있습니다.

이 앱에는 회사의 두 부서(영업 및 R&D)에 속한 직원이 나열되며, 두 가지 구성요소가 있습니다.

  • AppComponent(앱의 루트 구성요소)
  • EmployeeListComponent의 인스턴스 2개(영업용 및 R&D용)

샘플 애플리케이션

AppComponent 템플릿에서 EmployeeListComponent의 두 인스턴스를 확인할 수 있습니다.

<app-employee-list
  [data]="salesList"
  department="Sales"
  (add)="add(salesList, $event)"
  (remove)="remove(salesList, $event)"
></app-employee-list>

<app-employee-list
  [data]="rndList"
  department="R&D"
  (add)="add(rndList, $event)"
  (remove)="remove(rndList, $event)"
></app-employee-list>

직원마다 이름과 숫자 값이 있습니다. 앱은 직원의 숫자 값을 비즈니스 계산에 전달하고 결과를 화면에 시각화합니다.

이제 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는 직원 목록과 부서 이름을 입력으로 받습니다. 사용자가 직원을 삭제하거나 추가하려고 하면 구성요소가 해당 출력을 트리거합니다. 또한 이 구성요소는 비즈니스 계산을 구현하는 calculate 메서드를 정의합니다.

다음은 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>
    <md-chip title="Score" class="mat-chip mat-primary mat-chip-selected" color="primary" selected="true">
      {{ calculate(item.num) }}
    </md-chip>
  </mat-list-item>
</mat-list>

이 코드는 목록의 모든 직원을 반복하고 각 직원에 대해 목록 항목을 렌더링합니다. 또한 입력과 EmployeeListComponent에 선언된 label 속성 간의 양방향 데이터 결합을 위한 ngModel 디렉티브도 포함되어 있습니다.

앱은 EmployeeListComponent의 두 인스턴스를 사용하여 다음과 같은 구성요소 트리를 구성합니다.

구성요소 트리

AppComponent은 애플리케이션의 루트 구성요소입니다. 하위 구성요소는 EmployeeListComponent의 두 인스턴스입니다. 각 인스턴스에는 부서의 개별 직원을 나타내는 항목 (E1, E2 등) 목록이 있습니다.

사용자가 EmployeeListComponent의 입력란에 신입 직원의 이름을 입력하기 시작하면 Angular는 AppComponent부터 시작하여 전체 구성요소 트리의 변경 감지를 트리거합니다. 즉, 사용자가 텍스트를 입력하는 동안 Angular가 각 직원과 관련된 숫자 값을 반복적으로 다시 계산하여 마지막 확인 이후 변경되지 않았는지 확인합니다.

속도가 얼마나 느릴 수 있는지 확인하려면 StackBlitz에서 최적화되지 않은 버전의 프로젝트를 열고 직원 이름을 입력해 봅니다.

예시 프로젝트를 설정하고 Chrome DevTools의 성능 탭을 열어 fibonacci 함수로 인해 속도가 느려지는지 확인할 수 있습니다.

  1. `Control+Shift+J` (또는 Mac의 경우 `Command+Option+J`)를 눌러 DevTools를 엽니다.
  2. 실적 탭을 클릭합니다.

이제 성능 패널의 왼쪽 상단에 있는 기록 을 클릭하고 앱의 텍스트 상자 중 하나에 입력을 시작합니다. 몇 초 후 기록 을 다시 클릭하여 녹화를 중지합니다. Chrome DevTools가 수집한 모든 프로파일링 데이터를 처리하면 다음과 같은 내용이 표시됩니다.

성능 프로파일링

목록에 직원이 많은 경우 이 프로세스로 인해 브라우저의 UI 스레드가 차단되고 프레임 드롭이 발생하여 사용자 환경이 저하될 수 있습니다.

구성요소 하위 트리 건너뛰기

사용자가 영업 EmployeeListComponent의 텍스트 입력을 입력하면 R&D 부서의 데이터가 변경되지 않는다는 것을 알 수 있으므로 구성요소에서 변경 감지를 실행할 이유가 없습니다. R&D 인스턴스가 변경 감지를 트리거하지 않도록 하려면 EmployeeListComponentchangeDetectionStrategyOnPush로 설정합니다.

import { ChangeDetectionStrategy, ... } from '@angular/core';

@Component({
  selector: 'app-employee-list',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['employee-list.component.css']
})
export class EmployeeListComponent {...}

이제 사용자가 텍스트를 입력하면 변경 감지가 해당 부서에 대해서만 트리거됩니다.

구성요소 하위 트리에서 감지 변경

원래 애플리케이션에 적용된 이 최적화는 여기에서 확인할 수 있습니다.

OnPush 변경 감지 전략에 관한 자세한 내용은 공식 Angular 문서를 참고하세요.

이 최적화의 효과를 확인하려면 StackBlitz의 애플리케이션에 신입 사원을 입력합니다.

순수 파이프 사용

이제 EmployeeListComponent의 변경 감지 전략이 OnPush로 설정되었지만 사용자가 상응하는 텍스트 입력을 입력하면 Angular는 여전히 부서의 모든 직원의 숫자 값을 다시 계산합니다.

이 동작을 개선하려면 순수 파이프를 활용하세요. 순수 파이프와 불순 파이프 모두 입력을 허용하고 템플릿에서 사용할 수 있는 결과를 반환합니다. 둘의 차이점은 순수 파이프는 이전 호출에서 다른 입력을 받는 경우에만 결과를 다시 계산한다는 것입니다.

앱은 EmployeeListComponent에 정의된 calculate 메서드를 호출하여 직원의 숫자 값을 기반으로 표시할 값을 계산합니다. 계산을 순수 파이프로 이동하면 Angular는 인수가 변경될 때만 파이프 식을 다시 계산합니다. 프레임워크는 참조 검사를 실행하여 파이프의 인수가 변경되었는지 확인합니다. 즉, 직원의 숫자 값이 업데이트되지 않으면 Angular는 재계산을 수행하지 않습니다.

다음은 비즈니스 계산을 CalculatePipe라는 파이프로 이동하는 방법입니다.

import { Pipe, PipeTransform } from '@angular/core';

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Pipe({
  name: 'calculate'
})
export class CalculatePipe implements PipeTransform {
  transform(val: number) {
    return fibonacci(val);
  }
}

파이프의 transform 메서드는 fibonacci 함수를 호출합니다. 파이프가 순수한 것을 볼 수 있습니다. Angular는 달리 지정하지 않는 한 모든 파이프를 순수한 것으로 간주합니다.

마지막으로 EmployeeListComponent 템플릿 내부의 표현식을 업데이트합니다.

<mat-chip-list>
  <md-chip>
    {{ item.num | calculate }}
  </md-chip>
</mat-chip-list>

작업이 끝났습니다. 이제 사용자가 부서와 관련된 텍스트를 입력할 때 앱이 개별 직원의 숫자 값을 다시 계산하지 않습니다.

아래 앱에서 입력 과정이 얼마나 매끄러워졌는지 확인할 수 있습니다.

마지막 최적화의 효과를 확인하려면 StackBlitz에서 이 예를 사용해 보세요.

원래 애플리케이션의 순수 파이프 최적화 코드는 여기에서 확인할 수 있습니다.

결론

Angular 앱에서 런타임 속도가 느려지는 경우 다음 단계를 따르세요.

  1. Chrome DevTools로 애플리케이션을 프로파일링하여 속도 저하의 원인을 확인하세요.
  2. OnPush 변경 감지 전략을 도입하여 구성요소의 하위 트리를 프루닝합니다.
  3. 프레임워크가 계산된 값을 캐싱할 수 있도록 과도한 계산을 순수한 파이프로 이동합니다.