Angular 2026의 RxJS: 연산자, Subject, Signals 상호 운용

Angular 2026의 RxJS: 실무에서 쓰이는 연산자, Subject, Signals 상호 운용 패턴을 가장 자주 나오는 면접 질문과 함께 익힙니다.

Angular 2026의 RxJS 연산자, Subject, Signals 상호 운용

Angular에서 RxJS는 Signals가 Angular 컴포넌트의 동기적 상태 추적 방식을 재편한 2026년에도 여전히 비동기 프로그래밍의 근간으로 남아 있습니다. Observable을 선택해야 할 때, Signal이 더 잘 맞을 때, 그리고 이 둘이 어떻게 상호 운용되는지를 아는 것은 이제 모든 Angular 개발자에게 핵심 역량입니다. 이 심층 분석은 면접에서 자신 있게 답하는 지원자와 말문이 막히는 지원자를 가르는 연산자, Subject, 상호 운용 패턴을 하나씩 짚어 갑니다.

Signals와 Observables를 한 줄로

Signals는 항상 현재 값을 갖는 동기적 상태를 모델링하고, Observables는 시간에 따라 흐르는 이벤트의 비동기 스트림을 모델링합니다. Angular 20은 둘 다 유지하며, @angular/core/rxjs-interop 패키지가 이 둘을 잇는 공식 다리 역할을 합니다.

Angular Signals와 나란히 RxJS가 여전히 중요한 이유

Signals의 등장으로 많은 팀이 RxJS가 사라지는 것 아니냐고 묻기 시작했습니다. 메이저 버전을 세 번 더 거친 지금의 답은 「아니요」입니다. Signals는 지금 값을 갖고 있는 상태, 즉 카운터, 폼 필드, 선택된 탭 같은 것을 표현하는 데 뛰어납니다. 반면 Observables는 시간에 따라 도착하며 변환, 취소, 결합이 필요할 수 있는 이벤트, 즉 HTTP 응답, WebSocket 메시지, 키보드 입력, 라우터 이벤트, 타이머를 모델링하는 데 뛰어납니다.

Angular 자체 API가 이 구분을 눈에 보이게 드러냅니다. HttpClient는 여전히 Observable을 반환합니다. 리액티브 폼은 valueChangesstatusChanges를 Observable로 노출합니다. 라우터 이벤트는 Observable을 통해 스트리밍됩니다. 여러 비동기 이벤트가 같은 채널로 흐르는 곳이라면 어디서든 RxJS 연산자는 여전히 가장 표현력 있는 도구로 남아 있습니다. 공식 Angular 반응성 가이드는 이 두 모델을 경쟁이 아니라 상호 보완 관계로 다루며, RxJS 문서 역시 조금이라도 복잡한 데이터 흐름에는 반드시 참고해야 할 자료로 남아 있습니다.

코드 리뷰에서 통하는 실용적인 원칙은 이렇습니다. 템플릿에서 읽는 로컬 컴포넌트 상태에는 Signals를, 연산자가 필요한 비동기 파이프라인에는 Observables를 사용합니다. 상호 운용 레이어가 둘을 연결하므로 어느 모델도 벽이 되지 않습니다.

결합 연산자는 이 점을 한층 뒷받침합니다. 여러 비동기 소스의 조율, 예를 들어 라우트 파라미터와 사용자가 저장한 필터, 그리고 실시간 가격 피드를 병합하는 것이야말로 combineLatest, withLatestFrom, forkJoin이 설계상 겨냥한 작업입니다. Signals는 다른 signal로부터 계산된 값을 파생시킬 수 있지만, 「세 소스 모두가 값을 하나 이상 내보낸 뒤에만 내보낸다」 같은 시간 기반 조율은 표현할 수 없습니다. 시간에 걸친 이러한 선언적 합성이야말로 RxJS가 도구 상자에 남아 있는 변치 않는 이유입니다.

모든 Angular 개발자가 의존하는 핵심 RxJS 연산자

연산자는 Observable을 받아 새로운 Observable을 반환하는 순수 함수입니다. 전형적인 면접 시나리오는 입력과 동시에 이뤄지는 검색(search-as-you-type)입니다. 입력 제어, 중복 건너뛰기, 오래된 요청 취소, 오류 복구라는 네 가지 연산자를 한꺼번에 다루기 때문입니다.

search.component.tstypescript
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap, catchError } from 'rxjs/operators';
import { of } from 'rxjs';

@Component({
  selector: 'app-search',
  imports: [ReactiveFormsModule],
  template: `<input [formControl]="query" placeholder="Search repositories" />`,
})
export class SearchComponent {
  private http = inject(HttpClient);
  query = new FormControl('');

  results$ = this.query.valueChanges.pipe(
    debounceTime(300),               // wait until typing pauses for 300ms
    distinctUntilChanged(),          // ignore emissions that repeat the last value
    switchMap((term) =>              // cancel the in-flight request, start a fresh one
      this.http.get(`/api/search?q=${term}`).pipe(
        catchError(() => of([]))     // recover from a failed request without killing the stream
      )
    )
  );
}

면접관이 파고드는 미묘한 지점은 왜 switchMap이 바깥 레벨에 있고 catchError가 그 안에 중첩되어 있는가입니다. 안쪽 HTTP Observable에 catchError를 두면 실패한 요청은 빈 배열을 반환하고 바깥 스트림은 계속 흐릅니다. catchError를 바깥 파이프로 옮기면 첫 오류에서 검색 전체가 종료되어 이후의 모든 키 입력이 동작하지 않게 됩니다. 네 개의 평탄화 연산자는 각각 서로 다른 동시성 질문에 답하며, 잘못된 것을 고르는 일은 Angular 앱에서 가장 흔한 경쟁 상태의 원인 중 하나입니다.

평탄화 연산자 고르기

switchMap은 이전 안쪽 요청을 취소하고 가장 최근 것만 남깁니다(타입어헤드 검색). mergeMap은 모든 안쪽 요청을 동시에 실행합니다(독립적인 병렬 업로드). concatMap은 요청을 엄격한 순서로 큐에 넣습니다(겹쳐선 안 되는 순차 쓰기). exhaustMap은 현재 작업이 끝날 때까지 새 입력을 무시합니다(더블 클릭된 제출 버튼 차단).

이 선택을 제대로 하는 것이 불안정한 기능을 예측 가능한 기능으로 바꿉니다. SharpSkill RxJS 연산자 모듈은 각 연산자가 막아 주는 바로 그 경쟁 상태를 재현하는 단계별 문제로 이 차이를 집중 훈련합니다.

Subject와 멀티캐스트: BehaviorSubject, ReplaySubject, Subject

평범한 Observable은 유니캐스트로, 구독자마다 새로운 실행을 트리거합니다. Subject는 Observable이면서 동시에 Observer이므로 멀티캐스트가 됩니다. 이 성질 덕분에 Subject는 서비스 안에 가벼운 상태 저장소나 이벤트 버스를 구축하는 표준적인 방법이 됩니다.

세 가지 변형은 늦게 구독한 구독자에게 무엇을 리플레이하는지에서 갈립니다. 평범한 Subject는 과거 값을 전혀 내보내지 않으므로 구독자는 구독 이후에 도착한 값만 봅니다. BehaviorSubject는 현재 값을 저장하고 즉시 리플레이하기 때문에 초기값이 필요합니다. ReplaySubject는 설정 가능한 개수의 과거 발행을 버퍼링하고 그것들을 모두 리플레이합니다.

cart.service.tstypescript
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class CartService {
  // BehaviorSubject holds the latest value and replays it to new subscribers
  private readonly itemsSubject = new BehaviorSubject<string[]>([]);

  // expose a read-only stream, never the writable Subject itself
  readonly items$ = this.itemsSubject.asObservable();

  add(item: string): void {
    const current = this.itemsSubject.value;    // synchronous access to the last value
    this.itemsSubject.next([...current, item]); // emit the next immutable state
  }
}

여기서는 두 가지 습관이 중요합니다. 첫째, Subject 자체가 아니라 asObservable()을 노출하면 외부 코드가 next()를 호출해 뒷문으로 상태를 변경하는 것을 막습니다. 둘째, 제자리에서 변경하는 대신 새로운 배열 참조를 내보내면 변경 감지와 동등성 검사를 예측 가능하게 유지합니다. 이 패턴은 Signals보다 앞서 존재했으며, 서로 무관한 여러 컴포넌트가 소비하는 공유·주입 가능한 상태에는 지금도 잘 작동합니다.

그렇지만 템플릿에서 읽는 단일 현재 값을 담기 위해서만 쓰이는 BehaviorSubject는 이제 쓰기 가능한 Signal로 표현하는 편이 나은 경우가 많으며, 그러면 구독과 asObservable()이라는 형식적 절차가 사라집니다. 저장소가 발행값을 변환하거나 디바운스하거나 다른 스트림과 결합해야 한다면, 이런 연산자에는 Signal의 직접적인 등가물이 없으므로 Subject가 여전히 더 강력한 선택지입니다. 어디서나 하나를 기본값으로 삼는 대신 사용 사례마다 둘을 가려 쓰는 것이 면접관이 높이 평가하는 숙련도의 표식입니다.

toSignal과 toObservable로 하는 RxJS와 Signals 상호 운용

상호 운용 패키지는 두 개의 대표 함수를 제공합니다. toSignal은 Observable을 템플릿이 직접 소비할 수 있는 읽기 전용 Signal로 변환하고 구독 수명 주기를 자동으로 관리합니다. toObservable은 그 반대로, Signal을 Observable로 바꿔 RxJS 연산자가 그 변화를 처리할 수 있게 합니다.

dashboard.component.tstypescript
import { Component, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';

@Component({ selector: 'app-dashboard', template: `{{ user()?.name }}` })
export class DashboardComponent {
  private http = inject(HttpClient);

  // a Signal drives the query; toObservable turns each change into a stream event
  readonly userId = signal(1);
  private readonly user$ = toObservable(this.userId).pipe(
    switchMap((id) => this.http.get<{ name: string }>(`/api/users/${id}`))
  );

  // toSignal subscribes immediately and unsubscribes on destroy, no manual teardown
  readonly user = toSignal(this.user$, { initialValue: null });
}

initialValue 옵션은 주목할 만합니다. 이것이 없으면 아직 아무것도 발행되지 않았으므로 toSignal은 값 또는 undefined 타입의 Signal을 반환합니다. initialValue: null을 넘기면 템플릿에 명확한 시작 상태와 더 깔끔한 타입이 주어집니다. BehaviorSubject 같은 동기적 소스라면 requireSync: true가 첫 읽기에서 값이 준비되어 있음을 보장하고 타입에서 undefined를 완전히 제거합니다. 이 양방향 다리가 있기에 코드베이스는 기존 Observable 파이프라인을 전부 다시 쓰지 않고도 Signals를 점진적으로 도입할 수 있습니다. 함께 보면 좋은 Angular Signals 면접 모듈은 반응성 모델을 깊이 다루고, Angular 18 Signals 기능 개요는 이 원시 요소들이 어떻게 진화했는지 짚어 줍니다.

Angular 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

rxResource로 Observable을 signal 리소스로 잇기

Angular 20은 Observable에서 오는 비동기 데이터를 signal 기반 상태로 불러오는 선언적 방법으로 rxResource를 앞세웁니다. 로딩, 오류, 해결 상태를 signal로 추적하므로 HTTP 호출이 예전에 요구하던 수동 구독 관리의 대부분을 없애 줍니다.

products.component.tstypescript
import { Component, signal, inject } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

@Component({ selector: 'app-products', template: `` })
export class ProductsComponent {
  private http = inject(HttpClient);
  readonly category = signal('laptops');

  // Angular 20 renamed the fields: request -> params and loader -> stream
  readonly products = rxResource({
    params: () => ({ category: this.category() }),
    stream: ({ params }) =>
      this.http.get<Product[]>(`/api/products?category=${params.category}`),
  });
  // products.value(), products.isLoading() and products.error() are all signals
}

Angular 19의 request/loader에서 Angular 20의 params/stream으로의 이름 변경은 자주 걸려 넘어지는 지점이므로, 현재 필드 이름을 언급할 수 있으면 면접에서 최신 지식을 보여 줍니다. category signal이 바뀌면 rxResource는 이전 요청을 취소하고 새 스트림을 시작해 switchMap 의미론을 공짜로 제공합니다. rxResource API 레퍼런스는 상태 수명 주기 전체를 문서화합니다.

takeUntilDestroyed로 메모리 누수 막기

컴포넌트보다 오래 살아남는 수동 subscribe()는 모두 메모리를 누수시키고 파괴된 뷰에 대해 콜백을 실행시킬 수 있습니다. 예전 해법은 ngOnDestroy에서 완료시키는 Subject를 사용한 takeUntil(this.destroy$) 패턴이었습니다. Angular는 이 상용구를, 구독을 현재 주입 컨텍스트나 명시적 DestroyRef에 묶는 연산자 takeUntilDestroyed로 대체했습니다.

live-feed.component.tstypescript
import { Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({ selector: 'app-live-feed', template: `` })
export class LiveFeedComponent {
  private destroyRef = inject(DestroyRef);

  constructor() {
    interval(1000)
      // completes the subscription automatically when the component is destroyed
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((tick) => console.log('tick', tick));
  }
}

생성자나 필드 초기자 안에서 호출되면 takeUntilDestroyed()는 주변의 DestroyRef를 읽으므로 인자가 필요 없습니다. 그 밖의 곳, 예를 들어 메서드 안에서 호출되면 위 예시처럼 명시적 DestroyRef를 넘겨야 합니다. toSignalrxResource를 우선하면 수동 구독을 완전히 피할 수 있으며, 이것이 누수를 피하는 가장 깔끔한 방법입니다. 원시 구독이 정말로 필요할 때는 takeUntilDestroyed가 올바른 방어책입니다.

주입 컨텍스트 함정

주입 컨텍스트 밖에서 인자 없이 takeUntilDestroyed()를 호출하면 런타임 오류를 던집니다. 이벤트 핸들러, setTimeout, 또는 RxJS 콜백 안에서 하는 구독은 그 컨텍스트 밖이므로 DestroyRef를 (보통 주입된 필드로) 더 일찍 확보해 명시적으로 넘겨야 합니다. 이것을 잊는 일이 「takeUntilDestroyed() can only be used within an injection context」 오류의 가장 잦은 원인입니다.

연습해 둘 가치가 있는 Angular RxJS 면접 질문

면접관은 신호가 강한 같은 주제들을 반복해서 맴도는 경향이 있습니다. 이것들에 간결하게 답할 수 있는지가 RxJS를 실제로 써 본 지원자와 읽기만 한 지원자를 가릅니다.

  • 핫 Observable 대 콜드 Observable. 콜드 Observable은 구독마다 생산자를 시작하므로 각 구독자는 독립적인 실행(새 HTTP 요청)을 얻습니다. 핫 Observable은 하나의 생산자를 구독자들이 공유하며, Subject와 share()가 만들어 내는 것이 이것입니다.
  • switchMap 대 mergeMap 대 concatMap 대 exhaustMap. switchMap은 이전 안쪽 Observable을 취소합니다(검색에 이상적). mergeMap은 모든 안쪽 Observable을 동시에 실행합니다. concatMap은 그것들을 순서대로 큐에 넣습니다. exhaustMap은 하나가 실행 중일 때 새 입력을 무시합니다(폼 이중 제출 방지에 이상적).
  • 메모리 누수를 어떻게 막는가. toSignal, rxResource, 또는 async 파이프를 우선하며, 이들은 모두 자동으로 구독을 해제합니다. 수동 구독에는 takeUntilDestroyed를 사용합니다.
  • 언제 Observable을 Signal로 변환하는가. 템플릿이 값을 동기적으로 필요로 하고 소스가 비동기일 때 toSignal에 손을 뻗어, async 파이프 없이 변경 감지가 최신 값을 읽게 합니다.

시간 압박 속에서 이것들의 단계별 버전을 풀어 보는 것이 가장 빠르게 체화하는 방법입니다. Angular 기술 허브는 이 질문들에 직접 대응하는 RxJS, Signals, 변경 감지 모듈을 한데 모아 둡니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

결론

RxJS와 Signals는 현대 Angular 스택에서 라이벌이 아니라 파트너입니다. 다음 프로젝트나 면접으로 가져갈 만한 요점은 다음과 같습니다.

  • 동기적 컴포넌트 상태에는 Signals를, 연산자가 필요한 비동기 이벤트 파이프라인에는 Observables를 사용한다.
  • 오래된 작업을 취소하려면 switchMap, 중복을 막으려면 exhaustMap에 손을 뻗고, catchError는 안쪽 Observable에 두어 바깥 스트림이 실패를 견디게 한다.
  • Subject 상태는 asObservable()을 통해 노출하고 불변 값을 내보내 변경 감지를 예측 가능하게 유지한다.
  • 두 모델은 toSignaltoObservable로 잇고, 깔끔한 타입을 위해 initialValuerequireSync를 넘긴다.
  • Angular 20에서는 rxResource로 비동기 데이터를 선언적으로 불러오되 paramsstream 필드 이름을 기억한다.
  • 남은 모든 수동 구독은 takeUntilDestroyed로 방어해 누수를 없앤다.

태그

#angular
#rxjs
#signals
#observables
#typescript
#deep-dive

공유

관련 기사