심화 가이드
시그널

effect()

시그널은 값이 변경되면 해당 값을 읽는 소비자(consumer)에게 알릴 수 있다는 점에서 유용합니다. 이펙트 함수 는 하나 이상의 시그널 값이 변경될 때마다 실행되는 작업입니다. effect() 함수를 호출해 이펙트 함수를 등록할 수 있습니다:

import {effect} from '@angular/core';

effect(() => {
  console.log(`The current count is: ${count()}`);
});

이펙트 함수는 반드시 한 번은 실행됩니다. 이펙트 함수가 실행되면 실행 중 값을 읽은 시그널을 추적하며, 이 시그널 중 하나의 값이 변경되면 다시 실행됩니다. computed() 시그널과 비슷하게 의존성을 동적으로 추적하며, 가장 최근 실행에서 값을 읽은 시그널만 추적합니다.

이펙트 함수는 변경 감지 동작에서 언제나 비동기 로 실행됩니다.

활용사례

이펙트 함수는 마지막에 사용해야 하는 API입니다. 보통은 computed() 함수를 사용할 수 있으며, 시그널이 여러 개로 파생되거나 수동으로 조작하려면 linkedSignal()을 사용하는 것이 좋습니다. 만약 이펙트 함수로 어떤 시그널 값을 다른 시그널로 옮기고 있다면, 원본 데이터(source of truth)를 더 상단으로 올리고 computed() 함수나 linkedSignal()을 사용하는 것이 좋습니다. effect() 함수는 시그널이 아닌 API와 시그널을 동기화할 때 가장 효과적입니다.

참고: effect()가 더 나은 상황은 없습니다. 사용하기 적절한 경우만 있습니다.

  • 분석하거나 디버깅 툴에서 시그널 값을 로그에 출력하는 경우
  • window.localStorage나 세션 스토리지, 쿠키 등 다른 스토리지의 데이터를 동기화할 때
  • 템플릿 문법으로 표현할 수 없는 커스텀 DOM 동작을 추가할 때
  • <canvas> 엘리먼트나 차트 라이브러리, 서드 파티 UI 라이브러리를 사용해서 렌더링을 커스터마이징할 때

effect()를 사용하지 말아야 하는 경우

상태값을 단순하게 전파하는 용도로 effect()를 사용하지 마세요. 이런 용도로 사용하면 ExpressionChangedAfterItHasBeenChecked 오류가 발생하거나, 무한 루프에 빠지거나, 불필요한 변경 감지 싸이클이 실행될 수 있습니다.

다른 상태에 의존하는 시그널 모델을 다룰 때는 computed() 시그널을 사용하세요.

의존성 주입 컨텍스트

effect() 함수는 기본적으로 inject 함수를 사용할 수 있는 의존성 주입 컨텍스트 안에서만 실행할 수 있습니다. 이 조건을 만족하는 방법 중 가장 쉬운 방법은, 컴포넌트, 디렉티브, 서비스 클래스의 constructor 안에서 effect()를 실행하는 것입니다:

@Component(/* ... */)
export class EffectiveCounter {
  readonly count = signal(0);

  constructor() {
    // effect() 함수를 사용합니다.
    effect(() => {
      console.log(`The count is: ${this.count()}`);
    });
  }
}

클래스 생성자 밖에서 effect() 함수를 사용하려면 effect() 함수에 Injector를 다음과 같이 전달하면 됩니다:

@Component(/* ... */)
export class EffectiveCounter {
  readonly count = signal(0);
  private injector = inject(Injector);

  initializeLogging(): void {
    effect(
      () => {
        console.log(`The count is: ${this.count()}`);
      },
      {injector: this.injector},
    );
  }
}

effect() 실행과정

이펙트 함수는 생성된 컨텍스트에 따라 암묵적으로 두 가지 동작 중 하나가 적용됩니다.

"뷰 이펙트 함수(View Effect)"는 컴포넌트 인스턴스 생성 컨텍스트에서 등록된 이펙트 함수를 의미합니다. 컴포넌트 인젝터와 연결된 서비스가 등록한 이펙트 함수도 포함합니다.
"최상위 이펙트 함수(Root Effect)"는 최상위 계층에 등록된 서비스의 인스턴스 생성 컨텍스트에서 등록된 이펙트 함수를 의미합니다.

두 종류의 이펙트 함수는 모두 변경 감지 과정과 연동됩니다.

  • "뷰 이펙트 함수"는 컴포넌트 변화를 검사하기 전에 실행됩니다.
  • "최상위 이펙트 함수"는 변경 감지 싸이클에서 모든 컴포넌트를 검사하기 전에 실행됩니다.

두 경우 모두 이펙트 함수 실행 중 의존하는 시그널이 하나 이상 변경되면, 변경 감지 과정을 계속 진행하기 전에 이펙트 함수를 다시 실행합니다.

종료과정

컴포넌트나 디렉티브가 종료되면, Angular는 이와 관련된 이펙트 함수도 모두 종료합니다.

이펙트 함수는 두 가지 컨텍스트에서 생성될 수 있기 때문에 종료되는 시점이 다릅니다:

  • "뷰 이펙트 함수"는 컴포넌트가 종료되면 종료됩니다.
  • "최상위 이펙트 함수"는 애플리케이션이 종료되면 종료됩니다.

effect() 함수는 EffectRef를 반환합니다. 이펙트 함수의 자동 종료를 생략하고 수동으로 종료하려면 이펙트 함수를 등록할 때 manualCleanup 옵션을 지정한 후에 destroy() 메서드를 실행하면 됩니다. 이런 경우는 이펙트 함수가 필요하지 않을 때 반드시 종료해야 하는 것을 잊지 마세요.

이펙트 함수의 정리 콜백

이펙트 함수가 시작한 작업은 오랜 시간 동안 실행될 수 있습니다. 이 작업이 끝나기 전에 이펙트 함수가 종료되거나 다시 실행되면 이전 작업을 취소해야 합니다. 이펙트 콜백 함수의 첫 번째 인자로 onCleanup 함수를 받을 수 있습니다. onCleanup 함수로 등록한 정리 콜백은 이펙트 함수가 다음에 실행되기 전이나 종료될 때 호출됩니다.

effect((onCleanup) => {
  const user = currentUser();

  const timer = setTimeout(() => {
    console.log(`1 second ago, the user became ${user}`);
  }, 1000);

  onCleanup(() => {
    clearTimeout(timer);
  });
});

DOM 엘리먼트 부수효과

effect() 함수는 시그널이 변경되는 것에 반응하는 용도의 범용 도구입니다. 하지만 등록된 이펙트 콜백은 Angular가 DOM을 갱신하기 전에 실행됩니다. 그래서 DOM을 수동으로 검사하거나 조작해야 하는 경우, 서드 파티 라이브러리를 사용하는 경우라면 DOM에 직접 접근해야 합니다.

이런 경우에는 afterRenderEffect()를 사용하면 됩니다. 이 함수는 effect() 함수와 비슷하지만, Angular가 렌더링을 끝내고 변동사항을 DOM에 반영한 후에 실행됩니다.

@Component(/* ... */)
export class MyFancyChart {
  chartData = input.required<ChartData>();
  canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
  chart: ChartInstance;

  constructor() {
    // 차트 인스턴스를 생성할 때 한 번 실행합니다.
    afterNextRender({
      write: () => {
        this.chart = initializeChart(this.canvas().nativeElement, this.chartData());
      },
    });

    // `chartData`가 변경될 때마다 다시 실행합니다.
    afterRenderEffect(() => {
      this.chart.updateData(this.chartData());
    });
  }
}

위 예제에서 afterRenderEffect()는 서드 파티 라이브러리가 차트를 갱신하는 것을 전제로 실행했습니다.

참고: DOM 변화를 감지하는 경우라도 afterRenderEffect()를 꼭 사용해야 할 필요는 없습니다. effect()나 afterRenderEffect()를 사용하는 대신 ResizeObserver, MutationObserver, IntersectionObserver와 같은 API 활용을 검토해 보세요.

렌더링 단계

DOM에 접근하고 변경하는 것은 애플리케이션 성능에 영향을 미칠 수 있습니다. 불필요한 리플로우(reflows)가 발생하는 경우가 그렇습니다.

이 과정을 최적화하려면, afterRenderEffect()가 제공하는 단계마다 콜백 로직을 그룹화하고 최적화된 순서로 실행할 수 있습니다.

이런 단계로 진행됩니다:

단계 설명
earlyRead 쓰기 콜백 전에 DOM을 읽을 때 사용합니다. 브라우저가 기본적으로 지원하지 않는 레이아웃 수정을 실행할 때 유용합니다. 가능하다면 read 단계를 활용하는 것이 더 좋습니다.
write DOM에 쓰기 작업을 할 때 사용합니다. 이 단계에서 절대 DOM을 읽지 마세요.
mixedReadWrite DOM을 동시에 읽고 쓸 때 사용합니다. 되도록이면 이 단계를 사용하지 말고 다른 단계로 로직을 나누는 것이 좋습니다.
read DOM을 읽을 때 사용합니다. 이 단계에서 절대 DOM을 쓰지 마세요.

네 단계를 잘 활용하면 불필요한 레이아웃 스레싱(thrashing)을 막고, DOM 작업을 안전하고 효율적으로 수행할 수 있습니다.

afterRenderEffect에 단계별 콜백을 담은 객체를 전달하면 다음과 같이 실행 단계를 지정할 수 있습니다:

afterRenderEffect({
  earlyRead: (cleanupFn) => {
    /* ... */
  },
  write: (previousPhaseValue, cleanupFn) => {
    /* ... */
  },
  mixedReadWrite: (previousPhaseValue, cleanupFn) => {
    /* ... */
  },
  read: (previousPhaseValue, cleanupFn) => {
    /* ... */
  },
});

중요: 단계를 지정하지 않으면 afterRenderEffect()는 mixedReadWrite 단계에서 콜백을 실행합니다. DOM 리플로우가 발생하는 경우라면 애플리케이션 성능에 영향을 미칩니다.

단계 실행

earlyRead 단계의 콜백은 정리 함수만 인자로 받습니다. 이후 각 단계는 이전 단계 콜백의 반환값을 Signal로 전달받습니다. 이 시그널을 사용해 단계 사이의 작업을 조율할 수 있습니다.

이펙트 함수는 다음 단계로 실행됩니다:

  1. earlyRead
  2. write
  3. mixedReadWrite
  4. read

이 단계가 진행되는 동안 시그널의 값이 변경되면 afterRenderEffect() 함수에서 해당 단계가 다시 실행됩니다.

정리

각 단계마다 정리 콜백 함수를 인자로 전달할 수 있습니다. 이렇게 등록하는 정리 함수는 afterRenderEffect()가 종료되거나 페이즈 함수가 다시 실행되기 전에 실행됩니다.

서버 사이드 렌더링에서 주의사항

afterRenderEffect()는 afterNextRender(), afterEveryRender()와 비슷하게 클라이언트에서만 실행됩니다.

참고: 컴포넌트는 콜백 함수가 실행되기 전에 하이드레이션된 상태(hydrated)를 보장하지 않습니다. DOM이나 레이아웃을 직접 읽고 변경하는 경우에 주의해야 합니다.