프로그래밍에 있어서 메모리 관리는 핵심적인 요소입니다. 메모리는 프로그램 자체가 돌아가는 공간이며, 프로그램이 필요로 하는 데이터가 사용할 수 있는 상태로 존재할 수 있는 곳이기도 합니다. 하지만 이 공간은 제한 되어 있기 때문에, 잘 관리되지 않으면 프로그램이 비효율적으로 동작하게 되고 최악의 경우 프로그램이 런타임 에러를 내면서 죽기도 합니다.

기본적으로는 이 메모리 관리의 책임은 프로그래머에게 있는데, C나 C++ 같은 경우는 프로그래머가 모든 책임을 지고 메모리를 사용하게 됩니다. 하지만 사람은 실수하기 때문에 할당된 메모리를 필요하지 않은데도 해제하지 않는다던가, 이미 해제되버린 메모리에 접근한다던가 하는 문제가 발생할 수 있습니다. 이를 런타임 또는 컴파일 시점에서 검사하기 위해 여러 메모리 관리 기법들이 생겨났습니다.

ARC(Automatic Reference Counting)은 이러한 메모리 관리 기법 중의 하나로 할당된 메모리를 참조할 때 마다 참조 횟수를 카운팅하고, 이 카운터가 0이 될 때 자동으로 메모리를 반환하게 만드는 방법입니다. 이번 포스팅에서는 이 ARC에 대해 알아보도록 하겠습니다.

참조에 관한 이야기이기 때문에, 기본 타입, 구조체 등의 값 기반 타입에는 ARC가 적용되지 않습니다. 값 기반 타입은 다른 변수에 대입할 경우 값의 복사가 일어납니다.

이 글은 다음 문서와 책을 참조하여 작성되었습니다.
Language Guide - Automatic Reference Couning
Cocoa Internal

  • Objective-C 시절의 메모리 관리

    Objective-C 시절의 객체 할당 과정은 다음과 같습니다.

      SomeClass * a = [[SomeClass alloc] init];
    

    alloc 은 객체를 메모리에 할당하고, 참조 카운팅을 1로 만든 뒤, 그 포인터를 반환합니다. 그 후, init을 통해 해당 객체을 실제로 초기화 하는 과정을 거칩니다. 그러면 이제 이 객체를 다른 포인터로 참조하는 경우를 생각해 볼 수 있습니다.

      SomeClass * a = [[SomeClass alloc] init];
      SomeClass * b = a;
      b.someMember = someValue  // OK, a == b
    

    다음과 같이 별 문제 없이 사용할 수 있는 것 처럼 보입니다. 하지만 다음과 같은 경우는 문제가 생길 수 있습니다.

      SomeClass * a = [[SomeClass alloc] init];
      SomeClass * b = a; 
      [a release] //소유권을 반환하고 참조 카운팅을 하나 낮춘다. 참조 카운트: 0, 객체가 메모리에서 해제
      b.someMember = someValue  // error!
    

    b를 통해 객체를 참조하려 했지만, b는 소유권을 가지고 있지 않기 때문에 release를 호출할 때 참조 카운트가 0이 되면서, 객체가 메모리에서 해제되게 됩니다. 이를 막기 위해서는 다음과 같이 소유권을 명시적으로 얻어야 합니다.

      SomeClass * a = [[SomeClass alloc] init];
      SomeClass * b = [a retain]; // 소유권을 획득하고 참조 카운팅을 하나 늘린다.
      [a release] //소유권을 반환하고 참조 카운팅을 하나 낮춘다. 
      b.someMember = someValue  // OK,
    

    즉, Objective-C에서는 참조 카운팅을 수동으로 해줘야 했습니다. 해제를 편하게 하기 위해 autorelease 등을 사용할 수도 있지만, 이 역시 해제 자체는 프로그래머가 직접 해줘야 했기 때문에, 수동이라는 사실은 변하지 않았습니다.

    그러나 2011년 WWDC에서 애플은 Objective-C에서 ARC기법을 소개하게 되었고, 이후 Swift에서는 ARC를 기본으로 사용하게 되었습니다. (수동 카운팅 방식이 남아 있긴 하지만 일부 특수한 경우에만 사용할 수 있습니다.)

    ARC의 경우, 소유권을 요청하고 반환하는 과정을 명시적으로 할 필요가 없어졌습니다. 소유권의 반환 과정에서 객체의 해제가 일어났기 때문에, 객체 해제 역시 명시적으로 할 필요가 없이, 컴파일러가 필요할 때 자동으로 호출이 일어나게 됩니다.

  • swift에서 ARC

    간단한 예제를 보겠습니다.

      var ref1: SomeClass? = SomeClass() // 새로운 객체 생성, 참조 카운트: 1
      var ref2: SomeClass? = ref1 // 참조 카운트 1 증가, 참조 카운트: 2 
      // ... 중략 ..
      ref1 = nil // 참조 카운트 1개 감소, 참조 카운트: 1
      ref2 = nil // 참조 카운트 1개 감수, 참조 카운트: 0, 객체가 메모리에서 해제 
    

    이 처럼 객체 참조가 늘어나고 감소함에 따라 자동으로 카운트해주기 때문에 대부분의 경우는 신경쓰지 않아줘도 됩니다. 심지어 swift에서는 프로그래머가 명시적으로 deinit을 호출할 수 없고, 해제에 대해서도 컴파일러가 책임을 가져가기 때문에, 프로그래머가 신경 쓸 부분은 더욱 줄어듭니다. 하지만 문제가 그리 쉽지많은 않습니다.

  • 강한 참조와 순환 참조 문제

    Objective-C에서도 소유권을 가지지 않고 객체를 사용하던 예제를 보셨을 것입니다. 바로 위에서 보았듯이, 참조가 늘어날 때 마다 참조 카운팅이 늘어나는 방식을 강한 참조(strong reference) 라고 합니다. 복잡하지 않게 한 가지 방법으로 통일하면 좋겠지만, 강한 참조만으로는 해결할 수 없는 문제가 있습니다. 그 중 하나가 바로 순환 참조입니다.

    만약 두개 이상의 클래스가 서로에 대한 강한 참조를 가지고 있으면 자신들을 가리키는 포인터를 가지고 있어도 서로에 대한 참조가 남아 있어 참조 카운터가 0이 되지 않기 때문에 해제되지 못하고 메모리 누수(Memory Leak)이 발생하게 됩니다.

    Ref Cycle1


Ref Cycle2

이를 해결하기 위해서 swift에서는 두가지 방법을 제공합니다.

  1. 약한 참조(weak reference) : 값을 참조할 수는 있게 하되, 소유하지는 않습니다. 즉, 참조 카운트에 영향을 미치지 않습니다.
    또한, 원본 객체를 소유하지 않기 때문에 가리키는 객체가 소멸된 이후에 참조하는 가능성을 막기 위해, ARC는 자동으로 약한 참조를 nil로 만들어 줍니다. nil이 될 수 있다는 특성 때문에, var로 선언되어야 하고 옵셔널 타입을 가져야만 합니다.

       weak var someProperty: SomeType?
    

    weak1


    weak2


    weak3

  2. 비소유 참조(unowned reference) : 약한 참조와 비슷하지만, 가리키는 인스턴스가 절대 해제되지 않을거라 가정합니다. 따라서 메모리가 해제되도 ARC가 해당 참조를 nil로 만들지 않습니다. 이러한 점에서 암시적 해제 옵셔널과 비슷하고, 위험성도 똑같습니다.(해제된 뒤 접근하면 런타임 에러 발생) 따라서 해당 객체의 생명주기가 참조와 같거나 더 길다는 것이 보장될 때 사용되어야 합니다.

       unowned var someProperty: SomeType
    

    unowned1


    unowned

런타임 안전 체크를 비활성화하려면 다음과 같이 쓸 수 있습니다.

unowned(unsafe) var someProperty: SomeType

다만 이 경우 잘못된 접근에 대한 책임은 프로그래머가 져야 합니다.

  • 클로저에서의 순환 참조 문제
    클로저는 자신이 사용하는 외부 객체의 참조를 가지고 있습니다. 따라서 클로저 역시 순환 참조 문제에서 자유롭지 않은데, 객체 인스턴스가 클로저를 프로퍼티로 가지고, 클로저가 자신을 가지고 있는 인스턴스를 캡쳐하게되면, 순환 참조 문제가 발생하게 됩니다. 이는 클래스의 경우와 거의 비슷합니다

    closureReferenceCycle

    이러한 경우를 위해 swift는 캡쳐 리스트를 클로저 정의에 추가할 수 있도록 해놓았습니다. 캡쳐 리스트를 통해 특정 변수를 캡쳐할 때의 규칙을 정할 수 있는데, 해당 변수에 대해서도 weak, unowned 등을 명시해줄 수 있습니다.

     lazy var someClosure: (Int, String) -> String = {
      [unowned self, weak delegate = self.delegate!] (index: Int, stringToProcess: String) -> String in
          // 이후 동작...
      }
    

    swift에서는 클로저에서 self.를 생략할 수 없도록 강제합니다. 이는 self가 프로그래머 모르게 캡쳐되는 것을 막기 위합입니다.

    만약 클로저와 클래스 인스턴스가 계속 서로를 참조하게 하고, 언제나 같이 해제된다는 것이 확실하다면 unowned로 캡쳐하고, 그렇지 않다면 weak로 캡쳐해야 합니다. weak로 캡쳐하면 해당 인스턴스가 사라졌을 때 자동으로 nil 로 설정되기 때문에 인스턴스의 유효성을 클로저 내부에서 확인할 수 있습니다.

    만약 캡쳐하는 참조가 절대 nil이 되지 않는다면, unowned가 되어야만 합니다.

    closureReferenceCycle2


지금까지 ARC에 대해서 알아보았습니다. 메모리 관리는 프로그래밍 언어의 특징과 사용 분야를 결정할만큼 굉장히 중요한 개념이고, 초보자 단계를 넘기 위해서는 반드시 짚고 넘어가야 할 것입니다.