먹고 기도하고 코딩하라

[ReactorKit] 통신 중에 Reactor가 deinit되면 어떻게 될까? 본문

앱/Swift

[ReactorKit] 통신 중에 Reactor가 deinit되면 어떻게 될까?

사과먹는사람 2024. 1. 11. 21:43
728x90
728x90

 

지난달에 ReactorKit 사내 세미나를 열었다. ReactorKit에 대해 간단히 설명하고, 프로젝트 일부 코드를 ReactorKit으로 부분적으로 전환했을 때 얻을 수 있는 장점 등을 설명한 다음, 회사 코드 일부를 ReactorKit으로 바꿔서 시연했다.

세미나가 끝나고 2가지 질문이 나왔다:

  • mutate에서 통신 값으로 Observable<Mutation>을 return하게 하려면 기존에 쓰던 클로저 방식으로는 안 되는 건가?
    • 즉, Observable<ResponseType>을 반환하는 새로운 통신 함수를 작성하는 게 필수인가? 클로저로 값을 넘겨주는 건 안 되는 건가?
  • 통신 중에 Reactor와 연동된 View가 사라지면 통신 옵저버블은 어떻게 되는가? 메모리 릭의 원인이 될 수 있지 않은가?

첫 번째는 일단 클로저 방식으로는 하기 어려울 것이라고 답변을 드렸다. 기존 방식이라면 콜백으로 처리해야 한다는 건데, 스트림에서 나오는 값을 구독해 처리한다는 반응형 프로그래밍과 상성이 좋지 않은 방식이라고 생각했기 때문이다.

그런데 두 번째 질문을 듣고는 선뜻 답변을 드릴 수 없었다. 나도 헷갈렸다. View야 disposeBag을 갖고 있으니 액션과 상태 바인딩에서 나오는 Disposables들을 DisposeBag에 담아 deinit될 때 함께 소멸시킬 수 있지만, Reactor의 mutate 내에서 실행되는 통신에 대해서는 어떤가?

 

그래서 테스트해봤다.

1. 일단 View가 화면에서 사라질 때 reactor = nil로 만들기로 했다.

2. 그럼 StoryboardView 혹은 View의 reactor setter가 실행된다.

extension StoryboardView {
  public var reactor: Reactor? {
    get { return MapTables.reactor.value(forKey: self) as? Reactor }
    set {
      MapTables.reactor.setValue(newValue, forKey: self)
      self.isReactorBinded = false
      self.disposeBag = DisposeBag()
      self.performBinding()
    }
  }
}

MapTables는 WeakMapTable이다. (ReactorKit 패키지를 import하면 함께 오는)

WeakMapTable의 용도는 스레드 안전성과 weak 레퍼런스들을 조금 더 잘 다룰 수 있게 해주는(?) 역할을 한다고 문서에 소개되어 있다.

NSMapTable의 weakToStrongObjects()가 key 객체가 deallocated됐을 때에도 value 객체를 release하지 않는 반면 WeekMapTable은 key 객체가 deallocated되면 value 객체도 release한다는 설명이다.

3. 지금은 reactor를 할당 해제하고 있다. value가 nil이기 때문에 dictionary.removeValue한다.

final public class WeakMapTable<Key, Value> where Key: AnyObject {
  public func setValue(_ value: Value?, forKey key: Key) {
    let weakKey = Weak(key)

    self.lock.lock()
    defer {
      self.lock.unlock()
      if value != nil {
        self.installDeallocHook(to: key)
      }
    }

    if let value = value {
      self.dictionary[weakKey] = value
    } else {
      self.dictionary.removeValue(forKey: weakKey)
    }
  }
}

콜스택에서 더 위로 올라가보면,

4. DeallocHook이 deinit되며 handler()가 실행된다.

이 DeallocHook은 WeakMapTable의 installDeallocHook(to:Key) 메소드에서 훅을 거는데 사용된다. 음.. 자원에 대한 경합을 막기 위한 락이 아닐까 생각한다.

이 핸들러에는 DeallocHook이 deinit될 때 락을 걸고 딕셔너리에서 키에 대한 값을 지운 다음 락을 푸는 작업이 있다.

private func installDeallocHook(to key: Key) {
    let isInstalled = (objc_getAssociatedObject(key, &deallocHookKey) != nil)
    guard !isInstalled else { return }

    let weakKey = Weak(key)
    let hook = DeallocHook(handler: { [weak self] in
      self?.lock.lock()
      self?.dictionary.removeValue(forKey: weakKey)
      self?.lock.unlock()
    })
    objc_setAssociatedObject(key, &deallocHookKey, hook, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
  }

private final class DeallocHook {
  private let handler: () -> Void

  init(handler: @escaping () -> Void) {
    self.handler = handler
  }

  deinit {
    self.handler()
  }
}

5. 이제 Reactor가 메모리에서 사라진다. 그러면 Reactor가 갖고 있던 State, DisposeBag 등이 함께 사라지게 된다.

 

막간을 이용해 홍보 좀 하자면 Reactor가 웬 disposeBag을 갖고 있느냐는 의문이 드는 독자라면 이 글을 참고하시면 도움이 된다.

 

[ReactorKit] Reactor도 disposeBag이 있을까?

결론부터 얘기하면 있다. ReactorKit의 Reactor 파일을 살펴보면 extension에서 disposeBag을 찾을 수 있다. extension Reactor { fileprivate var disposeBag: DisposeBag { return MapTables.disposeBag.value(forKey: self, default: DisposeBag

dev-dain.tistory.com

6. disposeBag이 deinit되면서 백 내부의 Disposable들을 메모리에서 풀어주는 dispose가 실행된다.

여기서부터는 DisposeBag의 동작 원리에 가까운 내용이라 생략생략하며 지나간다.

public final class DisposeBag: DisposeBase {
		/// This is internal on purpose, take a look at `CompositeDisposable` instead.
    private func dispose() {
        let oldDisposables = self._dispose()

        for disposable in oldDisposables {
            disposable.dispose()
        }
    }

    deinit {
        self.dispose()
    }
}

subscription에 대한 dispose도 실행되고… 계~속 dispose가 실행된다.

RxSwift를 공부해본 독자라면 Observable.create가 subscribe 이스케이핑 클로저를 파라미터로 받아 생성되며, 이 subscribe 클로저는 Disposable을 반환한다는 것을 알고 있을 것이다.

그리고 이 Disposable을 만들 때도 클로저를 넣을 수 있다. Disposables.create할 때, 이렇게 생성될 Disposables가 dispose될 때 실행될 이스케이핑 클로저(disposeAction이 됨)를 받게 된다.

extension ObservableType {
		public static func create(_ subscribe: @escaping (AnyObserver<Element>) -> Disposable) -> Observable<Element> {
        AnonymousObservable(subscribe)
    }
}
extension Disposables {

    /// Constructs a new disposable with the given action used for disposal.
    ///
    /// - parameter dispose: Disposal action which will be run upon calling `dispose`.
    public static func create(with dispose: @escaping () -> Void) -> Cancelable {
        AnonymousDisposable(disposeAction: dispose)
    }

}

 

통신하는 Observable을 만들면서, 통신 완료 여부와 상관없이 dispose되면 통신을 취소하는 메소드를 클로저에 추가했다. 어떤 이유에서든지 dispose될 때 요청이 취소될 수 있도록 하게끔 말이다.

7. disposeBag에 담긴 요청에 대한 Disposable이 할당 해제되며, 요청이 진행되고 있었다면 취소된다.

func request() -> Observable<ResponseType> {
		// 코드
		return Observable.create { observer -> Disposable in 
				// 코드
				request = AF.request...
				return Disposables.create { request.cancel() }
		}
}

 

위에 링크를 걸었던 글에서 설명했듯, Reactor도 disposeBag을 가진다. 그리고 Reactor의 state에 접근할 때마다 state의 action → mutation → 새로운 state 방출로 돌아오는 한 사이클에 대한 관찰 끝의 결과인 state를 connect해 disposeBag에 넣고 있다.

 

결과적으로 disposeBag에 이 요청 역시 포함이 된다. mutate 메소드 내 요청 API가 호출됐다는 것은 결국 reactor에 바인딩한 액션이 트리거됐기 때문에, 요청 과정을 포함해서 Mutation으로 변경되고 State로 반환되는 한 사이클을 모두 disposeBag이 관장한다~ 이렇게 여겨진다.

실제로 createStateStream 내부에서도 액션, 뮤테이션 변환 중 에러가 있다면 .empty()로 잡기 때문에 통신 과정 중 에러가 있거나 뷰를 빠져나가 reactor가 사라지는 등의 상황이 있을 때도 안전하게 deallocated될 수 있다.

 

 

728x90
반응형
Comments