본문 바로가기
강의 정리

WWDC2021 - ARC in Swift - Basics and beyond 에 관하여.

by iOS 개린이 2023. 5. 31.

ARC에 대한 개념을 정리하던 중, ARC에 대한 깊은 이해를 위해 WWDC의 ARC 관련 세션을 보고 정리해본다.

 

Object lifetimes and ARC

-객체의 수명주기와 ARC에 대해서 알아보자.

 

  • 객체의 수명주기는 init() 초기화에서 시작되고, 마지막 사용에서 종료된다.
  • ARC는 수명주기가 종료된 객체를 메모리에서 해제시킨다. 
  • ARC는 참조 카운트를 통해 객체의 수명주기를 추적한다.
  • Swift의 컴파일러가 retain/release 작업을 주입한다.
  • Swift의 런타임에 'retain' 은 참조 카운트를 증가시키고, 'release' 는 참조 카운트를 감소시킨다. 참조 카운트가 0이 되면 객체는 메모리에서 할당 해제된다.

 

이 작업들이 어떻게 작동되는지 예시를 통해 알아보자.

우리가 여행 앱을 만든다고 상상해보자.

 

 

'traveler1' 이라는 'Traveler' 객체의 인스턴스를 생성했다. 이는 객체의 수명주기가 시작되었다는 것과 같다.

'traveler2' 에 'taveler1' 이 마지막으로 사용되었다. 이는 객체의 수명주기가 종료되었다는 것과 같다.

 

 

'traveler1' 의 시점에서 컴파일러는 어떻게 작동할까?

 

컴파일러는 'traveler1' reference 가 마지막 사용되는 즉시, 'release' 오퍼레이션을 주입한다.

근데 reference가 시작될때 'retain' 오퍼레이션은 왜 주입하지 않았을까?

왜냐하면 initialization이 참조 카운트를 1로 만들어주기 때문이다.

 

 

다음은 'traveler2' 를 보자.

 

'traveler2' 는 'Traveler' 객체의 또 다른 reference다.

'destination' 을 업데이트하면서 마지막으로 사용된다. 

 

 

여기서 컴파일러는 다음과 같이 오퍼레이션은 주입한다.

 

컴파일러는 reference의 시작 전 'retain' 오퍼레이션을 주입하고,

reference의 마지막 사용 후 즉시 'release' 오퍼레이션을 주입한다.

 

 

이제 코드의 단계별로 런타임에서는 어떤 일이 발생하는지 알아보자.

 

먼저, 'Traveler' 객체는 'heap' 영역에 초기화를 통해 생성된다. (참조 카운트 = 1)

 

 

 

그리고 새로운 reference를 준비하기 위해 'retain' 오퍼레이션이 실행된다. (참조 카운트 = 2)

이제 'traveler2' 도 'Traveler' 객체의 reference가 되었다.

 

'traveler1' reference의 마지막 사용 후에는 'release' 오퍼레이션이 실행된다. (참조 카운트 = 1)

다음은 Traveler' 객체의 'destination' 이 "Big Sur" 로 업데이트 된다. 이 코드는 'traveler2' reference의 마지막 사용이기 때문에 'release' 오퍼레이션이 실행된다. (참조 카운트 = 0)

 

참조 카운트가 0으로 떨어지면서 객체는 메모리에서 해제된다.

 

Object lifetimes

Swift의 객체 수명은 사용 기준(use-based)에 있다.

객체의 수명은 초기화에서 시작하여 마지막 사용을 통해 종료된다.

중괄호로 끝나는 것을 통해 객체의 수명이 종료되는 것을 보장받는 C++와 같은 언어들과는 다르다.

 

예를 통해, 우리는 마지막 사용 후 객체가 할당 해제되는 것을 보았다.

그러나 실제로 객체 수명은 컴파일러에 의해 주입된 'retain' 및 'release' 연산에 의해 결정된다.

ARC 최적화에 따라 관찰된 객체의 수명이 보장된 최소 생명주기와 다를 수 있으므로 객체의 마지막 사용 이후에 종료될 수 있다.

 

이게 무슨 말이냐?

 

객체의 생명주기는 마지막 사용을 통해 종료되고, 위 코드와 같은 시점에 메모리에서 해제된다.

근데 이게 ARC 최적화로 인해 달라 질 수 있다는 것이다.

 

실제로 메모리의 할당 해제가 실행되는 시점은 다음과 같다.

 

 

대부분의 경우 객체의 정확한 수명주기는 중요하지 않다.

하지만 'weak' 이나 'unowned' 및 'deinitializer' 의 sideeffects 들은 객체의 수명주기를 고려해야 한다.

 

Observed 객체 수명에 의존한다면, 나중에 문제가 발생할 수 있다.

왜냐하면 객체의 수명에 의존하는 것이 오늘은 효과가 있을지도 모르지만, 그것은 단지 우연에 불과하기 때문이다.

객체 수명은 Swift 컴파일러의 프로퍼티이며, 구현 세부사항이 변경됨에 따라 변경될 수 있다.

 

이러한 버그는 개발 중에 발견되지 않고, 오랫동안 숨겨져 있을 수 있다.

 

 

Observable Object lifetimes

-객체 수명을 관찰할 수 있도록 만드는 기능에 대해 살펴보고, 

Observed 객체 수명에만 의존할 경우 발생할 수 있는 일에 대해 살펴보자.

 

-강한 참조와 달리 'weak' 과 'unowned' 참조는 참조 카운트에 영향을 주지 않는다.

이런 이유로 그들은 순환 참조를 break 하는 것에 사용된다.

세부 사항에 들어가기 전에, 순환 참조가 무엇인지 알아보자.

 

 

 

위 코드는 우리의 여행 앱을 확장한 것이다. 

포인트 시스템을 표현하기 위해서 'Account' Class를 추가했다.

'Account' 객체와 'Traveler' 객체는 서로 참조하고 있다.

 

test() 함수에서는 'Traveler' 와 'Account' 객체를 만든 다음,

'Traveler' 객체를 통해 printSummary() 를 실행한다.

 

 

ARC에서는 무슨 일이 일어나는지 보자.

 

첫 번째, 'traveler' reference의 생성을 통해 'Traveler' 객체가 힙 영역에 생성된다.

'Traveler' 참조 카운트 = 1

 

두 번째, 'account' reference의 생성을 통해 'Account' 객체가 힙 영역에 생성된다.

동시에,  'Account' 객체는 'Traveler' 객체를 참조하기 때문에 'Traveler' 객체의 참조 카운트가 +1 된다.

'Traveler' 참조 카운트 = 2

'Account' 참조 카운트 = 1

 

traveler.account = account

세 번째, 'Traveler' 객체가  위 코드를 통해 'Account' 객체를 참조한다. 

따라서 'Account' 객체의 참조 카운트가 +1 된다.

'Traveler' 참조 카운트 = 2

'Account' 참조 카운트 = 2

 

위 코드는 'account' reference의 마지막 사용이기 때문에 코드가 실행되고 나면

'account' reference가 할당해제되면서, 'Account' 객체의 참조 카운트가 1로 줄어든다.

'Traveler' 참조 카운트 = 2

'Account' 참조 카운트 = 1

 

traveler.printSummary()

네 번째, 위 함수를 호출하여 이름과 포인트를 print() 한다.

이것은  'traveler' reference의 마지막 사용이기 때문에 코드가 실행된 후 

'traveler' reference가 할당해제 되면서, 'Traveler' 객체의 참조 카운트가 1로 줄어든다.

'Traveler' 참조 카운트 = 1

'Account' 참조 카운트 = 1

 

 

현재 객체에 접근할 수 있는 모든 참조가 사라졌는데, 참조 카운트는 1씩 남아있다.

이유는 순환참조 때문이다.

결국 이 객체들은 메모리에서 절대 해제되지 않고, 메모리 릭의 원인이 된다.

 

 

 

순환참조는 'weak', 'unowned' 참조로 해결할 수 있다.

'weak', 'unowned' 참조는 참조 카운트에 영향을 주지 않기 때문에

'weak', 'unowned' 으로 참조된 객체는 할당해제가 가능해진다.

이 때, Swift의 런타임에서는 'weak' 참조에 대한 액세스를 nil 로 안전하게 전환하고,

'unowned' 참조에 대한 접근을 trap으로 전환한다.

순환참조에 참여하는 모든 참조들은 'weak', 'unowned' 참조로 표시될 수 있다.

 

 

'traveler' reference 에 'weak' 를 사용해보자.

 

'weak' 참조는 참조 카운트에 참여하지 않기 때문에,

'Traveler' 객체의 마지막 사용이 끝난 후, 참조 카운트가 0으로 떨어지고, 메모리에서 해제된다.

'Traveler' 객체가 해제되면, 'Account' 객체 또한 참조 카운트가 0으로 떨어지고, 메모리에서 해제된다.

 

객체에 'weak' 참조가 사용되고, 객체 수명에 의존하는 경우 나중에 버그가 발생할 수 있다.

이에 대한 예시를 보자.

 

printSummary() 를 'Traveler' 객체에서 'Account' 객체로 옮겼음.

이제 test() 내에서 'account' reference를 통해 printSummary()가 호출된다.

 

printSummary()가 호출되면 정확히 무슨일이 일어날까?

내부에 작성한대로 이름과 포인트가 찍힐까요? 이 작업은 오늘은 제대로 작동될 지 몰라도, 그것은 단지 우연이다.

 

왜냐하면 printSummary() 가 호출되기 직전에 'traveler' reference가 마지막 사용되기 때문이다.

마지막 사용 이후 'Traveler' 객체의 참조 카운트는 컴파일러의 'release' 에 의해 참조 카운트가 0으로 떨어진다.

'Traveler' 객체의 참조 카운트가 0으로 떨어지면 메모리에서 해제되고,

Traveler' 객체를 'weak' 참조하고 있던 'traveler' 에 nil이 할당된다.

 

 

 

그래서 printSummary() 가 호출되면서, 'traveler' reference의 강제 언래핑을 해제하면 크래쉬가 발생한다.

여기서 강제언래핑이 문제라고 생각되어, 옵셔널 바인딩으로 해결해볼 수도 있다고 생각하겠죠?

 

 

옵셔널 바인딩은 오히려 더 문제를 악화시킬 수 있다.

명백한 크래쉬가 아닌, 객체수명과 관련 없는 이유로 변경되어 조용한 버그를 만들기 때문이다.

 

 

 

  • withExtendedLifetime()
  • 강한 참조를 통해 접근하도록 재설계
  • 'weak', 'unowned' 참조를 피하도록 재설계

 

'weak', 'unowned' 참조를 안전하게 처리하기 위한 다양한 기술이 있다.

각각 초기의 구현 비용과, 지속적인 유지보수 비용의 차이를 가지고 있다.

 

하나씩 살펴보자.

 

withExtendedLifetime() 방법

 

Swift는 withExtendedLifetime() 라는 객체의 수명을 명시적으로 연장할 수 있는 유틸리티를 제공한다. 

이것을 사용하면 printSummary()가 실행되는 동안, 'Traveler' 객체의 수명주기를 안전하게 연장할 수 있다. (잠재적인 버그를 방지.)

 

아래 예시와 같이 사용도 가능하다. (동일한 효과임.)

 

 

withExtendedLifetime() 은 수명주기 문제를 해결하는 쉬운 방법으로 보이지만,

이 기술은 fragile(불안전) 하고, 사용의 정확성을 요구한다.

 

withExtendedLifetime() 은 'weak' 참조가 버그를 발생시킬 가능성이 있을 때마다 사용해야하고,

이것을 제대로 컨트롤하지 않으면 전체 코드의 유지보수 비용이 증가한다.

 

 

Redesign to access via strong reference 방법

 

더 나은 API로 클래스를 재설계하는 방법은 훨씬 더 원칙적인 접근법이다.

객체의 접근을 'strong' 참조로 제한하는 경우, 객체 수명에 대한 놀라움을 방지할 수 있다.

 

printSummary() 를 다시 'Traveler' 클래스로 되돌려 놓았고,

'Account' 클래스의 'weak' 참조는 private 해두었다.

 

이제 'strong' 참조를 통해 printSummary() 함수를 호출하여 잠재적인 버그를 제거해야한다.

클래스 설계에 주의하지 않으면 성능 비용뿐만 아니라 'weak', 'unowned' 참조가 버그를 노출할 수 있다.

 

잠시 멈추고 생각 할 필요가 있다.

왜 'weak', 'unowned' 참조가 필요하지?

순환참조를 피하기 위해서 이 방법들만 사용해야 하는가?

만약 처음부터 순환참조를 피하도록 만든다면?

 

순환참조는 알고리즘을 재고하고 순환 클래스 관계를 트리 구조로 변환함으로써 피할 수 있다.

 

자 'Traveler' 클래스는 'Account' 클래스를 참조할 필요가 있고,

'Account' 클래스는 'Traveler' 클래스의 개인정보만 접근할 수 있으면 된다.

따라서, 'PersonalInfo' 라는 새로운 클래스를 하나 만들고 'Traveler' 클래스의 개인정보를 옮길 수 있다.

 

'Traveler' 클래스와 'Account' 클래스 모두 'PersonalInfo' 클래스를 참조할 수 있게 되면서 순환참조를 피할 수 있다.

 

이렇게 'weak', 'unowned' 참조를 사용하지 않는 방법은 추가적인 구현 비용이 들 수 있다.

하지만 잠재적인 객체 수명 버그를 제거하는 확실한 방법이다.

 

 

Deinitializer Side Effects (디이니셜라이저의 부작용)

 

오늘은 deinit() 메서드가  print("Done traveling") 이후에 실행될 수도 있지만, 

'Traveler' 객체의 마지막 사용은 'destination'의 업데이트이기 때문에

ARC의 최적화에 따라 print("Done traveling") 이전에 deinit() 메서드를 실행할 수 있다.

근데 여기선 순서와 관계없이 문제가 발생할 일이 없죠?

 

 

좀 더 복잡한 예제를 보자.

 

'Traveler' 클래스에 'TravelMetrics' 를 소개한다.

'destination' 이 업데이트 될 때마다, 'TravelMetrics'의  destinations 에 저장된다.

'Traveler' 객체가 deinit() 될 때, 'TravelMetrics' 객체의 publish() 를 호출한다.

 

test() 내에서는 먼저 'Traveler' 객체가 생성된 후,

'Traveler' 객체에 의해 'TravelerMetrics' 가 복사된다.

'traveler' 는 "Big sur" 로 destination을 업데이트 하고, "Big sur"은 'TravelerMetrics' 에 저장된다.

다음 "Catalina" 로 같은 작업을 수행한다.

그리고 저장된 'destinations' 에 의해 관심있는 여행 카테고리가 계산된다.

 

 

객체의 수명주기를 보자.

오늘은 deinit() 메서드가 computeTravelInterest() 후에 실행될 수도 있다.

하지만 updateDestination("Catalina") 이후에 바로 deinit() 메서드가 실행될 수도 있다.

 

computeTravelInterest() 메서드는 우리가 설정한 목적지들을 가지고 계산되죠?

근데 deinit() 메서드가 먼저 실행되어 버리면 우리가 계획한대로 코드가 실행되지 않는다. 

 

우리가 'weak', 'unowned' 참조를 사용하지 않고, 안전하게 처리했던 기술처럼

deinitializer side-effects 를 안전하게 처리하기 위한 다양한 기술도 존재한다.

하나씩 살펴보자.

 

 

withExtendedLifetime() 사용

 

아까 배웠던 withExtendedLifetime() 을 사용하여 'Traveler' 객체의 수명을 연장시켜 해결하는 방법이 있다.

단점도 동일하게 사용 정확도가 요구되며, 유지보수 비용이 증가할 수 있다.

 

 

Redesign to limit visibility of internal class details

 

'travelMetrics' 를 private 하게 만들어 내부적으로 사용하는 것이다.

이것도 효과는 있지만, 더 원칙적인 접근법은 deinit side-effects 를 완전히 제거하는 것이다.

 

 

Redesign to avoid deinitializer side - effects

 

이 방법은 deinit() 메서드 대신 defer() 를 사용하여 작업을 수행한다.

또한 deinit() 메서드는 원하는 작업이 수행되었는지 검증만 수행함.

이것은 원초적으로 deinit() 의 부작용을 제거하는 것으로 객체의 수명주기에 대한 잠재적인 버그를 제거할 수 있다.

 

우리는 ARC, 'weak', 'unowned' 참조, deinitializer sider-effects 에 대해 배우기 위해 예제를 탐험했다.

관찰 가능한 객체 수명주기를 만드는 기능을 이해하고, 잠재적인 버그를 제거하는 것이 중요하다.

 

 

xcode 13에서는 "Optimize Object Lifetimes" 라는 빌드 설정을 컴파일러에서 사용할 수 있다.

 

이를 통해 ARC 최적화가 가능하다.

마지막으로 사용한 객체는 메모리에서 즉시 할당되도록 만들어서,

객체 수명주기의 일관성을 확보할 수 있다.

 

또한 우리가 예제에서 보았던 객체 수명 버그를 파악할 수도 있다.

 

 

 

마치며..

 

배운점.

1. 객체의 수명주기(객체가 언제 생성되고, 언제 해제되는지)

2. ARC의 최적화에 따라 달라질 수 있는 객체의 수명주기.

3. 일관되지 않은 객체 수명주기로 인해 'weak', 'unowned' 참조 및 deinit () 에서 생길 수 있는 잠재적인 오류들.

4. 잠재적인 오류들을 해결하기 위한 3가지 방법과 각 방법의 장단점.

5. xcode 설정을 통해 잠재적인 오류를 노출시키는 방법.

 

느낀점.

1. 'weak' 와 'unowned' 이 장땡이 아니구나. 

2. 앞으로는 코드를 작성할 때, 근본적인 이유에 대해서 좀 더 생각해보자.

3. 코드가 메모리의 관점에서 어떻게 돌아가는지 알아보자. 

 

Reference

https://developer.apple.com/videos/play/wwdc2021/10216/