앵귤러 고급 강좌 #1 Change Detection — Default, OnPush, Zoneless

9 분 소요

중급 강좌의 마지막 글에서 고급 강좌에서 다룰 일곱 가지 주제를 예고했었습니다. 그 중 첫 번째로 둔 것이 바로 Change Detection이었습니다. 앵귤러 앱의 화면이 “왜, 언제 다시 그려지는지"를 결정하는 메커니즘이고, 큰 앱의 성능을 갈라놓는 가장 굵은 변수이기도 합니다.

이번 글에서는 Change Detection이 정확히 무엇이고, Default 전략OnPush 전략이 어떻게 다른지, 그 뒤에서 일하던 zone.js가 무슨 역할을 했는지, 그리고 Angular 18 이후 본격화된 Zoneless 흐름까지 한 번에 정리해보겠습니다.

Change Detection이란 #

한 줄로 말하면 “데이터가 바뀌었으니 화면을 다시 그려라"를 잇는 메커니즘입니다. 컴포넌트의 어떤 값이 바뀌었을 때 그 값을 참조하는 템플릿이 자동으로 새 값으로 갱신되는 — 우리가 너무 당연하게 누리고 있는 그 흐름의 정체입니다.

앵귤러는 컴포넌트 트리를 위에서 아래로 순회하면서 각 컴포넌트의 템플릿 표현식을 다시 평가하고, 이전 값과 달라졌으면 DOM을 업데이트합니다. 이 한 번의 순회를 change detection cycle이라고 부릅니다.

문제는 “언제 이 사이클을 돌리느냐"입니다. 너무 자주 돌리면 느려지고, 너무 안 돌리면 화면이 갱신되지 않습니다. 앵귤러는 오랫동안 “비동기 작업이 끝날 때마다 한 번” 이라는 규칙으로 이 문제를 풀어왔습니다. 그 규칙을 가능하게 한 것이 zone.js입니다(잠시 뒤에).

Default 전략 — 트리 전체를 탐색 #

특별히 지정하지 않으면 모든 컴포넌트는 **ChangeDetectionStrategy.Default**로 동작합니다. 변화 감지가 트리거되면 루트부터 모든 컴포넌트를 순회하면서 각자의 바인딩을 다시 평가합니다.

src/app/dashboard.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `<p>{{ user.name }} — {{ score }}</p>`,
  // changeDetection: ChangeDetectionStrategy.Default  ← 명시 안 하면 이거
})
export class DashboardComponent {
  user = { name: '커티스' };
  score = 1200;
}

장점은 단순하고 안전하다는 것입니다. 어떤 데이터가 어떻게 바뀌든 다음 사이클에 화면이 무조건 따라옵니다. “왜 화면이 갱신 안 되지?” 같은 디버깅이 거의 없습니다.

단점은 앱이 커질수록 비용이 빠르게 늘어난다는 것입니다. 컴포넌트가 수백 개 있는 화면에서 키 입력 한 번에 트리 전체를 훑는다고 생각하면 부담이 그려집니다. 대부분의 컴포넌트는 그 사이클 동안 아무것도 바뀌지 않았는데도 검사를 받게 되기 때문입니다.

OnPush 전략 — 트리의 일부만 검사 #

해법은 “꼭 필요할 때만 검사하자"입니다. **ChangeDetectionStrategy.OnPush**로 지정한 컴포넌트는 다음 네 가지 경우에만 검사 대상이 됩니다.

  1. 컴포넌트의 @Input(또는 input()) 참조가 바뀌었을 때
  2. 컴포넌트 또는 그 자식에서 이벤트가 발생했을 때(클릭, 입력 등)
  3. 템플릿 안에서 쓰는 async 파이프가 새 값을 흘려줄 때
  4. 컴포넌트가 읽는 시그널(signal)이 바뀌었을 때

위의 어느 경우에도 해당하지 않으면 그 서브트리는 통째로 검사를 건너뜁니다. 큰 앱에서 OnPush를 잘 적용해두면 사이클당 검사하는 컴포넌트 수가 극적으로 줄어듭니다.

src/app/user-card.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `<p>{{ user().name }} — {{ user().score }}</p>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
  user = input.required<{ name: string; score: number }>();
}

OnPush 적용 조건 — Immutable이어야 한다 #

OnPush가 @Input 변화를 감지하는 기준은 참조 비교(===) 입니다. 객체 내부 필드를 살짝 수정해도 참조가 같으면 OnPush 컴포넌트는 변화를 인지하지 못합니다.

잘못된 예 — mutate
// 부모 컴포넌트
addPoint() {
  this.user.score += 100;  // 같은 객체를 mutate
  // → OnPush 자식은 갱신되지 않을 수 있다
}
올바른 예 — 새 객체 반환
addPoint() {
  this.user = { ...this.user, score: this.user.score + 100 };
  // → 참조가 바뀌었으니 OnPush 자식도 갱신된다
}

규칙은 단순합니다. OnPush 컴포넌트의 입력으로 흘러가는 데이터는 immutable처럼 다룬다. 새 값을 만들어 통째로 갈아끼우는 패턴(...spread, map, filter)이 자연스럽게 어울립니다. 이 점에서 OnPush는 함수형 스타일과, 그리고 시그널과 매우 잘 맞습니다.

시그널을 입력으로 쓰면 이 문제가 거의 사라집니다. 시그널은 값이 바뀌면 그 자체로 신호를 보내고, OnPush 컴포넌트도 그 신호로 깨어나기 때문입니다. “OnPush를 쓰면서 mutate를 못 하는 게 답답하다"는 통증의 상당 부분은 시그널이 해결합니다.

NgZone과 zone.js — 그동안 일해주던 누군가 #

여기까지 보면 자연스러운 의문이 생깁니다 — “그래서 앵귤러는 언제 변화 감지를 돌리는 거지?” 그 결정을 그동안 대신 해주던 친구가 zone.js입니다.

zone.js는 브라우저의 비동기 API(setTimeout, Promise, addEventListener, XHR 등)를 monkey-patch해서 “이 비동기 작업이 시작됐다 / 끝났다"를 모두 감지할 수 있게 만들어주는 라이브러리입니다. 앵귤러는 자기가 띄운 영역 안의 zone(=NgZone)에서 비동기 작업이 끝나는 모든 순간에 **tick()**을 호출 — 즉 변화 감지 사이클을 돌립니다.

그래서 우리는 그동안 setState 같은 명시적 업데이트 호출 없이도 화면이 자동으로 갱신되는 마법을 누릴 수 있었습니다. 클릭 핸들러에서 그냥 this.count++ 한 줄만 써도 화면이 따라오는 이유는, zone.js가 그 클릭 이벤트가 끝나는 시점을 잡아 앵귤러에게 “이제 검사할 때야"라고 알려줬기 때문입니다.

대가는 무겁습니다. zone.js는 모든 비동기 API를 가로채기 때문에 번들 크기(약 30KB gzip)와 런타임 오버헤드가 따라옵니다. 그리고 변화 감지 빈도가 항상 비동기 작업과 1:1로 묶여 있어서, 사실은 화면을 갱신할 필요가 없는 경우에도 사이클이 돕니다.

runOutsideAngular — zone 밖에서 일하기 #

아직 zone 기반인 앱에서 고빈도 이벤트(스크롤, 마우스 무브, requestAnimationFrame 루프 등)를 다룰 때 자주 만나는 도구가 NgZone.runOutsideAngular입니다. 이름 그대로 앵귤러 zone 바깥에서 콜백을 실행시켜 변화 감지가 트리거되지 않게 막아줍니다.

src/app/scroll-tracker.component.ts
import { Component, NgZone, inject, OnInit, DestroyRef } from '@angular/core';

@Component({
  selector: 'app-scroll-tracker',
  standalone: true,
  template: `<p>스크롤 Y: {{ y }}</p>`,
})
export class ScrollTrackerComponent implements OnInit {
  private zone = inject(NgZone);
  private destroyRef = inject(DestroyRef);
  y = 0;

  ngOnInit() {
    this.zone.runOutsideAngular(() => {
      const onScroll = () => {
        // 여기서 this.y를 바꿔도 화면은 안 그려진다 — 변화 감지가 안 도니까
        // 정말 화면을 갱신해야 할 때만 zone.run으로 다시 돌아온다
        if (window.scrollY % 100 === 0) {
          this.zone.run(() => (this.y = window.scrollY));
        }
      };
      window.addEventListener('scroll', onScroll, { passive: true });
      this.destroyRef.onDestroy(() => {
        window.removeEventListener('scroll', onScroll);
      });
    });
  }
}

스크롤이 매 프레임 발생하더라도 변화 감지는 100픽셀에 한 번만 돕니다. 스크롤, 드래그, 차트 애니메이션처럼 이벤트 빈도가 화면 갱신 빈도보다 훨씬 높은 경우엔 이 패턴 하나로 체감 성능이 확 달라집니다.

Zoneless — zone.js 없이도 동작한다 #

Angular 18에서 zone.js를 빼고도 동작하는 Zoneless 모드가 본격적인 실험 단계로 들어왔습니다. 핵심 발상은 단순합니다 — “이제 시그널이 알아서 신호를 보내주니, zone이 모든 비동기를 감시할 필요가 없다.”

src/app/app.config.ts
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    // ...
  ],
};

angular.json 또는 polyfills 설정에서 zone.js를 제거하면 진짜로 zone 없는 앱이 만들어집니다. 변화 감지를 언제 돌릴지는 이제 다음과 같은 명시적 신호가 결정합니다.

  • 시그널 변경signal.set(...), signal.update(...)
  • async 파이프의 새 값
  • 이벤트 바인딩((click) 등)
  • ChangeDetectorRef.markForCheck() 같은 명시 호출

번들에서 zone.js가 빠지면서 약 30KB가 줄어들고, “쓸데없는 사이클"이 사라지면서 미세한 성능 이득도 생깁니다. 다만 시그널이나 명시적 신호 없이 그냥 필드를 바꾸면 화면이 갱신되지 않습니다. 그래서 Zoneless는 사실상 OnPush + 시그널을 전제로 한 모델이라고 봐도 됩니다.

노트
2026년 현재 Zoneless API는 아직 provideExperimentalZonelessChangeDetection이라는 이름을 쓰고 있습니다(이름의 Experimental이 빠지는 시점은 안정화 단계로 들어가는 신호입니다). 새 프로젝트에서 도입할 때는 사용 중인 라이브러리들이 zone에 의존하지 않는지(특히 RxJS scheduler, setTimeout 기반 유틸 등) 한 번 확인하세요.

Change Detection 디버깅 #

OnPush와 시그널 위주로 짜다 보면 가끔 “내 화면이 왜 안 그려지지?” 또는 “왜 이렇게 자주 돌지?” 싶은 순간이 옵니다. 두 가지 도구를 손에 넣어두세요.

  • ng.profiler.timeChangeDetection() — 개발 모드에서 콘솔에 ng.profiler.timeChangeDetection()을 찍으면 변화 감지 한 사이클이 평균 몇 ms 걸리는지 측정해줍니다. OnPush 적용 전후 비교에 유용합니다.
  • Angular DevTools — 크롬 확장. Profiler 탭에서 어느 컴포넌트가 어느 사이클에 검사됐는지 시각화해줍니다. “이 컴포넌트는 OnPush인데 왜 매번 검사되지?” 같은 질문에 가장 빠르게 답을 줍니다.
콘솔에서 측정
> ng.profiler.timeChangeDetection()
ran 500 change detection cycles in 312 ms; 0.624 ms per check

실전 가이드 — 그래서 어떻게 쓰면 되나 #

세 가지 권장사항으로 정리하겠습니다.

  1. 새 프로젝트는 OnPush를 기본값으로 가져가세요. 컴포넌트 generator에 --change-detection=OnPush를 붙이거나, 팀 컨벤션으로 모든 컴포넌트에 OnPush를 명시합니다. 처음부터 OnPush로 출발하면 immutable 패턴이 자연스럽게 자리 잡습니다.

  2. 시그널을 입력과 상태의 기본 도구로 쓰세요. OnPush + 시그널 조합은 “참조 비교 때문에 머리 아픈” 클래식 OnPush의 단점을 거의 없애줍니다. 중급 #4에서 본 effect()input()이 이 시점에서 진가를 발휘합니다.

  3. Zoneless는 새 프로젝트에서 적극 검토하되, 기존 앱은 단계적으로 가세요. 라이브러리 호환성 점검 → OnPush 전면 적용 → 시그널로의 점진 전환 → Zoneless 적용 순서가 안전합니다. zone.js를 빼는 건 마지막 단계여야 합니다.

마무리 #

이번 글에서는 앵귤러 성능의 심장인 Change Detection을 한 번에 훑었습니다.

  • Change Detection은 데이터 변화를 화면 갱신으로 잇는 메커니즘
  • Default는 트리 전체 순회. 단순하고 안전하지만 큰 앱에서는 비용이 늘어납니다.
  • OnPush는 입력 참조 변경 / 이벤트 / async pipe / 시그널 변화에만 검사
  • OnPush의 전제는 immutable 데이터 — 새 객체로 갈아끼우거나 시그널을 쓰기
  • zone.js + NgZone이 그동안 “언제 검사할지"를 결정해 줬다
  • 고빈도 이벤트는 **runOutsideAngular**로 zone 밖으로
  • Zoneless는 zone.js를 빼고 시그널,이벤트,async 파이프가 직접 신호를 보내는 모델
  • 디버깅은 **ng.profiler.timeChangeDetection**과 Angular DevTools Profiler

다음 글인 “앵귤러 고급 강좌 #2 Signals 깊이 — effect, computed, input,model, Resource API"에서는 오늘 자주 등장한 시그널 그 자체를 본격적으로 파보겠습니다. effect()의 미세한 동작 규칙, computed()가 캐시를 다루는 방식, 컴포넌트 입력으로서의 input()과 양방향 바인딩을 위한 model(), 그리고 비동기 데이터를 시그널로 다루는 새로운 표준이 될 Resource API까지 — 시그널의 결을 한 단계 더 깊이 들어가보겠습니다.

X