NgZone 사례

sooni·2025년 7월 20일
0

#1. NgZone.run()

Angular 는 Zone.js 로 모든 비동기 작업을 감싸고, 비동기 완료 시 자동으로 Change Detection 을 트리거한다.

하지만 외부 라이브러리나 DOM 이벤트 콜백은 Angular Zone 외부에서 실행되기 때문에, 뷰 업데이트가 자동으로 일어나지 않는다.

이 때, ngZone.run() 을 호출해 Angular Zone 안으로 복귀시키면 변경된 데이터를 UI에 반영할 수 있다.

3rd-party 이벤트 예제

someJsLib.onEvent(payload => {
  // 이 콜백은 Angular Zone 밖
  this.ngZone.run(() => {
    this.data = payload;
    // run() 내부라서 CD 자동 실행
  });
});
  1. 외부 콜백 실행
  2. ngZone.run() 진입
  3. this.data 변경
  4. Change Detection
  5. 뷰 업데이트

그 외 사용할 수 있는 곳

  • WebSocket 메시지 수신: 서버 푸시 데이터를 UI에 반영

  • 3rd‑party 콜백: 차트 라이브러리, 맵 API 이벤트

  • FileReader, IndexedDB 콜백 등

    • 단, run()을 남발하면 매번 Change Detection이 일어나 성능 저하로 이어질 수 있으니,
      이벤트 빈도가 높지 않은 곳에만 사용하거나, 꼭 필요한 부분만 감싸야한다.

#2. runOutsideAngular()

Angular Zone 안에서는 모든 이벤트가 CD를 트리거하기 때문에,

특히 스크롤, mousemove, resize 같은 반복 빈도 높은 이벤트를 처리하면 성능에 큰 부담이 된다.

이럴 때 runOutsideAngular() 를 사용해 Zone을 벗어나 이벤트를 등록하면, 불필요한 CD를 피할 수 있다.

scroll 최적화 예제

this.ngZone.runOutsideAngular(() => {
  fromEvent(window,'scroll').pipe(throttleTime(100))
    .subscribe(() => {
      const y = window.scrollY;
      if (y > 200) {
        this.ngZone.run(() => this.scrollY = y);
      }
    });
});
  1. runOutsideAngular() 로 모든 스크롤 이벤트가 Zone 밖에서 실행 -> CD 미발생
  2. throttleTime(100)으로 이벤트 빈도 제어
  3. 조건 통과 시 (y > 200) 에만 run() 호출해 UI 업데이트

mousemove 최적화 예제

this.ngZone.runOutsideAngular(() => {
  fromEvent(window, 'mousemove')
    .pipe(throttleTime(200))
    .subscribe((e: MouseEvent) => {
      const coords = { x: e.clientX, y: e.clientY };
      // 마우스가 화면 중앙 근처일 때만 UI 업데이트
      if (coords.x > window.innerWidth/2) {
        this.ngZone.run(() => {
          this.cursorPos = coords;
        });
      }
    });
});
  1. Zone 밖에서 스트림 생성
  2. RxJS 연산자로 빈도 제어 (throttleTime/debounceTime)
  3. 조건(혹은 결과) 만족 시에만
  4. Zone 안으로 복귀해 UI 업데이트 (ngZone.run())

왜 이렇게 처리를 할까🤔?

  • 매 프레임마다(60fps) 발생하는 이벤트를 Zone 안에서 처리하면 매번 CD → 성능 저하

  • Zone 밖에서 빈도 제어 후, 정말 필요한 순간에만 Zone 안으로 복귀

=>효과: Raw scroll 처리: 30fps 미만으로 뚝뚝 끊김, throttleTime+runOutside/run 적용: 55–60fps 유지

(참고) throttleTime vs. debounceTime

throttleTime, debounceTime 선택의 기준

  • throttleTime(ms): 최초 이벤트 방출 후, 지정한 ms 동안은 이후 이벤트 무시 스크롤 / scroll, mousemove 같은 빈번한 이벤트 제어
  • debounceTime(ms): 마지막 이벤트가 발생하고 지정한 ms 동안 추가 이벤트 없을 때 최종 이벤트만 방출 / 검색어 입력 후 최종 키 입력 시점 처리 (검색 API 호출)
// 1) throttleTime: 스크롤 위치 감지
this.ngZone.runOutsideAngular(() => {
  fromEvent(window, 'scroll')
    .pipe(throttleTime(100))
    .subscribe(() => {
      // 초당 최대 10번(1000/100)만 동작
      this.ngZone.run(() => this.scrollY = window.scrollY);
    });
});

// 2) debounceTime: 검색어 입력
fromEvent(searchInput.nativeElement, 'keyup')
  .pipe(
    map((e: any) => e.target.value),
    debounceTime(300)      // 마지막 입력 후 300ms 동안 입력 없을 때만
  )
  .subscribe(query => {
    this.searchService.search(query).subscribe(results => this.items = results);
  });

=> throttleTime은 “지속적으로 많은 이벤트 중 간헐적으로 한 번만” 처리,

debounceTime은 “마지막 이벤트가 완전히 멈춘 뒤에 한 번만” 처리

#3. onMicrotaskEmpty vs. onStable

Zone 이벤트 흐름

Angular(Zone.js)는 microtaskmacrotask 두 가지 큐를 관리한다.

  • microtask: Promise 콜백, queueMicrotask 등 → 우선 순위가 높아 macrotask 직후에 실행
  • macrotask: setTimeout, setInterval, DOM 이벤트, XHR 등

Zone 이벤트 실행 순서

  1. 하나의 macrotask 실행
    • 예: setTimeout 콜백, I/O 이벤트, DOM 이벤트, setInterval
  2. 해당 macrotask 내에서 스케줄된 모든 microtask 처리
    • 예: Promise.then, queueMicrotask, MutationObserver 콜백 등
  3. 렌더링(Optional)
    • 브라우저가 필요하다면 화면을 갱신
  4. 다음 macrotask 로 이동

=> “macrotask → microtask → (렌더링) → 다음 macrotask” 순서로 동작

onMicrotaskEmpty

onMicrotaskEmpty 는 모든 macrotask 가 끝난 직후, 그리고 그 사이에 발생한 모든 microtask 도 완료된 시점에 발생한다.

Angular 내부 로직 중 “Promise 기반 초기화”가 끝난 바로 다음 순간에 특정 코드를 실행하고 싶을 때,
예: 서비스 초기화(init().then()), 라우터 가드 canActivate() Promise 처리 직후 같은 때 사용 가능하다.

this.ngZone.onMicrotaskEmpty.pipe(take(1)).subscribe(() => {
  console.log('모든 Promise 콜백 처리 완료 직후');
});

pipe(take(1)): 스트림에서 첫 번째(next) 알림을 받은 즉시 그 값을 구독자에게 전달하고 메모리 누수 없이 자동으로 구독이 해제 (unsubscribe)

onStable

onStable 은 모든 macrotask + microtask 가 완전히 끝나고, Zone 이 "완전히 유휴(idle)" 상태가 된 뒤에 발생한다.

앱 초기 렌더링 + 비동기 데이터 바인딩이 모두 끝난 뒤에 실행할 로직,
ex. Analytics 초기화: 모든 컴포넌트가 로딩된 뒤 스크립트 로드 / Layout 측정: ViewChild,ElementRef 로 DOM 크기를 정확히 측정 할 때 사용가능하다.

this.ngZone.onStable.pipe(take(1)).subscribe(() => {
  console.log('모든 비동기 작업(타이머, HTTP, 이벤트) 종료 후');
});

Analytics & layout 측정 예제

import { Component, NgZone, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { take } from 'rxjs/operators';

// (가상의) Analytics, Chart 라이브러리 인터페이스
declare const Analytics: { init: () => void };
declare const ChartLib: { create: (el: HTMLElement, w: number, h: number) => void };

@Component({
  selector: 'app-analytics-layout-demo',
  template: `
    <div #chartContainer class="chart-container">
      <!-- 차트가 여기 렌더링됩니다 -->
    </div>
  `
})
export class AnalyticsLayoutDemoComponent implements AfterViewInit {
  @ViewChild('chartContainer', { static: true })
  chartContainer!: ElementRef<HTMLElement>;

  constructor(private ngZone: NgZone) {}

  ngAfterViewInit() {
    // 1) Change Detection + Promise 등 microtask 직후 → Analytics 초기화
    this.ngZone.onMicrotaskEmpty
      .pipe(take(1))
      .subscribe(() => {
        console.log('onMicrotaskEmpty: Analytics.init() 호출');
        Analytics.init();
      });

    // 2) setTimeout, HTTP 등 macrotask까지 모두 완료된 뒤 → Layout 측정 및 차트 생성
    this.ngZone.onStable
      .pipe(take(1))
      .subscribe(() => {
        const el = this.chartContainer.nativeElement;
        const { width, height } = el.getBoundingClientRect();
        console.log(`onStable: 차트 크기 ${width}x${height}로 초기화`);
        ChartLib.create(el, width, height);
      });

    // (옵션) 강제로 macrotask 하나 삽입해 보기
    setTimeout(() => {
      console.log('0ms 타이머 완료 (macrotask 예시)');
    }, 0);
  }
}
  1. ngAfterViewInit 진입
  2. Angular가 뷰 초기화 → Change Detection 수행
  3. Promise 콜백, queueMicrotask 등 마이크로태스크가 처리된 후 → onMicrotaskEmpty 이벤트 발생 → Analytics.init()
  4. setTimeout, HTTP 요청, 기타 매크로태스크가 모두 끝난 후 → onStable 이벤트 발생 → getBoundingClientRect()로 정확한 컨테이너 크기 측정 후 차트 렌더링

실행 순서

onMicrotaskEmpty: Analytics.init() 호출
0ms 타이머 완료 (macrotask 예시)
onStable: 차트 크기 800x400로 초기화

onMicrotaskEmpty: Analytics.init() 호출
– 뷰 초기화 + Change Detection 후, 마이크로태스크 큐가 비워지는 시점에 바로 발생한다.

0ms 타이머 완료 (macrotask 예시)
setTimeout(..., 0) 매크로태스크가 실행된 직후 콘솔에 찍힌다.

onStable: 차트 크기 {width}x{height}로 초기화
– 모든 macrotask(여기서는 타이머)와 남은 microtask가 끝나고 Zone이 완전 유휴가 된 뒤에 발생하며, getBoundingClientRect()로 읽어온 실제 컨테이너 크기가 찍힌다.

#4. setTimeout 의 Zone 내부 vs. 외부 차이

Zone.js는 브라우저의 macrotask API (setTimeout, setInterval, DOM 이벤트, XHR 등)를 후킹(patch) 한다.

각 타이머 호출 시 “현재 Zone” 정보를 함께 저장 → 콜백 실행 시 자동으로 그 Zone으로 복귀한다.

Angular Zone 내부 (run())

// Angular Zone 내부
setTimeout(() => {
  this.msg1 = 'Zone 내부 setTimeout 완료';
  // 이 시점에 Change Detection(CD) 자동으로 실행
}, 500);
  • 동작:

    1. 타이머 예약 시 Zone 컨텍스트 기록
    2. 500ms 후 콜백 실행 → Zone.js가 자동으로 ngZone.run() 효과를 주어 CD 트리거
    3. 뷰가 즉시 갱신
  • 장점: 코드가 간결

  • 주의: 폴링(polling) 등 반복 타이머가 많으면 CD 부담 증가

Angular Zone 외부 (runOutsideAngular()) 타이머

this.ngZone.runOutsideAngular(() => {
  setTimeout(() => {
    this.msg2 = 'Zone 외부 setTimeout 완료';
    // 아직 CD가 일어나지 않았다!
    // 강제 복귀 필요:
    this.ngZone.run(() => {
      this.msg2 += ' → run() 복귀 후 반영';
    });
  }, 1000);
});
  • 동작:
    1. runOutsideAngular()로 Zone 바깥에서 타이머 예약 → 콜백도 Zone 바깥에서 실행
    2. 첫 번째 this.msg2 변경 시 CD 미발생 → 뷰 미반영
    3. ngZone.run() 호출 → CD 트리거 → 뷰에 최종 문자열 반영
  • 장점: 불필요한 CD를 피할 수 있어, 반복 타이머나 폴링 시 성능 유리
  • 주의: UI 반영 시점을 명확하게 관리 필요

실시간 알림 폴링 예제

ngOnInit() {
  this.ngZone.runOutsideAngular(() => {
    this.pollSub = timer(0, 5000).subscribe(() => {
      this.notificationService.getNewCount()
        .subscribe(count => {
          if (count > this.lastCount) {
            // 변화가 있을 때만 Zone 복귀
            this.ngZone.run(() => {
              this.newCount = count;
            });
          }
          this.lastCount = count;
        });
    });
  });
}

ngOnDestroy() {
  this.pollSub.unsubscribe();
}
  • 폴링 성능
    • 매 5초마다 HTTP 요청 → 콜백 내부에서만 UI 반영
    • 불필요한 CD(false positive) 제거 → 전체 앱 반응성 향상

요약 & tip

  • runOutsideAngular() + 조건부 run() 조합으로,
    • 불필요한 CD 제거
    • 필요할 때만 UI 업데이트
  • 사용처: 폴링, 애니메이션, 마우스/키보드 이벤트 처리 등
  • 리소스 해제: 구독/타이머 해제 반드시 (unsubscribe(), clearInterval)

0개의 댓글