라떼군 이야기


Angular 18 Zoneless 환경에서 OnPush 변경 감지 전략이 여전히 필요할까?

Problem

Angular 18에서 provideExperimentalZonelessChangeDetection()을 사용하여 애플리케이션을 Zoneless(Zone.js 없는) 환경으로 마이그레이션하는 경우가 늘고 있습니다. 이때 Signal, Observable, 그리고 AsyncPipe를 혼합해서 사용하다 보면 개발자들은 한 가지 의문에 부딪힙니다. “Zone.js가 제거되어 프레임워크의 변경 감지(Change Detection) 스케줄링 방식이 완전히 바뀌었는데, 컴포넌트에 ChangeDetectionStrategy.OnPush를 여전히 설정해야 할까?“라는 고민입니다. 기존의 성능 최적화 기법이 새로운 패러다임에서도 유효한지, 아니면 중복되는 기능인지 헷갈리기 쉬운 상황입니다.

Background

이 문제를 명확히 이해하려면 Angular의 변경 감지(Change Detection, CD) 메커니즘을 스케줄링(Scheduling)과 검사(Checking) 두 가지 측면으로 나누어 보아야 합니다. 기존의 Zone 기반 앱에서는 Zone.js가 setTimeout, Promise, 이벤트 리스너 등 모든 비동기 API를 몽키 패치(Monkey patch)하여 변경 감지를 언제 시작할지 결정하는 ‘스케줄러’ 역할을 했습니다. 하지만 Zoneless 환경에서는 Zone.js가 없으므로 프레임워크는 Signal 업데이트, markForCheck(), 템플릿 내 이벤트 발생 등을 감지하여 CD를 스케줄링합니다. 즉, 변경 감지를 언제(When) 시작할지에 대한 근본적인 패러다임이 바뀐 상태입니다.

Solution

결론부터 말씀드리면 “네, Zoneless 환경에서도 OnPush 전략은 여전히 필요합니다.”

Zoneless는 변경 감지를 언제(When) 실행할지 결정하는 스케줄링의 영역이고, OnPush는 트리를 순회할 때 어떤(Which) 컴포넌트를 검사할지 결정하는 최적화의 영역이기 때문입니다. 또한 Zoneless 환경이라고 해서 OnPush가 기본값으로 자동 적용되는 것도 아닙니다.

OnPush가 여전히 필요한 이유

OnPush를 사용하면 불필요한 컴포넌트 검사를 건너뛰어 렌더링 성능을 크게 향상시킬 수 있습니다. OnPush가 적용된 컴포넌트는 다음 조건 중 하나를 만족할 때만 검사됩니다:

  1. @Input 참조값이 변경되었을 때
  2. 템플릿 내에서 이벤트 리스너가 실행되었을 때
  3. 컴포넌트 내에서 소비(consume) 중인 Signal 값이 변경되었을 때
  4. markForCheck()AsyncPipe를 통해 명시적으로 dirty 마킹이 되었을 때

Zoneless + OnPush 컴포넌트 작성 예시

import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-user-profile',
  standalone: true,
  imports: [CommonModule],
  // Zoneless 환경에서도 렌더링 최적화를 위해 OnPush를 명시적으로 설정합니다.
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="profile">
      <!-- Signal 값이 변경될 때만 이 컴포넌트가 변경 감지 대상이 됩니다. -->
      <h2>{{ userName() }}</h2>
      <button (click)="updateName()">이름 업데이트</button>
    </div>
  `
})
export class UserProfileComponent {
  // Signal을 사용하여 상태 관리 (Zoneless 환경에서 CD 스케줄링을 트리거하는 역할)
  userName = signal('홍길동');

  updateName() {
    // Signal 업데이트 (set 또는 update) 시 Angular에 변경 감지가 필요함을 알림 (When)
    // 동시에 OnPush 컴포넌트가 스스로를 dirty 상태로 마킹함 (Which)
    this.userName.set('김철수');
  }
}

위 코드에서 userName.set()이 호출되면, Zoneless 스케줄러는 변경 감지 사이클을 시작합니다. 그리고 Angular가 컴포넌트 트리를 순회할 때, OnPush가 설정된 UserProfileComponent는 Signal 변화로 인해 ‘dirty’ 상태가 되었으므로 검사 및 렌더링 대상이 됩니다.

Deep Dive

프로덕션 환경에서 대규모 Angular 애플리케이션을 개발할 때, Zone.js를 제거(Zoneless)하면 초기 번들 사이즈를 줄이고 런타임 오버헤드를 감소시킬 수 있습니다. 하지만 이것만으로는 충분하지 않습니다. 컴포넌트 트리가 깊고 복잡해질수록 상태 변화와 무관한 컴포넌트까지 불필요하게 재평가되는 것을 막으려면 반드시 OnPush를 함께 사용해야 합니다. 향후 Angular가 Signal 기반 컴포넌트(Signal-based components)를 정식 도입하기 전까지는, ZonelessOnPush의 조합이 Angular 애플리케이션의 성능을 극대화하는 가장 강력한 베스트 프랙티스입니다.

Conclusion

Angular 18에서 Zoneless 환경으로 전환하더라도 OnPush 변경 감지 전략은 컴포넌트 렌더링 최적화를 위해 반드시 유지해야 합니다. Zoneless는 변경 감지의 **타이밍(When)**을 최적화하고, OnPush는 변경 감지의 **대상(Which)**을 최적화하므로, 이 두 가지를 결합하여 최고의 애플리케이션 성능을 달성하시길 권장합니다.

References

프리랜서로 제품 기획과 개발을 맡길 파트너가 필요하신가요? 개인, 팀, 기업 누구나 의뢰할 수 있으며 문제 정의부터 출시까지 함께합니다.