banner

서론

해당 포스트는 ARC에 대해서 정리한 포스트입니다.

ARC (Automatic Reference Counting)

ARC 는 Swift 에서 사용되는 메모리 관리 방식이다. 기본적인 원리는 객체를 소유또는 참조하는 모든 객체의 수를 추적하여 객체가 더 이상 필요하지 않을 때 메모리에서 해제하는 방식이다. 예를 들어 객체 A를 어떤 변수에 할당하면 A의 참조 카운트가 1 증가하고, 또 객체 A를 다른 변수에 할당하면 참조 카운트가 1 증가한다. 만약 변수가 nil 이 되거나 다른 객체를 참조하게 되면 참조 카운트가 1 감소한다. 참조 카운트가 0이 되면 메모리에서 해제된다.

class Person {

  deinit {
    print("Person 객체가 메모리에서 해제됨")
  }
}

var a: Person? = Person() // Person이라는 객체가 생성됨과 동시에 a 변수에 할당되어 Person 객체의 참조 카운트가 1 증가
var b: Person? = a // Person 객체의 참조 카운트가 1 증가

a = nil // Person 객체의 참조 카운트가 1 감소
b = nil // Person 객체의 참조 카운트가 1 감소, Person 객체가 메모리에서 해제되면서 deinit 메소드가 호출됨

Strong Reference

일반적은 객체 참조 방식이다. 객체를 어떤 변수가 참조하게 되면 참조 카운트가 1 증가하고, 변수가 nil 이 되거나 다른 객체를 참조하게 되면 참조 카운트가 1 감소한다. 위 예제에서 사용한 방식이 Strong Reference 이다.

Weak Reference

객체를 참조하지만 객체의 참조 카운트를 증가시키지 않는 방법이 존재한다. 이를 Weak Reference 라고 한다. 변수 키워드 앞에 weak 또는 unowned 키워드를 사용하여 객체를 참조할 수 있다.

어떻게 보면 참조 카운트를 임의로 증가시키지 않아서 무언가 위험하고 버그가 발생할 것 같고 사용하면 안되는 것처럼 보일수 있다. 하지만 iOS Framework 에서는 많은 곳에서 사용되고 있다. iOS Framework 에서는 Delegate 패턴 또는 클로져 을 많이 사용하는데 이때 Delegate 객체를 참조할 때 Weak Reference 를 적극적으로 사용한다.

Delegate 패턴

Delegate 패턴은 객체의 행동을 다른 객체에게 위임하는 패턴이다. 객체 A가 객체 B에게 자신의 행동을 위임하고, 객체 B는 객체 A의 행동을 대신 수행한다. 이때 객체 A는 객체 B를 생성하면서 B를 참조하고 있고 객체 B의 생성자에 객체 A의 참조를 전달하는 방식이다.

여기서 객체 참조에 대해서 특별한 처리를 하지 않으면 객체 A와 객체 B는 서로 참조하고 있기 때문에 참조 카운트가 1 증가하고 두 객체는 메모리에서 해제되지 않는다. 이러한 문제를 해결하기 위해 Delegate 객체를 참조할 때 Weak Reference 를 사용한다.

Sample Code (Strong Reference)

class Person {
    let name: String
    init(name: String) { self.name = name }
    var apartment: Apartment?
    deinit { print("\(name) is being deinitialized") }
}


class Apartment {
    let unit: String
    init(unit: String) { self.unit = unit }
    var tenant: Person?
    deinit { print("Apartment \(unit) is being deinitialized") }
}

var john: Person?
var unit4A: Apartment?

john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")

위 코드는 Person과 Apartment 객체가 있고 Person john은 Apartment의 unit4A를 참조하고 있고, Apartment unit4A는 Person john을 참조하고 있다. 이때 객체 Person과 Apartment는 서로 참조하고 있고 또 변수에서 각각 참조하고 있기 때문에 각각 참조 카운트는 2이다.

image

이 때 john과 unit4A의 변수에 nil 를 할당하면 어떻게 될까?

john = nil
unit4A = nil

단순히 생각하면 객체를 외부에서 직접 참조하는 변수가 없기 때문에 메모리에서 해제가 될 것 같고 실제로 해제가 되어야 한다. 만약 여기서 해제가 되지 않는다면 해당 객체는 메모리에서 살아있지만 실제로 접근할 수 없는 상태 즉 메모리 누수가 발생한다.

위 코드를 실행해보면 안타깝게도 메모리에서 해제되지 않는다. 이유는 서로 참조하고 있기 때문에 참조 카운트가 1이 남아있기 때문이다.

image

위 그림과 같이 Person 객체와 Apartment는 살아있지만 프로그램에서 접근할수 없는 상태가 된다. 즉 개발자 또는 프로그램이 객체를 컨트롤 할 수 없고 메모리에서 자리만 차지하고 있는 상태, 즉 메모리 누수가 발생한 것이다.

위와 같은 상황은 swift 환경 뿐만 아니라 다른 언어에서도 발생할 수 있는 문제이다. 이러한 문제 때문에 엄격하게 상호 참조를 허용하지 않는다. 하지만 iOS Framework 에서는 Delegate 패턴을 많이 사용하는데 Delegate 패턴에서는 서로 참조하는 경우가 많다. 이런 경우에는 어떻게 해야할까?

위 문제를 해결하기 위해 약한 참조(Weak Reference)를 사용한다.

위 Person, Apartment 예제 코드에서 Apartment 를 다음과 같이 수정해보자.

class Apartment {
    let unit: String
    init(unit: String) { self.unit = unit }
    // var tenant: Person?
    weak var tenant: Person?
    deinit { print("Apartment \(unit) is being deinitialized") }
}

weak 키워드를 사용하여 Person 객체를 약한 참조를 하도록 하였다.

image

앞에서 설명했듯이 weak 를 사용하면 레퍼런스 카운트는 증가하지 않는다고 하였다. 따라서 Person 객체의 참조 카운트는 1 이 되고 Apartment 객체의 참조 카운트는 2 이다.

조금 이해하기 쉽게 설명하자면 위 그림에서 각 객체를 가리키는 굵은 화살표 갯수를 보면 알수 있다. Person 객체를 가리키는 화살표는 1개이고 Apartment 객체를 가리키는 화살표는 2개이다.

여기서 john에 nil 을 할당하면 어떻게 될까?

john = nil

Person 객체의 참조 카운트가 1 감소하고 0 이 되면서 Person 객체가 메모리에서 해제된다. 그렇게 되면 Apartment 객체의 tenant 변수에 저장된 값은 어떻게 될까? 바로 nil 이 된다.

iShot_2024-08-30_12 04 34

여기서 핵심은 tenant 변수에 따로 nil 을 할당하지 않았는데도 자동으로 nil 이 된다는 것이다. 과연 이게 가능한 것일까?

weak 로 선언된 변수는 어떻게 동작하는가?

weak 로 선언된 변수는 다음과 같은 특징을 가진다.

  1. weak 로 선언된 변수는 참조 카운트를 증가시키지 않는다.
  2. weak 로 선언된 변수는 참조하고 있는 객체가 메모리에서 해제되면 자동으로 nil 이 된다.

여기서 2 번째 특징이 중요하다.

weak 참조와 nil 할당

weak 로 선언된 변수가 참조하는 객체가 메모리에서 해제될 때, 해당 weak 변수는 자동으로 nil로 설정됩니다. 이 과정은 Swift 런타임 시스템에 의해 자동으로 처리된다.

구체적으로는

  1. Swift 런타임 : Swift의 런타임 시스템이 이 작업을 담당합니다.
  2. ARC : Swift는 ARC를 사용하여 메모리를 관리합니다. ARC는 객체의 참조 카운트를 추적하고 관리한다.
  3. 해제 과정 :
    • 객체의 참조 카운트가 0이 되면 메모리에서 해제된다.
    • 이 때, Swift 런타임 은 해당 객체를 참조하고 있는 모든 weak 변수를 nil로 설정한다. 이 과정을 Zeroing Weak Reference 라고도 한다.

Swift의 weak 참조 관리 메커니즘

  1. Side Table :
    • Swift 런타임은 각 객체에 대한 side table 이라는 추가적인 메모리 구조를 유지한다.
    • side table 은 해당 객체에 대한 weak 참조들의 정보를 저장한다.
  2. Weak 참조 등록 :
    • weak 참조가 생성되면, Swift 런타임은 해당 객체의 side table 에 weak 참조를 등록한다.
    • 이 등록 과정은 자동으로 이루어진다.
  3. 객체 해제 과정 :
    • 객체의 참조 카운트가 0이 되면, Swift 런타임은 해당 객체의 side table 을 확인한다.
    • side table 에 등록된 weak 참조들을 모두 nil로 설정한다.
  4. 동시성 처리 :
    • Swift 런타임은 weak 참조의 동시성 문제를 처리하기 위해 적절한 동시성 제어 메커니즘을 사용한다.
  5. 최적화 :
    • Swift 런타임은 이 과정을 매우 효율적으로 수행하도록 최적화되어 있다.
    • 대량의 weak 참조를 처리하는 경우에도 성능 저하가 발생하지 않도록 설계되어 있다.

unowned 참조

약한 참조를 위해서 weak 키워드 외에도 unowned 키워드를 사용할 수 있다. 위 예제에서 weak 키워드를 사용한 것을 unowned 키워드로 변경해보자.

class Apartment {
    let unit: String
    // 생성자에서 tenant 를 초기화 해주어야 한다.
    init(unit: String, tenant : Person) {     
      self.unit = unit
      self.tenant = tenant
    }
    // var tenant: Person?
    // weak var tenant: Person? 
    unowned var tenant: Person
    deinit { print("Apartment \(unit) is being deinitialized") }
}

정상적으로 메모리 누수가 발생하지 않는다. unowned 키워드는 weak 키워드와 비슷하지만 차이점이 존재한다.

  • unowned 참조는 기본적으로 non-optional 이다.
  • weak 참조는 optional operator(?)를 사용하여 값을 접근하지만, unowned 참조는 non-optional 이기 때문에 optional operator를 사용하지 않는다.
  • Swift 런타임은 unowned 참조가 nil이 되는 경우를 처리하지 않는다.
  • 따라서 unowned 참조가 nil이 될 수 있고 unowned 참조를 접근하면 런타임 에러가 발생한다.
  • unowned 참조는 객체의 수명이 unowned 참조보다 길어야 한다는 것을 보장해야 한다.
  • 성능상으로는 unowned 참조를 사용했을 때 weak 참조를 했을 때 보다 앱의 성능에 대해서 약간 이점이 있다. 왜냐하면 Zeroing Weak Reference 가 발생하지 않기 때문이다.
  • 안정성 측면에서는 weak 참조를 사용하는 것이 더 안전하다.
  • 경우에 따라서는 unowned 참조를 사용하는 것이 더 좋을수 있다. 명시적으로 nil 이 될 수 없는 경우에 사용함으로써 만약 런타임 에러가 발생한다면 이는 어딘가 잘못된 처리가 있음을 명확하게 알 수 있다. 보통은 에러가 최대한 발생하지 않도록 코드를 작성하는 것이 좋지만, 경우에 따라서는 에러가 발생하는 것이 더 좋을 수도 있다.

Reference

태그: ,

카테고리:

업데이트:

댓글남기기