본문 바로가기

iOS

[iOS] 강한 참조 사이클 (순환 참조) 해결하기 2편

두 인스턴스가 서로를 참조할 경우 강한 참조 사이클이 발생하고 인스턴스가 메모리에서 정상적으로 해제되지 않는 것을 강한 참조 사이클 해결하기 1탄에서 확인하였습니다. 강한 참조 사이클(Strong Reference Cycle)이 발생하는 문제를 해결하기 위해 약한(weak) 참조와 비소유(unowned) 참조로 해결하였죠.

 

클로저(Closure)에서도 강한 참조 사이클이 발생할 수 있습니다. 클로저가 인스턴스를 캡처하고 인스턴스가 클로저를 강한 참조로 저장하고 있다면 인스턴스는 메모리에서 정상적으로 해제되지 않습니다. 이 경우에도 약한 참조나 비소유 참조를 통해 해결할 수 있습니다.

강한 참조 사이클이 발생하는 경우를 예시를 통해 살펴보겠습니다.

 

class Car {
    var totalDrivingDistance = 0.0
    var totalUsedGas = 0.0

    lazy var gasMileage: () -> Double = {
        return self.totalDrivingDistance / self.totalUsedGas
    }

    func drive() {
        self.totalDrivingDistance = 1200.0
        self.totalUsedGas = 73.0
    }

    deinit {
        print("Car deinit")
    }
}

 

self 는 인스턴스 자체를 나타내는 특별한 속성입니다. 이 속성을 클로저에서 사용하면 self 가 나타내는 인스턴스가 캡처됩니다. 클로저는 코드가 끝날때까지 인스턴스를 강한 참조로 캡처합니다. 그러므로 self 가 나타낸 인스턴스는 클로저의 실행이 완료될 때까지 메모리에서 제거되지 않겠죠.

 

인스턴스를 캡처하는 클로저는 gasMileage 변수에 저장되어 있습니다. 속성이 약한 참조나 비소유 참조가 아니므로 인스턴스는 속성에 저장된 클로저를 강하게 참조합니다. 결과적으로 클로저와 인스턴스는 강한 참조로 연결됩니다.

 

var myCar: Car? = Car()
myCar?.drive()
// 아직 클로저가 실행되지 않았으므로 강한 참조 사이클도 발생되지 않음

myCar = nil
// Prints "Car deinit"
// 인스턴스가 메모리에서 정상적으로 해제됨
var myCar: Car? = Car()
myCar?.drive()
myCar?.gasMileage()
// 클로저가 실행되고 인스턴스를 캡처하게 됨

myCar = nil
// 인스턴스가 메모리에서 해제되지 않음

Closure Capture List

클로저의 강한 참조 사이클을 해결할 클로저 캡처 리스트에 대해 알아보겠습니다.

문법은 아래와 같습니다.

 

{ [list] (parameters) -> ReturnType in
    // Code
}

{ [list] in
    // Code
}

 

클로저 캡처 리스트는 파라미터 목록 앞에 옵니다. 대괄호 안에 캡처할 목록을 콤마(,)로 구분하여 나열합니다. 클로저 캡처 리스트를 사용하면 in 키워드는 생략할 수 없습니다.

Value Type

{ [value] in
    // Code
}

 

값 형식을 캡처할 때는 대상의 이름만 표기합니다.

 

var a = 0
var b = 0
let c = { print(a, b) }
// c에는 a,b 두 변수를 캡처한 클로저가 저장됨
// 클로저가 값을 캡처할 때는 복사본이 아닌 참조가 전달됨

a = 1
b = 2
c()
// Prints "1 2"
var a = 0
var b = 0
let c = { [a] in print(a, b) }
// 캡처 리스트에 추가하면 참조가 아닌 복사본이 전달됨

a = 1
b = 2
c()
// Prints "0 2"

 

a 변수를 캡처 리스트에 추가하게 되면 캡처하는 시점의 a 값의 복사본을 가지고 있게 됩니다. 클로저 캡처 리스트에 추가하기 전과 후의 c() 의 결과를 보시면 이해하기 쉬울 것입니다.

Reference Type

{ [weak instanceName, unowned instanceName] in
    // Code
}

 

참조 형식을 캡처할 때는 대상 앞에 weakunowned 키워드를 명시해야 합니다. 이제는 클로저 캡처 리스트를 사용해서 강한 참조 사이클이 발생했던 위의 예제를 해결해보겠습니다.

 

class Car {
    var totalDrivingDistance = 0.0
    var totalUsedGas = 0.0

    lazy var gasMileage: () -> Double = { [weak self] in
        guard let self = self else { return 0.0 }
        return self.totalDrivingDistance / self.totalUsedGas
    }

    func drive() {
        self.totalDrivingDistance = 1200.0
        self.totalUsedGas = 73.0
    }

    deinit {
        print("Car deinit")
    }
}

 

gasMileage 에 클로저 캡처 리스트가 추가된 클로저가 저장된 것을 알 수 있습니다. 약한 참조는 옵셔널 형식이므로 self 에 접근할 때 unwrapping 하거나 옵셔널 체이닝(Optional Chaining)을 사용해야 합니다.

 

클로저의 실행이 완료되지 않은 시점에 캡처 대상이 메모리에서 해제될 수 있다면 약한 참조를 사용합니다. Car 인스턴스가 해제된 이 후에 클로저가 실행됐다면 _self_는 nil이 될 것입니다. 그렇게 되면 옵셔널 바인딩에 실패하고 코드가 종료되겠죠.

클로저가 인스턴스를 약한 참조로 캡처하고 있기 때문에 더 이상 강한 참조 사이클은 발생하지 않습니다.

 

var myCar: Car? = Car()
myCar?.drive()
myCar?.gasMileage()
myCar = nil
// Prints "Car deinit"
// 정상적으로 해제됨

 

비소유 참조로 해결해 보겠습니다.

 

class Car {
    var totalDrivingDistance = 0.0
    var totalUsedGas = 0.0

    lazy var gasMileage: () -> Double = { [unowned self] in
        return self.totalDrivingDistance / self.totalUsedGas
    }

    func drive() {
        self.totalDrivingDistance = 1200.0
        self.totalUsedGas = 73.0
    }

    deinit {
        print("Car deinit")
    }
}

 

클로저 캡처 리스트를 사용하지 않은 코드와 동일한 방식으로 self 에 접근하고 있습니다. 하지만 비소유 참조로 캡처되었기 때문에 더 이상 강한 참조 사이클을 발생하지 않습니다.

 

var myCar: Car? = Car()
myCar?.drive()
myCar?.gasMileage()
myCar = nil
// Prints "Car deinit"
// 정상적으로 해제됨

 

마찬가지로 인스턴스가 정상적으로 해제된 것을 알 수 있습니다. 비소유 참조로 캡처한 대상은 클로저 실행이 종료되기 전에 해제될 수 있습니다. 이 때, 해제된 대상에 접근할 경우 런타임 오류가 발생할 수 있으므로 주의해야 합니다. 그러므로 비소유 참조는 캡처 대상의 생명주기가 클로저와 같거나 더 긴 경우에 사용해야 합니다.

정리

  • 클로저와 인스턴스간에도 강한 참조 사이클이 발생할 수 있습니다.
  • 약한 참조와 비소유 참조를 통해 강한 참조 사이클을 해결할 수 있습니다.
  • 클로저 캡처 리스트를 사용하는 방법을 알아보았습니다.
  • 비소유 참조를 사용할 경우 런타임 에러가 발생할 수 있습니다.