피어 세션을 진행하며 동료가 겪었던 트러블 슈팅을 공유 받았었습니다.
Task 블럭 내에서의 self 캡쳐와 관련된 내용이었는데, 당시 Concurrency에 대해 잘 몰라 이해가 가지 않았고 이해하기 위해 직접 실험해보았습니다!
weak self 를 하는 이유
Swift에서의 이스케이핑 클로저에서 self에 접근하려고 하면 명시적으로 self를 선언하라는 컴파일 에러가 아래와 같이 발생합니다.
이는 self의 프로퍼티로 할당된 클로저가 self를 캡쳐하면 클로저와 self 인스턴스 사이에 순환참조가 일어나 메모리 누수가 발생할 수 있는 경우를 방지하기 위해 Swift에서 띄워주는 컴파일 에러입니다.
이러한 문제를 해결하기 위해 이스케이핑 클로저에서는 self를 약하게 캡쳐해 순환 참조를 방지하도록 합니다.
Task 에서 self를 명시적으로 캡쳐하지 않아도 되는 이유
그러나 Task 블럭 내에선 self를 명시적으로 캡쳐할 필요가 없습니다.
Task의 경우, 전달된 클로저가 즉시 실행되며 클로저의 실행이 종료되면 즉시 내부 클로저가 소멸되고 클로저 내부에서 캡쳐된 self가 해제됩니다.
Swift 저장소의 Task 이니셜라이저 부분을 확인해보면 @_implicitSelfCapture 라는 어트리뷰트를 볼 수 있는데, 이 어트리뷰트를 통해 이스케이핑 클로저임에도 self를 생략하도록 할 수 있습니다.
출처: Swift — Task에서 weak self는 언제 쓸까요?
Task에서 weak self를 사용하는 경우도 존재
Task 블럭 내에서 self를 생략할 수 있는 이유는 순환 참조가 발생할 확률이 없기 때문이지만, self 캡쳐는 발생합니다.
그렇기 때문에 self를 생략가능하다고 해서 메모리 누수가 아예 발생하지 않는 것은 아닙니다.
오래 걸리는 비동기 작업의 경우, 작업이 끝날 때까지 self가 릴리즈되지 않기 때문에 메모리 누수가 발생할 수 있습니다.
이런 경우 self를 명시적으로 약한 참조로 캡쳐해 릴리즈가 지연되는 문제를 해결해야 합니다.
글로만 설명하면 와닿지 않을 것 같아 코드와 실행결과를 통해 알아보겠습니다.
암시적인 self 캡쳐를 하는 경우
스코프 내에서 worker 인스턴스를 생성하고 3초가 걸리는 비동기 작업을 수행하는 예시입니다.
결과를 예상 해보자면 start() 함수는 즉시 리턴되고 스코프를 벗어나게 될테니 Worker 인스턴스는 릴리즈될 것이고 스코프 종료 -> deinit -> 새로운 스코프
순으로 진행될까요?
class Worker {
var result: String = "None"
init() { print("Worker init") }
deinit { print("Worker deinit. result: \(result)") }
func start() {
Task { in
try! await Task.sleep(for: .seconds(3))
result = "작업 완료"
}
}
}
if true {
let worker = Worker()
worker.start()
print("스코프 종료")
}
print("새로운 스코프")
결과는 예상과 달랐습니다.
그 이유는 위에서 설명했듯이 오래 걸리는 비동기 작업이 있으면 작업이 끝날 때까지 self가 릴리즈되지 않기 때문입니다.
self를 명시적으로 weak self 캡쳐하는 경우
이번에는 weak self
로 참조하는 경우를 보겠습니다.
weak self로 캡쳐를 했음에도 result를 self 없이 사용할 수가 있습니다. 이는 캡쳐된 self를 사용한 것이 아니고 암시적으로 캡쳐된 self가 자동으로 사용된 것으로 이렇게 사용할 경우 객체가 여전히 Task의 스코프에 강하게 참조됩니다. self를 생략하지 않도록 주의해야 합니다. Swift 6에선 이렇게 선언할 경우 컴파일 오류를 내도록 바뀔 예정입니다.
출처: Swift — Task에서 weak self는 언제 쓸까요?
// ...
func start() {
Task { [weak self] in
try! await Task.sleep(for: .seconds(3))
self?.result = "작업 완료"
}
}
// ... 생략
재밌는 결과입니다.
스코프를 벗어나자마자 worker 인스턴스는 릴리즈되었고 스코프 종료 -> deinit -> 새로운 스코프
순으로 진행되었습니다.
결과 해석
어떻게 이런 결과가 나왔을까요?
그 이유는 ARC의 동작에 있습니다.
Swift는 ARC를 통해 메모리를 관리하는데, retain count가 남아 있다면 참조를 유지하고, retain count가 0이되는 순간 메모리에서 참조를 해제합니다.
retain count를 출력해보면 이해가 빠르실 것 같습니다.
암시적인 self 캡쳐를 하는 경우
func start() {
Task {
try! await Task.sleep(for: .seconds(3))
result = "작업 완료" // 암시적인 강한 캡쳐 발생
}
}
// ... 생략
if true {
let worker = Worker() // retain count 증가
printRetainCount(worker)
worker.start() // retain count 증가
printRetainCount(worker)
print("스코프 종료")
} // retain count 감소
print("새로운 스코프")
// 3초 뒤 클로저가 종료되고 retain count 감소
// 릴리즈되어 deinit 호출
self를 명시적으로 weak self 캡쳐하는 경우
func start() {
Task { [weak self] in
try! await Task.sleep(for: .seconds(3))
self?.result = "작업 완료" // 약한 캡쳐
}
}
// ... 생략
if true {
let worker = Worker() // retain count 증가
printRetainCount(worker)
worker.start() // retain count 증가하지 않음
printRetainCount(worker)
print("스코프 종료")
} // retain count 감소
// 릴리즈되어 deinit 호출
print("새로운 스코프")
self가 릴리즈될 때 비동기 작업도 취소하기
class Worker {
var result: String = "None"
var task: Task<Void, Never>? // <-
deinit {
task?.cancel() // <- 릴리즈 시 작업 취소
}
func start() {
task?.cancel() // <- 중복 작업 방어 코드
task = Task { [weak self] in // <- 취소할 수 있도록 변수에 담음
do {
// try catch 를 사용해 작업이 취소되었을 때 아래 코드 실행 방지
try await Task.sleep(for: .seconds(3))
guard let self else { return }
self.result = "작업 완료"
} catch {}
}
}
}
약한 참조를 사용해 self가 메모리에서 사라지더라도 비동기 작업은 여전히 진행되고 있을 것입니다.
해당 비동기 작업이 만약 서버 요청이라면 리소스 절약 측면에서 작업을 취소해주는 게 효율적일 것입니다. (또 다른 예시가 있을 것 같은데 떠오르는게 이거밖에 없네요,,)
위 코드의 흐름은 Task.sleep()
이 취소되면 Task.CancellationError
가 방출되는데, 다음 코드 진행을 막고 catch 블록으로 빠지게 됩니다.
실행 결과
스코프가 종료되면서, 인스턴스가 릴리즈될 때 task가 cancel 되는 것을 볼 수 있습니다.
task?.isCancelled 프로퍼티를 사용해 출력했습니다.
guard let self 위치의 중요성 (아직 잘 이해가 가지 않음..)
guard 문을 사용해 self를 바인딩하는 경우에도 주의해야합니다.
오래 걸리는 비동기 작업 이후에 바인딩을 해야 정상적으로 코드 흐름을 중단할 수 있습니다.
습관처럼 맨 첫줄에 self를 바인딩하게 될 경우 스코프 내에서 강하게 참조되어 스코프가 종료될 때까지 self가 릴리즈되지 않습니다. 결국 약한 참조로 캡쳐한 이유가 없어지게 되는 셈입니다.
func start() {
task = Task { [weak self] in
do {
guard let self else { return }
try await Task.sleep(for: .seconds(3))
self.result = "작업 완료"
} catch {}
}
}
func start() {
task = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(3))
guard let self else { return }
self.result = "작업 완료"
} catch {}
}
}
아직 이 부분이 이해가 잘안되서 그런지 아래 코드에서 didSet이 없으면 guard문이 맨윗줄에 있어도 정상적으로 릴리즈되던데 이유를 모르겠네요ㅜㅜ
func printRetainCount(_ cf: CFTypeRef!) {
print("retain count:", CFGetRetainCount(cf) - 1)
}
class Worker {
var result: String = "None"
var task: Task<Void, Never>?
{
didSet {
print("task isCancelled:", task?.isCancelled ?? "")
}
}
// didSet 이 부분만 없으면 guard let self을 맨윗줄에 해도 괜찮음 뭐지..?
init() { print("Worker init") }
deinit {
task?.cancel()
print("task isCancelled:", task?.isCancelled ?? "")
print("Worker deinit. result: \(result)")
}
func start() {
task = Task { [weak self] in
do {
guard let self else { return }
try await Task.sleep(for: .seconds(3))
self.result = "작업 완료"
} catch {}
}
}
}
if true {
let worker = Worker()
printRetainCount(worker)
worker.start()
printRetainCount(worker)
print("스코프 종료")
}
print("새로운 스코프")
RunLoop.main.run() // CLI 환경에서 프로그램 종료를 막기 위해
결론
- Task 블럭 내에서 self의 참조를 명시하지 않으면 암시적인 강한 참조가 발생한다.
- 그렇기 때문에 약한 참조가 필요한 경우도 있다.
- 약한 참조를 사용할 경우, 릴리즈될 때 task를 취소하도록 하면 좋다.
- 비동기 작업이 끝나고 난 뒤에 self 를 guard let 바인딩해야 한다. (이건 확실치 않음)
참고 링크
'iOS' 카테고리의 다른 글
Swift Concurrency - Sendable (2) | 2024.10.25 |
---|---|
Swift Concurrency - Task (1) | 2024.10.24 |
SwiftUI 프로젝트에서 AppDelegate, SceneDelegate 사용하기 (0) | 2024.10.12 |
SwiftLint SPM으로 설치하기 (2) | 2024.10.12 |
View Draw Cycle (4) | 2024.10.07 |