라떼군 이야기


Angular Input Signal 변경 시 비동기 데이터 Fetch 처리 방법 (rxResource, toObservable)

Problem

Angular에서 input() 시그널(Signals)을 사용하는 컴포넌트를 개발할 때, 입력값이 변경됨에 따라 서버에서 새로운 데이터를 비동기적으로 불러와야(Fetch) 하는 상황이 자주 발생합니다. 하지만 시그널의 파생 상태를 만드는 computed는 동기적(synchronous)으로만 동작하므로 비동기 요청을 처리할 수 없습니다. 또한, effect 내부에서 컴포넌트의 상태를 변경하는 것은 공식 문서상 권장되지 않으며, 기존의 ngOnChanges 라이프사이클 훅은 시그널 기반의 Zoneless 환경으로 넘어가면서 점차 사용이 지양되고 있습니다. 이로 인해 개발자들은 시그널 값 변경에 반응하여 안전하고 우아하게 비동기 데이터를 패칭하는 패턴을 찾는 데 어려움을 겪습니다.

Background

Angular 16부터 도입된 시그널(Signals)은 애플리케이션의 상태 변화를 세밀하게 추적하고 UI를 효율적으로 업데이트하기 위한 동기적 반응형 원시 타입(reactive primitive)입니다. 반면, HTTP 요청과 같은 데이터 패칭은 본질적으로 시간이 걸리는 비동기(asynchronous) 작업입니다. Angular 생태계에서는 전통적으로 이러한 비동기 스트림을 처리하기 위해 RxJS를 강력하게 활용해 왔습니다. 따라서 시그널의 동기적인 세계와 RxJS의 비동기적인 세계를 매끄럽게 연결하는 브릿지(Bridge) 역할이 필요합니다. 상태 변경에 반응하여 부수 효과(Side Effect)를 일으키는 effect 안에서 데이터를 패치하고 상태를 업데이트하면 무한 루프나 예측 불가능한 상태 변경이 발생할 수 있으므로, 프레임워크 차원에서 제공하는 상호 운용성(Interop) 도구를 올바르게 이해하고 사용해야 합니다.

Solution

Angular 버전에 따라 이 문제를 해결하는 권장 방식이 다릅니다. 최신 버전에서는 프레임워크 자체에서 제공하는 리소스 API를 사용하며, 이전 버전에서는 RxJS와의 상호 운용성(Interop) 함수를 활용합니다.

1. Angular 20 이상: rxResource 사용 (권장)

Angular 최신 버전에서는 비동기 데이터 로딩을 위해 rxResource라는 새로운 API를 제공합니다. 입력 시그널이 변경될 때마다 자동으로 이전 요청을 취소하고 새로운 요청을 보냅니다.

import { Component, input, computed } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { of, delay } from 'rxjs';

@Component({
  selector: 'app-chart',
  template: `
    <!-- resource.value()로 데이터에 접근 -->
    {{ resource.value() }}
    
    <!-- 로딩 상태와 에러 상태를 기본적으로 제공 -->
    @if(resource.isLoading()) { Loading... }
    @if(resource.error(); as error) { {{ error }} }
  `,
})
export class ChartComponent {
  // 1. 입력 시그널 정의
  dataSeriesId = input.required<string>();
  fromDate = input.required<Date>();
  toDate = input.required<Date>();

  // 2. 입력 시그널들을 하나의 파라미터 객체로 묶는 computed 시그널 생성
  params = computed(() => ({
    id: this.dataSeriesId(),
    from: this.fromDate(),
    to: this.toDate(),
  }));

  // 3. rxResource를 사용하여 params가 변경될 때마다 fetchData 호출
  resource = rxResource({
    params: this.params,
    stream: ({ params }) => this.fetchData(params),
  });

  // 가상의 데이터 패칭 함수 (실제로는 HttpClient 사용)
  private fetchData({ id, from, to }: { id: string; from: Date; to: Date }) {
    return of(`Example data for id [${id}] from [${from}] to [${to}]`).pipe(
      delay(1000)
    );
  }
}

참고: Angular 19에서는 rxResource의 API가 requestloader를 사용하는 형태였으나, 20부터 paramsstream으로 변경되었습니다.

2. Angular 19 이전: rxjs-interop 활용

rxResource를 사용할 수 없는 환경이라면, @angular/core/rxjs-interop에서 제공하는 toObservabletoSignal을 조합하여 사용합니다.

import { Component, input, computed } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap, of, delay } from 'rxjs';

@Component({
  selector: 'app-chart',
  standalone: true,
  template: ` {{ data() }} `,
})
export class ChartComponent {
  dataSeriesId = input.required<string>();
  fromDate = input.required<Date>();
  toDate = input.required<Date>();

  params = computed(() => ({
    id: this.dataSeriesId(),
    from: this.fromDate(),
    to: this.toDate(),
  }));

  // 1. params 시그널을 Observable로 변환
  // 2. switchMap을 통해 이전 요청을 취소하고 새 요청 실행
  // 3. 결과를 다시 시그널로 변환하여 템플릿에서 사용
  data = toSignal(
    toObservable(this.params).pipe(
      switchMap((params) => this.fetchData(params))
    )
  );

  private fetchData({ id, from, to }: { id: string; from: Date; to: Date }) {
    return of(`Example data for id [${id}] from [${from}] to [${to}]`).pipe(
      delay(1000)
    );
  }
}

3. 서드파티 라이브러리 활용: ngxtension

보일러플레이트 코드를 줄이고 싶다면 ngxtension 라이브러리의 derivedAsync 유틸리티를 사용할 수 있습니다. 코드가 훨씬 간결해집니다.

import { Component, input } from '@angular/core';
import { derivedAsync } from 'ngxtension/derived-async';
import { of, delay } from 'rxjs';

@Component({
  selector: 'app-chart',
  standalone: true,
  template: ` {{ data() }} `,
})
export class ChartComponent {
  dataSeriesId = input.required<string>();
  fromDate = input.required<Date>();
  toDate = input.required<Date>();

  // 입력 시그널의 값을 읽어 바로 비동기 함수로 전달
  data = derivedAsync(() => 
    this.fetchData(this.dataSeriesId(), this.fromDate(), this.toDate())
  );

  private fetchData(id: string, from: Date, to: Date) {
    return of(`Example data for id [${id}] from [${from}] to [${to}]`).pipe(
      delay(1000)
    );
  }
}

Deep Dive

위의 해결책들에서 가장 중요한 핵심은 경쟁 상태(Race Condition) 방지입니다. 사용자가 날짜를 빠르게 여러 번 변경할 경우, switchMap이나 rxResource는 이전에 진행 중이던 HTTP 요청을 자동으로 취소(Cancel)하고 가장 마지막 요청의 결과만 반영합니다. 이는 effect 내부에서 수동으로 fetch를 호출하고 상태를 업데이트할 때 발생할 수 있는 데이터 불일치 문제를 원천적으로 차단합니다. 또한, 이러한 패턴들은 Angular가 향후 지향하는 Zone.js 없는(Zoneless) 애플리케이션 환경에서도 완벽하게 동작하도록 설계되었습니다. 단, rxResource는 아직 실험적(Experimental) 기능이므로 프로덕션 환경 도입 시 향후 API 변경 가능성을 염두에 두어야 합니다.

Conclusion

Angular 컴포넌트에서 Input Signal 변경에 따른 비동기 데이터 패칭은 effect를 남용하기보다는 프레임워크가 제공하는 반응형 파이프라인을 구축하는 것이 올바른 접근입니다. 안정적인 프로덕션 환경에서는 toObservabletoSignal을 결합한 방식을 사용하고, 최신 Angular 버전을 적극적으로 도입하는 프로젝트라면 로딩 및 에러 상태 관리까지 내장된 rxResource를 활용해 보시길 권장합니다.

References

협업 및 후원 연락하기 →