Closure가 인스턴스를 잡을 때! self 명시의 이유

동시성에 대해서 제대로 다시 알아보기 전에 우선적으로 self , escaping 클로저에 대한 내용을 짚고 넘어가보자.

메서드를 구현하다보면 self를 명시해야 하는 경우가 생기는데, 정확하게 이 self를 사용하는 이유는 다음과 같다.

 

@escaping 클로저 안에서 인스턴스 멤버에 접근하면, 클로저가 그 인스턴스를 캡처해 함수 스코프 밖에서도 살아남을 수 있기 때문에(보통 힙에 보관되는 형태) “정말 이 인스턴스를 잡아둘 거야?”라는 의도를 명확히 하기 위해 self.를 요구한다. 또한, 파라미터 이름과 프로퍼티 이름이 같을 경우 어떤 것을 가리키는지 분명히 하기 위해서 self를 사용하기도 한다.

 

코드를 통해서 한 번 보면

final class ImageLoader {
    var isLoading = false
    private var completion: ((Result<Data, Error>) -> Void)?

    // @escaping: 클로저를 저장 → self를 명시해야 함
    func load(completion: @escaping (Result<Data, Error>) -> Void) {
        self.completion = completion      // self. 명시
        self.isLoading = true             // self. 명시

        // 네트워크 흉내
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) { [weak self] in
            guard let self = self else { return }
            self.isLoading = false
            self.completion?(.success(Data()))
            self.completion = nil
        }
    }
}

위 예시에서처럼, 클로저가 인스턴스를 오래 보관할 수 있기 때문에 의도를 명확하게 드러내는 과정으로 self를 명시한 것이다.

 

load(completion:)에서 completion은 함수가 끝난 뒤에도 쓰이는 프로퍼티로, 함수 스코프를 탈출해 사용될 수 있기 때문에 파라미터에 @escaping을 붙여야 한다.

 

그렇다면 이 escaping 클로저에서는 self.를 왜 명시해야 할까? 위에서 말했던 것처럼 이 클로저가 인스턴스를 잡아둘 수 있다는 사실을 명시해야 하기 때문!! → escaping 클로저 본문에서 인스턴스 멤버에 접근하려면 “나 진짜 이 인스턴스를 캡처할 거야”라고 분명히 하라는 점!

 

escaping 클로저에서는 해당 인스턴스를 추후에 사용하게 되기 때문에 self를 붙여 사용해야 하는 경우가 있다.

 

그렇다면 더 나아가서 강한 참조로 인한 메모리 누수가 발생했을 때, 캡처 관계에서 weak self를 사용하여 self를 약하게 참조하게 되는데

이 경우에서도

DispatchQueue.global().asyncAfter(deadline: .now() + 1) { [weak self] in
    guard let self = self else { return }
    self.isLoading = false
    self.completion?(.success(Data()))
    self.completion = nil
}

가장 흔한 메모리 누수는 A가 클로저를 보유하고 그 클로저가 다시 A를 강하게 캡처하는 경우인데, 이 코드에서는 asyncAfter의 클로저는 self에 의해 보유되진 않지만, 지연 실행 동안 불필요하게 self의 수명을 늘리는 것을 피하기 위해 [weak self]를 사용한 것이다!

 

만약 self가 이미 해제된 뒤 클로저가 실행되면 → weak이면 self가 nil이 되어 guard let self에서 빠져나가게 되고, unowned였다면 nil이 올 수 없기 때문에 크래시가 되는 것! 이라고 이해하면 된다.

 

그럼 프로젝트를 진행하면서 guard let self = self else { return }를 사용하게 되는 경우는, [weak self]로 약하게 잡았기 때문에 클로저 실행 시점에 self가 없을 수도 있기 때문이다. 그래서 살아있으면 강한 참조로 잠깐 붙잡는다는 의미다.

{ [weak self] in
    guard let self = self else { return } // 살아있을 때만 아래 코드 실행
    self.isLoading = false
    self.completion?(.success(Data()))
    self.completion = nil
}

이렇게 작성해두면 해제된 후엔 아무것도 하지 않고, 살아있다면 그 순간에만 짧게 강한 참조로 작업하고 끝내게 된다.

 

그렇다면 여기서 weak와 unowned를 명확하게 어떤 걸 선택해서 사용할지, 그 근거와 이유는 무엇일까?

 

먼저 weak는 수명 관계가 확실치 않거나, self가 먼저 해제될 수 있고, Optional로 안전하게 처리하고 싶을 때 사용하면 된다.

 

unowned는 클로저가 실행되는 동안 self가 반드시 살아있어야 한다는 강한 보장이 있을 때만 사용해야 한다. 옵셔널이 올 수 없기 때문에 값이 nil이 되면 크래시가 발생하게 된다.

 

다시 본론으로 돌아와서, 그렇다면 @escaping을 빼게 된다면 컴파일 에러가 나는 이유는 무엇일까?

completion 파라미터는 함수가 끝난 뒤에도 쓰일 수 있다. (프로퍼티에 보관하거나, 지연 클로저에서 호출되는 등등) → 컴파일러가 “얘는 탈출이야”라고 명시를 요구하기 때문에, 해당 표기가 없으면 에러가 발생한다는 점.

 

아래 코드와 같이 보관하지 않고 지연 클로저 안에서 호출하는 것만으로도 @escaping이 필요하다.

func load(completion: @escaping (Result<Data, Error>) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        completion(.success(Data()))
    }
}

 

일단 오늘은 여기까지~~!

'◽️ Programming > Swift 문법' 카테고리의 다른 글

옵셔널(Optional) 에 대한 정리 🧑🏻‍💻  (0) 2024.03.28
Swift Protocal  (1) 2024.03.02
Swift Extention  (0) 2024.03.02
Swift Initializer  (0) 2024.03.02
Swift Type Casting  (0) 2024.03.02