project/개발 업무

Swift Concurrency Task weak self 실험 정리

lgvv 2025. 7. 29. 23:19

Swift Concurrency Task weak self 실험 정리

 

Task에서 weak self 사용과 관련해서 실험들을 정리.

개념에 대한 부분을 따로 생략함.

 

Swift에서 Task를 사용할 때 self를 강하게 캡처하지만, 클로저가 종료될 때 메모리

 

Swift의 Task는 기본적으로 self를 강하게 캡처하기 때문에, Task의 수명이 self보다 길어질 경우 retain cycle 없이도 self deinit 호출이 지연될 수 있음.

따라서 Task가 더 오래 살아 있을 가능성이 있다면 [weak self]를 명시하는 것이 안전.

반면, Task의 수명이 self보다 짧고 일회성 작업에 그친다면 굳이 [weak self]를 사용할 필요는 없음.

 

목차

  • example_1
    • Task에 weak self를 명시하지 않아도 메모리 누수가 발생하지 않는지 확인
  • example_2
    • 취소 로직 없이 Task를 실행하는 기본 예제
  • example_3
    • [weak self]로 캡처하고, 비동기 작업 이후에 self를 언래핑하여 사용하는 예제
    • [weak self]로 캡처하고 Task 시작 시점에 self를 언래핑하는 예제
    • 정리
  • example_4
    • Task를 저장하고 명시적으로 취소하는 예제 [weak self] 미사용
    • Task를 저장하고 명시적으로 취소하는 예제 [weak self] 사용
    • 정리
  • example_5
    • 단순 옵저빙, 명시적 참조 없음 - 메모리 누수 없음
    • 취소 없는 옵저빙 - 메모리 누수
    • weak self를 사용하지만 guard let self else { return } await 이전에 위치 - 메모리 누수
    • weak self를 사용하지만 guard let self else { return } await 이후에 위치 - 메모리 누수 없음
    • Task를 저장하고 필요시 취소 - 메모리 누수 없음

 

 

 

example_1 (Task에 weak self를 명시하지 않아도 메모리 누수가 발생하지 않는지 확인)

weak self를 사용하지 않아도 메모리 누수가 발생하지 않음.

/// Task에서 weak self 없이도 메모리 누수가 발생하지 않는지 확인
    private func example_1() {
        Task {
            count += 1
        }
    }
    
/// 결과
ViewModel: init
ViewController: init
ViewController: deinit
ViewModel: deinit

 

 

example_2 (취소 로직 없이 Task를 실행하는 기본 예제)

ViewModel의 deinit이 APIService의 응답이 도착할 때 까지 지연.

해당 케이스는 의도와 다르게 동작할 수 있음.

/// 취소 로직 없이 Task를 실행하는 기본 예제
    private func example_2() {
        Task {
            try? await self.apiService.fetch()
            count += 1
        }
    }
    
    
/// 결과
ViewModel: init
ViewController: init
🛜 APIService: run
ViewController: deinit
🛜 APIService: response
ViewModel: deinit

 

example_3 ([weak self]로 캡처하고, 비동기 작업 이후에 self를 언래핑하여 사용하는 예제)

await 이후에 self를 언래핑하기 때문에, Task 내부에서 self를 불필요하게 오래 잡고 있지 않음.

비동기 작업 도중에 self가 해제되었다면(ViewModel deinit) 이후 로직은 실행되지 않으며, self의 생명 주기를 최소한으로 유지할 수 있음.

 /// [weak self]로 캡처하고, 비동기 작업 이후에 self를 언래핑하여 사용하는 예제
    /// - self가 fetch 도중 해제되면 이후 로직은 실행되지 않음
    /// - Task는 self를 오래 붙잡지 않음 (self 생존 기간 최소화)
    private func example_3_1() {
        Task { [weak self] in
            do {
                try await self?.apiService.fetch()
                guard let self else { return }
                count += 1
                print("count", count)
            } catch {
                print("catch")
            }
        }
    }
    
/// 결과
ViewModel: init
ViewController: init
🛜 APIService: run
ViewController: deinit
ViewModel: deinit
🛜 APIService: response

 

 

example_3 ([weak self]로 캡처하고 Task 시작 시점에 self를 언래핑하는 예제)

ViewModel의 deinit이 지연.

해당 케이스의 경우 `guard let self else { return }` 는 weak self를 로컬 strong 참조로 전환하기 때문에, 이 구문이 await 이전에 실행되면 self는 Task가 끝날 때까지 retain 됨. 결과적으로 self의 해제가 지연될 수 있어, 예상보다 늦게 deinit이 호출될 수 있음.

 

/// [weak self]로 캡처하고 Task 시작 시점에 self를 언래핑하는 예제
    /// - Task가 self를 strong하게 붙잡기 때문에 self의 해제가 지연될 수 있음
    /// - fetch 실행 전에 self가 해제되면 Task는 바로 종료됨
    private func example_3_2() {
        Task { [weak self] in
            guard let self else { return }
            do {
                try await self.apiService.fetch()
                count += 1
                print("count", count)
            } catch {
                print("catch")
            }
        }
    }
    
/// 결과
ViewModel: init
ViewController: init
🛜 APIService: run
ViewController: deinit
🛜 APIService: response
count 1
ViewModel: deinit

 

 

example_3 정리

weak self를 사용할 경우, 모든 상황에서 Task.isCancelled를 정상적으로 처리하지 못할 수도 있음. 이는 구조적 동시성(structured concurrency) 안에서 취소 신호가 제대로 전파되지 않는 경우에 발생하며, 그로 인해 예상치 못한 사이드 이펙트가 발생할 수 있음.

 

 

 

example_4 

예제 4 부터는 ViewModeldeinittasks 변수 때문에 호출되지 않음.

동작 확인 및 단계별 코드 작성을 위해 임시로 ViewController의 라이프사이클에 위임해서 처리함.

실서비스에서는 뷰가 가려질 때 사이드이펙트 발생 가능하므로 해당 방식 사용하지 않음.

 

다른 객체에서 취소를 수행하거나, RIBs 사용 중이라면 detach 타이밍에 cancel 호출하는 방식도 좋음.

 

example_4 (Task를 저장하고 명시적으로 취소하는 예제 [weak self] 미사용)

명시적으로 취소하고 있어서 메모리 누수가 발생하지 않음.

/// Task를 저장하고 명시적으로 취소하는 예제 (weak self 미사용)
    private func example_4_1() {
        tasks[.apiService]?.cancel()
        tasks[.apiService] = Task {
            do {
                try await self.apiService.fetch()
                guard !Task.isCancelled else {
                    print(#function, "cancelled")
                    return
                }
                count += 1
                print("count", count)
            } catch {
                print("catch")
            }
        }
    }

/// 결과
ViewModel: init
ViewController: init
🛜 APIService: run
🆑 cancelAllTasks
🛜 APIService: isCancelled
example_4_1() cancelled
ViewController: deinit
ViewModel: deinit

 

 

example_4 (Task를 저장하고 명시적으로 취소하는 예제 [weak self] 사용)

명시적으로 취소하고 있어서 메모리 누수가 발생하지 않음.

/// Task를 저장하고 명시적으로 취소하는 예제 (weak self 사용)
    private func example_4_2() {
        tasks[.apiService]?.cancel()
        tasks[.apiService] = Task { [weak self] in
            do {
                try await self?.apiService.fetch()
                guard let self else { return }
                guard !Task.isCancelled else {
                    print(#function, "cancelled")
                    return
                }
                count += 1
                print("count", count)
            } catch {
                print("catch")
            }
        }
    }
    
/// 결과
ViewModel: init
ViewController: init
🛜 APIService: run
🆑 cancelAllTasks
🛜 APIService: isCancelled
example_4_2() cancelled
ViewController: deinit
ViewModel: deinit

 

example_4 정리

예제 4에서는 Task를 딕셔너리에 저장하고 명시적으로 취소하는 구조를 실험.

[weak self] 없이 strong하게 캡처한 경우(4_1)에도 cancel 후 정상 해제되며 릭은 발생하지 않았고, [weak self]를 사용한 구조(4_2)는 동일한 동작을 보여줌.

 

아래 전체 코드에는 예제 4_4 번까지 존재함.


example_5_1 (단순 옵저빙, 명시적 참조 없음 - 메모리 누수 없음)

명시적으로 참조하는게 없어서 메모리 해제

/// [기본 예제] 단순 옵저빙 - 명시적 참조 없음 → ViewModel 해제 가능
    private func example_5_1() {
        Task {
            for await _ in EventBus.shared._stream {
            }
        }
    }

/// 결과
ViewModel: init
ViewController: init
🆑 cancelAllTasks
ViewController: deinit
ViewModel: deinit

 

example_5_2 (취소 없는 옵저빙 - 메모리 누수)

count 사용으로 self가 strong 캡처되어 ViewModel 해제 안 됨.

/// [메모리 누수 발생] count 사용으로 self가 strong 캡처됨 → ViewModel 해제 안 됨
    private func example_5_2() {
        Task {
            count += 1
            for await _ in EventBus.shared._stream {
            }
        }
    }
    
/// 결과    
ViewModel: init
ViewController: init
🆑 cancelAllTasks
ViewController: deinit

 

example_5_3 (weak self를 사용하지만 guard let self else { return } await 이전에 위치 - 메모리 누수)

 

weak self로 시작하지만 guard let self가 await 이전에 실행되어 strong 참조됨

/// [메모리 누수 발생] weak self로 시작하지만 guard let self가 await 이전에 실행되어 strong 참조됨
    private func example_5_3() {
        Task { [weak self] in
            guard let self else { return }
            count += 1
            for await _ in EventBus.shared._stream {
            }
        }
    }

/// 결과
ViewModel: init
ViewController: init
🆑 cancelAllTasks
ViewController: deinit

 

example_5_4 (weak self를 사용하지만 guard let self else { return } await 이후에 위치 - 메모리 누수 없음)

await 이후에 self를 언래핑 → Task가 self를 강하게 붙잡지 않음

/// [메모리 누수 없음] await 이후에 self를 언래핑 → Task가 self를 강하게 붙잡지 않음
    private func example_5_4() {
        Task { [weak self] in
            self?.count += 1
            for await _ in EventBus.shared._stream {
                guard let self else { return }
                self.count += 1
            }
        }
    }
    
/// 결과
ViewModel: init
ViewController: init
🆑 cancelAllTasks
ViewController: deinit
ViewModel: deinit


example_5_5 (Task를 저장하고 필요시 취소)

Task를 저장하고 필요 시 취소 가능 → 구조적 정리 가능

/// [메모리 누수 없음] Task를 저장하고 필요 시 취소 가능 → 구조적 정리 가능
    private func example_5_5() {
        tasks[.observe]?.cancel()
        tasks[.observe] = Task {
            for await _ in EventBus.shared._stream {
                count += 1
            }
        }
    }

/// 결과
ViewModel: init
ViewController: init
🆑 cancelAllTasks
ViewController: deinit
ViewModel: deinit

 

 

 

전체 코드

동작은 #Preview에서 NavigationView의 Back Button을 누르면서 확인

APIService와 화면 전체 코드필요

import UIKit
import SwiftUI

@MainActor
final class ViewModel {
    
    private var count: Int = 0
    private let apiService: APIService = APIService()
    
    private var tasks: [CancelID: Task<Void, Never>] = [:]
    
    enum CancelID: Hashable {
        case apiService
    }
    
    enum Action {
        case viewDidLoad
    }
    
    init() {
        print("ViewModel: init")
        
        bind()
    }
    
    deinit {
        print("ViewModel: deinit")
    }
    
    func send(action: Action) {
        switch action {
        case .viewDidLoad:
            // do something
            break
        }
    }
    
    private func bind() {
        
    }
    
}

final class ViewController: UIViewController {
    
    private let viewModel = ViewModel()
    
    init() {
        super.init(nibName: nil, bundle: nil)
        view.backgroundColor = .cyan
        
        print("ViewController: init")
    }
    
    deinit {
        print("ViewController: deinit")
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewModel.send(action: .viewDidLoad)
    }
}

#Preview {
    let viewController = ViewController()
    let navigationController = UINavigationController(
        rootViewController: UIViewController()
    )
    navigationController.pushViewController(
        viewController,
        animated: false
    )
    return navigationController
}

 

 

 

(참고)

 

 

 

 

 

'project > 개발 업무' 카테고리의 다른 글

l-value, r-value  (0) 2025.10.23
Actor에서 Class + OSAllocatedUnfairLock  (2) 2025.08.06
Tuist CocoaPod 연동  (0) 2025.07.05
(Concurrency, Combine) 전역 이벤트 관리  (1) 2025.05.31
iOS 앱 크기 줄이기  (1) 2024.12.29