ARC

2021년 7월 22일 수정

Automatic Reference Counting

ARC(Automatic Reference Counting)는 Objective-CSwift에서 주로 쓰이는 메모리 관리 시스템이다.

이름으로 보면 Reference Counting 이라는 것을 자동화한 것 같은 늬앙스인데 정말 그렇다.

설명을 위해서 우선 레퍼런스 카운팅 메모리 관리 체계부터 살펴보자.

레퍼런스 카운팅(Reference Counting)

번역하여 참조 횟수를 검사하는 방식의 메모리 관리 체계다.

모든 인스턴스는 내부에 카운터를 가지고 있다. 그리고 인스턴스가 생성될 때 카운터가 1 증가한다.

이후 이 인스턴스를 참조하려면 카운트를 1 증가시킨다. 카운트를 증가시키는 것을 리테인(retain)이라 부른다.

그리고 사용이 끝나면 카운터를 1 감소시킨다. 카운트를 감소시키는 것을 릴리즈(release)라고 부른다.

이러다가 카운터가 0이 되면 해당 인스턴스의 메모리는 해제(deallocation)된다.

아래는 ARC를 사용하지 않던 방식의 Objective-C 예제다.

SomeClass *obj = [[SomeClass alloc] init];  // retain
SomeClass *objStalker = [obj retain];

...

[objStalker release];
[obj release];
// obj가 가리키는 메모리 할당이 해제

레퍼런스 카운트가 0이 되면 자동으로 메모리가 해제되는 시스템이다 보니 retain과 release 쌍만 잘 맞추면 굉장히 효율적이고 낭비 없이 메모리 관리가 가능하면서도 GC 같이 퍼포먼스를 저하시키는 요소도 없어서 나름 효율적인 메모리 방식으로 볼 수 있다.

단지 retain - release 코드가 추가로 들어가야 되서 코드 양이 많아진다는 단점이 있을 뿐이다. 그리고 쌍을 잘못 맞추면 망가지는 건 당연한 일이다.

자동 레퍼런스 카운팅(ARC)

이런 수동 레퍼런스 카운팅의 단점, 즉 코드 양이 많아지거나 쌍을 맞춰야 하는 불합리함(?)을 해소하기 위한 목적으로 ARC라는 시스템이 등장한다.

이 방식은 제법 단순하다. 컴파일러가 알아서 retain - release 코드를 빌드 타임에 자동으로 집어 넣고 빌드하는 방식이다. 그래서 레퍼런스 카운팅 시스템 고유의 장점을 살리면서도 코드 라인 수를 획기적으로 줄일 수 있게 된다.

if (...) {
    SomeClass *obj = [[SomeClass alloc] init];  // ARC: [obj retain];
    SomeClass *objStalker = obj;                // ARC: [objStalker retain];

    // ...

    // ARC: [obj release];
    // ARC: [objStalker release];
}

주석으로 표기한 부분들이 컴파일러가 자동으로 해주는 부분들이다. 레퍼런스 참조와 관련된 코드의 거의 대부분이 사라졌음에도 레퍼런스 카운트 관리 방식을 그대로 구현하고 있다.

물론 그렇다고 모든 것을 컴파일러가 알아서 자동으로 해주지 않겠지만 말이다.

강한 참조와 약한 참조

참조에는 강한 참조와 약한 참조가 있는데, 여기서 약한 참조(Weak Reference)는 레퍼런스 카운트 증감에 관여하지 않는다. 대신 참조하는 메모리가 해제되면 nil 이 되어버려서 더이상 참조가 불가능해진다.

이와는 반대로 강한 참조(Strong Reference)는 앞서 말한 retain - release를 제대로 활용한다. 즉 레퍼런스 카운팅 시스템의 핵심이라고 볼 수 있다.

@interface SomeClass
@property (strong) AnotherClass *obj;
@property (weak) AnotherClass *refObj;
@end

위 코드는 강한 참조와 약한 참조 프로퍼티를 하나씩 선언하는 예제다.

강한 참조의 경우 의미 상 소유하고 있는 객체로도 볼 수 있다. 위 예제에서 obj 프로퍼티는 SomeClass가 소유하는 프로퍼티로 해석해도 틀린 말이 아니다.

따라서 아래와 같은 코드에선 ARC에 의해 자동으로 retain 및 release가 들어가게 된다.

if (...) {
    SomeClass *a = [[SomeClass alloc] init];        // [a retain];
    AnotherClass *b = [[AnotherClass alloc] init];  // [b retain];
    a.obj = b;                                      // [a.obj retain]
    ...
    // [a.obj release];
    // [b release];
}

더이상 소유할 필요가 없을 때 인위적으로 release를 할 수도 있다. 다른 오브젝트를 어사인하면 릴리즈가 되지만 nil 을 어사인하는 방법이 대표적이다.

a.obj = c;    // [a.obj release];
              // a.obj = [c retain];

a.obj = nil;  // [a.obj release];
              // a.obj = nil;

그런데 이 ARC의 핵심인 강한 참조 때문에 ARC의 대표적인 단점이자 문제점이 나타난다.

순환 참조 문제

ARC 이전에는 별 문제가 없다가 ARC가 도입된 이후 가장 큰 단점으로 손꼽히는 문제가 발생하게 되었다. 바로 순환 참조(Retain Cycle) 문제다.

순환 참조는 서로의 프로퍼티가 상대를 강하게 참조하는 상태에서 이들을 쫓아갈 수단을 없애버리는 경우 발생하는 문제로 메모리 릭(leak)을 일으킨다.

objA.ref = objB;
objB.ref = objA;

objA = nil;
objB = nil;

위의 예에서 objAobjB 는 자신의 프로퍼티가 서로를 강하게 참조하고 있다. 이 상황에서 자기 자신에 대한 정보를 읽어버리고 .ref 프로퍼티가 release 될 곳이 사라져 버렸다. 결국 참조하는 메모리가 영원히 해제되니 못 하는 순환 참조 상태가 되어버린다.

물론 고칠 수 있는 방법은 있다. 참조를 미리 잘 끝어주면 된다.

objA.ref = objB;
objB.ref = objA;

objA.ref = nil;
objB.ref = nil;
objA = nil;
objB = nil;

미리 참조하던 프로퍼티들이 가리키는 인스턴스를 release 해두면 당연히 문제는 생기지 않는다. 단지 코드가 더 추가되어야 해서 귀찮아질 뿐이다.

하지만 특정한 설계가 아니고선 이런 문제는 생각보다 잘 일어나지 않는다. 단지 UIKit이나 AppKit 프로그래밍을 하다보면 뷰의 인스턴스를 여기저기 넣다가 메모리가 제대로 해제되지 않는 경험은 어쩌다 겪을 수도 있다.

Swift의 ARC

여태까지 Objective-C 기준으로 ARC를 설명했는데 이제 애플에서 주력으로 미는 언어인 Swift를 기준으로 살펴보자.

사실 설명할 것이 거의 없다. Swift는 기본적으로 ARC를 사용하고 따라서 메모리 관리와 관련된 부분은 거의 동일하다. 그저 기본적으로 강한 참조를 한다는 것만 차이가 있다.

class SomeClass {
    var strongProerty: AnotherClass
    weak var weakProperty: AnotherClass
}

의미도 개념도 완전히 동일하다. 거기다 순환 참조가 발생하는 단점까지 그대로 가지고 있다. 순환 참조 예방법 조차 동일하다.

클로저의 경우 객체 납치(capture)라는 현상으로 인해 순환참조 못지 않은 메모리 릭 문제가 발생할 수 있다. 그래서 여기에도 예방 장치가 생겼다.

let someClosure: (Int) -> Int {
    [unowned self] (y) in
    return self.x + y
}

위의 클로저self 를 참조하고 있기 때문에 이 클로저를 어디에서 참조하느냐에 따라 self 가 가리키는 객체의 메모리 해제 여부가 갈리게 된다.

그래서 제때 메모리가 해제될 수 있도록 몇 가지 캡처 지시어를 제공하고 있는데 대표적인 것이 unonwed 다. weak 와 비슷하지만 Optional이 아니라는 특징이 있다. 대신 self 의 메모리가 해제되면 런타임 오류가 나겠지만 말이다.

참고로 이 부분은 이제 Objective-C판 클로저인 블록(block) 문법에서도 비슷하게 지원한다. 이제 self 참조에 대해서는 weak self를 만들라는 경고를 자주 볼 수 있다.