문제상황

저는 최근 AsyncStream을 사용하여 실시간 코인 정보를 받아오는 애플리케이션을 개발하고 있습니다.

웹소켓과 같이 지속적으로 연결을 유지하면 전달되는 데이터의 경우 AsyncStream을 사용하면 직관적인 코딩을 할 수 있습니다.

예를들어 아래와 같이 코드작성이 가능합니다.

for await tickerVO in useCase.get24hTickerChange(symbolPair: symbolPair) {
    // 프로퍼티 업데이트
}

 

for-await문은 동시성 환경에서 실행되야 하기때문에 Task를 사용해서 실행해야합니다.

스트림을 연결하는 전체코드는 다음과 같습니다.

func listenToChangeInTickerStream() {
    Task { [weak self] in
        guard let self else { return }
        for await tickerVO in useCase.get24hTickerChange(symbolPair: symbolPair) {
            action.send(.updateTickerInfo(entity: tickerVO))
        }
    }
}

 

웹소켓을 사용하여 지속적으로 데이터를 받아오는 경우 for-await가 이론적으로 무한히 실행됩니다.

강한 self참조를 할경우 해당 객체는 무한히 메모리해제되지 않을 수 있습니다. 따라서 약한 참조를 사용하였습니다.

그런데 약한참조를 하여도 해당 객체가 메모리해제가 되지 않는 현상이 발생했습니다.

 

해당 예제 케이스만 따로 분류하여 실험을 해보았습니다.

아래 코드에서 MyViewModel은 MyView가 source of truth이기 때문에 MyView가 사라지면 함께 사라져야정상입니다.

 

struct ContentView: View {
    @State private var isPresent: Bool = false
    var body: some View {
        VStack {
            Button("Present") {
                isPresent = true
            }
        }
        .fullScreenCover(isPresented: $isPresent, content: {
            MyView()
        })
        .padding()
    }
}

struct MyView: View {
    @StateObject var viewModel: MyViewModel = .init()
    @Environment(\.dismiss) var dismiss
    var body: some View {
        ZStack {
            Color.red
            Button("돌아가기") {
                dismiss()
            }
            .onAppear { viewModel.startStream() //⭐️⭐️⭐️⭐️⭐️ }
        }
    }
}

class MyViewModel: ObservableObject {
    
    deinit {
        print("MyViewModel deinit")
    }
    
    private let sequence = AsyncStream { continuation in
        continuation.yield(1)
        continuation.yield(2)
        continuation.yield(3)
        
        continuation.onTermination = { _ in
            print("stream terminated")
        }
    }
    
    func startStream() {
        Task { [weak self] in
            guard let self else { return }
            for await element in sequence {
                
            }
        }
    }
}

 

 

deinit시 문자열을 출력되게한 후 실행해 보겠습니다.

같은 상황에 대해 deinit이 호출되지 않는 것을 확인할 수 있습니다.

 

 

문제해결

Sequence타입이 사용가능한 for-in문의 경우 컴파일러가 while문+ 이터레이터 객체로 변환하게 됩니다.

※ Sequence프로토콜에 대한 자세한 내용은 여기서 확인 부탁드립니다.

 

AsyncStream은 AsynceSequence타입으로 for-await문에도 비슷한 매커니즘이 적용됩니다.

현재 생각할 수 있는 점은 컴파일러가 코드를 변경하는 과정에서 의도치 않게 강한 참조가 발생한게 아닌가? 라고 추측할 수 밖에 없습니다.

예시 코드의 sequence프로퍼티의 경우 일반 클로저에 전달되게되는 경우 해당 프로퍼티를 소유하는 객체가 클로저에 강하게 참조됩니다.

// 클로저
{
  sequence사용 // sequence프로퍼티를 가지는 객체가 클로저에 강하게 참조
}

 

 

이 문제의 경우 강한 순한참조에 의해 발견되긴 하였지만 기본적으로 무한히 실행되는 Task블럭을 종료하여 무한 무한순회중인 시퀸스를 종료하는 것이 중요합니다.

메모리, 쓰레드가 낭비될 수 있고 무엇보다 예기치 못한 지금과 같은 상황이 발생할 수 있기 때문입니다.

 

명시적으로 Task를 deinit하는 코드를 추가하겠습니다

dismiss함수를 호출하기 전에 테스크를 취소하는 함수를 호출합니다.

struct MyView: View {
    @StateObject var viewModel: MyViewModel = .init()
    @Environment(\.dismiss) var dismiss
    var body: some View {
        ZStack {
            Color.red
            Button("돌아가기") {
            	// ⭐️⭐️⭐️⭐️⭐️
                viewModel.cancelTask()
                dismiss()
            }
            .onAppear { viewModel.startStream() }
        }
    }
}

class MyViewModel: ObservableObject {
    
    var task: Task<Void, Never>?
    
    deinit {
        print("MyViewModel deinit")
    }
    
    private let sequence = AsyncStream { continuation in
        continuation.yield(1)
        continuation.yield(2)
        continuation.yield(3)
        
        continuation.onTermination = { _ in
            print("stream terminated")
        }
    }
    
    func cancelTask() {
        task?.cancel()
    }
    
    func startStream() {
        task = Task { [weak self] in
            guard let self else { return }
            for await element in sequence {
                
            }
        }
    }

.

 

ViewModel이 메모리해제되는 것을 확인할 수 있습니다.

 

정리

  • 종료시점을 알 수 없는 AsyncStream의 순회는 반드시 명시적으로 종료하자, 강한 참조 트랩에 걸릴 수 있다.