본문 바로가기
iOS/Swift

ARC in Swift: Basics and beyond

by 바등쪼 2023. 4. 13.
 

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

 

  • Swift에서 struct, enum은 값타입, class는 참조타입이다.
  • class를 사용하면 의도하지 않은 share가 발생 할 수 있다.
  • class를 사용한다면 Swift는 Authomatic Reference Counting (ARC)를 이용해 메모리를 관리한다.

Object Lifetime in Swift

  1. Object의 lifetime은 init()에서 시작해서 마지막으로 사용되고 끝이난다.
  2. ARC는 object의 lifetime이 끝나면 메모리에서 deallocate한다.
  3. ARC는 reference count를 이용해 object의 lifetime을 추적한다.
  4. Swift 컴파일러는 retain/release 작업을 수행한다.
  5. 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

댓글