THIS IS ELLIE

WWDC2021 ARC in Swift: Basics and beyond 본문

공부/WWDC

WWDC2021 ARC in Swift: Basics and beyond

Ellie Kim 2022. 2. 20. 02:51

ARC에 대해서 궁금한 게 있어서 공부하다가 WWDC21에 좋은 영상이 있어 정리해봤습니다.
주제는 스위프트의 ARC!


스위프트는 구조체 및 열거형과 같은 강력한 벨류 타입을 제공합니다.
레퍼런스 타입에서 의도치 않게 sharing되는 것의 위험을 피하기 위해서는 벨류 타입을 사용하는 것을 지향해야 합니다.
스위프트에서 클래스는 레퍼런스 타입이며 클래스를 사용하다면? 스위프트는 ARC를 통해 메모리를 관리합니다.

효과적인 코드를 작성하려면 ARC를 이해하는 것이 중요합니다.
스위프트에서 객체의 lifetime은 초기화 시점에서 시작되고 마지막으로 사용될 때 종료됩니다.
ARC는 lifetime이 끝난 후 객체 할당을 해제하여 자동으로 메모리를 관리합니다.
또 ARC는 reference count를 추적해 객체의 lifetime을 결정합니다.
스위프트 컴파일러는 자동으로 retain/relase를 넣어주는데 retain은 reference count를 증가시키고 relase는 감소시킵니다.
그리고 reference count가 0이 되면 객체 할당이 해제됩니다.

 

여행 앱을 만들고 싶다고 생각해보세요.
여행자를 나타내기 위해 name과 destination이 있는 Traveler 클래스를 만들어보겠습니다.
test() 함수에서는 Traveler 객체가 생성된 다음 reference가 복사되고 destination이 업데이트됩니다.
스위프트 컴파일러는 자동으로 참조가 시작될 때 retain을 넣어주고 참조를 마지막으로 사용할 때 release를 넣어줍니다.

traveler1은 Traveler 객체에 대한 첫 번째 참조이고 copy가 마지막 사용 시점입니다.

여기서 스위프트 컴파일러는 traveler1 레퍼런스를 마지막으로 사용한 직후에 release를 넣어줍니다.
참조가 시작될 때는 retian은 넣어주지 않습니다.
왜냐하면 초기화하는 작업은 reference count를 1로 설정하기 때문입니다.

traveler2는 Traveler객체에 대한 다른 참조이며 destination이 업데이트되는 부분이 마지막 사용되는 시점입니다.

여기서 스위프트 컴파일러는 참조가 시작될 때 retain을 넣어주고 참조를 마지막으로 사용한 직후에 release를 넣어줍니다.

런타임에서는 어떻게 되는지 살펴보겠습니다.

먼저 Traveler 객체가 힙에 생성되고 reference count가 1로 초기화됩니다.

새로운 참조를 준비하기 위해 retain이 실행되고 reference count가 2로 증가됩니다.

traveler2는 Traveler 객체에 대한 참조이기도 합니다.

traveler1 참조를 마지막으로 사용한 직후에 release가 실행되어 reference count가 1로 감소됩니다.

다음 Traveler 객체의 destination이 "Big Sur"로 업데이트됩니다.
이 작업이 traveler2 참조의 마지막 사용 시점이었기 때문에 release가 실행되어 reference count가 0으로 감소됩니다.
reference count가 0이 되면 객체를 할당 해제할 수 있습니다.

 

스위프트에서 객체 lifetime은 사용되는 것에 기반합니다.
객체의 보장된 최소 lifetime은 초기화 시점에 시작하고 마지막 사용 시점에 종료됩니다.
객체의 lifetime이 초기화 시점 시작하고 닫는 중괄호에 종료되도록 보장되는 C++ 같은 언어들과는 다릅니다.

위 예를 통해서 마지막 사용 직후 객체가 할당 해제되는 것을 보았습니다.
하지만 실제로 객체 lifetime은 스위프트 컴파일러가 넣는 retain/release에 의해 결정됩니다.
ARC 최적화에 따라 객체 lifetime은 보장된 최소 lifetime과는 다를 수 있고 객체의 마지막 사용을 넘어서 종료될 수도 있습니다.

 

Observable 객체의 lifetime에 대해서 살펴보겠습니다.
강한 참조인 기본 참조와 달리 weak reference와 unowned reference는 reference counting에 참여하지 않기 때문에 일반적으로 reference cycles를 깨는 것에 사용됩니다.

그전에 reference cycles가 무엇인지 살펴봅시다.
여행 앱의 확장입니다.
이제 포인트 시스템을 도입하고자 합니다.
여행자는 계정을 가지고 포인트를 적립할 수 있습니다.

Account클래스는 Traveler 클래스를 참조하고 Traveler 클래스는 다시 Account 클래스를 참조합니다.
test() 함수에서 Traveler, Account 객체를 만들고 traveler 참조를 통해 printSummary() 함수를 호출합니다.
코드를 단계별로 살펴보고 ARC에서 어떤 일이 발생하는지 살펴봅시다.

먼저 Traveler 객체가 힙에 생성되고 reference count가 1로 초기화됩니다.

다음 Account 객체가 힙에 생성되고 reference count가 1로 초기화됩니다.
Account객체는 Traveler 객체를 참조하므로 Traveler 객체의 reference count는 2로 증가합니다.

이제 Traveler 객체가 Account 객체를 참조하기 시작하므로 Account 객체의 reference count도 2로 증가합니다.
여기서가 Account 참조의 마지막 사용 시점입니다.
그런 다음 Account 참조가 사라지고 Account 객체의 reference count도 1로 감소됩니다.

그리고 printSummary()를 호출해 이름과 포인트를 출력합니다.
여기서가 Traveler 참조의 마지막 사용 시점입니다.
이후 Traveler 참조는 사라지고 Traveler 객체의 reference count는 1로 감소됩니다.
객체들에 닿을 수 있는 모든 참조가 사라진 이후에도 reference count는 1로 남아있습니다.
이것이 바로 reference cycles 때문입니다. 
결국 객체가 할당 해제되지 않아서 메모리 릭이 발생하게 됩니다.

 

weak reference나 unowned reference로 reference cycles을 깰 수 있습니다.
왜냐하면 reference counting에 참여하지 않기 때문입니다.
스위프트 런타임은 weak reference에는 nil로 unowned reference는 덫으로 안전하게 전환합니다.

Account 클래스의 treveler를 weak으로 변경해보겠습니다.

weak reference는 reference counting에 참여하지 않기 때문에 Traveler 객체를 마지막으로 사용한 후 reference count는 0이 됩니다. 

Traveler 객체의 reference count가 0이 되면 할당을 해제할 수 있습니다.

Traveler 객체가 사라지면 Account 객체에 대한 참조가 사라지고 reference count가 0이 됩니다.

이제 Account 객체를 할당 해제할 수 있습니다.

printSummary() 함수는 Traveler 클래스에서 Account클래스로 이동되었습니다.
그리고 test() 함수는 Account 참조를 통해 printSummary() 함수를 호출합니다.
printSummary() 함수가 호출되면 어떻게 될까요?

이름과 포인트가 출력될 수 있지만 우연의 일치일 뿐입니다.
Traveler 객체의 마지막 사용이 printSummary() 전이기 때문입니다.
이후 컴파일러가 마지막 사용 직후 release를 넣는다면 Traveler객체의 reference count가 0이 될 수 있습니다.

reference count가 0이 되면 Traveler 객체가 할당 해제될 수 있습니다. 

그때 printSummary() 함수가 호출되면 강제 언래핑으로 크래쉬가 나게 됩니다.

옵셔널 바인딩을 사용하면 문제를 약화시킬 수 있습니다.
명확하게 크래시를 내지 않아 객체의 lifetime이 변경될 때 눈에 띄지 않는 버그가 생길 수 있습니다.



이를 처리하기 위한 다양한 기술이 있습니다.
예제를 통해 하나씩 살펴보겠습니다.
스위프트는 명시적으로 객체의 lifetime을 연장할 수 있는 withExtenededLifetime() 유틸리티를 제공합니다.

withExtendedLifetime()을 사용하면 printSummary() 함수가 호출되는 동안 Traveler 객체의 lifetime을 안전하게 연장해 잠재적인 버그를 방지할 수 있습니다.

끝에 withExtendedLifetime()를 호출함으로 동일한 효과를 얻을 수 있습니다.

defer를 사용해 컴파일러에 현재 범위 끝까지 객체 lifetime을 연장하도록 요청할 수도 있습니다. 
하지만 이는 개발자한테 모든 책임을 전달합니다.
weak reference가 버그를 일으킬 가능성이 있을 때마다 withExtendedLifetime()을 사용해야 해서 유지 관리 비용이 증가할 수 있습니다.

클래스를 재설계하는 것이 훨씬 더 좋은 접근 방식입니다.

printSummary() 함수는 Traveler 클래스로 다시 이동되고 Account 클래스의 weak reference는 숨겨집니다.
strong reference를 통해서 printSummary() 함수를 호출해 잠재적인 버그를 제거합니다.

알고리즘을 다시 생각해보거나 트리 구조로 변환하면 순한 참조를 피할 수도 있습니다.

아까의 예제에서는 Traveler 클래스가 Account 클래스를 참조해야 했습니다.
Account 클래스가 Traveler 클래스를 참조할 필요는 없습니다. 
Account 클래스는 오직 treveler의 개인 정보에만 접근하면 됩니다.

treveler의 개인 정보를 새로운 클래스인 PersonalInfo로 이동할 수 있습니다.
Traveler 클래스와 Account 클래스 모두 PersonalInfo 클래스를 참조할 수 있고 사이클을 피할 수 있습니다.
이 방법은 추가 구현 비용이 발생할 수 있겠지만 객체의 lifetime 버그를 제거하는 확실한 방법입니다.

 

deinit side-effect
deinit에는 global side-effect가 있습니다. 

콘솔에 메세지를 출력하는 것입니다.
"Done traveling"이 출력된 이후에 deinit이 될 수 있습니다.

하지만 Traveler 객체의 마지막 사용은 destination이 업데이트되는 시점이기 때문에 ARC 최적화에 따라서 "Done traveling"이 출력되기 전에 deinit이 실행될 수 있습니다.

더 복잡한 예를 들어보겠습니다.

Traveler 클래스에 TravelMetrics를 넣어줍니다.

destinaion이 업데이트될 때마다 TravelMetrics 클래스에 기록됩니다.

Traveler 객체가 deinit 되면 travelMetrics publish를 호출합니다.
publish()는 traveler의 익명 ID, 조회한 destination 수, 관심 카테고리를 나타냅니다.

테스트 예제를 살펴봅시다.
Traveler 객체를 만들고 Traveler객체에서 TravelMetrics에 대한 참조를 copy 합니다.
traveler의 destination은 TravelMetrics에서 BigSur로 기록한 BigSur로 업데이트됩니다.
traveler의 destination은 TravelMetrics에서 Catalina로 기록한 Catalina로 업데이트됩니다.
그리고 기록된 destination을 관심 카테고리를 계산합니다.

deinit은 관심도를 계산한 후 실행되어 관심 카테고리를 Nature로 publish 할 수도 있습니다.

하지만 Traveler 객체의 마지막 사용은 destination이 Catalina로 업데이트되는 시점이고, 그 후에 바로 deinit이 실행될 수도 있습니다. 

관심도를 계산하기 전에 deinit이 실행되기 때문에 이때는 nil이 publish 되어 버그가 발생합니다.

withExtendedLifetime()을 사용해 관심 카테고리가 계산될 때까지 Traveler 객체의 lifetime을 연장해 잠재적인 버그를 막을 수 있습니다.

정확성은 개발자에게 책임을 전달합니다.
deinit side-effect 가능성이 있는 모든 부분에 withExtendedLifetile()을 등록해줘야 합니다.

클래스를 재설계해서 객체 lifetime버그를 방지할 수 있습니다. 

travelMetrics를 private 하게 만들어 외부에서 접근하지 못하도록 숨깁니다. 
이제 deinit에서 관심 있는 여행 카테고리를 계산하고 publish합니다.
이 방법은 효과는 있지만 deinit의 side-effect를 완전히 제거하는 것이 원칙입니다. 

 

deinit가 publish하는 것 대신 defer를 사용해 대체합니다.
그리고 deinit은 검증만 수행합니다.
deinit의 side-effect를 제거함으로 우리는 모든 잠재적인 객체 lifetime 버그를 제거할 수 있습니다.

 

Xcode 13에서 스위프트 컴파일러에 Optimize Object Lifetimes설정을 사용할 수 있습니다.

이는 강력한 lifetime 단축 ARC 최적화를 가능하게 합니다.
이 빌드를 설정하면 객체가 마지막 사용한 후 할당이 해제되는 것을 직관적으로 볼 수 있습니다.


(생략되는 말도 꽤 있으니 영상을 직접 보시는 게 가장 좋을듯합니다)
resource: https://developer.apple.com/videos/play/wwdc2021/10216/ 

반응형

'공부 > WWDC' 카테고리의 다른 글

WWDC2021 What's new in Swift  (0) 2021.06.11
WWDC2020 Master Picture in Picture on tvOS  (0) 2020.10.18
Apple 이벤트 2020  (0) 2020.09.23
WWDC2020 Become a Simulator Export  (0) 2020.08.12
WWDC2020 What's New in Swift  (0) 2020.06.30