Memory Leak - Combine Sink, Swift Concurrency
시뮬에서 앱을 빌드해보면서 뭔가 이상했다.
왜 객체의 init메서드는 호출이 되는데 deinit 메서드는 호출이 되지 않지...?
앱을 사용해보면서 트래킹한 결과
메모리 Leak이 발생하고 있는 것을 확인할 수 있었다.
상황을 생각해 봤을 때 View에서 일어나는 Action이
View가 Appear 되는 시점에 viewModel에 task가 되었라는 Action을 취하고
.task {
viewModel.action(.viewOnTask)
}
구독을 통해 영화 상세 정보를 Fetch 해오게 된다.
input.viewOnTask
.sink {
Task { [weak self] in
guard let self else { return }
await fetchMovieInfo()
}
}
.store(in: &cancellable)
코드 상에서 강제적으로 구독을 해지시킨다면 어떻게 될까?
input.viewOnTask
.sink {
Task { [weak self] in
guard let self else { return }
await fetchMovieInfo()
}
}
.cancel()
위에서 계속 init만 일어나던 객체들이
구독을 강제적으로 cancel()
을 통해 해제 해준 결과
deinit이 잘 호출되는 것을 확인할 수 있다.
여기까지 확인해 봤을 때
객체들의 deinit이 일어나지 않은 이유는
구독이 해제가 되지 않기 때문이며
그로 인해 메모리 누수가 일어나고 있다고 판단이 되었다.
그렇지만 바로 cancel()
을 통해 해제해 버리는 경우
event를 방출하자마자 바로 구독이 해제가 되어버리는 것 같다.
그렇게 생각한 이유는 sink { }
내부 코드가 작동이 되지 않기 때문이다.
즉, 뷰의 내부 컨텐츠를 그려줄 데이터를 받아오지 못하고있다.
🔖 해결해야 하는 문제는 두 가지
- 구독 후
sink { }
내부에서 비동기로 API를 호출할 경우 해지가 되지 않는 현상 cancel()
을 통해 강제적으로 구독을 해지할 경우,sink { }
내부 코드가 작동이 되지 않는 현상
🔖 추가로 발견된 현상
sink내부에서 Task를 불러오는 것 만으로도 deinit이 되지 않는다.
즉, 구독 해제가 일어나지 않는다. 왜일까?
🔖 해결완료? Nope
생각보다 간단하게 해결되었다.
아니, 해결된 줄 알았다.send(completion: .finished)
를 통해 이벤트 방출이 완료됐음을
Task가 완료된 시점에 Publisher
에게 명시적으로 알려줬다.
문제는 이 send(completion: .finished)
코드는 스트림이 완료됐음을 명시하는
코드였던 것이다. 즉, 스트림을 종료시켜 버린 것이다.
그로 인해 생기는 문제가 존재한다.
앱에서는 네트워크를 모니터링하면서 인터넷에 연결이 안됐을 경우
예외 처리로 인터넷 연결에 문제가 있음을 사용자에게 문구를 통해
알리고 다시 인터넷 연결 후, 새로고침을 할 수 있도록 제공하고 있다.
하지만, 위의 코드로 해결할 경우 새로고침 버튼을 아무리 눌러도 아무런
액션이 일어나지 않는다. 왜냐하면 Task
가 종료되고 스트림이 종료됐기 때문이다.
지금까지의 상황으로 봤을 때 sink
의 비동기적인 스트림과 Task
의 비동기 네트워크 통신의
스트림은 순서가 다르게 작동한다. 즉, 두 스트림의 종료 시점이 다르기 때문에 문제가 발생한다.sink
내부는 동기적으로 작동이 되겠지만 Task
내부는 비동기적으로 코드가 작동될 것이다.
즉, sink
클로저는 종료가 되었지만, 내부의 Task
는 작동되고있는 것이다.
이 문제를 해결하기 위해서는 두 스트림을 맞춰줘야하지 않을까라는 생각이 들면서 sink
는
Combine의 스트림을 따르고 있지 않을까? 그렇다면 Task
도 Combine의 스트림을 따르도록
래핑해주면 해결이 되지 않을까 라는 생각을 하게 되었다.
이 생각에서 나온 결과는 바로 Combine 프레임워크의 Future
이다.
왜 Future
를 사용해야 하는가?
내가 원했던 부분은 Task
의 스트림이 종료되면 sink
의 스트림도 종료되어야 한다.
즉, 두 스트림의 종료 시점이 같아야 하는 것이다.sink
내부에서 Future
를 사용하게 되면 Future
는 값 혹은 에러에 대한 값이 방출되는
순간 스트림이 종료되면서 구독이 해제가 되고 sink
는 이 시점을 감지하여 ViewModel과 같은
객체가 deinit이 되는 순간 구독이 해제가 된다.
(Future
의 구독 해제 시점이 명확하기 때문에 감지가 가능)
결론적으로는 Task
를 Future
로 한 번 래핑하여 처리하였으며
값과 에러를 한 번에 처리하기 위해 Future
의 값에 해당하는 부분을 Result를 사용하였다.
결과적으로 더 이상 메모리 Leak
이 일어나지도 않고
deinit
도 잘 되는 것을 확인할 수 있다