멀티캐스팅 개념과 share, shareReplay: 하나의 구독으로 여러 구독자에게 같은 실행 결과를 나누는 방법
RxJS의 옵저버블(Observable)은 기본적으로 유니캐스트(unicast) 방식이라 구독자마다 독립적인 실행이 일어난다. 멀티캐스팅(multicasting)은 하나의 실행 결과를 여러 구독자가 공유하도록 만드는 개념이며, share와 shareReplay 연산자로 구현한다. 이 문서는 두 연산자의 동작 원리 차이와 실무에서 흔히 저지르는 실수를 다룬다
1. 개념
RxJS에서 옵저버블(Observable)은 기본적으로 콜드(cold)하다. 콜드 옵저버블은 구독(subscribe)할 때마다 내부 실행 로직이 처음부터 다시 실행되는 옵저버블을 말한다. 예를 들어 HTTP 요청을 감싼 옵저버블을 두 곳에서 구독하면 요청이 두 번 나간다. 이렇게 구독자마다 독립된 실행이 일어나는 방식을 유니캐스트(unicast)라고 부른다.
멀티캐스팅(multicasting)은 하나의 실행(execution)을 여러 구독자가 공유하는 방식이다. 즉 옵저버블 내부 로직은 한 번만 실행되고, 그 결과가 여러 구독자에게 동시에 전달된다. RxJS에서 멀티캐스팅을 구현하는 핵심 도구는 Subject다. Subject는 옵저버블이면서 동시에 옵저버(Observer)이기 때문에, 소스 옵저버블의 값을 받아서 여러 구독자에게 다시 내보내는 중계자 역할을 할 수 있다.
share와 shareReplay는 이 Subject 기반 멀티캐스팅을 편리하게 감싼 연산자다. 직접 Subject를 만들고 subscribe와 connect를 관리하는 대신, 소스 옵저블에 파이프(pipe)로 연결하기만 하면 여러 구독자가 하나의 실행을 공유하도록 만들어준다.
2. 왜 사용하는가
콜드 옵저버블을 여러 곳에서 구독하면 다음과 같은 문제가 생긴다. 먼저 동일한 HTTP 요청이 구독자 수만큼 중복으로 발생해서 서버 부하와 네트워크 낭비가 생긴다. 그다음 타이머나 웹소켓처럼 부작용(side effect)이 있는 리소스를 여러 번 생성하게 되어 메모리 누수나 예상치 못한 동작으로 이어질 수 있다.
멀티캐스팅을 적용하면 소스 실행은 한 번만 일어나고, 그 결과만 여러 구독자에게 나눠준다. 이렇게 하면 리소스 생성 비용을 줄이고, 여러 구독자가 정확히 같은 시점의 같은 데이터를 받도록 보장할 수 있다.
3. 동작 원리
share 연산자는 내부적으로 Subject를 하나 만들고, 첫 구독자가 나타나면 소스 옵저버블을 구독해서 그 Subject로 값을 흘려보낸다. 이후 추가되는 구독자는 소스를 다시 구독하지 않고 같은 Subject를 구독한다. 마지막 구독자가 구독 해지(unsubscribe)하면 기본적으로 소스에 대한 구독도 함께 해지되고, Subject도 리셋된다. 즉 share는 "참조 카운팅(reference counting)" 방식으로 동작해서, 구독자가 0명이 되었다가 다시 생기면 소스 실행이 처음부터 다시 시작된다.
이때 문제는 일반 Subject를 쓰면 새로 구독하는 사람은 그 이전에 발행된 값을 받지 못한다는 점이다. Subject는 구독 시점 이후의 값만 전달하기 때문이다. 그래서 늦게 합류한 구독자가 과거 값을 알고 싶을 때는 share만으로는 부족하다.
shareReplay는 이 문제를 해결하기 위해 내부에 Subject 대신 ReplaySubject를 사용한다. ReplaySubject는 지정한 개수(bufferSize)만큼의 과거 값을 버퍼에 저장해두었다가, 새로 구독하는 사람에게 즉시 재생(replay)해준다. 그래서 shareReplay(1)로 설정하면 새로 구독하는 모든 구독자가 가장 최근에 발행된 값 하나를 즉시 받고, 그 이후 값부터는 실시간으로 함께 받는다.
버전에 따라 차이가 있는데, 최신 RxJS의 shareReplay는 refCount 옵션을 명시적으로 설정할 수 있다. refCount를 true로 주면 share처럼 구독자가 모두 사라졌을 때 소스 구독을 해지하고 버퍼도 정리한다. refCount를 false로 주거나 생략하면(구버전 기본값) 구독자가 모두 사라져도 소스 구독이 유지되고 버퍼도 계속 남아있어서, 나중에 새 구독자가 와도 오래된 캐시 값을 그대로 재생받는다. 이 차이를 모르고 쓰면 예상치 못하게 소스가 계속 살아있거나, 반대로 필요할 때 캐시가 사라지는 문제가 생긴다.
4. 예제
먼저 콜드 옵저버블의 기본 동작을 보자. const source$ = new Observable(subscriber => { console.log('실행 시작'); subscriber.next(Math.random()); }); 이렇게 만든 source$를 source$.subscribe(v => console.log('A', v));와 source$.subscribe(v => console.log('B', v));로 두 번 구독하면, "실행 시작" 로그가 두 번 찍히고 A와 B가 서로 다른 랜덤 값을 받는다.
이제 share를 적용해보자. const shared$ = source$.pipe(share()); 로 만든 다음, 같은 방식으로 shared$.subscribe(v => console.log('A', v));와 shared$.subscribe(v => console.log('B', v));를 실행하면, 두 구독이 거의 동시에 이루어질 경우 "실행 시작" 로그가 한 번만 찍히고 A와 B가 같은 값을 받는다. 다만 A가 구독한 뒤 시간이 흘러 소스가 이미 완료(complete)된 상태에서 B가 구독하면, B는 아무 값도 받지 못할 수 있다.
이 문제를 shareReplay로 해결할 수 있다. const cached$ = source$.pipe(shareReplay({ bufferSize: 1, refCount: true })); 로 만들면, A가 구독해서 값을 받은 뒤 한참 뒤에 B가 구독해도 버퍼에 저장된 마지막 값을 즉시 받는다. 즉 "먼저 A가 구독해서 실행이 시작되고 값을 받은 다음, 시간이 지나 B가 구독하면 B는 소스를 다시 실행시키지 않고 캐시된 값을 즉시 받는다."
5. 실무 사용 사례
가장 흔한 사례는 HTTP 요청 결과를 여러 컴포넌트나 여러 구독처에서 공유해야 할 때다. 예를 들어 사용자 프로필 정보를 가져오는 옵저버블을 여러 UI 요소가 각각 구독한다면, share나 shareReplay를 적용해서 실제 네트워크 요청은 한 번만 나가도록 만든다. 이때 늦게 합류하는 구독자도 마지막 응답을 받아야 한다면 shareReplay(1)을 쓰는 것이 자연스럽다.
또 다른 사례는 설정값이나 인증 토큰처럼 자주 바뀌지 않지만 여러 곳에서 참조하는 상태를 캐싱할 때다. 이런 경우 shareReplay(1)로 만들어두면 처음 요청한 값을 앱 전체에서 재사용할 수 있다. 다만 이 값이 시간이 지나면 만료되어야 하는 경우, refCount 옵션과 함께 캐시 무효화 전략을 별도로 설계해야 한다.
웹소켓처럼 하나의 연결을 여러 구독자가 공유해야 하는 상황에서도 share 계열 연산자를 사용한다. 여러 화면이 같은 실시간 스트림을 구독할 때, 각 화면마다 별도의 웹소켓 연결을 만들지 않고 하나의 연결을 공유하게 만드는 용도다.
6. 주의할 점
가장 흔한 실수는 shareReplay를 쓰면서 refCount 옵션을 신경 쓰지 않는 경우다. refCount가 false인 상태로 두면, 모든 구독자가 사라져도 소스 구독이 해지되지 않고 계속 살아있어서 메모리 누수나 불필요한 리소스 점유로 이어질 수 있다. 반대로 refCount를 true로 설정하면, 구독자가 일시적으로 0명이 되었다가 다시 생길 때 소스가 처음부터 재실행되므로 캐시가 사라진다는 점도 기억해야 한다.
또 하나 주의할 점은 share와 shareReplay를 파이프라인의 어느 위치에 놓느냐에 따라 동작이 달라진다는 것이다. 예를 들어 source$.pipe(map(...), share())와 source$.pipe(share(), map(...))는 map이 공유되는 실행에 포함되는지 여부가 다르다. 앞쪽에 share를 넣으면 map 연산조차 구독자마다 따로 실행되므로, 원하는 지점에 정확히 배치해야 한다.
에러 처리 관점에서도 주의가 필요하다. 소스 옵저버블이 에러를 던지면 share가 만든 내부 Subject도 에러 상태로 전이되고, 이후 새로 구독하는 사람은 그 에러를 즉시 받게 된다. 재시도(retry) 로직이 필요하다면 share나 shareReplay를 적용하기 전에 retry를 먼저 배치해서, 에러가 나도 소스 자체가 재구독되도록 순서를 신경 써야 한다.
마지막으로 shareReplay의 bufferSize를 너무 크게 잡으면 메모리에 오래된 값이 쌓여서 불필요한 자원을 소비할 수 있다. 필요한 최소한의 개수만 버퍼링하는 것이 안전하다.
7. 핵심 정리
콜드 옵저버블은 구독자마다 독립적으로 실행되는 유니캐스트 방식이고, 멀티캐스팅은 Subject를 매개로 하나의 실행을 여러 구독자가 공유하는 방식이다. share는 참조 카운팅 기반으로 소스 실행을 공유하지만 늦게 합류한 구독자는 과거 값을 받지 못한다. shareReplay는 ReplaySubject를 내부에 사용해서 지정한 개수만큼 과거 값을 새 구독자에게 재생해주며, refCount 옵션에 따라 구독자가 사라졌을 때 소스 구독과 캐시를 정리할지 여부가 달라진다. 실무에서는 파이프라인 내 배치 위치, 에러 처리 순서, 버퍼 크기, refCount 설정을 함께 고려해서 적용해야 한다.