Angular의 변경 감지 최적화

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

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

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

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

Angular의 변경 감지 내부

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

앱 코드는 이 GitHub 저장소에서 찾을 수 있습니다.

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

  • AppComponent: 앱의 루트 구성요소
  • EmployeeListComponent의 두 인스턴스(판매용 1개, R&D용 1개)

샘플 애플리케이션

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. 실적 탭을 클릭합니다.

이제 Performance 패널의 왼쪽 상단에 있는 Record 를 클릭하고 앱의 텍스트 상자 중 하나에 입력합니다. 몇 초 후에 Record 를 다시 클릭하여 녹화를 중지합니다. 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. 프레임워크가 계산된 값의 캐싱을 수행할 수 있도록 과도한 계산을 순수 파이프로 이동합니다.