- Swift에서 struct, enum은 값타입, class는 참조타입이다.
- class를 사용하면 의도하지 않은 share가 발생 할 수 있다.
- class를 사용한다면 Swift는 Authomatic Reference Counting (ARC)를 이용해 메모리를 관리한다.
Object Lifetime in Swift
- Object의 lifetime은 init()에서 시작해서 마지막으로 사용되고 끝이난다.
- ARC는 object의 lifetime이 끝나면 메모리에서 deallocate한다.
- ARC는 reference count를 이용해 object의 lifetime을 추적한다.
- Swift 컴파일러는 retain/release 작업을 수행한다.
- Swift는 reference count가 0인 object를 런타임에 deallocate한다.
- 코드로 예시를 들면 위와 같다.
- test() 안에서 Traveler 객체를 생성할 때 자동으로 reference count가 1로 시작한다. (retain 생략)
- 참조가 추가로 발생하면 retain -> 참조 카운트 1 증가
- object가 마지막으로 사용되면 release -> 참조 카운트 1 감소
- 참조 카운트가 0이 되면 메모리에서 해제
Swift에서 Object의 마지막 사용 후에 release가 발생하여 해당 Object가 메모리에서 해제될 수 있다는 점은 C++ 등 다른 언어들과의 차별점이다. 이 언어들에서는 중괄호 {} 가 끝나는 시점에 메모리 해제가 발생하는 것을 보장한다.
이 부분이 이번 WWDC에서 가장 흥미로운 파트였다.
다른 언어들을 사용했던 습관 때문인지 Swift에서도 당연히 중괄호 스코프를 기준으로 객체가 메모리에서 해제될 것이라고 생각했지만 사실 Swift에서는 객체의 마지막 사용 시점에 자동으로 해제된다는 점을 알 수 있었다! (극한으로 메모리를 관리하기 위한 애플 개발자들의 집착인가...!)
물론, 이 부분은 컴파일러와 ARC 최적화 작업에 따라 실제 상황에서 다르게 작동할 수 있다.
메모리 해제 시점에 대한 이해는 대부분의 상황에서 크게 중요하지 않을 수 있지만 weak, unowned 참조와 deinitializer side effect와 같은 언어적 기능을 사용할 때 실행 결과에 영향을 미칠 수 있다..!
Observable object lifetimes
- 관측 되는 object의 lifetime에 의존하는 것은 버그를 발생시킬 수 있다.
Weak and unonwed references
- reference counting에 영향을 미치지 않는다.
- reference cycle을 끊을 수 있다.
- 참조되는 객체가 도중에 메모리에서 해제 되었다면
- weak 참조에 접근하는 것은 nil을 리턴
- unowned 참조에 접근하는 것은 앱 크래시
예시
- 순환 참조가 발생하여 traveler와 account의 사용이 끝나도 ref_count가 1이 되어 메모리에서 해제되지 않는 문제가 발생한다.
- weak 키워드를 사용하여 약한 참조를 하게 되면 앞서 발생한 순환 참조를 방지 할 수 있다. Account 가 traveler를 참조할 때 ref_count를 증가시키지 않기 때문에 두 객체 모두 메모리에서 해제될 수 있다.
이제 모든 문제가 해결되었을까?
weak나 unowned를 사용하게 되면 순환 참조는 끊을 수 있지만 의도하지 않은 상황에서 객체가 메모리에서 사라지게 되어 예상하지 못한 실행 결과가 발생할 수 있다.
예시
- printSummary() 함수의 위치를 Traveler 클래스에서 Account 클래스를 변경하고 이 코드를 실행하면 어떻게 될까?
- "Lily has 1000 points" 라는 문자열이 잘 출력될 수 있지만 이것은 우연이다!!
- traveler는 사진의 시점에서 마지막으로 사용되고 메모리에서 해제될 수 있다. 따라서, printSummary 함수에서 traveler를 강제 언래핑하여 사용하는 것은 매우 위험하다. (앱 크래시 발생)
- 옵셔널 바인딩을 사용하여 앱 크래시를 막을 수는 있지만 이것이 오히려 더 큰 문제이다.
- 앱 크래시가 발생한다면 개발자가 해당 버그를 빠르게 찾아서 고칠 수 있지만 옵셔널 바인딩으로 앱은 꺼지지 않게 했지만 우리가 원하는 실행 결과가 나오지 않는 Silent Bug가 남게 된다. -> 이것은 디버깅하기 매우 어렵다.
Weak, unowned references에서 발생할 수 있는 문제 해결 방법
1. withExtendedLifetime()
- withExtendedLifetime을 이용해 특정 객체의 lifetime을 연장하여 문제를 해결 할 수 있다.
- account.printSummary를 먼저 호출하고 withExtendedLifetime을 emptyCall로 실행해도 동일하게 동작한다.
- defer문을 사용해 함수 중간에 withExtendedLifetime를 넣어도 동일하게 동작한다.
- withExtendedLifetime 는 쉽게 object lifetime bug를 해결 할 수 있지만, weak 참조가 버그를 발생시킬 수도 있는 모든 곳에 withExtendedLifetime를 넣어줘야 하기 때문에 개발자가 실수할 여지가 커지는 문제가 있다.
2. Redesign to access via strong reference
- 초반의 예시 코드처럼 printSummary를 Traveler Class로 위치시켜서 printSummary 내부에서 weak한 객체를 사용하는 것을 막는 방법으로 재구현했다.
- 이렇게 Strong reference로만 접근하여 작업을 수행하도록 코드를 Redesign하는 방식이 가장 교과서적이다.
3. Redesign to avoid weak/unowned reference
- 순환 참조가 발생하지 않는 구조로 코드를 개선하여 weak 키워드를 사용할 필요가 없도록 만드는 것도 좋은 방법이다.
- 앞선 예제에서 traveler의 name을 사용하기 위해 순환 참조가 발생했는데, 이 name을 따로 분리하여 PersonalInfo 객체로 분리하고 Traveler와 Account 가 이것을 참조하게 하면 순환 참조를 제거하고 동작은 동일하게 하는 코드를 구현할 수 있다.
Deinitilizer side-effects
Deinit에 Side effect가 있는 경우에도 object의 lifetime 관리는 중요해진다.
- 이 코드를 실행시켜보면 사진에 있는 초록색 박스의 텍스트들이 출력된다. -> 예상 가능한 출력
- 하지만, ARC 최적화에 따라 출력 순서가 바뀔 수 있다. (traveler의 마지막 사용 직후 메모리에서 해제될 경우)
다른 예시 (조금 더 복잡한 코드)
- Traveler가 updateDestination 메서드를 통해 destination을 TravelMetrics에 기록하고 TravelMetrics는 Traveler의 deinit 시점에 publish()하는 구조이다.
- test()를 실행시켰을 때 우리가 원하는 결과는 metrics.computeTravelInterest()가 실행되고 traveler가 deinit되어 travelMetrics.pubulish()가 실행되는 순서이다.
- 하지만, traveler.updateDestination("Catalina")가 실행되면 traveler 자체는 본인의 작업을 모두 마친 상태이기 때문에 메모리에서 해제될 수도 있다.
- 따라서, deinit이 Catalina를 추가하자 마자 발생 할 수 있고, 이는 metrics가 computeTravelInterst()를 하기 전이기 때문에 우리가 원하는 실행 결과와 다른 결과가 발생한다. == 버그
Deinitializer Side-effects 해결 방법
- weak, unowned를 사용했을 때 발생할 수 있는 lifetime 문제를 해결하는 방식과 유사한 방식으로 deinit side-effects를 해결할 수 있다.
1. withExtendedLifetime()
- 역시나, withExtendedLifetime을 사용해 객체의 lifetime을 늘려서 해결하는 방식이 있지만 개발자가 실수할 가능성이 높아 권장하지 않는다.
2. Redesign to limit visibility of internal class details
- effect를 전부 local로 수정하여 해결한 방식이다.
- travelMetrics의 접근 제한자를 private으로 두고 deinit 할 때, computeTravelInterest()를 publish() 전에 실행시키는 것이다.
- 이렇게 하면 외부에서 computeTravelInterest()를 실행할 때와 다르게 traveler가 메모리에 존재하는 것이 확실한 상황에서 작업이 발생하여 버그가 없어진다.
- 교과서적인 방법이다.
3. Redesign to avoid deinitializer side-effects
- deinit 에서의 작업 자체를 지양하는 방식이다.
- 원래 deinit에서 하던 작업을 다른 함수로 분리하고 test() 에서는 defer로 분리한 함수를 실행한다.
- deinit에서는 assert를 이용해 제대로 수행되었는지 검사만 한다.
Object Lifetime shortening optimization
- Xcode의 세팅으로 Object lifetime을 짧게 만들 수 있다. yes로 설정하면 minimum lifetime과 가깝게 lifetime을 설정하여 테스트해 볼 수 있다.
- 이렇게 하면 숨어있던 lifetime bug들을 찾아 낼 수 있다!
- Xcode 14부터는 이 기능이 기본적으로 켜져 있다고 합니다!
'iOS > Swift' 카테고리의 다른 글
Getting to Know Swift Package Manager (0) | 2023.05.28 |
---|---|
Swift의 디자인 프로토콜 인터페이스 (0) | 2023.04.27 |
Understanding Swift Performance (2) | 2023.03.13 |
Explore structured concurrency in Swift (0) | 2023.02.11 |
Meet AsyncSequence (0) | 2023.02.11 |
댓글