본문 바로가기

Angular

The Last Guide For Angular Change Detection You'll Ever Need (한글)

이 글은 아래 원문을 번역한 글로 의역이 있을 수 있습니다. 정확한 의미를 파악하고 싶으신 분은 원문을 참고해주시기 바랍니다.

 

원문: https://www.mokkapps.de/blog/the-last-guide-for-angular-change-detection-you-will-ever-need/

저작권 정보: 원문 작성자인 Michael Hoffmann 에게 번역 허락을 받았습니다.

 

※ Change Detection 은 '변화 감지' 로 번역했습니다. 참고 부탁드립니다.

 


Angular의 변화 감지(Change Detection) 은 프레임워크의 핵심 메커니즘 (적어도 제 경험에 따르면요)이지만 매우 이해하기 힘듭니다. 불행히도, 이 주제에 관해서는 공식 웹사이트에선 공식적인 가이드는 없습니다.

이 블로그 포스트에서 저는 당신에게 변화감지에 관해 알아야 할 필요가 있는 모든 정보를 제공할 겁니다. 저는 이 블로그 포스트를 만드는 데 사용한 데모 프로젝트를 통해서 메커니즘을 설명할 것입니다.

 

콘텐츠 목차

  • 변화 감지란 무엇인가 (What Is Change Detection)
  • 변화 감지는 어떻게 작동하는가 (How Change Detection Works)
  • 성능 (Performance)

변화 감지란 무엇인가 (What is Change Detection)

Angular의 두 가지 주요 목표는 예측 가능함(predictable)과 뛰어난 성능입니다. 프레임워크는 상태(state)와 템플릿을 결합함으로써 UI에 애플리케이션의 상태를 복제해야 할 필요가 있습니다.

Angular Data-Template-DOM

state에 어떤 변화던 발생만 하면, 뷰를 업데이트해야 하는 것 또한 필수적입니다. 우리의 데이터를 HTML과 동기화하는 (싱크를 맞추는) 이 메커니즘을 "변화 감지(Change Detection)"라고 합니다. 각 프론트엔드 프레임워크는 각자의 구현 방식을 사용합니다. 예를 들어 React는 가상 DOM을(Virtual DOM), Angualr는 변화 감지를 사용합니다. 저는 이 주제에 관해 전반적으로 좋은 개요를 주는 자바스크립트 프레임워크 내의 변화와 그것의 감지 기사를 추천합니다.

 

변화 감지: 데이터 변화가 발생했을 때, 뷰(DOM) 을 업데이트하는 과정

 

개발자들은 어플리케이션의 성능을 최적화할 필요가 있기 전까지는 많은 시간을 변화 감지에 대해 신경을 쓸 필요는 없습니다. 변화 감지를 올바르게 다루지 않으면 큰 애플리케이션에서는 오히려 성능을 저하시킬 수 있습니다.

 

변화 감지는 어떻게 작동하는가(How Change Detection Works)

변화 감지 사이클은 두개의 부분으로 나눠질 수 있습니다.

  • 개발자가 어플리케이션 모델을 업데이트하는 부분
  • Angular 가 업데이트 된 모델을 뷰에 동기화하고 뷰를 다시 렌더링 하는 부분

이 과정을 좀더 자세하게 살펴봅시다.

 

  1. 개발자가 데이터 모델을 업데이트합니다. 예) 컴포넌트 바인딩을 업데이트 함으로써
  2. 앵귤러는 변화를 감지합니다.
  3. 변화 감지는 컴포넌트 트리의 모든 컴포넌트들 위에서 아래로 검사하면서, 해당 모델이 변경되었는지 확인합니다.
  4. 만약 새로운 값이 있다면, 그 컴포넌트의 뷰(DOM)를 업데이트합니다.

 

위 그림은 앵귤러 컴포넌트 트리와 애플리케이션 부트스트랩 프로세스 중에 생성된 각 컴포넌트들에 대한 변화 감지를 보여줍니다. 이 감지기는 프로퍼티의 현재 값과 이전 값을 비교합니다. 만약 값이 변경되었으면, 그것은 isChanged 값을 true로 설정합니다. 프레임워크 코드 내에 있는 구현을 보면, 단지 NaN에 대한 처리와 함께 ===연산자로만 비교하고 있음을 확인할 수 있습니다.

변화 감지는 깊은(deep) 오브젝트 검사를 수행하지 않습니다. 그것은 단지 템플릿에서 사용되는 프로퍼티들의 현재 값과 이전 값을 비교할 뿐입니다.

Zone.js

보통, zone 은 비동기적인 일들을 가로채고(intercept) 추적할 수 있습니다.

존은 보통 다음의 단계들을 가집니다.

  • 안정된 상태로 시작한다.
  • 존안에서 테스트가 실행되면 불안정해진다.
  • 테스크가 끝나면 다시 안정된다.

앵귤러는 시작 시에 애플리케이션의 변화들을 감지할 수 있도록 여러 저수준(low-level) 브라우저 API 들을 패치합니다.

이 작업은 zone.js를 사용해 수행됩니다. zone.js 는 EventEmitter, DOM 이벤트 리스너, XMLHttpRequest, Node.js 내 fs API 등과 같은 API 들을 패치합니다.

요약하면, 프레임워크는 다음과 같은 이벤트들 중 하나라도 발생하면 변화 감지를 작동시킵니다.

  • 브라우저 이벤트 (click, keyup, etc.)
  • setInerval()setTimeout()
  • XMLHttpReques 를 통한 HTTP 요청

앵귤러는 자신만의 NgZone 이라는 존을 사용합니다.

오직 하나의 NgZone 이 존재하고, 변화 감지는 이 존 안에서 트리거 된 비동기 연산에만 작동됩니다.

성능 (Performance)

기본적으로, 앵귤러 변화 감지는 템플릿 값이 변경되면 위에서부터 아래로 모든 컴포넌트들을 확인합니다.

앵귤러는 VM-최적화된 코드를 생성해내는 inline-caching을 사용해서 밀리초 동안 수천 번의 검사를 할 수 있기 때문에, 모든 하나하나의 컴포넌트에 대한 변화 감지를 빠르게 수행할 수 있습니다.

이 주제에 대해 더 자세한 설명을 원한다면, Victor Savkin'sChange Detection Reinvented 에 대해 말한 것을 보는 걸 추천합니다.

앵귤러가 씬 뒤에서 많은 최적화를 하지만, 대규모 애플리케이션에서는 성능이 떨어질 수 있습니다. 다음 챕터에서는, 다른 변화 감지 전략을 사용해서, 어떻게 더 적극적으로 앵귤러 성능을 개선시킬 수 있는지 알아볼 것입니다.

변화 감지 전략 (Change Detection Strategies)

앵귤러는 변화 감지를 수행하기 위해 두 가지의 전략을 제공합니다.

  • Default
  • OnPush

각각의 변화 감지 전략을 살펴봅시다.

Default 변화 감지 전략 (Default Change Detection Strategy)

기본적으로, 앵귤러는 ChangeDetectionStrategy.Default 변화 감지 전략을 사용합니다. 이 기본 전략은 이벤트가 변경 감지(예: 사용자 이벤트, 타이머, XHR, promise 기타 등등)를 트리거할 때마다 트리의 위에서부터 아래로 모든 컴포넌트들을 검사합니다.

컴포넌트의 종속성이 있다는 어떠한 가정도 하지 않는 이 보수적인 방식을 더티(dirty) 검사라고 합니다. 이것은 많은 컴포넌트들로 이루어진 대규모 애플리케이션에서는 성능에 안 좋은 영향을 줄 수 있습니다.

 

OnPush 변화 감지 전략(OnPush Change Detection Strategy)

우리는 컴포넌트 데코레이터 메타데이터에 changeDetection 속성을 추가함으로써 변화 감지 전략을ChangeDetectionStrategy.OnPush 로 바꿀 수 있습니다.

 

@Component({
    selector: 'hero-card',
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: ...
})
export class HeroCard {
    ...
}

이 변화 감지 전략은 이 컴포넌트와 그 자식 컴포넌트들에 대한 불필요한 검사를 생략할 수 있는 가능성을 제공해 줍니다.

다음의 GIF는 onPush 변화 감지 전략을 사용함으로써 컴포넌트 트리의 부분을 생략하고 넘어가는 것을 증명하고 있습니다.

 

 

이 전략을 사용하면, 앵귤러는 다음과 같은 상황에서만 컴포넌트 업데이트가 필요하다는 것을 알게 됩니다.

  • input 레퍼런스가 변경됐을 때
  • 컴포넌트 또는 그것의 자식의 이벤트 핸들러가 트리거 됐을 때
  • 변화 감지가 수동으로 트리거 됐을 때
  • async pipe를 통해 템플릿에 연결된 observable 이 새로운 값을 방출(emit) 할 때

위와 같은 타입의 이벤트를 좀 더 면밀히 살펴봅시다.

Input 레퍼런스 변경 (Input Reference Changes)

기본 변화 감지 전략에서는, 앵귤러는 @Input() 데이터가 바뀌거나 수정되면 변화 감지가 실행될 겁니다. onPush 를 사용하면, 변화 감지는 @Input() 값에 새로운 레퍼런스가 전달될 때만 변화감지가 트리거 됩니다.

 

numbers, string, booleans, null 그리고 undefined과 같은 원시(primitive) 타입들은 값에 의해 전달됩니다. Object와 array 또한 값에 의해 전달되지만, object 프로퍼티나 array 엔트리를 변경한다고 새로운 레퍼런스를 만들지는 않습니다. 따라서 onPush 컴포넌트에도 변화 감지가 트리거 되지 않습니다. 변화 감지를 트리거하기 위해서는 새로운 object 나 array 레퍼런스를 넘겨줘야 합니다.

 

간단한 데모를 통해 이 동작을 테스트해볼 수 있습니다.

  1. ChangeDetectionStrategy.Default 가 적용된 HeroCardComponent 의 나이(age)를 변경해보세요.
  2. ChangeDetectionStrategy.OnPush 가 적용된 HeroCardOnPushComponent 는 바뀐 나이(age)가 반영되지 않는 것을 확인해보세요. (컴포넌트 주변이 빨간색 테두리로 표시됩니다.)
  3. "Modify Heroes" 패널에 있는 "Create new object reference"를 클릭해보세요.
  4. ChangeDetectionStrategy.OnPush 가 적용된 HeroCardOnPushComponent는 변화 감지에 의해서 검사됨을 확인해보세요.

 

변화감지 버그를 방지하기 위해서는 오직 불변성(immutable)한 객체나 리스트만을 사용해서 어디서나 OnPush 변화 감지를 사용하여 애플리케이션을 만드는 것이 유용합니다. 불변성(immutable)한 객체는 새로운 객체 레퍼런스를 생성해야만 변경될 수 있어서 우린 다음의 것들을 보장할 수 있습니다.

  • OnPush 변화 감지는 각각의 변화에 대해 변화 감지가 트리거 됩니다.
  • 우리는 버그를 불러일으킬 수 있는 새로운 객체 레퍼런스 생성을 잊지 않을 수 있습니다.

Immutable.js 은 좋은 선택이며, 해당 라이브러리는 객체(Map) 및 목록(List) 에 대해 영구 불변 데이터 구조를 제공합니다. npm 을 통해서 라이브러리를 설치하면 IDE 내에서 타입 제너릭, 에러 감지, 그리고 자동 완성을 할 수 있도록 타입 정의가 제공됩니다.

 

이벤트 핸들러가 트리거 될 경우 (Event Handler Is Triggered)

변화 감지는(컴포넌트 트리 내에 있는 모든 컴포넌트들에 대해) OnPush 컴포넌트 또는 그것의 자식 컴포넌트들 중 하나가 버튼 클릭 같은 이벤트 핸들러를 트리거 시켰을 때 트리거 됩니다.

 

조심하세요, 다음의 행동들은 OnPush 변화 감지 전략을 사용하면 변화 감지가 트리거 되지 않습니다.

  • setTimeout
  • setInterval
  • Promise.resolve().then() , (물론, Promise.reject().then() 도 동일합니다.)
  • this.http.get('...').subscribe() (보통, 어떤 RxJs 옵저버블 구독이던지)

당신은 이 simple demo 를 통해 동작을 테스트해볼 수 있습니다.

  1. ChangeDetectionStrategy.OnPush 를 사용하는 HeroCardOnPushComponent 안에 있는 "Change Age" 버튼을 클릭하세요.
  2. 변화 감지가 트리거 됐는지 확인하고 모든 컴포넌트들을 확인 해 보세요.

수동으로 변화감지 발생시키기(Trigger Change Detection Manually)

변화 감지를 수동으로 트리거할 수 있는 세 가지의 메서드가 존재합니다.

  • ChangeDetectorRef 에 있는 detectChanges()는 변화 감지 전략을 염두에 두고 해당 뷰와 그것의 자식 뷰에서 변화 감지를 실행합니다. detach() 와 함께 사용해서 로컬 변화 감지 검사를 할 수도 있습니다.
  • ApplicationRef.tick() 은 컴포넌트의 변화 감지 전략을 따르면서 전체 애플리케이션에 변화 감지를 트리거 시킵니다.
  • ChangeDetectorRef 에 있는 markForCheck() 는 변화 감지를 트리거 하진 않습니다. 하지만 모든 OnPush 조상들이 현재 또는 다음 변화감지 주기의 일부로 한 번 검사될 수 있도록 표시(mark) 합니다. 그것은 표시된 컴포넌트들이 OnPhsh 전략을 사용하고 있더라도 해당 컴포넌트들에 변화 감지를 실행합니다.
수동으로 변화감지를 실행하는 것은 핵이 아닙니다. 하지만 당신은 합리적인 경우에만 그것을 사용해야 합니다.

아래 그림은 다른 ChangeDetectorRef 메서드를 사용하는 것을 시각적인 표현으로 보여줍니다.

 

ChangeDetectorRef methods

simple demo 에서 "DC" (detectChanges()) 와 "MFC" (markForCheck()) 버튼을 이용해서 이 액션들을 테스트해볼 수 있습니다.

 

비동기 파이프 (Async Pipe)

빌트인 AsyncPipe 는 옵저버블을 구독하고 옵저버블이 방출한 가장 최신의 값을 반환합니다.

AsyncPipe 의 내부에서는 새로운 값을 방출할 때마다 markForCheck를 호출합니다. 그것의 소스코드를 확인해 보세요.

 

private _updateLatestValue(async: any, value: Object): void {
  if (async === this._obj) {
    this._latestValue = value;
    this._ref.markForCheck();
  }
}

 

보인 것처럼, AsyncPipe는 자동으로 OnPush 변화 감지 전략을 사용해 동작합니다. 그래서 기본 변화 감지 전략에서 OnPush 로 쉽게 전환할 수 있도록 많이 사용하는 것이 좋습니다.

async demo 에서 이 동작을 확인해 볼 수 있습니다.

 

첫 번째 컴포넌트는 템플릿에 AsyncPipe를 통해 직접적으로 옵저버블과 연결되어 있습니다.

 

<mat-card-title>{{ (hero$ | async).name }}</mat-card-title>
hero$: Observable<Hero>;

  ngOnInit(): void {
    this.hero$ = interval(1000).pipe(
        startWith(createHero()),
        map(() => createHero())
      );
  }

반면에 두 번째 컴포넌트는 옵저버블을 구독하고 바인딩한 데이터 값을 업데이트하고 있습니다.

hero: Hero = createHero();

  ngOnInit(): void {
    interval(1000)
      .pipe(map(() => createHero()))
        .subscribe(() => {
          this.hero = createHero();
          console.log(
            'HeroCardAsyncPipeComponent new hero without AsyncPipe: ',
            this.hero
          );
        });
  }

AsyncPipe 가 없는 구현은 변화 감지가 트리거 되지 않는 것을 볼 수 있습니다. 그래서 우리는 옵저버블로부터 방출된 각각의 새로운 이벤트들에 대해 수동으로 detectChanges() 를 호출해 줘야 합니다.

 

변화 감지 루프와 ExpressionChangedAfterCheckedError 를 피하자 (Avoiding Change Detection Loops and ExpressionChangedAfterCheckedError)

앵귤러는 변화감지 루프 메커니즘을 가지고 있습니다. 개발 모드에서 프레임워크는 값이 처음 실행한 이후에 변했는지 확인하기 위해 두 번의 변화 감지를 실행합니다. 프로덕션 모드에서는 더 나은 성능을 위해 오직 한번만 변화감지를 실행합니다.

ExpressionChangedAfterCheckedError demo 에서 제가 에러를 강제하고 있다는 것을 브라우저 콘솔을 열어 확인할 수 있을 겁니다.

 

Angular ExpressionChangedAfterCheckedError

이 데모에서는 저는 ngAfterViewInit 라이프 사이클 훅에서 hero 프로퍼티를 업데이트 함으로써 에러를 강제하고 있습니다.

 

ngAfterViewInit(): void {
    this.hero.name = 'Another name which triggers ExpressionChangedAfterItHasBeenCheckedError';
  }

이것이 왜 에러를 발생시키는지 이해하기 위해서는 변화 감지가 실행되는 동안의 여러 단계들을 살펴봐야 합니다.

 

Angular Lifecycle Hooks

보다시피, AfterViewInit 라이프사이클 훅은 현재 뷰의 DOM 업데이트가 렌더링 된 후에 호출됩니다. 만약에 해당 훅에서 값을 변경시킨다면 두 번째 변화 감지를 실행할 때 (위에서 말했던 것처럼, 개발자 모드에서는 자동으로 트리거 되는) 다른 값을 가지게 됩니다. 그에 따라 앵귤러는 ExpressionChangedAfterCheckedError 를 던지게 됩니다.

 

저는 Max Koretskyi 가 쓴 Everything you need to know about change detection in Angular 기사를 매우 강력하게 추천합니다. 거기서는 유명한 ExpressionChangedAfterCheckedError 의 구현과 사용 사례들을 자세히 살펴봅니다.

 

변화 감지 없이 코드 실행하기 (Run Code Without Change Detection)

변화 감지를 트리거하지 않기 위해 특정한 코드 블록을 NgZone 의 바깥 영역에서 실행시킬 수도 있습니다.

 

constructor(private ngZone: NgZone) {}

  runWithoutChangeDetection() {
    this.ngZone.runOutsideAngular(() => {
      // 아래의 setTimeout 은 변화감지를 트리거 하지 않습니다.
      setTimeout(() => doStuff(), 1000);
    });
  }

simple demo는 앵귤러 존의 밖에서 액션을 트리거하는 버튼을 제공합니다.

 

runOutsideAngular Demo

콘솔에는 액션 로그가 남는 것을 볼 수 있지만 HeroCard 컴포넌트는 검사 되지 않으므로 테두리가 빨간색으로 변화지 않았습니다.

 

이 메커니즘은 Protractor 에 의해 실행 된 E2E 테스트에 유용합니다. 특히 당신이 browser.waitForAngular 를 당신의 테스트에 사용 중이라면요. 브라우저에 각각의 명령이 보내지고 난 후에, Protactor는 존이 안정될 때까지 기다립니다. 만약 setInterval을 사용 중이라면, 존은 안정적인 상태가 절대 되지 않고 당신의 테스트는 아마 시간 초과가 될 것입니다.

 

동일한 이슈는 RxJS 옵저버블에서도 발생할 수 있으므로 Zone.js 가 지원하는 비표준 APIs 에 설명된 대로 polyfill.ts 에 패치된 버전을 추가해야 합니다.

 

import 'zone.js/dist/zone';  // Angular CLI 에 포함되어 짐
import 'zone.js/dist/zone-patch-rxjs'; // 올바른 존에서 RxJS 가 실행되는 것을 보장하기 위해 RxJS 패치를 import 한다.

이 패치 없이도 당신은 ngZone.runOutsideAngular 안에서 옵서버를 코드를 실행시킬 수는 있지만, 그 코드는 NgZone 안에서 계속 실행되고 있을 겁니다.

 

변화 감지 비활성화시키기(Deactivate Change Detection)

변화 감지를 비활성화시키는 것이 합당한 특수한 사용 케이스들이 있습니다. 예를 들어, 당신이 웹소켓을 이용해서 백엔드로부터 프론트로 수많은 데이터를 넣고 있다면, 해당 프론트엔드 컴포넌트는 10초마다 업데이트해야 합니다. 이 경우에 우리는 detach() 를 호출함으로써 변화 감지를 비활성화 시키고 detectChanges()를 사용해서 변화감지를 수동으로 트리거할 수 있습니다.

constructor(private ref: ChangeDetectorRef) {
    ref.detach(); // 변화 감지 비활성화
    setInterval(() => {
      this.ref.detectChanges(); // 변화감지 동적으로 트리거
    }, 10 * 1000);
  }

앵귤러 애플리케이션이 부트스트래핑 하는 동안에 Zone.js를 완전히 비활성화하는 것도 가능합니다. 이것은 자동 변화 감지가 완전히 비활성화되는 것을 의미하고 우리는 UI 변화를 수동으로 트리거해야 합니다. 예를 들어, ChangeDetectorRef.detectChanges() 를 호출함으로 써요.

먼저, polyfills.ts에서 Zone.js를 임포트 시키는 코멘트가 필요합니다.

import 'zone.js/dist/zone';  // Angular CLI 에 포함됩니다.

다음, 우리는 main.ts 안에 있는 존에 noop를 넘겨줘야 합니다.

platformBrowserDynamic().bootstrapModule(AppModule, {
      ngZone: 'noop';
}).catch(err => console.log(err));

Zone.js를 비활성화하는 것에 관한 더 자세한 것들은 Zone.js 없는 앵귤러 요소 기사에서 찾아볼 수 있습니다.

 

Ivy

앵귤러 9는 앵귤러의 차세대 컴파일과 렌더링 파이프라인 Ivy 를 디폴트로 사용할 것입니다.

앵귤러 팀은 새로운 렌더 엔진이 모든 프레임워크 라이프사이클 훅을 올바른 순서로 처리해서 변화 감지가 이전처럼 동작하도록 보장할 것입니다. 따라서 당신의 애플리케이션에서 동일한 ExpressionChangedAfterCheckedError 가 표시될 겁니다.

 

Max Koretskyi기사에 다음과 같이 썼습니다.

알다시피, 모든 친숙한 오퍼레이션들은 여전히 있어요. 하지만 오퍼레이션의 순서가 바뀐 것이 보이네요. 예를 들어, 이제 앵귤러는 먼저 자식 컴포넌트들을 검사한 다음에 임베디드 된 뷰들만 확인하는 것처럼 보여요. 현재는 제 가정을 테스트할만한 적당한 출력을 만들 컴파일러가 없어서, 확신할 수는 없지만요.

이 블로그 포스트의 마지막에 있는 "추천 기사들" 섹션 안에 있는 Ivy 관련된 흥미로운 두 개의 기사를 찾을 수 있습니다.

결론(Conclusion)

앵귤러 변화 감지는 UI 가 데이터를 예측 가능하고 효율적인 방식으로 표현하도록 보장하는 강력한 프레임워크 메커니즘입니다. 변화 감지는 특히 50개 이상의 컴포넌트로 구성되어 있지 않은 대부분의 애플리케이션에서 동작한다고 할 때만 안전하다고 할 수 있습니다.

 

개발자로서, 당신은 두 가지 이유로 이 주제에 대해 깊이 알아야 할 필요가 있습니다.

  • 당신이 ExpressionChangedAfterCheckedError를 받았고 그것을 해결해야 할 필요가 있을 때
  • 당신이 애플리케이션 성능을 향상해야 할 때

저는 이 기사사 앵귤러의 변화 감지에 관해 더 잘 이해하는데 도움이 될 수 있길 바랍니다. 제 데모 프로젝트를 사용해서 다양한 변화 감지 전략들을 자유롭게 써보세요.

추천 기사들

'Angular' 카테고리의 다른 글

Rxjs Bad Practice (한글)  (0) 2022.03.23
RxJS: Why memory leaks occur when using a Subject (한글)  (0) 2022.02.09