[Swift Concurrency] Task에서 retain cycle은 어떻게 관리해야 할까?

이정훈·2025년 3월 31일
0

Swift Concurrency

목록 보기
3/5
post-thumbnail

기존 클로저에서 참조 관리

클로저에서 self 키워드를 사용하면 클로저가 호출되는 시점에 self가 메모리에 남아 있다는 것을 보장할 수 없기 때문에 클로저self강한 참조한다. 예를 들어 네트워크 요청과 같이 비교적 시간이 오래 걸리는 작업에서 클로저self를 강한 참조하면 네트워크 요청이 종료될 때까지 self레퍼런스 카운트가 감소하지 않아 메모리에서 해제되지 않게 된다. 이는 잠재적인 메모리 누수로 이어질 가능성이 있기 때문에 클로저 매개변수 앞에 캡쳐 목록을 통해 weak self를 통해 self약한 참조 하게 만들 수 있다. 가령 아래와 같은 상황에서 말이다.

class ViewModel: ObservableObject {
	@Published var content: String = ""
	...

	func fetch() {
		repository.fetch()
			.receive(on: DispatchQueue.main)
			.sink { [weak self] content in    //self 약한 참조
				self?.content = content
			}
			.store(in: &cancellables)
	}
}

Swift Concurrency에서 참조 관리

Swift Concurrency에서 비동기 작업은 Task 단위로 실행된다. 이때, Task를 생성하면서 수행할 비동기 작업을 클로저로 전달하게 되는데 이때도 마찬가지로 외부의 참조가 필요한 경우 강한 참조를 하게 된다. 하지만 일반적인 경우 Task에 전달되는 클로저self를 약한 참조하지 않아도 되는데, 그 이유는 Task가 생성되면 그 즉시 클로저는 실행되고 클로저의 실행이 끝나면 self 또한 즉시 메모리에서 해제된다.

기본적으로 Task로 전달되는 클로저에서 self 키워드를 표기해도 되고, 안 해도 되는데, Task.swift 깃허브 레포지토리의 생성자 선언를 보면 @_implicitSelfCapture 어트리뷰트를 통해 탈출 클로저에서 self 키워드를 생략해도 암시적으로 self를 캡쳐할 수 있도록 되어 있다.

하지만 네트워크 통신 같이 시간이 걸리는 작업을 Task로 전달하는 클로저에서 실행하는 경우, 만약 해당 작업이 시간이 오래 걸리거나, 영영 끝나지 않는 경우 강한 참조된 인스턴스는 참조 카운트가 유지되어 메모리에서 해제되지 못 하고 메모리 누수를 일으키게 된다.

이런 비슷한 상황을 구현한 아래의 코드를 통해 다시 살펴보자.

class Foo {
    var bat: String?

    deinit {
        print("Foo deinit")
    }

    func foobar() {
        Task {
	        do {
	            try await Task.sleep(for: .seconds(3))
	            bat = "Hello Swift!"
	            print(bat)
            } catch {
	            print(error)
            }
        }
    }
}

var foo: Foo? = .init()
foo?.foobar()
foo = nil
print(foo)

// nil
// Optional("Hello Swift!")
// Foo deinit

위의 상황처럼 Task클로저 내부에서 self가 캡쳐되어 있고, Task는 3초의 시간이 걸리기 때문에 foonil을 할당하더라도 foodeinit가 바로 호출되지 않고 Task 작업이 온전히 종료된 후에 deinit가 호출 되는 것을 확인할 수 있었다.

이러 상황을 해결하기 위해서 기존 클로저에서 사용했던 것처럼 Task에 전달하는 클로저캡쳐 목록selfweak 키워드를 통해 약한 참조를 만들면 메모리 누수를 해결할 수 있다.

class Foo {
    var bat: String?
    
    deinit {
        print("Foo deinit")
    }
    
    func foobar() {
        Task { [weak self] in
	        do {
	            try await Task.sleep(for: .seconds(3))
            
	            guard let self else {
	                print("self is deallocated")
	                return
	            }
	            bat = "Hello Swift!"
	            print(bat)
            } catch {
	            print(error)
            }
        }
    }
}

var foo: Foo? = .init()
foo?.foobar()
foo = nil
print(foo)

// Foo deinit
// nil
// self is deallocated

결과적으로 메모리 누수의 상황은 피하긴 했으나, 위의 상황도 썩 달갑지만은 않다. Foo 클래스의 인스턴스는 약한 참조를 통해 메모리에서 해제 되었지만 아직 Task는 끝나지 전까지 메모리에 남아 있다가 self is deallocated를 출력하는 것을 확인할 수 있었다.

따라서 self가 메모리에서 해제될 때 관련된 Task 또한 함께 취소하는 것이 좋다.

Task가 생성되면 그 즉시 클로저는 실행되고 클로저의 실행이 끝나면 self 또한 즉시 메모리에서 해제된다.

위에서 언급한 Task의 동작 방식과 Task의 생명 주기를 관리할 수 있는 .cancel() 메서드를 잘 조합하면 self약한 참조하지 않아도 메모리를 안전하게 관리할 수 있다고 생각한다.

먼저, .cancel() 메서드를 사용하기 위해서는 아래의 코드와 같이 생성된 Task를 저장할 변수가 필요하다.

class Foo {
    var bat: String?
    var task: Task<Void, Never>?
    
    deinit {
        print("Foo deinit")
    }
    
    func foobar() {
        task = Task {
            try? await Task.sleep(for: .seconds(3))
            bat = "Hello Swift!"
            print(bat)
        }
    }
}

var foo: Foo? = .init()
foo?.foobar()
foo?.task?.cancel()
foo = nil
print(foo)

// nil
// Optional("Hello Swift!")
// Foo deinit

그리고 selfnil을 할당하기 전에 .cancel() 메서드를 호출하면 현재 진행 중인 Task의 작업과 앞으로 생성될 작업까지 모두 취소되고 self 메모리에서 안전하게 해제되며 메모리 누수를 방지할 수 있다.

이러한 취소 작업은 View 생명주기와 함께 사용하면 효과적으로 사용할 수 있다고 생각하는데, 예를 들어 아래와 같이 View가 사라지면 더 이상 진행 중인 비동기 작업은 필요가 없게 되므로 viewDidDisappear(_:)에서 .cancel()을 호출하여 메모리 누수를 안전하게 방지할 수 있고 생각한다.

import UIKit

class ViewModel {
    var task: Task<Void, Never>?
    
    func fetch() {
        task = Task {
            // do something
        }
    }
}

class SomeViewController: UIViewController {
    ...
    
    override func viewDidDisappear(_ animated: Bool) {
        viewModel.task?.cancel()
    }
}
profile
새롭게 알게된 것을 기록하는 공간

0개의 댓글