본문 바로가기
TYLER SONGBlog
블로그 목록
Frontend

Subject 계열: 옵저버(observer)이면서 옵저버블(observable)인 멀티캐스트(multicast) 스트림

Subject는 여러 구독자에게 값을 동시에 밀어넣을 수 있는 특수한 옵저버블이며, 동시에 값을 흘려보내는 옵저버 역할도 겸한다. BehaviorSubject, ReplaySubject, AsyncSubject는 각각 "현재 값 보관", "과거 값 일부 재생", "완료 시점 값만 전달"이라는 서로 다른 캐싱 전략을 가진 Subject의 변형이다. 이 네 가

송민성8분 읽기

1. 개념

일반 옵저버블(observable)은 콜드(cold)하다. 구독(subscribe)할 때마다 내부 로직이 처음부터 다시 실행되고, 구독자마다 독립적인 실행 흐름을 갖는다. 반면 Subject는 핫(hot)한 성격을 가진 특수한 객체로, 옵저버블이면서 동시에 옵저버(observer)다. 즉 next, error, complete 메서드를 직접 호출해서 값을 흘려보낼 수 있고, 동시에 subscribe로 구독도 가능하다.

Subject 하나에 여러 구독자가 붙으면 next로 흘려보낸 값을 모든 구독자가 동시에 받는다. 이를 멀티캐스트(multicast)라고 부르며, 각 구독자마다 독립적으로 실행되는 콜드 옵저버블의 유니캐스트(unicast) 방식과 대비된다.

BehaviorSubject, ReplaySubject, AsyncSubject는 기본 Subject를 상속하면서 "새로 구독한 옵저버에게 과거 값을 어떻게 전달할지"에 대한 정책만 다르게 구현한 변형이다.

2. 왜 사용하는가

일반 옵저버블은 발행 시점에 구독하지 않은 옵저버는 이전 값을 받을 수 없다. 예를 들어 클릭 이벤트 스트림을 여러 컴포넌트가 공유하고 싶거나, 애플리케이션의 현재 상태를 여러 곳에서 구독해서 참조하고 싶을 때 일반 옵저버블만으로는 값 공유가 번거롭다.

Subject 계열은 다음 문제를 해결한다. 먼저 하나의 데이터 소스를 여러 구독자가 공유하는 문제, 그다음 늦게 합류한 구독자에게 마지막 값이나 과거 값을 전달하는 문제, 마지막으로 상태를 가진 스토어처럼 "현재 값"을 동기적으로 꺼내 써야 하는 문제다. 각 변형은 이 중 어떤 부분에 초점을 맞추는지가 다르다.

3. 동작 원리

Subject는 내부에 구독자 목록(observer list)만 들고 있다. next(value)가 호출되면 그 시점에 등록된 모든 구독자에게 값을 순회하며 전달한다. 구독 이전에 발행된 값은 저장하지 않으므로, 늦게 구독한 옵저버는 아무것도 받지 못한 채 이후 값부터 받는다.

BehaviorSubject는 생성 시 초기값을 받고, 내부에 "현재 값" 하나만 저장한다. next가 호출될 때마다 이 값을 갱신하고, 새로운 구독이 들어오면 즉시 저장된 현재 값을 동기적으로 방출한 뒤 이후 값을 이어서 전달한다. getValue() 메서드로 현재 값을 꺼낼 수도 있다.

ReplaySubject는 버퍼(buffer)를 가진다. 생성자에 버퍼 크기(bufferSize)와 선택적으로 시간 윈도우(windowTime)를 지정할 수 있다. next로 값이 들어올 때마다 버퍼에 쌓아두고, 버퍼 크기를 초과하면 오래된 값부터 버린다. 새로운 구독이 들어오면 버퍼에 남아있는 값들을 순서대로 재생(replay)한 뒤 이후 값을 이어서 전달한다.

AsyncSubject는 값을 즉시 방출하지 않는다. next가 호출될 때마다 마지막 값만 내부에 저장해두고 아무것도 내보내지 않다가, complete가 호출되는 순간 저장해둔 마지막 값 단 하나만 모든 구독자에게 방출하고 완료한다. complete가 호출되기 전에 구독한 옵저버든 나중에 구독한 옵저버든 동일하게 완료 시점의 마지막 값 하나만 받는다.

4. 예제

Subject 기본 동작을 보자. 먼저 const subject = new Subject()로 생성하고, 구독자 A를 subject.subscribe(v => console.log('A:', v))로 등록한다. 그다음 subject.next(1)을 호출하면 A는 A: 1을 받는다. 이후 구독자 B를 subject.subscribe(v => console.log('B:', v))로 등록하고 subject.next(2)를 호출하면, A는 A: 2를 받지만 B는 1을 놓치고 B: 2만 받는다.

BehaviorSubject 예제를 보자. 먼저 const behavior = new BehaviorSubject(0)으로 초기값 0을 가진 Subject를 만든다. behavior.next(1)을 호출한 뒤 새로운 구독자를 behavior.subscribe(v => console.log(v))로 등록하면, 이 구독자는 즉시 1을 받는다. 이는 상태를 다루는 스토어와 유사하게 동작한다.

ReplaySubject 예제를 보자. 먼저 const replay = new ReplaySubject(2)로 버퍼 크기 2를 지정한다. replay.next('a'), replay.next('b'), replay.next('c')를 순서대로 호출하면 버퍼에는 bc만 남는다. 이후 구독자가 replay.subscribe(v => console.log(v))로 등록하면 b, c를 순서대로 받은 뒤 이후 값을 이어받는다.

AsyncSubject 예제를 보자. 먼저 const async$ = new AsyncSubject()를 만들고 구독자를 async$.subscribe(v => console.log(v))로 등록한다. 그다음 async$.next(1), async$.next(2), async$.next(3)을 호출해도 아무 값도 출력되지 않는다. 마지막으로 async$.complete()를 호출하는 순간 3만 한 번 출력된다.

5. 실무 사용 사례

BehaviorSubject는 애플리케이션 상태 관리에서 가장 널리 쓰인다. 로그인 사용자 정보, 현재 테마, 폼 상태처럼 "항상 현재 값이 존재해야 하는" 데이터를 다룰 때 적합하다. 구독 시점과 무관하게 최신 상태를 즉시 받을 수 있어 컴포넌트 간 상태 공유에 활용된다.

ReplaySubject는 늦게 합류하는 구독자에게 최근 이력을 보여줘야 하는 경우에 쓰인다. 예를 들어 채팅 애플리케이션에서 최근 메시지 몇 개를 새로 접속한 클라이언트에게 재생하거나, 로그 스트림의 최근 항목을 캐시해서 디버깅 도구에 제공하는 경우가 있다.

AsyncSubject는 한 번만 완료되는 비동기 작업의 최종 결과를 여러 구독자에게 공유할 때 쓰인다. 예를 들어 인증 토큰을 한 번만 요청하고 그 결과를 여러 곳에서 대기해야 할 때, 요청이 완료된 시점의 최종 값만 필요한 경우에 적합하다.

일반 Subject는 이벤트 버스(event bus)나 다중 소스를 하나로 합치는 브릿지 역할, 또는 Observable.create 내부에서 외부 이벤트를 옵저버블로 변환할 때 자주 쓰인다.

6. 주의할 점

Subject 계열은 값을 손실할 위험이 있다. 일반 Subject는 구독 이전 값을 전혀 보관하지 않으므로 타이밍에 매우 민감하다. 구독 순서와 next 호출 순서를 항상 염두에 두어야 한다.

BehaviorSubject는 초기값이 강제되므로, 실제로 아직 값이 없는 상태를 표현하려면 null이나 별도의 sentinel 값을 초기값으로 넣는 방식을 고려해야 한다. 이 경우 구독자 쪽에서 초기값과 실제 값을 구분하는 로직이 필요할 수 있다.

ReplaySubject는 버퍼 크기를 무제한으로 두면 메모리 누수로 이어질 수 있다. 장기간 실행되는 애플리케이션에서 버퍼 크기와 시간 윈도우를 반드시 명시적으로 제한해야 한다.

AsyncSubject는 complete가 호출되지 않으면 어떤 값도 방출되지 않는다는 점을 놓치기 쉽다. 무한히 지속되는 스트림에 AsyncSubject를 사용하면 아무 값도 나오지 않는 상황이 발생한다.

공통적으로 Subject는 구독자가 직접 next, error, complete를 호출할 수 있다는 점에서 캡슐화가 깨지기 쉽다. 외부에 노출할 때는 asObservable() 메서드로 옵저버블 인터페이스만 노출하고, next 호출 권한은 내부 로직에서만 갖도록 제한하는 패턴이 일반적이다.

7. 핵심 정리

Subject는 옵저버와 옵저버블을 겸하는 멀티캐스트 스트림이며, 구독 이전 값은 보관하지 않는다. BehaviorSubject는 현재 값 하나를 항상 유지하며 신규 구독자에게 즉시 전달하고, 상태 관리에 적합하다. ReplaySubject는 버퍼에 과거 값 일부를 저장했다가 신규 구독자에게 재생하며, 이력이 필요한 경우에 적합하다. AsyncSubject는 완료 시점의 마지막 값 하나만 전달하며, 단발성 비동기 결과 공유에 적합하다. 네 가지 모두 값 손실, 메모리 관리, 캡슐화 문제를 함께 고려하며 사용해야 한다.

© 2026 Tyler Song