코딩캣: 코딩하는 고양이.
[번역글] Swift의 자동 레퍼런스 카운트 (1)
Language/Objective-C & Swift
2021. 9. 16. 23:36

본 게시글은 Automatic Reference Counting (ARC) in Swift를 바탕으로 작성하였습니다.

Swift의 자동 레퍼런스 카운트

자동 레퍼런스 카운트(ARC: Automatic Reference Counting)는 Swift에서 메모리를 관리하는 메커니즘입니다. ARC라는 개념을 가지고 작업하는 것은 최신의 iOS 개발에서 필수적입니다. 이번 게시글에서 우리는 iOS의 메모리 관리를 위해 ARC가 어떻게 사용되는지 살펴보겠습니다.

  • ARC는 어떻게 작동되는가
  • iOS 앱에서 메모리 관리가 왜 중요한가
  • 객체의 retain 횟수가 소멸에 어떻게 영향을 주는가
  • 메모리 관리의 실전 예제
  • 순환 retain과 같은 흔한 결함
  • strong 참조 및 weak 참조를 써서 작업하기

준비 되었다면 시작하겠습니다.

 

메모리 관리란 무엇인가?

모든 iPhone은 한정된 양의 RAM을 가졌는데 오늘날에는 대략 4GB 정도입니다. RAM은 여러분이 iPhone을 사용하는 동안 정보를 일시적으로 보관할 수 있는 저장소 역할을 합니다. RAM에 보관된 데이터는 iPhone이 재부트될 때 유지되지 않습니다. 하지만 읽고 쓰는 속도는 정말로 빠릅니다.

  • RAM 빠르긴 하지만 작은 메모리이며 컴퓨터에서 데이터를 단기간 보관할 수 있는 기억 장치입니다.
  • flash 느리긴 하지만 큰 메모리이며 컴퓨터에서 데이터를 장기간 보관할 수 있는 기억 장치입니다.

iPhone에서는 활성화된 모든 앱들, 심지어 iOS라는 운영체제 자체도 가용 RAM을 공유합니다. 하나의 앱이 많은 메모리를 사용한다면 나머지 앱은 적은 메모리밖에 사용할 수 없습니다. iOS는 운영체제 차원에서 메모리를 효율적으로 사용할 수 있는 프로세스들을 가지고 있습니다만, 우리는 우리의 앱 내에서 메모리를 어떻게 관리할 지에 대해서만 살펴보겠습니다.

Swift로 새 객체를 생성할 때, 그 객체에 맞는 소정의 메모리가 할당됩니다. 객체가 더 이상 필요가 없을때 이 객체는 메모리 공간을 비워주며 소멸됩니다. 할당과 소멸은 전력 및 CPU 시간을 적지않게 소비합니다. 여러분은 이러한 개념을 저장 창고에 비유할 수 있습니다. 어떤 꾸러미를 창고에 보관하기 위해 일정한 시간과 노력이 필요하다는 의미입니다.

메모리 관리는 소멸 및 할당 해제될 수 있는 대상들을 찾아내는 과정입니다. 그래서 RAM 공간 내에서 여유분이 확보됩니다. 이것은 연속적으로 수행되는 작업인데, 새로운 데이터를 위해 RAM 일부가 사용되고 그 데이터가 더 이상 필요 없을 때는 RAM에서 제거됩니다. 물론 필요한 데이터가 RAM에서 잘못 소멸되면 문제입니다. 앱 개발자로서 여러분이 할 업무는 메모리가 (거의) 자동으로 관리될 수 있게끔 앱을 작성하는 것입니다.

PC나 매킨토시와는 다르게 iOS는 자주 참조되지 않거나 크기가 큰 데이터를 보조기억장치에 기록하는 스왑(swap) 파일을 사용하지 않습니다. iPhone에 탑재되는 플래시 저장장치는 스왑을 사용할 수 있을만큼 빠르지 않기 때문입니다. 대신에 iOS 앱은 운영체제가 할당해 준 양보다 더 많은 메모리를 사용하려할 때 iOS는 그 앱에게 활성 메모리의 점유를 줄이도록 요구하거나 아예 그 앱을 직권으로 종료시켜 버립니다.

 

왜 메모리를 관리해야 하는가?

RAM의 크기는 한정적이고 모든 앱이 동일한 풀(pool)을 공유하기 때문에 각각의 앱들은 그 운영체제 안에서 "모범 시민"으로서 행동하며 효율적으로 메모리를 관리해나가야 합니다. 이론적으로 메일(Mail) 앱이 단말기 RAM의 90%를 차지하고 있는 중이라면, 곧 메모리가 바닥날 것이기 때문에 이와 동시에 지도(Maps) 앱을 사용할 수는 없습니다.

여러분의 iPhone에서 RAM의 여분이 부족할 것이라 판단되면, iOS는 메모리를 너무 많이 차지하고 있는 앱들을 강제종료시킵니다. 이러한 작동은 사용자 경험(User Experience)을 떨어뜨릴 것이고 개발자로서도 그러한 사태를 원치 않을 것입니다.

특히 모바일 플랫폼이기 때문에 Apple은 배터리 사용에 가장 엄격하게 주안점을 두고 있습니다. 활성화된 데이터를 RAM에 적재해 두고 있는 것은 상당한 에너지를 소비하게 됩니다. 그렇다면 어떤 앱이 너무 많은 메모리를 사용하고 있고 어쩌면 필요 이상으로 RAM을 잡아먹고 있다면 배터리 또한 빨리 소모될 것임을 여러분도 상상하실 것입니다. 그러한 앱을 강제 종료시켰을 때 어쩌면 그 앱의 당시 상태가 보조기억장치인 플래시 저장소에 기록될 수 있고 이는 또 다른 CPU 및 전력 소비로 이어질 수 있습니다.

그렇다면 iOS에서 왜 메모리 관리에 대해 공부해야 할까요?

  1. 그냥 재미있기 때문에?
  2. retain의 순환을 피하기 위해?

간단히 말하면 iPhone에서 RAM은 한정된 자원이기 때문에 이 메모리를 효율적으로 잘 사용한다면 iPhone 사용자에게 최상의 사용자 경험을 제공할 수 있을 것입니다. 이것이 우리가 개발자로서 메모리를 관리해야 하는 이유입니다.

 

ARC와 가비지 콜렉션(Garbage Collection)

계속 설명하겠으나 iOS에서 여러분이 메모리 관리를 위해 ARC를 사용할 때, 안드로이드 기반 플랫폼에서는 메모리를 관리하기 위해 가비지 콜렉션(Garbage Collection)을 사용합니다.

간단히 설명하면 가비지 콜렉션은 사용되지 않는 데이터를 메모리에서 삭제하는 작동을 담당하는 독립된 OS 프로세스입니다. 이 작업은 메모리에서 제거되어야 할 대상을 찾아내야 하기 때문에(이를 “mark-and-sweep”라 합니다), 종종 기기의 성능을 잡아먹습니다.

하지만 ARC는 이와 같인 병목 현상이 없습니다. 왜냐하면 메모리 관리를 위한 코드가 이미 그 앱의 일부로 들어가기 때문입니다(이에 비해 가비지 콜렉션은 앱과는 별도의 프로세스로 돌아갑니다). 물론 ARC는 개발자에게 좀 더 많은 노력을 요구하는 특징이 있으며 잘못 관리될 경우 retain 순환 같은 문제가 발생할 수 있습니다.

 

ARC는 어떻게 작동되는가?

지금부터는 ARC가 어떻게 작동되는지 살펴보겠습니다. ARC를 이해하기 위한 필수 사항들을 훑어보시기 바랍니다.

  • ARC의 목적은 RAM 공간 확보를 위해 어떤 데이터가 RAM에서 제거되어야 하는 지를 알기 위함입니다.
  • ARC의 필수적인 개념은 retain의 횟수입니다. 이것은 어떤 객체가 다른 객체들에게 몇 번째로 참조(hold into)되고 있는지를 추적할 수 있는 숫자입니다.
  • ARC는 클래스(class)와 같은 레퍼런스 타입(reference type)에만 적용될 수 있습니다. 구조체(struct)와 같은 값 타입(value type)에는 적용되지 않습니다. 값 타입은 통째로 복제가 이루어질 뿐 참조로써 작동되지는 않습니다.

Automatic Reference Counting이라는 이름이 어떻게 유래되었는지 여러분은 이미 살펴보았습니다.

(1) ARC는 여러분의 소스 코드에서 객체들 사이에 이뤄지는 연결고리, 즉 참조(reference)와 관계가 있는 것입니다.

(2) ARC는 어떤 객체가 retain된 횟수를 추적하는 계수(counting)에 대한 것입니다.

(3) ARC는 여러분이 개발자로서 메모리 관리에 대해 최소한의 노력만 하면 되게끔 도울 수 있는 자동화(automating)에 대한 것입니다.

참고로 2011년 iOS5 및 Xcode 4.2가 공개되기 전까지 개발자들은 객체의 할당과 소멸을 일일이 직접 해줘야 했습니다. 여러분은 어쩌면 어떤 객체에 대한 참조 횟수 증감을 위해 alloc, retainrelease 등의 메소드를 사용해 보았을 지도 모릅니다. retain 메소드는 어떤 객체의 참조 횟수를 증가시키는 역할을 하고, release 메소드는 감소시키는 역할을 합니다. ARC와 마찬가지로 참조 횟수가 0이 되면 그 객체는 메모리에서 제거됩니다. 오늘날에는 ARC가 이 과정을 자동으로 수행합니다.

 

Retain Count를 추적하기

Swift에서 모든 객체(클래스의 인스턴스들)은 retainCount라는 프로퍼티를 가지고 있습니다. 이 값이 0보다 크면 그 객체는 메모리에 계속 남아있겠지만 계속 감소하다가 0에 도달하면 메모리에서 제거됩니다.

앞서 살펴보았듯이 클래스로 표현되는 레퍼런스 타입에 한에서 이 retainCount를 갖습니다. 구조체는 소스 코드의 한 부분에서 다른 부분으로 전달될 때 복사가 이뤄지기 때문에 참조 횟수를 증가시켜야할 이유가 없습니다. 여러분의 소스 코드에서 ARC는 오직 “공유”되는 인스턴스 또는 참조에 의해 전달되는 인스턴스에 대해서만 역할을 합니다.

계속 내용을 이어가기에 앞서 비유를 하나 들어보겠습니다. Berg, Pete 및 Sharon이라는 3명의 사람이 전화번호가 적혀있는 쪽지 하나를 서로 공유하고 있다고 상상해 봅니다. 우리는 전화번호가 적혀 있는 그 쪽지를 paper라고 이름짓겠습니다. 물론 그 paper라는 것은 Swift 클래스입니다.

이 때 여러분은 세 사람이 하나의 쪽지를 가지고 서로 손을 잡게 되는 상황을 상상할 수 있습니다. Berg, Pete 및 Sharon은 모두 각자의 용도가 있어서 그 쪽지를 필요로 합니다. 그래서 하나의 쪽지가 세 명에게 붙잡혀 있는 것이지요.

이 상황에서 paperretainCount3입니다. 왜일까요? 3명이 paper라는 그 객체를 붙잡고 있기 때문입니다. 여기서 결론이 나옵니다. paper 객체는 현재 사용중이기 때문에 메모리에서 제거될 수 없습니다.

만일 Sharon이 그 쪽지가 더 이상 필요 없기에 손을 놓아버렸다고 가정합니다. 이후 Pete도 쪽지에서 손을 놓습니다. 이 두 명은 쪽지에 적힌 전화번호를 가지고 용무를 다 끝냈기 때문이죠. 그러면 retainCount2가 되었다가 다시 1로 감소합니다. Berg만이 아직 그 쪽지를 붙잡고 있습니다. 때문에 이 쪽지는 아직 사라질 수 없습니다.

마침내 Berg도 그 쪽지에서 손을 놓아버립니다. 이는 retainCount0에 도달하게 만듭니다. 따라서 paper 객체가 메모리에서 제거됩니다. 그 객체를 필요로 하는 곳이 더 이상 없기 때문에 그 객체가 점유하고 있던 메모리도 해제되어서 다른 앱이 사용할 수 있게 됩니다.

실전 Swift 프로그래밍을 통해 이것이 어떻게 작동될 수 있는지 확인해 보겠습니다.

 

실전 ARC

우리는 이제 ARC가 실제로 코드에서 어떻게 나타나는지를 보려 합니다. 먼저 다음과 같이 Car라는 이름의 간단한 클래스를 하나 선언 및 정의해 보겠습니다.

// Swift
class Car {
    var name = ""
    init (name: String) {
        self.name = name
        print("Instance of Car '\(name)' is initialized.")
    }
    deinit {
        print("Instance of Car '\(name)' is deinitialized.")
    }
}
// Objective-C
@interface Car : NSObject
@property NSString * name;
@end

@implementation Car
@synthesize name;
- (instancetype)initWithName:(NSString *)name {
    self = [self init];
    if (self) {
        [self setName:name];
        NSLog(@"Instance of Car '%@' is initialized.", [self name]);
    }
    return self;
}
- (void)dealloc {
    NSLog(@"Instance of Car '%@' is deinitialized.", [self name]);
}
@end

 

Car 클래스는 String 타입의 프로퍼티인 name을 갖고 있습니다. 우리는 또한 initdeinit라는 생성자와 소멸자도 정의하였습니다. 각각 Car형 인스턴스가 생성되고 소멸될 때 호출될 것이고, print가 호출되기에 우리에게 내부적으로 어떤 호출이 발생하는지 알 수 있도록 도와줄 것입니다.

그리고 다음과 같이 두 개의 변수를 선언합니다.

// Swift
var car1: Car?
var car2: Car?
// Objective-C
Car * car1;
Car * car2;

 

두 변수의 타입은 Car?형, 즉 옵셔널(optional) 타입이고 둘 다 아직 초기화되지는 않았습니다.

Car 클래스의 새 인스턴스를 car1 변수에 대입합니다. 새 인스턴스를 만들 때 name 프로퍼티도 다음과 같이 설정합니다.

// Swift
car1 = Car(name: "Maserati")
// Objective-C
car1 = [[Car alloc] initWithName: @"Maserati"];

그리고 car1가 갖게 된 참조를 car2에도 다음과 같이 대입합니다.

// Swift
car2 = car1
// Objective-C
car2 = car1;

이렇게 하면 동일한 Car 클래스형 인스턴스에 대해 car1car2함께 참조하게 됩니다. Swift에서 이것이 어떻게 작동하는가 하면, 객체 자체는 복사되지 않았지만 그 객체에 대한 참조가 복사되었습니다. 그래서 car1car2동일한 객체를 현재 가리키고 있습니다.

흥미로운 부분은 여기에서부터 시작합니다. 다음과 같이 해 보겠습니다.

// Swift
car1 = nil
// Objective-C
car1 = nil;

car1 변수가 nil로 설정되어 이 변수는 더 이상 앞서 생성한 Car 클래스형 인스턴스를 가리키고 있지 않습니다. 그렇다면 car2는 어떨까요?

car1 변수가 nil 참조로 바뀌었다 하더라도 car2 변수는 여전히 Car 클래스형 인스턴스를 가리키고 있습니다. 그래서 이 인스턴스는 소멸되지 않고 여전히 메모리에 남아있습니다.

여기서 우리는 이것도 해 보겠습니다.

// Swift
car2 = nil
// Objective-C
car2 = nil;

이를 실행해 보았을 때 다음과 같이 출력될 것입니다.

 

추측하였겠지만 위와 같은 코드로 인해 인스턴스를 참조하고 있는 변수가 없어졌기 때문에 이 인스턴스의 retainCount0이 되면서 메모리에서 소멸됩니다.

이 예제 실습에서 우리가 알게 모르게 확인했던 핵심 개념으로 강한(strong) 참조가 있습니다. 이 예제에서 car1 변수와 car2 변수는 Car 클래스형 인스턴스에 대해 “strong” 참조를 하고 있습니다.

strong 참조 변수는 자신이 참조하게 될 객체에 대해 retainCount를 증가시킵니다. 그 결과 객체는 다른 변수에서 전부 nil 참조로 바뀌어도 한 변수가 참조를 유지하고 있는 한 메모리에 남아있을 것이고, 모든 변수에서 참조가 해제되었을 때가 되어서야 그 객체는 메모리에서 사라질 것입니다. 이것이 ARC가 작동하는 방식입니다.

다음의 예제를 자유롭게 변형해보면서 ARC의 작동을 확인해보시기 바랍니다.

// Swift
class Car {
    var name = ""
    
    init(name: String) {
        self.name = name
        print("Instance of Car '\(name)' is initialized.")
    }
    
    deinit {
        print("Instance of Car '\(name)' is deinitialized.")
    }
}

var var1: Car?
var var2: Car?

car1 = Car(name: "Maserati")
car2 = car1

car1 = nil
car2 = nil
// Objective-C
@interface Car : NSObject
@property NSString * name;
@end

@implementation Car
@synthesize name;
- (instancetype)initWithName:(NSString *)name {
    self = [self init];
    if (self) {
        [self setName:name];
        NSLog(@"Instance of Car '%@' is initialized.", [self name]);
    }
    return self;
}
- (void)dealloc {
    NSLog(@"Instance of Car '%@' is deinitialized.", [self name]);
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        Car * car1;
        Car * car2;
        
        car1 = [[Car alloc] initWithName:@"Maserati"];
        car2 = car1;
        
        car2 = nil;
        car1 = nil;
    }
    return 0;
}

다음 장에서는 할당 해제에 대해 보다 상세히 살펴보도록 하겠습니다.

 

'Language/Objective-C & Swift' 카테고리의 다른 글
더 보기...
태그 : 
댓글