Where who wants to meet someone

Swift ARC (WWDC2021) 본문

Apple Developer/iOS(Swift)

Swift ARC (WWDC2021)

Lust3r 2023. 8. 8. 20:40
728x90
 

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는 StructEnum같은 값 유형을 제공
    의도하지 않은 공유의 위험을 피하기 위해 가능하면 참조 유형보다는 값 유형을 사용하는 것이 좋음
  • Class는 Swift의 참조 유형이며, 이를 사용하기로 결정하면 Swift는 자동 참조 카운팅(ARC, Automatic Reference Counting)을 통해 메모리를 관리

1. Object lifetimes and ARC

  • Swift의 객체 수명 및 ARC에 대한 검토
  • Swift에서 객체의 수명초기화에서 시작하여 마지막 사용에서 끝남
    (Object's lifetime begins at init() and ends at last use)
  • ARC는 객체의 수명이 다한 후에 객체 할당을 해제하여 자동으로 메모리를 관리
    (ARC deallocates an object after its lifetime ends)
  • ARC참조 횟수를 추적하여 객체의 수명을 결정
    (ARC tracks object's lifetime with reference count)
  • ARC는 주로 retainrelease 작업을 삽입하는 Swift 컴파일러에 의해 구동
    (Swift compiler inserts retain/release operations)
  • 런타임retain은 참조 횟수를 증가시키고, release는 참조 횟수를 감소시킴
    참조 횟수가 0으로 떨어지면 객체가 할당 해제
    (Swift runtime deallocates object with 0 reference count)

  • Traveler라는 class를 만들었다고 가정했을 때, test 메서드에서 Traveler는 traveler 1에서 참조 시작, traveler 2에서 참조 끝
    그렇기에 Swift 컴파일러는 traveler 2 아래에서 release 작업을 삽입
  • 초기화(initialization)가 참조 횟수를 1로 설정하기 때문에 참조가 시작될 때 retain 작업을 삽입하지 않음

  • traveler 2는 Traveler 객체에 대한 또 다른 참조이며 마지막 사용은 업데이트(destination 변경)임.

  • Swift 컴파일러는 참조가 시작될 때 retain 작업을 삽입하고, 마지막으로 사용한 직후에 release 작업을 삽입
  • traveler 1때에는 Traveler(name:)으로 초기화 하면서 참조 횟수가 1이 되기 때문에 따로 retain이 없었지만, traveler 2는 이미 초기화 된 것을 작업하는 것이기에 retain 작업이 삽입됨

  • 먼저, Traveler 객체가 힙에 생성되고 참조 횟수 1로 초기화된다.

  • 이후 traveler 2에서 참조되기 전, 새 참조를 준비하면서 retain 작업이 실행되어 참조 횟수를 2로 늘린다

  • 그 다음, traveler 1 참조를 마지막으로 사용한 후 release 작업이 실행되어 참조 횟수가 1로 감소한다

  • Traveler 객체의 destination이 "Big Sur"로 업데이트 된다

  • destination의 업데이트가 traveler 2의 마지막 사용이었기 때문에 release 작업이 실행되어 참조 카운트를 0으로 줄인다

  • Swift의 객체 수명은 사용 기반이다(use-based).
    객체의 보장된 최소 수명은 초기화(initialization)에서 시작하여 마지막 사용(last use)에서 끝남
  • 이는 객체의 수명이 닫는 중괄호에서 끝나는 것을 보장하는 C++과 같은 언어와 다른 점

  • 위 과정을 통해 객체가 마지막 사용 직후에 할당 해제되는 것을 보았음

  • 그러나 실제로 객체 수명은 Swift 컴파일러가 삽입한 retain 및 release 작업에 의해 결정

  • 그리고 시작되는 ARC 최적화에 따라 관찰된 객체 수명은 보장된 최소값과 다를 수 있으며 객체의 마지막 사용 이후에 종료됨
    이런 경우 객체는 마지막 사용 이후 프로그램 지점에서 할당이 취소됨

2. Observable object lifetimes

  • 관찰 가능한 객체 수명이란 무엇인가
    객체 수명을 관찰 가능하게 만드는 언어 기능, 관찰된 객체 수명에 의존하는 결과 및 이를 수정하는 몇 가지 안전한 기술
  • 대부분의 경우 객체의 정확한 수명은 중요하지 않음
    그러나 약하고(weak), 소유되지 않은(unowned) 참조 및 초기화 해제(deinitializer) 부작용과 같은 언어 기능을 사용하면체 수명을 관찰할 수 있음

  • Weak and unowned references (2-1)
  • Deinitializer side-effects (2-2)

  • 보장된 객체 수명 대신 관찰된 객체 수명에 의존하는 프로그램이 있는 경우 나중에 문제가 발생할 수 있음
    (Relying on observed object lifetimes causes bugs)
    • 오늘날에는 통할 수 있지만 이 관찰된 객체 수명에 의존하는 것은 우연의 일치이기 때문.
    • 관찰된 객체 수명은 Swift 컴파일러의 긴급 속성이며 구현 세부 정보가 변경되면 수명도 변경될 수 있다.
    • 이러한 버그는 개발 중에 발견되지 않을 수 있으며 오랫동안 숨겨진 상태로 남아있을 수 있고
      (May remain hidden for long time)
    • 개선된 ARC 최적화 또는 이전에 제한된 ARC 최적화를 가능하게 하는 관련 없는 소스 변경으로 컴파일러 업데이트를 통해서만 발견될 수 있음
      (Maybe uncovered at surprising times - Compiler update, Source changes)
  • 아래에서 객체 수명을 관찰 가능하게 만드는 언어 기능을 살펴보고 관찰된 객체 수명에만 의존할 경우 발생할 수 있는 일과 이를 수정하는 몇 가지 안전한 기술을 살펴볼 예정

 

2-1. Weak and unowned references

  • 강한(Strong) 참조기본 참조와 달리 약한(weak) 참조소유되지 않은(unowned) 참조는 참조 카운트에 참여하지 않으므로 일반적으로 참조 순환을 끊는 데 사용됨
    (Do not participate in reference counting and Break references cycles)

  • 그렇다면 참조 순환이란 무엇인가?

  • 위 코드는 이전 예시로 들었던 여행 앱의 확장.
  • 여행자는 계정(Account)를 가지고 포인트를 적립할 수 있음
  • Account 클래스는 Traveler 클래스를 참조하고, Traveler 클래스는 Account 클래스를 다시 참조하는 모습

  • test() 함수에서 Traveler 및 Account 객체를 생성한 다음 traveler 참조를 통해 printSummary() 함수를 호출함
  • 이 과정을 ARC와 함께 본다면 다음과 같음

  • 먼저 Traveler 객체가 참조 횟수 1로 힙에 생성된다

  • 그 다음, 참조 횟수가 1인 Account 객체가 힙에 생성된다
  • Account 객체가 Traveler 객체를 참조하므로 Traveler 객체의 참조 횟수가 1에서 2로 증가한다

  • traveler의 account를 account로 설정해 줌으로써 Traveler 객체가 Account 객체를 참조하기 시작하므로 Account 객체의 참조 횟수도 1에서 2로 증가한다

  • 해당 코드가 Account 객체의 마지막 사용이므로 참조가 사라지고 Account 객체의 참조 횟수가 1로 감소한다.

  • traveler의 printSummary() 함수를 실행하는 것이 Traveler 객체의 마지막 사용이므로 참조가 사라지고 Traveler 객체의 참조 횟수가 1로 감소한다.
  • 이전 예제대로라면 모든 마지막 사용이 끝난 지금, 참조 횟수가 0이 되어 할당이 해제되어야 하지만 각 객체의 참조 횟수는 아직도 1씩 있는 상태

  • 이는 순환참조 때문이며, 결과적으로 객체의 할당이 취소되지 않아 메모리 누수가 발생하게 된다.
  • 이를 해결하기 위한 것이 weakunowned
  • 둘은 참조 카운팅에 참여하지 않기 때문에 약한(weak) 참조 또는 소유되지 않은(unowned) 참조가 사용 중인 동안 참조된 객체의 할당이 취소될 수 있음
    (Break reference cycles, Referred object can be deallocated while in use)
    • 이런 일이 발생하면 Swift 런타임약한(weak) 참조에 대한 접근을 nil로 안전하게 전환하고
      (Access to weak reference returns nil)
    • 소유되지 않은(unowned) 참조에 대한 접근을 트랩으로 전환한다.
      (Access to unowned reference traps)
  • 참조 순환에 참여하는 모든 참조는 이 순환을 깨기 위해 weak이나 unowned로 표시될 수 있음
    (응용 프로그램에 따라 다름)

  • 위 예시에서는 Account 클래스의 traveler 참조를 weak으로 표시하였음

  • 약한 참조는 카운트에 참여하지 않기 때문에 Traveler 객체를 마지막으로 사용한 후(printSummary) 해당 참조 횟수가 0으로 떨어짐

  • 참조 횟수가 0이면 할당을 취소할 수 있음

  • Traveler 객체가 할당이 취소되어 사라지면 Account 객체에 대한 참조가 사라지기에 Account의 참조 횟수가 0이 됨

  • 이제 Account 객체도 할당 해제할 수 있음
  • 위와 같은 과정처럼 약한 참조를 사용하여 순환 참조를 끊을 수 있음
  • 보장된 객체 수명이 종료된 동안 약한 참조를 사용하여 객체에 접근하고 객체를 사용할 수 있도록 관찰된 객체 수명에 의존하는 경우, 나중에 객체 수명이 관련되지 않은 항목으로 변경될 때 버그가 발생할 수 있음.
    예를 들어

  • 위의 코드에서 printSummary() 함수는 Traveler 클래스에서 Account 클래스로 이동된 것을 볼 수 있다.
  • 그리고 test() 함수는 이제 Account 객체 참조를 통해 printSummary() 함수를 호출한다(account.printSummary())
  • 이렇게 printSummary() 함수가 호출되면 어떤 일이 일어날까?
    traveler의 name과 points가 출력될 수 있지만 이는 우연의 일치일 뿐임.

  • Why? Traveler 객체의 마지막 사용이 printSummary() 함수 호출 이전이기 때문에.
    함수 호출 이전에 마지막 사용이 되었기 때문에 컴파일러가 이때 바로 release를 삽입한 경우 참조 횟수가 0이 될 수 있음.
    참조 횟수가 0이 되면 약한 참조를 통한 Account의 Traveler 객체에 대한 접근이 nil이 되고 Traveler 객체의 할당이 취소될 수 있음

  • 따라서 printSummary() 함수가 호출되면 약한 참조를 했던 Traveler의 강제 언래핑(force unwrap)이 trap되어 충돌을 일으킨다

  • 강제 언래핑이 충돌의 원인인지 궁금해할 수 있고, 옵셔널 바인딩이 충돌을 방지했을 수 있다.

  • 하지만 옵셔널 바인딩은 실제로 문제를 일으킨다.
    명백한 충돌이 없으면 관찰된 객체 수명이 관련 없는 이유로 변경될 때 눈에 띄지 않을 수 있는 조용한 버그를 만들기 때문.

 

  • weak과 unowned 참조를 안전하게 처리하는 다양한 기술이 있으며, 각 기술마다 초기 구현 비용지속적인 유지 관리 비용이 다름
    (Safe techniques)
    • withExtendedLifetime()
    • Redesign to access via strong reference
    • Redesign to avoid weak/unowned reference

  • Swift는 객체의 수명을 명시적으로 연장할 수 있는 withExtendedLifetime() 유틸리티를 제공
  • 이 메서드를 사용하면 printSummary() 함수가 호출되는 동안 traveler 객체의 수명을 안전하게 연장하여 잠재적인 버그를 방지할 수 있음
  • 위 코드와 같이 사용해도 되고, 아래 코드와 같이 기존 범위의 끝에 withExtendedLifetime()에 대한 빈 호출을 넣어 동일한 효과를 얻을 수 있음

  • 더 복잡한 경우에는 defer를 사용하여 객체의 수명을 현재 범위의 끝까지 확장하도록 컴파일러에 요청할 수 있음
  • 이렇게 보면 withExtendedLifetime()은 좋은 해결책같지 이 기법은 깨지기 쉬우며 정확성에 대한 책임을 사용자에게 전가
    이 접근 방식을 사용하면 약한 참조가 버그를 일으킬 가능성이 있을 때마다 이 메서드가 사용되는지 확인해야 함
    제어되지 않는 경우 withExtendedLifetime()이 코드베이스 전체에 퍼져 유지 관리 비용이 증가할 수 있음

  • 더 나은 API로 클래스를 재설계하는 것이 훨씬 더 원칙적인 접근 방식임
  • 객체에 대한 접근을 강력한 참조로만 제한할 수 있는 경우 객체 수명에 예상치 못한 일이 발생하지 않도록 방지할 수 있음
  • 위 코드에서 printSummary() 함수는 Traveler 클래스로 다시 옮겨지고, Account 클래스의 약한 참조는 숨겨짐(private)
  • 이제 test()는 강력한 참조를 통해 printSummary() 함수를 강제로 호출하여 잠재적인 버그를 제거

 

  • 클래스 설계에 주의하지 않으면 약한 참조와 소유되지 않은 참조는 성능 비용을 수반할 뿐만 아니라 버그를 노출할 수 있음
  • weak, unowned 참조가 왜 필요한가? 참조 순환을 중단하는 데만 사용이 되는가? 애초에 참조 순환 생성을 피한다면?
    참조 순환은 종종 알고리즘을 재고하고 순환 클래스 관계를 트리 구조로 변환하여 피할 수 있음

  • 위 예에서 Traveler 클래스는 Account 클래스를 참조해야 함.
    Account 클래스는 Traveler 클래스를 참조할 필요는 없음
  • Account 클래스는 Traveler 클래스의 개인 정보에만 접근하면 됨

  • Traveler의 개인 정보를 PersonalInfo라는 새 클래스로 옮길 수 있음
  • Traveler, Account 클래스는 모두 PersonalInfo 클래스를 참조하여 주기(cycle)를 피할 수 있음
  • weak, unowned 참조의 필요성을 피하는 것은 추가 구현 비용이 있을 수 있지만(PersonalInfo 같은 추가 클래스를 구현하는) 이는 모든 잠재적 객체 수명 버그를 제거하는 확실한 방법임

 

2-2. Deinitializer side-effects

  • 객체 수명을 관찰 가능하게 만드는 또 다른 언어 기능은 초기화 해제기 부작용(Deinitializer side-effects)

  • Deinitializer할당 해제 전에 실행되며 그 부작용은 외부 프로그램 효과에 의해 관찰될 수 있음
  • 외부 프로그램 효과를 사용하여 시퀀스 해제 초기화 부작용에 대한 코드를 작성하면 관련 없는 이유로 관찰된 객체 수명이 변경될 때만 발견되는 숨겨진 버그로 이어질 수 있음
  • 이러한 버그가 발생하는 방법을 알아보기 전에 Deinitializer가 무엇인지 살펴보려 함

  • 첫 번째 예시와 같은 코드이지만 deinit이 있음
  • deinitializer콘솔에 메시지를 출력하는 전역적인 부작용이 있음

  • 오늘은 Done traveling이 출력된 후 deinitializer가 실행될 수 있음

  • 하지만 Traveler 객체의 마지막 사용은 destination 업데이트이므로 시작되는 ARC 최적화에 따라 Done traveling이 출력되기 전에  deinitializer가 실행될 수 있음(초록 창 순서 주목)
  • 위 예제에서 deinitializer 부작용은 관찰할 수 있었지만(순서) 의존하지는 않았음

  • Traveler 클래스에 TravelMetrics를 사용해보았음

  • destination이 업데이트 될 때마다 TravelMetrics 클래스에 기록됨

  • Traveler 객체를 deinit하면 Metrics는 전역 레코드에 게시됨(metrics get published to a global record)
  • 게시되는 Metrics는 traveler의 익명 id, 조회한 목적지(destinations) 수 및 계산된 여행 관심사 범주(computeTravelInterest)임

  • test() 함수에서 먼저 Traveler 객체가 생성된 다음 travelMetrics에 대한 참조가 Traveler 객체에서 복사됨

  • traveler의 destination은 "Big Sur"로 업데이트 되고, TravelMetrics에도 기록됨

  • traveler의 destination은 "Catalina"로 업데이트 되고, TravelMetrics에 역시 기록됨

  • 이후 기록된 destinations를 보고 여행 관심 범주를 계산함(computeTravelInterest())

  • deinitializer는 computeTravelInterest()를 계산한 후 관심 있는 category를 Nature로 Publish하여 실행할 수 있음

  • 하지만 Traveler 객체의 마지막 사용은 Catalina에 대한 destination 업데이트이고, 그 직후 deinitializer가 실행될 수 있음

  • 이 경우 computeTravelInterest()를 실행하기 전에 deinitializer가 실행되기 때문에 nil이 게시되어 버그가 발생함

  • weak, unowned 참조와 마찬가지로 deinitializer도 부작용을 안전하게 처리하는 다양한 기술이 있음
    이들 각각은 초기 구현 비용과 지속적인 유지 관리 비용의 정도가 다름

  • withExtendedIfetime()을 사용하면 computeTravelInterest()가 실행될 때까지 traveler 객체의 수명을 명시적으로 연장하여 잠재적인 버그를 방지할 수 있음
    하지만 이 역시 앞서 weak, unowned와 마찬가지로 정확성에 대한 책임을 묻게 됨
  • 이 접근 방식을 사용하면 deinitializer 부작용과 외부 프로그램 효과 간에 잘못된 상호 작용이 발생할 가능성이 있을 때마다 withExtendedLifetime이 사용되어 유지 관리 비용이 증가하는지 확인해야 함

  • 영향이 모두 로컬이면 Deinitializer 부작용을 관찰할 수 없음
  • 내부 클래스 세부 정보의 가시성을 제한하여 클래스 API를 재설계하면 객체 수명 버그를 방지할 수 있음
  • 위의 코드에서 travelMetrics는 private로 표시되어 외부 접근으로부터 숨겨진다
  • 이제 deinitializer는 computeTravelInterest()를 실행하고, publish()를 하게 됨
  • 위의 방법은 효과가 있지만 보다 원칙적인 접근 방식은 deinitializer의 부작용을 모두 제거하는 것.

  • deinitializer 대신 defer를 사용하여 metrics를 publish하고 deinitializer는 검증만 수행한다.
  • deinitializer 부작용을 제거함으로써 모든 잠재적 객체 수명 버그를 제거할 수 있음

  • Xcode 13부터는 "Optimize Object Lifetimes"라는 새로운 실험적 빌드 설정을 Swift 컴파일러에 사용할 수 있음
  • 이는 강력한 lifetime 단축 ARC 최적화를 가능하게 함
  • 이 설정을 켜게 되면 객체가 마지막 사용 직후 훨씬 더 일관되게 할당 해제되어 관찰된 객체 수명이 보장된 최소값에 가까워지는 것을 볼 수 있음
  • 이는 앞서 이야기했던 예제와 유사한 숨겨진 객체 수명 버그를 노출할 수도 있음. 하지만 역시 앞서 논의한 안전한 기법에 따라 이러한 모든 버그를 제거할 수 있음