구독 해제(teardown)와 메모리 누수 방지: Observable의 생명주기를 안전하게 마무리하는 방법
Observable을 구독(subscribe)하면 내부적으로 리소스가 할당되는데, 이를 제때 정리하지 않으면 메모리 누수와 예기치 않은 부작용이 발생한다. 이 글에서는 teardown 로직이 무엇이고 언제 실행되는지, 그리고 구독 해제(unsubscribe)를 어떻게 명시적으로 관리해야 하는지 다룬다. addEventListener와 removeEventL
1. 개념
teardown이란 구독(subscribe)이 종료될 때 실행되어야 하는 정리 로직을 말한다. RxJS에서 new Observable(subscriber => { ... }) 형태로 Observable을 만들 때, 함수 본문의 마지막에 함수를 하나 반환하면 그 함수가 teardown 로직으로 등록된다.
구독을 실행하면 subscribe()는 Subscription 객체를 반환하는데, 이 객체가 가진 unsubscribe() 메서드를 호출하면 등록해둔 teardown 함수가 실행된다. 즉 teardown은 "이 구독이 더 이상 필요 없어졌을 때 뒷정리를 담당하는 콜백"이다.
2. 왜 사용하는가
Observable 내부에서는 setInterval, addEventListener, WebSocket 연결, HTTP 요청 등 외부 리소스를 다루는 경우가 많다. 이런 리소스는 사용이 끝나면 명시적으로 해제해줘야 하는데, 해제하지 않으면 다음과 같은 문제가 생긴다.
먼저, 이미 화면에서 사라진 컴포넌트를 위해 만든 타이머나 이벤트 리스너가 계속 살아있으면서 메모리를 점유한다. 그다음, 콜백 안에서 이미 사라진 DOM 요소나 상태를 참조하려다 에러가 발생할 수 있다. 마지막으로, 불필요한 네트워크 요청이나 구독이 계속 실행되면서 서버나 클라이언트에 부하를 준다.
teardown은 이런 상황을 방지하기 위한 표준화된 정리 지점을 제공한다.
3. 동작 원리
Observable 생성자에 전달하는 함수(구독자 함수)는 subscriber를 인자로 받고, 그 안에서 subscriber.next(), subscriber.error(), subscriber.complete()를 호출해 값을 흘려보낸다. 이 함수가 마지막에 함수를 하나 반환하면 RxJS는 그것을 teardown으로 저장해둔다.
teardown이 실행되는 시점은 세 가지다. 첫째, 구독자가 명시적으로 subscription.unsubscribe()를 호출했을 때. 둘째, Observable 내부에서 complete()가 호출되어 스트림이 정상 종료되었을 때. 셋째, error()가 호출되어 스트림이 에러로 종료되었을 때. 세 경우 모두 RxJS가 내부적으로 teardown 함수를 자동으로 실행해준다.
또한 Subscription 객체는 add() 메서드를 통해 여러 개의 하위 구독이나 정리 함수를 하나로 묶을 수 있다. 부모 구독의 unsubscribe()를 호출하면 여기에 묶인 모든 teardown이 연쇄적으로 실행된다.
4. 예제
1초마다 숫자를 증가시키며 방출하는 Observable을 직접 만든다고 가정하자. 먼저 new Observable(subscriber => { ... }) 형태로 Observable을 만들고, 함수 본문에서 let count = 0을 선언한 다음 setInterval(() => subscriber.next(count++), 1000)으로 타이머를 등록한다. 그리고 함수의 마지막 줄에서 return () => clearInterval(intervalId)를 작성해 teardown 로직을 등록한다.
이 Observable을 구독할 때는 const subscription = counter$.subscribe(value => console.log(value))처럼 반환값을 변수에 저장해둔다. 그다음 setTimeout(() => subscription.unsubscribe(), 5000)을 실행하면, 5초 뒤 unsubscribe()가 호출되면서 앞서 등록했던 clearInterval 로직이 실행되어 타이머가 완전히 정리된다. 만약 이 반환값을 저장하지 않고 그냥 counter$.subscribe(...)만 호출하면, 이후 이 구독을 해제할 방법이 없어 타이머가 영원히 살아있게 된다.
여러 구독을 한 번에 관리하고 싶다면 const subscriptions = new Subscription()을 만들고, subscriptions.add(counter$.subscribe(...)), subscriptions.add(anotherStream$.subscribe(...)) 형태로 계속 추가한 뒤, 나중에 subscriptions.unsubscribe() 한 번만 호출하면 등록된 모든 구독이 한꺼번에 해제된다.
5. 실무 사용 사례
프런트엔드 프레임워크와 결합할 때는 컴포넌트가 화면에서 제거되는 시점에 구독을 해제하는 패턴이 흔하다. 예를 들어 React라면 useEffect 안에서 구독을 시작하고, useEffect가 반환하는 클린업 함수 안에서 subscription.unsubscribe()를 호출하는 식이다.
WebSocket이나 Server-Sent Events처럼 지속적인 연결을 다루는 Observable에서는 teardown 안에서 소켓을 close()하거나 연결을 종료하는 로직을 넣어야 페이지 이동 후에도 연결이 남아있는 문제를 막을 수 있다.
폴링(polling) 방식으로 주기적인 HTTP 요청을 보내는 스트림도 마찬가지로, 사용자가 해당 화면을 벗어나면 더 이상 요청을 보낼 필요가 없으므로 teardown에서 진행 중인 요청을 취소하거나 다음 요청 예약을 막는 처리를 해준다.
6. 주의할 점
take, first, takeUntil 같은 연산자를 사용하면 조건이 충족되는 순간 내부적으로 complete()가 호출되면서 teardown이 자동으로 실행된다. 따라서 매번 수동으로 unsubscribe()를 호출하지 않아도 되는 경우가 많으므로, 무조건 구독 변수를 저장해서 관리하기보다는 스트림 자체를 완결시키는 연산자를 우선 고려하는 편이 코드가 간결해진다.
에러가 발생해 error()가 호출된 경우에도 teardown은 실행되지만, 이후 같은 구독에서 값을 다시 받을 수는 없다는 점을 기억해야 한다. 스트림이 에러로 종료된 뒤 재시도가 필요하다면 retry 계열 연산자로 별도 처리해야 한다.
teardown 함수 내부에서 예외가 발생하면 그 예외가 그대로 던져질 수 있으므로, 리소스 해제 코드 안에 조건 체크(예: 타이머가 이미 정리되었는지 확인)를 넣어 방어적으로 작성하는 것이 안전하다.
마지막으로, 구독 시점에 반환값을 변수에 저장하지 않으면 나중에 해제할 방법이 없어진다는 점이 가장 흔한 실수다. 특히 반복문이나 조건문 안에서 구독을 생성하는 경우, 구독 목록을 배열이나 Subscription 객체에 모아두는 습관을 들여야 한다.
7. 핵심 정리
teardown은 Observable 구독이 끝날 때 실행되는 정리 콜백이며, Observable 생성 시 반환하는 함수로 등록한다. unsubscribe() 호출, complete(), error() 세 가지 상황에서 teardown이 자동 실행된다. 구독 객체를 반드시 변수에 저장해 나중에 해제할 수 있도록 관리해야 하며, 여러 구독은 Subscription.add()로 묶어서 한 번에 정리할 수 있다. take, takeUntil 같은 연산자를 활용하면 수동 해제 없이도 스트림을 자연스럽게 완결시켜 메모리 누수를 예방할 수 있다.