swift

[WWDC 2021] ARC in Swift: Basics and beyond

kimyounggyun 2023. 1. 31. 16:52

Automatic Reference Counting in Swift

스위프트의 참조 타입 인스턴스는 힙 메모리에 저장되고 Automatic Reference Counting (or ARC) 방식으로 관리된다. ARC 방식은 단순한데 어떤 한 참조 타입의 인스턴스는 refCount라는 참조값을 가지고 이 인스턴스가 참조되면 참조값을 늘리고 더 이상 참조되지 않으면 참조값을 줄인다. 만약 참조값이 0이 되면 인스턴스의 생명이 끝난 것으로 판단하여 메모리에서 해제한다. 

ARC

스위프트의 인스턴스는 생성자를 통한 초기화부터 마지막 사용될 때까지 메모리 위에 있다. 인스턴스의 참조 여부를 수동으로 계산해 retain 연산 release 연산을 추가해야 하는 Objective-C의 Memory Reference Code (or MRC) 방식과는 다르게 스위프트의 ARC는 인스턴스의 생명을 결정하기 위해 컴파일 시간에 retain 연산과 release 연산을 적절한 위치에 추가한다. 이후 런타임 시간에 retain, release 연산을 실행해 참조값을 관리한다. 결과적으로 참조 값이 0이 되었을 때 스위프트는 인스턴스의 생명이 끝났다고 판단하여 인스턴스를 메모리에서 해제한다.

traveler1Traveler의 인스턴스를 참조한다. traveler1traveler2Traveler 인스턴스의 메모리 주소를 복사하고 더 이상 사용되지 않아 컴파일러는 다음 라인에 release 연산을 추가하여 Traveler인스턴스의 참조 값을 줄인다. 이 경우에 컴파일러는 retain 연산을 추가하지 않는데 인스턴스가 참조 값이 1인 상태로 생성되기 때문이다.

traveler2Traveler인스턴스를 참조한다. traveler2Traveler인스턴스의 메모리 주소가 복사될 때 참조 카운트가 1개 증가하므로 컴파일러는 이전 라인에 retain 연산을 추가하고 destination에 값이 정해지고 나면 traveler2는 사용되지 않으므로 release 연산을 추가하여 참조 값을 줄인다.

인스턴스의 참조 여부에 따라 컴파일 시간에 retain, release 연산이 추가되었다면 런타임 시간에 추가된 연산을 바탕으로 인스턴스의 참조값을 계산한다. 먼저 Traveler인스턴스가 참조 값이 1인 상태로 힙 메모리에 생성된다.

이후, retain 연산이 실행되어 참조값이 증가한다.

새로운 traveler2Traveler 인스턴스를 참조한다. ref_countTraveler을 참조하는 변수의 개수가 동일하다.

release 연산이 실행되어 참조값이 감소한다. 

traveler2destination 값이 업데이트된다.

release 연산이 실행되고 참조값이 0이 되어 Traveler 인스턴스가 해제된다.

Object lifetimes

인스턴스의 생명이 스코프에서 정해지는 C++와 다르게 스위프트에서 인스턴스의 생명은 인스턴스가 초기화되는 순간부터 마지막까지 사용되는 순간까지로 인스턴스의 사용여부로 결정된다. 따라서 Traveler 인스턴스는 print("Done traveling") 위에서 해제되어야 하지만 실제로 실행하면 그렇지 않다.

실제로는 ARC 최적화 등에 의해 실제 생명주기 끝보다 더 뒤쪽에서 해제될 수 있다. 보통은 언제 해제되던 상관은 없지만 약한 참조,  미소유 참조, deinitializer에 의해 참조되는 인스턴스는 버그의 원인이 될 수 있다.

Observable object lifetimes

weak, unowned reference

강한 참조와 달리 약한 참조, 미소유 참조는 참조 값 연산에 영향을 주지 않아 보통 순환 참조를 끊는 데 사용된다. 약한 참조 혹은 미소유 참조한 인스턴스가 런타임 시간에 해제되는 경우가 있는데 약한 참조는 nil에 접근하고 미소유 참조는 트랩에 걸리게 되므로 사용에 주의해야 한다.

Accounttraveler 프로퍼티는 Traveler를 약한 참조하고 있어 참조값이 증가하지 않아 traveler.account = account이 끝나면 메모리에서 해제된다. 따라서 Accounttraveler프로퍼티는 nil을 가지고 printSummary()는 강제 언래핑으로 인한 nil에 접근해 오류가 발생한다. 

이 문제는 간단하게 옵셔널 바인딩을 사용해 해결할 수 있다. withExtendedLifetime() 메서드를 사용해 약한 참조와 미소유 참조를 좀 더 안전하게 사용할 수 있다. withExtendedLifetime() 메서드를 사용하여 printSummary()가 호출될 때까지 Traveler 인스턴스의 생명을 연장해 버그를 피할 수 있다.

defer와 함께 사용해 간편하게 인스턴스의 생명을 연장할 수 있다. 하지만 withExtendedLifetime()은 약한, 미소유 참조로 인해 발생하는 모든 버그에 대한 컨트롤을 개발자에게 위임하기 때문에 사용해야 한다. 

클래스 설계 단계에서 공통된 부분을 추출해 클래스들의 구조를 트리 구조로 바꾸어 순환 참조를 피할 수 있다. 성능적으로 약간의 손해를 볼 수 있지만 약한, 미소유 참조로 인해 발생하는 버그는 막을 수 있다.

Deinitializer

소멸자는 인스턴스가 메모리에서 해제되기 전에 호출된다. 스위프트의 인스턴스는 초기화부터 마지막 사용까지 생명을 가지지만 ARC 최적화 등에 의해 인스턴스의 생명이 달라질 수 있다. 따라서 소멸자가 호출되는 시점이 달라질 수 있다. 그러므로 소멸자에서 인스턴스를 참조하는 것은 위험하다. 

소멸자로 인한 버그 해결방법은 withExtendedLifeTime()을 사용해 인스턴스의 수명을 연장하거나 소멸자의 내용을 defer에서 실행하면 된다.

참고