해당 글은 WWDC21 - ARC in Swift: Basics and Beyond를 공부하고 난 후, 정리한 글입니다!
먼저 오브젝트의 라이프 타임과 Swift의 ARC에 대해서 복습해보겠습니다.
오브젝트의 라이프타임은 초기화와 같이 시작이되고, 마지막 사용 이후에 라이프 타임은 종료됩니다.
라이프 타임 이후에는 ARC가 자동으로 메모리 할당 해제하게 됩니다. 그리고 Swift Compiler는 retain/release 작업의 삽입을컴파일 타임에 진행합니다. 여기서 말하는 retain 작업은 런타임에 reference count를 증가시키고, release 작업은 reference count를 감소시킵니다. 마지막으로 reference count가 0이되면, 오브젝트는 할당해제가 됩니다.
예를 통해서 조금 더 자세하게 알아보죠.
위의 test() 메소드 속 traveler1은 Traveler object의 첫번째 참조입니다. 그리고 traveler1의 마지막 사용은 그 다음 라인에서 copy가 되는 시점이죠. Swift compiler는 traveler1의 마지막 사용 이후에 release 작업을 바로 삽입합니다. retain 작업의 경우, 초기화 작업에서 reference count가 1이 되므로, 따로 reference가 시작될 때 retain 작업을 삽입하지는 않습니다.
traveler2는 Traveler 오브젝트의 또 다른 참조입니다. 그리고 마지막 사용은 프로퍼티 값인 destination의 업데이트 시점입니다. Swift Compiler는 위의 그림과 같이 retain 작업을 reference가 시작될 때 삽입하고, 마지막 사용때에 release 작업을 삽입합니다.
그러면 위의 코드가 런타임 시에는 어떻게 실행이되는 지 알아봅시다.
Traveler 오브젝트는 reference count가 1로 초기화가 되고, heap에 생성이됩니다.
위와 같이 traveler1이 copy가 되면, reference count가 +1이 되어, 최종적으로 reference count가 2가 됩니다.
traveler1의 마지막 사용이후에는 release 작업이 실행되어, reference count가 -1이되어 최종적으로 1이됩니다.
마찬가지로, traveler2의 마지막 사용 이후에는 release 작업이 실행되어 reference count가 0이됩니다. 그리고, reference count가 0이면 메모리에서 할당해제가 됩니다.
이 부분이 Swift 언어가 C++과 같은 다른 언어와 차별점을 갖게되는 부분입니다. Swift는 retain과 release를 활용하여 객체를 해제하기 때문에, 함수의 block이 끝나기 전에 메모리에서 할당 해제를 할 수가 있습니다. 그러나, 항상 그러한 것은 아니고 ARC의 최적화 작업에 따라서, 마지막 사용 이후에 할당 해제가 일어날 때도 있습니다.
사실, 위와 같은 객체의 라이프타임이 정확히 언제 끝나는 지 아는 것은 중요하지 않을 수 있습니다. 그러나 weak, unowned, deinitializer를 활용하는 경우에는 중요할 수 있습니다.
위와 같이 life time을 관찰하는 장치를 활용한 개발은 버그를 유발할 수 있습니다. ARC 최적화를 위해서 컴파일러가 업데이트가 되거나, 연관되지 않은 소스들이 변화가 되어 ARC 최적화를 가능하게 되는 상황에서는 우리가 기대하고 있는 바와는 다르게 행동할 수 있기 때문입니다.
밑에서는 오브젝트의 라이프 타임을 관찰할 수 있는 언어적인 특성을 알아보고, 그러한 것들에만 의존했을 때 생길 수 있는 문제점 및 그러한 문제점을 해결할 수 있는 방법들을 한번 배워보겠습니다.
먼저 Weak, Unowned 키워드를 활용하면 순환 참조를 해결할 수 있습니다. 일단 순환 참조가 무엇인 지 한번 예를 통해서 알아보겠습니다.
위와 같이 코드가 주어졌다고 생각해보고, test() 메소드가 실행되었다고 가정해봅시다. test() 메소드 속 traveler.account = account가 실행이되면 위와 같은 그림이 됩니다.
그리고, account는 마지막 사용이되고 난 후, release 작업이 실행이되어 위의 그림과 같이 reference count가 1 감소하게 됩니다.
마지막으로 printSummary() 메소드가 실행이되면 traveler의 마지막 사용이 끝나므로 동일하게 release 작업이 실행되고, reference count가 -1이 되어 최종적으로는 referecen count가 1이 됩니다. Traveler 객체나 Account 객체는 reference count가 0이 아니므로, 계속해서 메모리에 남게됩니다. 우리는 이것을 순환참조라고 부릅니다.
이러한 문제점을 weak나 unowned 키워드를 통해서 해결할 수 있습니다.
위와 같이 weak 키워드를 활용하면 reference count가 0이되고, 차례대로 deallocate가 됩니다. 지금같은 경우에는 Observed Object Lifetime에 의존하고 있습니다. 앞서 말했드시, 이러한 경우에는 bug가 생길 가능성이 있다고 말씀을 드렸습니다. 이 부분과 관련된 예를 다시 봐보겠습니다.
위의 코드는 앞선 코드와 다르게 printSummary() 메소드가 Account 타입으로 옮겨갔습니다. 이러한 경우 test() 메소드 속에서 printSummary 메소드가 불리게 되면 어떻게 될까요?
위 그림에서 하이라이트 된 것과 같이 account에 할당을 하고 난 후에는, traveler는 마지막으로 사용이되어 release가 되고, 최종적으로 Traveler의 reference count가 0이 됩니다.
Traveler 객체의 reference count가 0이되면, 메모리에서 할당해제가 되고, 이런 상황에서 printSummary() 메소드가 실행이되어 traveler 프로퍼티에 접근을 하게 되면 forced unwrapping Crash가 일어나게 됩니다.
위와 같이 옵셔널 바인딩을 사용할 수 있지만, crash가 발생하지 않으므로 오히려 디버깅을 하기에 어려울 수 있습니다.
이러한 경우에 weak나 unowned를 안전하게 핸들링할 수 있는 테크닉이 존재합니다. 하나하나씩 알아보도록 하죠.
그 기술은 withExtenedLifeTime() 입니다.
withExtenedLifeTime()은 명시적으로 오브젝트의 라이프타임을 연장시킬 수 있습니다. 위와 같이 Traveler 객체의 라이프타임을 연장시켜서 유발될 수 있는 버그를 예방하죠.
Swift 공식문서 속 withExtenedLifeTime()는 아래와 같습니다.
▪︎ closure가 return 되기 전까지는 주어진 instance가 메모리 할당해제 되지 않음을 보장합니다.
혹은 위와 같이 empty call을 현재 scope의 맨 마지막에 추가하여 똑같은 효과를 얻을 수 있습니다.
복잡한 상황 속에서는, defer를 통해서 현재 scope의 마지막까지 오브젝트의 라이프타임을 연장하게 할 수 있습니다. 여기서 defer는 클로져 안에 있는 내용을 함수가 끝나기 직전에 실행시키는 클로져입니다.
이러한 withExtenedLifeTime() 메소드는 개발자에게 책임을 돌려버립니다. weak를 활용하는 모든 곳에서 사용하게 되면, 너무 큰 유지비용을 지불해야될 수 있기때문입니다.
더 좋은 방법은 더 좋은 API로 클래스를 다시 디자인하는 것입니다.
위의 코드는 printSummary 메소드를 다시 Traveler 타입으로 옮겼습니다. 그리고 강하게 참조하고 있는 account 프로퍼티를 통해서 printSummary 메소드를 호출합니다. 이렇게 디자인을 할 경우, 잠재적인 버그가 발생할 가능성이 적어집니다.
순환 참조는 또 다르게 해결을 할 수 있습니다. 알고리즘을 다시한번 생각해보고, tree 자료구조를 활용한 순환관계로 변형을 하면 해결할 수 있습니다.
기존의 디자인은 위와 같습니다. 근데, Account 클래스는 굳이 traveler 객체를 참조하고 있을 필요가 없습니다. 그저 traveler의 개인 정보만을 갖고 있으면 되니까요.
그래서 아래와 같이 트리 자료구조를 활용하여 코드를 변경시킬 수 있습니다.
위와 같이 코드를 구현할 경우, weak 나 unowned를 활용하여 순환참조를 해결하는 것이 아니라 훨씬 더 안전하게 순환참조를 해결할 수 있게 됩니다. 그러나 구현비용이 더 들어갈 수는 있겠죠.
객체의 생명주기와 관련된 또 다른 문제는 Deinitilizer의 부작용입니다.
위의 예에서 Traveler 객체는 마지막 사용 이후에 바로 메모리에서 할당해제가 일어나게됩니다. 따라서 위에서는 print("Done traveling") 이전에 deinit 메소드가 실행이되는 문제가 발생합니다. 현재 예시에서는 크게 문제가 되지는 않습니다. 그러나, 밑의 복잡한 예시에서는 문제가 생기게 됩니다.
위와 같이 TravelerMetrics를 가지고 있는 Traveler 타입이 존재한다고 가정해봅시다.
위와 같이 test() 메소드에서는 Traveler의 travelmetrics의 destination 값에 새로운 값을 계속해서 집어 넣습니다. 그리고 최종적으로는 computeTravelInterest() 메소드를 통해서 TravelMetrics의 값을 내뱉도록 합니다.
traveler가 마지막 사용될 때의 metrics의 값은 위와 같이 초록색 그림과 같습니다.
그러나, traveler의 마지막 사용은 마지막 destination을 업데이트한 직후입니다. 따라서 computeTravelInterest() 메소드가 실행되기 전에 할당 해제가 되고(deinit이 실행된다.), 결과적으로 nil이 되므로 bug를 유발할 수 있습니다.
지금까지 이야기했던 내용들을 정리해보겠습니다.
withExtendedLifeTime() 메소드는 명시적으로 객체의 라이프타임을 연장시킵니다. 이러한 작업은 개발자에게 정확함을 요구합니다. 왜냐하면 개발자가 직접적으로 메모리 할당을 관리해야 되기때문입니다. (유지보수에 큰 어려움을 줄 수 있습니다.)
위와 같이 private을 활용하여 숨기는 것(Limiting the visibility of internal class)과 같은 클래스를 리디자인하는 것또한 bug를 줄일 수 있는 방법중 하나입니다. 그러나, 이러한 방법도 근본적인 해결책은 아닙니다. 확실한 방법은 deinitilizer의 side effect를 아예 제거하는 것입니다.
위와 같이 defer 또한, 하나의 해결책일 수 있습니다. deinilizer는 오직 검증만을 담당합니다.
(defer와 관련해서는 다른 곳에서 조금 더 자세하게 알아보겠습니다!~)
추가적으로 해당 영상에서 Observed Object Lifetime과 Guaranteed Object LifeTime을 계속 언급합니다.
둘의 차이점이 무엇인 지 한번 알아보겠습니다.
▪︎ Observed Object Lifetime -> 참조하는 객체가 있는 한 객체가 살아있는 것을 의미합니다.
▪︎ Guaranteed Object LifeTime -> 객체가 생성된 블록이 종료될 때까지 객체가 살아있는 것을 의미합니다.
Guaranteed Object LifeTime은 객체가 생성된 블록이 종료될 떄까지 객체가 살아있습니다. 따라서 객체가 생성된 블록이 종료되기 전에 참조를 제거할 필요가 없습니다. 그러나, 객체의 lifetime을 제어할 수 있는 범위가 적습니다. Observed Object Lifetime은 이에 비해서 더 유연한 라이프타임이지만, 참조를 제거하는 것을 잊을 경우, 순환참조가 생길 수 있습니다.
'Swift' 카테고리의 다른 글
[Swift] Async, Await (2) [feat. WWDC] (1) | 2023.08.28 |
---|---|
[Swift] Async, Await(1) (feat. WWDC) (0) | 2023.08.22 |
[Swift] [weak self]는 언제 사용할까? (0) | 2023.08.11 |
[Swift] WWDC16 Understanding Swift Performance(3) (0) | 2023.08.03 |
[Swift] WWDC16 Understanding Swift Performance(2) (0) | 2023.07.31 |