[WWDC] ARC in Swift: Basic and beyond
https://developer.apple.com/videos/play/wwdc2021/10216
ARC in Swift: Basics and beyond - WWDC21 - Videos - Apple Developer
Learn about the basics of object lifetimes and ARC in Swift. Dive deep into what language features make object lifetimes observable,...
developer.apple.com
데이터를 저장하는 메모리는 Stack과 Heap, 두 가지가 있었쬬
[WWDC] Understanding Swift Performance 글에서, 값 타입과 참조 타입의 성능에 관련해서 톺아보았는데요.
그 중 Swift에서 Class는 참조 타입이고,
ARC(Automatic Reference Counting)으로 메모리를 관리한다는 내용을 언급했어요.
오늘운 ARC에 관한 WWDC 영상을 시청했습니다.
Object lifetimes and ARC
객체의 생명주기는
초기화 시점부터 → 마지막 사용 시점에 끝납니다.
객체의 생명주기가 끝나면 메모리에서 자동으로 해제되는데요.
Swift의 ARC는 컴파일러에 의해, retain(참조 수 증가)과 release(참조 수 감소)가 삽입되어
참조 수가 0으로 떨어지면, 메모리에서 해제되는 방식입니다.
위 예시 코드에서
Traveler 객체는 traveler1에 의해 첫번째 참조 → 이후 복사를 끝으로 사용이 종료됩니다.
이 때, Swift 컴파일러는 알아서 종료 시점에 release 작업을 삽입합니다.
단, 참조가 시작될 때는 retain을 삽입하지 않는데, 초기화가 참조 수를 1로 설정하기 때문입니다.
traveler2는 Traveler 객체에 대한 또 다른 참조인데요.
목적지 업데이트 (.destination) 이후 종료되고 있습니다.
이번에도 컴파일러가 참조 시작 시점에 알아서 retain을 넣고, 참조가 끝나면 release 작업을 수행합니다.
메모리 구조와 함께 더 자세히 살펴보겠습니다.
1. 먼저 힙에 Traveler 객체가 생성되고, 참조 수가 1로 초기화됩니다.
2. 그 다음, 새로운 참조 작업을 준비하기 위해 retain이 실행되고, 참조 수가 2로 증가합니다.
3. traveler1가 마지막으로 사용되고 release 되면, 참조 수가 1로 감소합니다.
4. traveler2가 마지막으로 사용되고 release 되면, 참조 수가 0으로 감소되어 최종적으로 메모리에서 해제됩니다.
Swift에서 객체의 사용은 use-based (사용 기반) 입니다.
즉 객체는, 초기화 시점 ~ 마지막 사용 시점까지의 생명주기가 최소로 보장되는데요.
이는 중괄호 { } 를 닫는 시점에서 객체의 생명 주기가 끝나는 C++과 같은 언어와 상반되는 특징입니다.
또한, Swift의 객체의 생명 주기는
위의 예시처럼 컴파일러가 삽입한 retain, release 작업에 의해 결정됩니다.
따라서, ARC 최적화를 적용하는 방식에 따라 객체의 생명주기를 늘일수도 있습니다.
Observable object lifetimes
객체의 생명 주기를 관찰하는 방법은 두 가지가 있습니다.
1. weak나 unowned 참조
2. deinitializer side-effetcts
움.. 그런데요.
만약에~ 보장된 객체 생명 주기(초기화 ~ 마지막 사용 시점)가 아니라!
관찰된 객체 생명주기에 의존하면 버그의 원인이 될 수 있습니다.
당장은 문제가 되지 않아도,
컴파일러가 업데이트 되거나 관련 없는 source changes와 같은 예상치 못한 이유로 버그가 발생할 수 있다고 하는데요.
어떤 문제가 일어날 수 있을까요?
기본적으로 참조는 Strong 참조입니다.
일반적으로 weak나 unowned는 reference cycle을 끊기 위해 사용되는데,
이 친구들은 공통적으로 참조 카운트를 증가시키지 않고 참조한다는 특징이 있기 때문입니다.
weak나 unowned에 대해 언급하기 전에,
reference cycle를 알아보겠습니다.
위는 Traveler와 Account 서로가 서로를 참조하고 있는 상황인데요.
1. 처음에 Traveler 객체가 힙에 생성되며, 참조 수가 1로 초기화되었습니다.
2. Account 객체도 힙에 생성되면서, 참조 수가 1로 초기화되고 → 이 때, Traveler를 참조하기 때문에 Traveler의 참조 수가 2로 증가합니다.
3. 이후 Traveler 객체가 Account를 참조하게 되며, Account의 참조 수 또한 2로 증가합니다.
4. 다음으로는 account와 traveler 참조가 더 이상 사용되지 않기 때문에, 둘 다 참조 수가 1로 감소합니다.
but … 이제 코드가 끝났쬬
두 객체의 사용이 모두 끝났는데도 참조 수가 둘 다 1로 남아 있는 상황입니다.
이렇게 서로가 서로를 바라보았기 때문에, 영원히..
메모리에서 해제되지 않는 상황을 (로맨틱하다 . ㅋ ㅋ) reference cycle이라고 합니다.
메모리 릭을 초래하겠쬬
그렇다면 weak, unowned 참조로 reference cycle를 끊어봅시다.
- weak 참조에 접근할 때 nil로 설정
- unowned 참조에 접근할 때 오류 발생 시킴
위의 예시 코드에 weak을 적용시켜볼까요?
1. 위 예제에서 traveler를 weak으로 선언하면, Traveler 객체를 마지막으로 사용하고 난 뒤 참조 카운트가 0으로 떨어집니다.
2. Traveler가 사라지고 나면, Account에 대한 참조도 사라져 Account도 0이 됩니다.
3. 최종적으로, 모두 메모리에서 사라집니다.
흠 근데 이런 경우는 없을까요..
만약, 보장된 객체 생명 주기가 끝났는데 .. weak 참조로 객체에 접근하려 하면 어떻게 될까요?

예시로 살펴봅시다.
1. printSummary()를 호출하기 이전 시점에, traveler 참조의 마지막 사용이 끝납니다. (메모리에서 해제됨)
2. traveler의 객체가 해제되었기 때문에, printSummary()에서 weak 참조로 강제 언랩을 하려 하면 crash!
움. 이미 메모리에서 해제된 친구를 참조하려 했기 때문에 crash가 나는군요.
이거 혹시.. 강제 언랩이라서 그런 거 아닌가? 하는 사람들을 위해. .
옵셔널 바인딩을 해버리면,
crash 없이 silent bug를 발생시킬 수 있어, 더 버그를 찾을 수가 없게 됩니다.
그럼 weak이나 unowned를 안전하게 사용하려면 어떻게 해야 할까요?
강의에서 3가지 방법이 소개되는데요.
1. withExtendedLifetime()
생명주기를 명시적으로 연장해주어, 메모리에서 해제되지 않게 해주는 함수입니다.
만약 더 복잡한 상황이라면?
위와 같이 defer로 현재 스코프의 끝까지 생명 주기를 연장해 줄 수 있는데요.
but.. 이 방법은 weak가 버그를 일으킬 수 있는 상황마다 다 삽입해줘야 합니다.
(나 .. 자신을 믿을 수 있나 ..? 실수하지 않겠다고 .. 단언할 수 있나? ㅋ ㅋ )
→ 때문에, 유지 관리 비용이 증가되는 방식입니다.
2. Redesign to access via strong reference
두 번째.
printSummary를 Traveler 내부로 위치 시켜서, Account 객체를 strong하게 참조하게 만드는 것입니다.
이렇게 하면 Account 객체가 메모리에서 해제될 걱정을 안 해도 되겠쬬
3. Redesign to avoid weak/unowned reference
훔. 하지만 그냥 reference cycle이 없도록 구조적으로 리디자인 한다면 어떨까요.
위처럼 순환적 클래스 관계를 트리 구조로 만드는 것도 좋은 방법입니다.
Observable object lifetimes 두 번째!
Deinitializer side-effetcts 입니다.
deallocation 직전에 실행되어 → side-effects를 관찰할 수 있는데요.
위의 상황에서 문제는,
traveler의 사용이 destination 업데이트로 끝나기 때문에
ARC 최적화에 따라 Done traveling 전에 deinitializer가 실행될 수 있습니다.

글쿤아.
문제가 있는 것을 확인했지만.. 딱히 문제에 의존적이지 않으니 .
더 복잡한 상황을 봅시다.
위 예시에서 computeTravelInterest()는 Traveler 객체를 내부에서 참조해야 하는데요.
computeTravelInterest() 이전에,
Traveler의 객체 사용이 끝나버리기 때문에 compute 전에 deinitializer가 먼저 실행될 수 있습니다.
따라서, nil이 되어 버그가 초래되는데요.
어떻게 해결하면 좋을까요?
여기에서 제시된 해결방법도 weak, unowned에서와 유사합니다.
1. withExtendedLifetime()
첫째로, withExtendedLifetime()을 사용해 생명 주기를 연장하는 방식은,
매번 처리를 해주어야 하기 때문에 권장되지 않습니다.
2. Redesign to limit visibilty of internal class details
두 번째는 해당 작업들을 다 로컬로 설정해서, 객체 내부에서만 작업을 실행하도록 제한하는 방식입니다.
(computeTravelInterest()를 Traveler 안에서 실행)
그림으로 표현해보았는데요..!
기존에는 Metric에서 (Traveler 객체를 참조해야 하는) 연산 함수를 호출하고 있어,
만약 Traveler가 먼저 메모리에서 해제될 경우, 연산 진행이 불가능했는데요.
따라서, Traveler가 내부에서 Metrics를 들고 있게 (강하게 참조) 합니다.
또한 private로 외부에서 이 속성을 접근하지 못하게 하고 (encapsulation)
객체가 소멸되기 직전에 compute를 실행하도록 deinit에서 해당 함수를 호출합니다.
이후, publish를 제일 마지막에 실행시켜
계산 작업이 모두 처리된 후 외부에 데이터를 제공합니다. (일관성, 안정성 보장)
3. Redesign to avoid deinitializer side-effects
더 정확하게 side-effect를 제거하기 위해서는,
해야 할 일을 함수로 분리해 deinit에서가 아닌 defer에서 실행하게 합니다.
그리고 deinit에서는 검증만 수행하게 하도록 수정합니다.
Xcode 13부터, Optimize Object Lifetimes라는 새로운 기능이 추가되었는데요.
객체의 생명 주기를 더 엄격하게 관리해서,
불필요한 객체 참조를 줄이고 메모리 최적화에 도움을 준다고 하네용