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. 프레임워크가 계산된 값의 캐싱을 실행할 수 있도록 리소스 집약적인 계산을 순수 파이프로 이동합니다.