Angular 는 Zone.js 로 모든 비동기 작업을 감싸고, 비동기 완료 시 자동으로 Change Detection 을 트리거한다.
하지만 외부 라이브러리나 DOM 이벤트 콜백은 Angular Zone 외부에서 실행되기 때문에, 뷰 업데이트가 자동으로 일어나지 않는다.
이 때, ngZone.run()
을 호출해 Angular Zone 안으로 복귀시키면 변경된 데이터를 UI에 반영할 수 있다.
someJsLib.onEvent(payload => {
// 이 콜백은 Angular Zone 밖
this.ngZone.run(() => {
this.data = payload;
// run() 내부라서 CD 자동 실행
});
});
ngZone.run()
진입this.data
변경WebSocket 메시지 수신: 서버 푸시 데이터를 UI에 반영
3rd‑party 콜백: 차트 라이브러리, 맵 API 이벤트
FileReader, IndexedDB 콜백 등
run()
을 남발하면 매번 Change Detection이 일어나 성능 저하로 이어질 수 있으니,Angular Zone 안에서는 모든 이벤트가 CD를 트리거하기 때문에,
특히 스크롤, mousemove, resize 같은 반복 빈도 높은 이벤트를 처리하면 성능에 큰 부담이 된다.
이럴 때 runOutsideAngular()
를 사용해 Zone을 벗어나 이벤트를 등록하면, 불필요한 CD를 피할 수 있다.
this.ngZone.runOutsideAngular(() => {
fromEvent(window,'scroll').pipe(throttleTime(100))
.subscribe(() => {
const y = window.scrollY;
if (y > 200) {
this.ngZone.run(() => this.scrollY = y);
}
});
});
runOutsideAngular()
로 모든 스크롤 이벤트가 Zone 밖에서 실행 -> CD 미발생throttleTime(100)
으로 이벤트 빈도 제어(y > 200)
에만 run()
호출해 UI 업데이트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;
});
}
});
});
throttleTime
/debounceTime
)ngZone.run()
)매 프레임마다(60fps) 발생하는 이벤트를 Zone 안에서 처리하면 매번 CD → 성능 저하
Zone 밖에서 빈도 제어 후, 정말 필요한 순간에만 Zone 안으로 복귀
=>효과: Raw scroll 처리: 30fps 미만으로 뚝뚝 끊김, throttleTime+runOutside/run 적용: 55–60fps 유지
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은 “마지막 이벤트가 완전히 멈춘 뒤에 한 번만” 처리
Angular(Zone.js)는 microtask와 macrotask 두 가지 큐를 관리한다.
queueMicrotask
등 → 우선 순위가 높아 macrotask 직후에 실행setTimeout
, setInterval
, DOM 이벤트, XHR 등setTimeout
콜백, I/O 이벤트, DOM 이벤트, setInterval
등Promise.then
, queueMicrotask
, MutationObserver
콜백 등=> “macrotask → microtask → (렌더링) → 다음 macrotask” 순서로 동작
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
은 모든 macrotask + microtask 가 완전히 끝나고, Zone 이 "완전히 유휴(idle)" 상태가 된 뒤에 발생한다.
앱 초기 렌더링 + 비동기 데이터 바인딩이 모두 끝난 뒤에 실행할 로직,
ex. Analytics 초기화: 모든 컴포넌트가 로딩된 뒤 스크립트 로드 / Layout 측정: ViewChild,ElementRef 로 DOM 크기를 정확히 측정 할 때 사용가능하다.
this.ngZone.onStable.pipe(take(1)).subscribe(() => {
console.log('모든 비동기 작업(타이머, HTTP, 이벤트) 종료 후');
});
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);
}
}
ngAfterViewInit
진입queueMicrotask
등 마이크로태스크가 처리된 후 → onMicrotaskEmpty
이벤트 발생 → Analytics.init()
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()
로 읽어온 실제 컨테이너 크기가 찍힌다.
Zone.js는 브라우저의 macrotask API (setTimeout
, setInterval
, DOM 이벤트, XHR 등)를 후킹(patch) 한다.
각 타이머 호출 시 “현재 Zone” 정보를 함께 저장 → 콜백 실행 시 자동으로 그 Zone으로 복귀한다.
// Angular Zone 내부
setTimeout(() => {
this.msg1 = 'Zone 내부 setTimeout 완료';
// 이 시점에 Change Detection(CD) 자동으로 실행
}, 500);
동작:
ngZone.run()
효과를 주어 CD 트리거장점: 코드가 간결
주의: 폴링(polling) 등 반복 타이머가 많으면 CD 부담 증가
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
this.msg2 = 'Zone 외부 setTimeout 완료';
// 아직 CD가 일어나지 않았다!
// 강제 복귀 필요:
this.ngZone.run(() => {
this.msg2 += ' → run() 복귀 후 반영';
});
}, 1000);
});
runOutsideAngular()
로 Zone 바깥에서 타이머 예약 → 콜백도 Zone 바깥에서 실행this.msg2
변경 시 CD 미발생 → 뷰 미반영ngZone.run()
호출 → CD 트리거 → 뷰에 최종 문자열 반영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();
}
unsubscribe()
, clearInterval
)