메모리관리(weak self와 guard의 만남)

2025. 1. 12. 20:27·면접준비

강한 참조 순환 vs 지연된 할당 해제

처음에 저는 [weak self]를 언제 써야 하는지 정말 헷갈렸어요. "그냥 항상 쓰면 되는 거 아닌가?" 싶었는데, 알고 보니 두 가지 서로 다른 문제를 해결하는 거더라고요.

1. 강한 참조 순환 (Strong Reference Cycle)

  • 원인: 두 객체가 서로를 강하게 참조해서 순환 고리가 형성됨
  • 결과: 메모리 누수 - 객체가 영원히 해제되지 않음 💥
  • 해결책: weak 또는 unowned 참조 사용

2. 지연된 할당 해제 (Delayed Deallocation)

  • 원인: 객체가 클로저에 의해 강하게 참조되고, 클로저 실행이 지연됨
  • 결과: 객체의 메모리 해제가 지연되지만, 결국에는 해제됨
  • 부작용: 불필요하게 메모리를 오래 점유하거나, 해제된 후에도 작업이 계속될 수 있음

가장 중요한 건 메모리 누수와 지연된 해제는 다른 문제라는 점이에요!

escaping vs non-escaping 클로저

  • non-escaping: 함수가 반환되기 전에 실행 완료 → 일반적으로 [weak self] 불필요
  • @escaping: 함수 반환 후에도 실행될 수 있음 → 참조 순환이나 지연된 할당 해제 가능성 ⚠️
class Ho {
    private var closureEscaper: ((String) -> ())?

    func escape(closure: @escaping (String) -> ()) {
        print("escaping!")
        closureEscaper = closure
    }
}

[weak self]를 써야해?

지연된 할당 해제가 발생하는 4가지 시나리오

1. 값비싼 연속 작업

클로저 내부에서 무거운 이미지 처리나 대용량 데이터 계산을 할 때

2. 스레드 차단 메커니즘

DispatchSemaphore 같은 걸 써서 스레드를 블록하는 경우

3. 지연 실행되는 클로저

DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
    // 10초 후에 실행 - 그때까지 self가 메모리에 유지됨
}

4. 긴 타임아웃을 가진 콜백

func performRequest() {
    let url = URL(string: "http://localhost:81")! // 81번 포트는 막혀 있음
    let config = URLSessionConfiguration.default
    config.timeoutIntervalForRequest = 999.0 // 999초 타임아웃!
    
    let session = URLSession(configuration: config)
    let task = session.downloadTask(with: url) { data, _, error in
        // 클로저에서 self 참조
        print(self.view.description) // 타임아웃까지 self가 해제 안됨
    }
    task.resume()
}

URLSession을 이용하여 port 81에 요청을 보내는데, 이 포트는 차단되어 있어 타임아웃이 발생해요.

요청 타임아웃은 999초로 설정되어 있고, 클로저에서 self를 참조했으며, weak, unowned 키워드가 사용되지 않았어요.

중요하게 봐야할 것은 강한 순환참조를 일으키는 코드는 아니라는 점이에요.

다른 객체를 참조하지는 않으니까요. 하지만 downloadTask를 종료하지 않고 뷰컨을 dismiss하면 경고가 뜰 거예요.

이것은 저 4가지 경우 중 4번의 케이스예요. 이 클로저는 호출되거나 시간 초과 기한에 도달할 때까지 또는 작업이 취소될 때까지 그 안에 참조된 모든 개체(이 경우 자체)에 대한 강력한 참조를 유지해요.

 

댕글링 포인터와 unowned의 위험성

unowned는 정말 조심해서 써야 해요. 특히 이런 경우:

class NetworkManager {
    func fetchData(completion: @escaping (String) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
            completion("Data received")
        }
    }
}

class ViewController {
    let networkManager = NetworkManager()
    
    func startFetching() {
        networkManager.fetchData { [unowned self] data in
            print("Received data: \(data)")
        }
    }
}

var vc: ViewController? = ViewController()
vc?.startFetching()
vc = nil // ViewController가 해제됨

ViewController가 사라졌는데 5초 후 클로저가 실행되면서 unowned self가 댕글링 포인터가 되어 크래시가 발생해요. 그래서 저는 객체의 생명주기를 100% 확신할 수 있는 경우가 아니면 weak self를 쓰는 편이에요.

weak self는 값이 있을 수도 없을수도 계속해서 추적하고 있기에 optional self?다. 그래서 일반적으로 guard let을 통해 optional Binding을 한다. 그런데 주의해야할 점이 있다고 한다.

weak self 사용 패턴 비교

이전에 말했던 지연 할당 해제가 이루어지는 경우중 1번과 2번을 다시 살펴보자. 1번은 비싼 연속작업의 경우고 2번은 쓰레드 블락하는 경우다. 

패턴 1: guard let self 사용

networkManager.fetchData { [weak self] data in
    guard let self = self else { return } // ① 여기서 강한 참조 생성
    self.processData(data)                // ② 클로저 내 비싼 작업 시작
    self.updateUI()                       // ③ UI 업데이트
}

2번째줄에서 클로저의 중간에 비싼 작업(예: 이미지처리, 대규모 데이터 계산) 등이 포함되어 있는 경우 이 작업이 끝날때 까지 self가 해제되지 않아요. 만약 해당 클로저가 뷰 컨트롤러가 해제된 이후에도 실행 중이라면 불필요한 작업을 계속해 수행하게 돼요.

guard let 은 클로저가 끝날때까지 self를 강하게 참조하기 때문에 뷰컨트롤러를 즉시 해제하고 싶을 때 적합하지 않아요.

패턴 2: 옵셔널 체이닝 사용

networkManager.fetchData { [weak self] data in
    self?.processData(data)      // ① 여기서 self가 nil이면 이 라인 스킵
    self?.updateUI()             // ② 여기서도 self가 nil이면 이 라인 스킵
}

여기서는 클로저가 실행되는 중간에 self가 해제되더라도 이후의 작업은 수행하지 않아요. 결국 불필요한 작업을 수행하지 않고 즉시 중단되기 때문에 메모리 효율성이 올라가죠.

결국 self?.는 클로저내에 self가 nil일 가능성을 고려하며 특정 작업이 반드시 실행되어야 할 필요가 없는 경우에 적합해요.

그렇다면 weak self랑 guard문을 결국 함께쓰면 안되는거 아냐? 라는 궁금증이 들었어요

특히 그러면 strong reference랑 똑같은 거 아냐?

 

guard let self와 강한 참조의 차이

guard let self = self else { return }을 사용하면 클로저 내에서 일시적으로 강한 참조가 생성되지만, 완전한 강한 참조와는 달라요:

클로저 실행 전 확인

weak self를 사용하면 클로저가 실행되기 전에 self가 이미 nil인지 확인해요. nil이면 클로저 자체가 실행되지 않아요.

일시적인 강한 참조

guard let self = self는 클로저 내부에서만 강한 참조가 유지되며, 클로저가 종료되면 해제돼요.

명시적인 의도

코드에서 self의 존재 여부를 명시적으로 처리한다는 의도를 표현해요.

데이터 무결성이 중요한 작업: 작업 도중 self가 해제되면 데이터 손상이 발생할 수 있는 경우

→ 결국 weak self와 guard의 만남은 객체가 해제되기 전에 모든 작업을 끝내고 싶을 때 사용하자

그렇다면 [weak self]가 필요하지 않은경우는 언제일까?

1. GCD 

참조 순환이 발행하지 않은 경우는 코드가 즉시 실행(클로저에서 GCD에 즉시 전달되고 실행 스케쥴에 따라 처리된다는 뜻)되어 self에 대한 참조가 제거된다. 

DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
    self.view.backgroundColor = .red
}
DispatchQueue.main.async {
    self.view.backgroundColor = .red
}
DispatchQueue.global(qos: .background).async {
    print(self.navigationItem.description)
}

GCD를 사용할때 비동기 작업은 클로저를 실행 대기열에 넣어요. GCD의 실행 대기열에 추가된 클로저는 GCD에 의해 관리되는 것이죠. 작업이 완료되면 GCD는 클로저에 대한 참조를 해제해요

이 구조 덕분에 클로저가 대기열에서 끝나면 강한 순환참조가 발생하지 않아요.

제가 헷갈렸던 부분이 여기였어요.

그러면 데드라인을 1000초뒤에 실행하면 그 전에 뷰를 dismiss하면 메모리 릭이 발생할 수 있는거 아닌가?

하지만 잘 생각해보면 1000초뒤에 알아서 self를 반환하기에 순환참조 이슈는 아니기에 메모리 릭이 발생하지 않아요.

그냥 메모리 해제가 지연되는 것이죠!!! 뭐 좋은 코드는 아니겠지만요.

 

class MyClass {
    var workItem: DispatchWorkItem?

    func setupWorkItem() {
        workItem = DispatchWorkItem {
            self.doSomething()  // 강한 참조 순환 발생
        }
    }
}

이 경우에서는 GCD작업을 나중에 처리하기 위해 클로저 내부에서 self를 강한 참조로 캡처하게 되면 참조순환이 발생한다. 

2. UIView 애니메이션.

UIView.animate(withDuration: 0.3) {
    self.view.alpha = 0.0  // [weak self] 없이 사용 가능
}

애니메이션 클로저는 즉시 실행되며, 애니메이션이 끝나면 클로저에 대한 참조도 사라지기에.

객체 속성에 함수를 저장하는 경우

자 이번에는 객체간에 클로저나 함수를 전달하여 다른 객체의 속성으로 저장하는 경우를 살펴보자. DelegatePattern의 경량화 버전으로 한 객체(A)가 다른 객체(B)의 메서드를 호출할 수 있다. 하지만 이 과정에서 메모리 누수가 발생 할 수 잇다.

class PresentedController {
    var closure: (() -> Void)?
}

이 객체는 클로저를 프로퍼티로 저장.

class MainController {
    var presentedController = PresentedController()

    func setupClosure() {
        presentedController.closure = printer
    }

    func printer() {
        print("MainController description")
    }
}

MainController라는 객체가 있고 여기서 자신의 메서드를 PresentedController의 클로저 속성에 전달해요.

printer메서드는 MainController의 메서드인데, 이 메서드를 presentedController.closure에 할당하고 있어요.

그리고 함수 자체를 할당하기에 ()를 붙이지 않았어요.

결과적으로 PresentedController에서 closure를 호출하면 MainController의 printer 메서드가 실행되며 MainController description이 프린트돼요.

하지만 이 경우 강한 참조 순환이 발생할 수 있어요:

  • closure는 self.printer를 참조하고
  • MainController는 PresentedController를 소유하며
  • PresentedController는 다시 클로저를 소유

MainController → PresentedController → closure → MainController

weak self의 위치에 따른

let workItem = DispatchWorkItem { // <-- first closure
        UIView.animate(withDuration: 1.0) { // <-- second closure
            self.view.backgroundColor = .red
        }
    }
self.workItem = workItem

일반적으로 강한 참조 순환을 막기 위해 weak self를 사용한다 가정했을때 해당 코드에서 두개 중 고민을 할 거예요.

하지만 2번에 넣으면 문제가 있어요. self와 DispatchWorkItem의 강한 순환 참조 관계 때문이에요. self가 workItem을 강하게 참조하고, 반대로 workItem의 클로저 내부에서 self를 참조하므로, self와 workItem이 강한 순환 참조가 발생해요.

2번에 넣어도 여전히 self를 강하게 참조하기에 그래서 바깥에 넣어야 해요.

 

 

https://medium.com/@almalehdev/you-dont-always-need-weak-self-a778bec505ef

 

You don’t (always) need [weak self]

We will talk about weak self inside of Swift closures to avoid retain cycles & explore cases where it may not be necessary to capture self weakly.

medium.com

 

'면접준비' 카테고리의 다른 글

명령형과 선언형, 그리고 FP까지  (0) 2025.02.16
SwiftUI runLoop  (0) 2025.02.05
Hash-Hashable을 곁들인  (1) 2025.01.05
SilentPush&RichPush  (2) 2024.11.08
test Code (with TCA)  (0) 2024.11.04
'면접준비' 카테고리의 다른 글
  • 명령형과 선언형, 그리고 FP까지
  • SwiftUI runLoop
  • Hash-Hashable을 곁들인
  • SilentPush&RichPush
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (120)
      • SWIFT개발 (29)
      • 알고리즘 (25)
      • Design (6)
      • ARkit (1)
      • 면접준비 (30)
      • UIkit (2)
      • Vapor-Server with swift (3)
      • 디자인패턴 (5)
      • 반응형프로그래밍 (12)
      • CS (3)
      • 도서관 (1)
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
2료일
메모리관리(weak self와 guard의 만남)
상단으로

티스토리툴바