Search

[WWDC] ARC in Swift: Basics and beyond

들어가며

struct, enum 는 value type이기에 reference type에서 나타나는 의도되지 않은 데이터 공유 문제를 피할 수 있습니다.
class 는 Swift에서 reference type으로 쓰입니다. 당신이 class 를 사용한다면,
Swift는 ARC(Automatic Reference Counting)를 통해서 메모리를 관리해줄거예요!
이번 세션에서는 object lifetime를 보고 ARC에 대해서 다룹니다.
그리고 observable object lifetime이 무엇인지 알아봅시다.

Object’s lifetime

객체의 수명은 initalization될 때부터 마지막으로 사용될 때까지 입니다.
ARC는 객체 수명 종료 후에 객체의 할당을 해제하여 메모리를 자동으로 관리해줍니다.
reference count를 추적하여 객체의 수명을 결정해요.
ARC는 주로 retainrelease 작업을 해주는 Swift Compiler에 의해서 구동됩니다.
retain : reference count 를 증가 시킴
release : reference count 를 감소 시킴
 reference count가 0이 되면 객체 할당이 해제됩니다.

Example) Travel app

class Traveler { var name: String var destination: String? } func test() { let traveler1 = Traveler(name: "Lily") let traveler2 = traveler1 traveler2.destination = "Big Sur" print("Done traveling") }
Swift
복사
Traveler 객체를 자동으로 메모리 관리를 해주기 위해서 Swift compiler는
1.
reference가 시작됐을 때 retain operation
2.
객체의 사용이 끝났을 때 release operation
를 삽입해줍니다.

1) traveler1 객체의 life cycle

traveler1Traveler 객체로 만들어졌을 때 처음 참조되고, traveler2에 복사될 때 마지막으로 사용됩니다.
마지막으로 사용되고 나서 Swift compiler가 release operation를 즉시 넣어줍니다.
 retain operation를 reference가 시작됐을 때 넣지 않은 이유는 객체 초기화가 reference count를 1로 설정했기 때문입니다.

2) traveler2 객체의 life cycle

traveler2는 Traveler 객체에 대한 또 다른 참조값이에요. traveler2의 마지막 사용은 traveler2의 destination이 갱신되었을 때 입니다.
이번에는 traveler1과 다르게 Swift compiler가 reference가 시작됐을 때 retain operation를 삽입해주고, 마지막 사용을 하고나서 release operation를 삽입해주는 걸 볼 수 있습니다.

3) test 함수의 Runtime Flow

1.
Traveler 객체는 heap 메모리에 생성되고, reference count를 1로 설정해줍니다.
2.
새로운 reference를 들어오면 retain operation를 실행시키고 reference count를 2로 증가시킵니다. → 이제 traveler2도 Traveler 객체의 참조가 되었습니다.
3.
traveler1는 참조가 끝났기 때문에 release operation를 실행시키고, reference count를 1로 감소시킵니다.
4.
traveler2의 destination 값을 “Big Sur”로 갱신했습니다.
5.
destination를 갱신하는 것이 마지막 사용이었기 때문에 release operation를 실행하고 reference count를 0으로 감소시킵니다.
6.
reference count가 0으로 떨어졌기 때문에 객체 할당을 해제합니다.
Swift에서 객체의 수명은 use-based 입니다. 객체의 보장된 최소 수명은 initalization 시에 시작되고 마지막 사용이 끝나면 해제된다는 겁니다.
객체의 수명이 closing brace에서 끝나는 C++같은 언어와 달라요.
방금 예시에서 봤듯 객체가 마지막 사용 후에 즉시 할당 해제됩니다.
하지만, 실제로 객체의 수명은 Swift compiler에 의해 삽입되는 retain, release operation에 의해서 결정됩니다. 그리고 ARC 최적화로 인해 observed object 수명이 객체의 마지막 사용 이상으로 끝나게 되면서 보장된 최소 수명과 다를 수 있어요.
몇몇 경우에서 객체가 마지막 사용하고 나서 그 후에 할당이 해제됩니다.
 대부분의 경우는 object의 정확한 수명이 얼마인지는 별로 중요하지 않습니다.

Observable object lifetimes

weak, unowned referencedeinitalizer side effect같은 기능을 사용하면 객체의 수명을 관찰할 수 있습니다.
보장된 객체 수명 대신 관찰된 객체 수명에 의존하는 프로그램이 있다면 추후에 문제가 발생할 수 있습니다.
Observed object lifetime은 Swift compiler의 emergent 속성이고 구현 세부 정보가 변경되면 변경될 수 있습니다.
Relying on observed object lifetimes causes bugs.
이러한 버그들은 개발 기간동안 발견되지 않아서 긴 시간동안 숨겨진 채로 남겨질 수 있습니다.
갑자기 버그들이 발견될 수 있어요!
이전에 제한된 ARC 최적화를 가능하게 하는 source 변경 또는 개선된 ARC 최적화와 함께 Compiler 업데이트하는 경우
객체 수명을 관찰할 수 있게 하는 언어적 특성을 알아보고 observed object lifetime에 의존하게 되는 경우에 무슨일이 일어나는지, 그것을 고칠 수 있는 안전한 방법들이 무엇이 있는지 알아봅시다.
strong reference인 기본 참조와는 달리, weak, unowned referencereference count를 하지 않습니다. 그렇기 때문에 weak, unowned reference를 reference cycle를 깨는데 주로 사용해요.

Example) Travel app with reference count

class Traveler { var name: String var account: Account? func printSummary() { if let account = account { print("\(name) has \(account.points) points") } } } class Account { var traveler: Traveler var points: Int } func test() { let traveler = Traveler(name: "Lily") let account = Account(traveler: traveler, points: 1000) traveler.account = account traveler.printSummary() }
Swift
복사
Traveler는 account 변수를 가지고 account에 point를 쌓을 수 있습니다.
Account class를 만들고 그 안에 point 변수를 만들어줍시다.
Account class는 Traveler class를 참조하고 있어요. 그리고 Traveler class도 Account class를 참조하고 있습니다.
Test() 함수에서 우리는 Traveler, Account 객체를 만들고 traveler를 통해서 printSummary()함수를 불러왔습니다.

1) test 함수의 Runtime Flow

1.
Traveler 객체는 힙 메모리에 만들어지고 reference count를 1로 올려줍니다.
2.
Account 객체가 힙 메모리에 만들어지고 reference count를 1로 올려줍니다. → Account 객체는 Traveler 객체를 참조하고 있기 때문에 Traveler 객체의 reference count가 2로 올라갑니다.
3.
이제 Traveler 객체도 Account객체를 참조하기 시작했습니다. → 따라서 Account 객체의 reference count도 2로 올라갔습니다.
4.
traveler의 account에 account를 넣으면서 account의 마지막 사용이 끝났습니다. → account reference는 사라지고 Account 객체의 reference count가 1 감소합니다.
5.
printSummary()는 Traveler reference의 마지막 사용입니다. → Traveler reference는 사라지고 Traveler 객체의 reference count가 1 감소합니다.
6.
객체가 연결할 수 있는 모든 참조가 사라진 후에도 reference count가 1로 남아있는 걸 확인할 수 있습니다.
 reference cycle 때문입니다. 객체가 영원히 해제되지 않고 메모리 누수를 일으킵니다.
아마 이런 reference cycle를 weak, unowned reference를 사용해서 깰 수도 있습니다.
why?
weak, unowned reference는 reference counting를 하지 않습니다.
참조된 객체는 weak, unowned reference가 사용되는 동안에 할당 해제될 수 있습니다.
Swift runtime은 안전하게 weak reference에 대한 접근을 nil로 전환하고 unowned reference에 대한 접근을 traps으로 전환합니다.
reference cylce에 있는 모든 reference들은 reference cycle를 깨기 위해서 weak, unowned로 표시될 수 있습니다.

Example) Travel app with Weak reference

class Traveler { var name: String var account: Account? func printSummary() { if let account = account { print("\(name) has \(account.points) points") } } } class Account { weak var traveler: Traveler? var points: Int } func test() { let traveler = Traveler(name: "Lily") let account = Account(traveler: traveler, points: 1000) traveler.account = account traveler.printSummary() }
Swift
복사
weak referencereference count를 하지 않기 때문에 Traveler객체를 마지막으로 사용하고 나서 reference count가 0으로 떨어진다.
Traveler 객체의 reference count가 0으로 떨어지면 이는 할당 해제된다.
reference count가 0이 되면 객체 할당이 해제
Traveler 객체가 사라지고나면, TravelerAccount객체를 참조했던 게 사라진다. 즉, Account의 reference count가 0이 된다.
 Account 객체가 할당 해제된다!
우리는 weak reference를 reference cycle를 깨는데만 사용을 했다.
하지만,
객체 수명이 보장된 상테에서 객체에 접근하는데 weak reference를 사용하는 경우
객체를 사용할 수 있도록 관찰된 객체 수명에 의존하는 경우
 관련 없는 이유로 관찰된 객체 수명이 변경되면 버그가 발생할 수 있다.

Example) Travel app with printSummary move to Account Class

var name: String var account: Account? } class Account { weak var traveler: Traveler? var points: Int func printSummary() { print("\(traveler!.name) has \(points) points") } } func test() { let traveler = Traveler(name: "Lily") let account = Account(traveler: traveler, points: 1000) traveler.account = account account.printSummary() }
Swift
복사
1.
Traveler 객체의 마지막 사용은 printSummary 함수를 부르기 전이다. → 이 후에 Traveler 객체의 reference count가 0으로 바뀌고 compiler가 release operation를 즉시 삽입할 것이다.
2.
printSummary 함수가 불렸을 때, Traveler reference를 강제 언래핑하게 되고 crash가 발생한다.
3.
강제 언래핑의 원인이 뭔지 궁금할 것이며, 옵셔널 바인딩으로 충돌을 방지했을 수도 있습니다.
4.
옵셔널 바인딩은 실제로 문제를 악화시킵니다. → 명백한 충돌이 없어서 무관한 이유로 관찰된 객체 수명이 변경될 때 알아채지 못하는 버그를 생성해냅니다.

Weak and unowned references

weak, unowned reference를 안전하게 다루는 기술들이 있습니다. 각 참조는 사전 구현 비용과 유지 보수 비용의 수준이 다릅니다.

Example) Safe Techniques for handling weak references - withExtendedLifetime()

func test() { let traveler = Traveler(name: "Lily") let account = Account(traveler: traveler, points: 1000) traveler.account = account withExtendedLifetime(traveler) { account.printSummary() } } func test() { let traveler = Traveler(name: "Lily") let account = Account(traveler: traveler, points: 1000) traveler.account = account account.printSummary() withExtendedLifetime(traveler) {} } func test() { let traveler = Traveler(name: "Lily") let account = Account(traveler: traveler, points: 1000) defer {withExtendedLifetime(traveler) {}} traveler.account = account account.printSummary() }
Swift
복사
1)
Swift는 객체의 수명을 연장시킬 수 있는 withExtendedLifetime() 함수를 제공합니다.
withExtendedLifetime()를 사용하면 printSummary함수가 불려지는 동안 Traveler 객체의 수명을 안전하게 연장시켜서 잠재적인 버그를 막을 수 있습니다.
2)
기존 영역의 맨 끝에 있는 withExtenedLifetime 함수에 빈 호출을 배치해도 비슷한 효과를 낼 수 있습니다.
3)
더 복잡한 경우에는 compiler에게 defer를 사용해서 현재 영역의 끝까지 객체의 수명을 연장하도록 요청할 수 있습니다.
withExtendedLifetime()은 객체 수명 오류에서 벗어나는 방법으로 보여요. 하지만, 이 기술은 취약하기 때문에 정확성에 대한 책임을 당신에게 떠넘깁니다. 이런 관점에서는 weak reference에서 버그가 일어날 가능성이 있을 때마다 withExtendedLifetime()를 사용해야 합니다. 제대로 컨트롤하지 않으면 withExtendedLifetime()이 코드 베이스에 걸쳐 서서히 증가해서 유지 비용이 증가하게 됩니다.

Redesign to access via strong reference

객체에 대한 참조를 strong reference로 국한하는 경우, object lifetime surprise를 막을 수 있습니다.
In example,
printSummary()함수를 Traveler로 다시 옮기고, Account class에 있는 weak travelerprivate으로 변경했습니다.
테스트에서는 strong reference를 사용해서 printSummary() 함수 호출하여 잠재적인 버그를 제거합니다.
performance cost뿐만 아니라, class 설계에 주의하지 않으면 weak, unowned reference로 인해 버그에 노출될 수 있습니다.
“weak, unowned reference가 왜 필요한지” , “reference cycle를 깨기 위해서만 필요한지” , “처음부터 reference cycle의 생성을 막기 위해서는 어떻게 해야하는지”. 생각하는게 중요합니다.
reference cycle은 알고리즘을 다시 생각하고 순환 클래스 관계를 트리 구조로 변환함으로써 피할 수 있습니다.
In our example,
Traveler class는 Account class 참조가 필요합니다.
Account class가 Traveler class를 참조하는 건 꼭 필요하지 않습니다.
Account class는 traveler의 Personal information만 접근할 필요가 있습니다.
Traveler의 personal information를 PersonalInfo라는 새로운 class로 옮깁니다.
cycle를 피하기 위해서, Traveler class, Account class 모두 PersonalInfo class를 참조할 수 있습니다.
 weak, unowned reference를 피하기 위하면 추가 구현 비용이 발생할 수 있지만, 모든 잠재적인 객체 수명 버그를 제거하는 확실한 방식입니다.

Deinitalizer side-effects

object lifetimes observable를 만드는 또다른 기능은 deinializer side effect입니다.
deinitalizer는 할당 해제 전에 실행되며, 이에 대한 부작용은 외부 프로그램 효과에 의해서 관찰될 수 있습니다.
외부 프로그램 효과와 함께 sequence deinitializer side effects에 코드를 작성하는 경우 숨겨진 버그가 발생할 수 있으며, 이는 관련 없는 이유로 인해 observed object lifetime이 변경되는 경우에만 발견됩니다.

Example) Deinitalizer Example

class Traveler { var name: String var destination: String? deinit { print("\(name) is deinitializing") } } func test() { let traveler1 = Traveler(name: "Lily") let traveler2 = traveler1 traveler2.destination = "Big Sur" print("Done traveling") }
Swift
복사
1.
deinitializer는 console에 메시지를 인쇄하는 등의 전반적인 부작용을 가지고 있습니다. → deinitializer는 “Done traveling”를 프린트한 다음에 실행될 거예요.
2.
Traveler 객체의 마지막 사용은 destination 업데이트이므로 ARC 최적화에 따라 deinitalizer는 “Done traveling”가 프린트되기 전에 실행할 수 있습니다.
 이 예시에서 deinitalizer의 부작용은 관찰이 되었지만 의존하지는 않았습니다.

Example) Travel app: TravelMetrics

class Traveler { var name: String var id: UInt var destination: String? var travelMetrics: TravelMetrics // Update destination and record travelMetrics func updateDestination(_ destination: String) { self.destination = destination travelMetrics.destinations.append(self.destination) } // Publish computed metrics deinit { travelMetrics.publish() } } class TravelMetrics { let id: UInt var destinations = [String]() var category: String? // Finds the most interested travel category based on recorded destinations func computeTravelInterest() // Publishes id, destinations.count and travel interest category func publish() } func test() { let traveler = Traveler(name: "Lily", id: 1) let metrics = traveler.travelMetrics ... traveler.updateDestination("Big Sur") ... traveler.updateDestination("Catalina") metrics.computeTravelInterest() } verifyGlobalTravelMetrics()
Swift
복사
언제든지 destination이 업데이트되면, TravelMetrics class에 기록이 됩니다.
결국 Traveler 객체를 deinitalize할 때 metrics이 global record에 게시됩니다.
travelMetrics.publish()
Swift
복사
게시된 Metrics는 traveler의 익명 ID, 조회된 destination 수 및 계산된 여행 범주입니다.
class TravelMetrics { let id: UInt var destinations = [String]() var category: String? }
Swift
복사
1.
traveler의 destination이 TravelMetrics안에 Big Sur로 기록되어 있는 Big Sur로 업데이트했습니다.
2.
traveler의 destination이 TravelMetrics안에 Catalina로 기록되어 있는 Catalina로 업데이트했습니다.
3.
그러고나서, travel interest category가 기록된 destination들을 보고 계산을 합니다.
4.
deinitializer는 travel interest를 계산한 후 실행되기 때문에 interested category를 Nature로 게시할 수 있습니다.
5.
Traveler 객체의 마지막 사용은 Catalina로 destination를 갱신하는 것이고, 후에 즉시 deinitalizer를 실행할 수 있습니다. → deinitializer가 travel interest를 계산한 후에 실행되면 nil이 출력되고 버그를 일으킵니다.
weak, unowned reference처럼 deinitializer의 부작용을 안전하게 다루는 기술들이 있습니다. withExtendedLifetime()
1)
withExtendedLifetime()를 사용하면 travel interest category가 계산되는동안 Traveler 객체의 수명을 연장시켜서 잠재적인 버그를 막을 수 있습니다.
정확성에 대한 책임은 당신에게 넘겨집니다.
이 방법을 사용하면 deinitializer 부작용과 외부 프로그램 효과 사이에 잘못된 상호 작용이 발생할 가능성이 있을 때마다 withExtendedLifetime()을 사용하여 유지 관리 비용을 증가시켜야 합니다.
효과가 local인 경우에는 deinitializer의 부작용을 관찰할 수 없습니다.
2)
내부 class의 세부 정보의 가시성을 제한하여 클래스 API를 재 설계하면 객체 수명 버그를 방지할 수 있습니다.
TravelMetrics는 private으로 표시되고, external 접근으로부터 가려집니다.
deinitializer는 most interested travel category를 계산하고 metrics를 출력합니다.
이것이 효과가 있긴 하지만 더 원칙적인 방식은 deinitializer의 부작용을 제거하는 겁니다.
3)
defer는 metrics를 게시하기위해 deinitializer 대신 사용되고, deinitalizer는 확인을 위해서만 사용됩니다.
deinitalizer의 부작용을 없애기 위해서, 우리는 모든 잠재적인 object lifetime 버그를 제거해야합니다.
객체 수명을 관찰할 수 있는 Swift의 특성을 이해하고 observed object lifetime에 대한 잠재적으로 잘못된 의존을 제거하여 갑자기 버그를 발견하지 않도록 하는 것이 중요합니다.

Enable powerful lifetime shortening optimization

Xcode 13부터는 “Optimize Object Lifetimes”라는 새로운 experimental build setting을 Swift compiler로부터 사용할 수 있게 되었습니다.
강력한 수명 단축 ARC 최적화를 가능하게 합니다.
이 build setting을 키면 마지막으로 사용한 직후에 object가 훨씬 더 일관되게 관찰된 object lifetime의 보장된 최소값과 비슷하게 되는 것을 볼 수 있습니다.
Optimize Object Lifetime은 논의했던 예시와 비슷하게 숨겨진 객체 수명 버그를 드러낼 수 있습니다.
 세션에서 얘기했던 안전한 기술들을 사용해서 이러한 버그들을 모두 제거할 수 있습니다!