티스토리 뷰

반응형

개요

Swift ARC(Auto Reference Counting)를 보다 잘 이해하기 위해 WWDC21 - ARC in Swift: Basics and beyond를 본 후 글을 정리해 보았어요!

 

Swift는 메모리 관리를 ARC를 통해서 진행하는데요, ARC의 기초 동작 원리부터 이야기해보도록 할게요 :)

 

Object lifetimes and ARC(객체의 수명(?)과 ARC)

 

객체의 생명 주기는 위와 같아요.

물론 여기서 말하는 객체는 ARC에 의해 관리되는 참조 타입의 객체겠죠?

순서대로 보면(동작 순서는 아니에요!)

1. 객체의 init() 시점에 생명주기가 시작되고 그 객체의 사용이 끝나는 시점에 생명주기가 종료됨
2. ARC가 생명주기가 끝난 객체를 deallocate(할당 해제)함
3. ARC가 reference count를 활용해 객체의 생명주기를 추적
4. Swift 컴파일러가 retain / release 연산자를 삽입
5. Swift가 런타임에서 reference count가 0인 값들을 할당 해제

 

로 간략하게 해석할 수 있어요.

 

예시를 통해 자세히 살펴볼게요 👀

여기서는 여행을 위한 프로그램을 만든다고 가정했어요.

 

Traveler 객체는 class이므로 참조 타입이에요.

아래 test 메서드의 동작 순서를 보면 아래와 같아요.

1. Traveler 인스턴스 생성
2. Traveler 인스턴스를 복사
3. 복사된 인스턴스의 값 변경

 

생명주기 관점에서 위의 로직을 살펴볼게요.

traveler1은 인스턴스가 init 되는 시점에 참조가 시작되고, traveler2로 복사가 끝나면 더 이상 사용되는 일이 없기 때문에 참조가 끝이 나요.

그럼 Swift 컴파일러는 참조가 끝나는 시점에 release 작업을 insert 해요.

그럼 여기에서 궁금할 수 있는 것은 traveler1 생성 시점에 retain 메서드는 삽입을 했는가🤔 일 텐데 컴파일러가 직접 삽입하지 않아요.

왜냐하면 initialization에서 reference count를 1로 설정해주기 때문이죠!

 

traveler2의 경우는 traveler1처럼 init으로 시작하는 것이 아니라 값의 복사로 시작돼요. 

복사가 일어나는 시점에 참조가 시작되고, destination을 변경한 후에는 사용이 끝나 참조가 종료돼요.

마찬가지로 Swift 컴파일러는 연산자를 삽입할 거예요.

이번에는 init을 통해 생성해주는 것이 아니기 때문에 retain 연산을 삽입해주고 사용이 종료되면 마찬가지로 release 연산을 삽입해줘요.

이 과정을 메모리 관점에서 살펴볼게요!

 

우선, Traveler 객체가 heap 영역에서 생성돼요.

reference count는 1로 올라가고, name은 Lily, 옵셔널이지만 초기화가 안된 destination은 nil로 할당이 돼요.

 

retain 연산자가 작동하며 reference count가 2로 증가해요.

신기했던 것은 traveler2가 생성된 이후 reference count가 증가하는 것이 아니라, retain이 먼저 일어나서 ref_count가 증가한 후 traveler2가 생성된다는 점이에요.

 

이제 이 시점에 traveler2가 할당이 돼요.

 

release 연산자를 거치며 사용이 끝난 traveler1의 할당을 해제해주고, count는 1로 감소해요.

retain은 할당 전에, release은 할당 후에 일어나는 것을 볼 수 있어요 :)

 

destination을 변경했기 때문에 nil이 Big Sur로 변경되었고

 

release를 통해 count가 0이 되며 메모리에서 할당 해제가 돼요.

 

위의 내용을 정리하면 결국 Traveler라는 참조 타입 객체는 init으로 생명주기를 시작하고, 더 이상 사용이 되지 않는 시점에 생명 주기가 멈춰요. 이것을 Object의 lifetime이 use-based(사용성 중심)로 동작한다고 해요.

이러한 Swift의 특징은 C++와 같은 언어와 다른데, C++는 객체의 생명주기가 괄호가 닫힐 때까지 보장돼요. 사용성이 기준이 아니라 그 객체가 포함된 scope가 기준이 되는 것 같아요.

 

하지만 위에서 말한 lifetime이 종료되는 시점은 minimum lifetime으로 항상 저 위치에서 종료되는 것은 아니에요. ARC Optimization(최적화)를 통해 그 이후(여기서는 마지막 시점)에 종료될 수도 있다는군요..😯

대부분의 경우 정확히 어떤 시점에 lifetime이 끝나는지 알 필요는 없다고 하네요.

 

역시 프로그래밍에는 "무조건"은 없는 것 같아요..🥲

객체의 생명주기를 관찰하며 동작하는 코드의 경우 버그를 유발할 수 있는데, 예를 들면 컴파일러가 업데이트되거나 다른 세부적인 코드들이 수정되어 현재 정상적으로 동작하던 코드들이 미래에는 원하지 않는 동작으로 수행될 수 있는 상황이에요.

 

스크린샷이 많아서 글이 길어지고 있는데, 이제 안전하게 이것들을 관리할 수 있는 방법에 대해 이야기해볼게요 :)

 

Reference Cycle(참조 순환)

지금까지의 예시에서 이어지는 코드를 살펴볼게요.

마찬가지로 Traveler라는 객체를 생성했는데, 이번에는 계정이라는 Account 객체가 추가되었어요.

Traveler는 Account 타입의 프로퍼티로 가지고, 반대로 Account는 Traveler 타입의 프로퍼티를 가지는 상황이에요.

이 상황에서 test 메서드를 작성해봤어요.

 

travler 인스턴스를 생성하며 Traveler의 count가 1개 증가해요.

그리고 account 인스턴스가 생성되며 Account의 count를 증가시키는 동시에, traveler의 count도 1 증가시켜요.

account에서 traveler를 관찰하고 있기 때문이에요!

 

그러면 Account의 count는 1, Traveler의 count는 2인 상황에서 Traveler에서 account를 쳐다보는 상황이 되기 때문에 마찬가지로 Account의 count 역시 2가 되죠.

 

오른쪽 메모리 그림에서 확인할 수 있듯이 2개 이상의 참조 타입이 서로를 참조하고 있는 상황이 되어 Cycle을 형성하고 있어요.

 

여기까지가 traveler, account 인스턴스를 생성하는 과정에서 일어나는 일이었고, 이제 account의 사용이 끝나면 Account의 count가 1 감소하고, test 메서드의 마지막 줄이 끝나며 마찬가지로 traveler의 count도 1 감소해요.

 

그러면 두 객체의 사용이 끝났음에도 서로를 관찰하고 있기 때문에 reference count가 1에서 감소하지 않아 메모리에 상주하게 되는 문제가 발생해요.

 

이것이 강한 순환 참조 문제이고, 메모리 누수(memory leak)를 발생시켜요.

이 문제를 해결할 방법에 대해 이야기해볼게요!

 

Weak & Unowned

아까와 다른 것이 Account 객체 내에서 traveler가 weak로 선언되었어요.

그리고 오른쪽 메모리 그림에서 실선이 점선으로 표시되었죠.

weak로 선언된 경우 해당 객체의 reference count를 증가시키지 않아요. 

그래서 순환되지 않고 할당 해제를 할 수 있죠.

그리고 또 하나 확인할 점은 traveler 인스턴스의 타입이 Traveler에서 Traveler?로 옵셔널 처리가 되었어요.

 

만약 Account에서 어떤 동작을 실행시키는데 이미 Traveler의 count가 0이 되어 할당 해제된 상태라면 그 코드는 문제를 일으킬 것이에요.

할당 해제된 객체 타입의 프로퍼티는 nil로 할당될 수 있게 옵셔널 처리가 필수예요!

 

그럼 nil 값에 대처하기 위해 옵셔널 바인딩을 할 수 있겠죠?

 

하지만 그림에도 나오듯 이것은 버그 가능성을 내포하고 있어요.

WWDC에 따르면 문제를 더 심각하게 한다네요 🥲

명백하게 crash를 내는 게 아니라 조용히 있기 때문에 Traveler의 lifetime이 변함에 따라 원하는 데로 동작하지 않을 수 있는 가능성을 가지기 때문이에요.

 

그래서 WWDC에서는 다른 방법을 제시해요.

 

withExtenedLifetime 메서드

withExtenedLifetime 메서드를 통해 traveler 객체의 lifetime을 안전하게 연장할 수 있어요.

원래대로라면 traveler.account = account 로직이 끝난 직후 release를 통해 traveler의 할당이 해제가 되어야 했지만 이 메서드를 통해 그 기간을 늘리는 것이죠.

이렇게 하면 잠재적 버그나 즉각적 crash 없이 동작하게 만들 수 있어요.

 

또한 특정 동작에만 적용하는 게 아니라 위의 그림처럼 빈 클로저를 통해 명시적으로 lifetime을 연장할 수도 있어요!

 

defer를 통해 중간에 코드를 삽입할 수도 있을 거예요 :)

 

하지만 이 방식은 무조건 해야 하는 게 아니라 잠재적으로 오류 가능성이 있는 부분에서만 적용해주는 것이 좋기 때문에 개발자에게 책임이 있어요 ㅎㅎ

 

그래서 이런 문제가 발생할 경우는 설계를 다시 디자인하는 방법이 있지 않을까?🤔 고민해보는 것이 필요해요.

아래처럼요 :)

 

Redesign(재설계) 하기

조금 더 디테일하게 재설계해볼게요 :)

 

생각해보면 Account 클래스는 Traveler 클래스를 직접적으로 참조해야만 하는 상황이 아니라 Traveler의 personal info(개인 정보)만 필요한 상황이에요. 개인정보는 여기서는 name이 되겠죠?

 

그래서 제3의 클래스인 정보 클래스를 구현하는 방식으로 재설계해볼게요.

 

PersonalInfo라는 객체를 생성하고 Traveler도, Account도 info를 보는 상황이 되어 weak 없이도 Cycle을 벗어날 수 있는 상황이 되었어요.

 

즉, 이렇게 재설계하여 새로운 객체를 생성하는 것은 분명히 추가적인 비용이 발생하지만, weak나 unowned를 사용할 경우 잠재적 버그의 가능성을 내포하기 때문에 문제를 해결할 수 있는 더 좋은 방법임에는 분명해요!! 

weak, unowned를 통해 강한 순환 참조 문제를 해결해야 할 상황이라면 잠시 멈추고 전체 구조의 재설계가 필요하지 않은지 고민해보는 시간을 갖는 것이 중요할 것 같아요 :)

 

Deinitializer Side-Effects(부작용)

Swift 참조 타입(class)의 경우 Deinit을 통해 할당 해제되는 시점의 동작을 구현할 수 있어요.

반대로 말하면 Deinit은 해당 객체의 reference_count가 감소하는 시점이 아닌 메모리에서 할당 해제되는 시점, 즉 count가 0이 되는 순간 동작해요.

 

일반적인 경우 Traveler의 deinit 메서드는 위와 같은 상황에서 호출돼요.

하지만 ARC Optimization(최적화)가 작동하면 메서드 마지막에서 호출될 수도 있죠.

이것은 Compiler의 판단에 따르기 때문에 현재로서는 예측할 수 없어요.

현재는 단순 print문이기 때문에 타이밍 차이만 발생할 수 있지만 더 심각한 문제를 유발하는 경우를 살펴볼게요.

 

코드에서 metrics는 traveler의 travelMetrics의 주소 값을 가지고 있을 거예요.

이때 metrics에서 출력하는 destination의 개수는 0개가 되겠죠?

의도된 동작은 traveler의 Destinaion이 "Bit Sur", "Catalina"로 업데이트되었기 때문에 2개가 나와야 해요.

하지만 traveler.updateDestination("Catalina") 작업이 끝나는 동시에 Traveler의 사용이 끝나게 되고, ARC가 release를 metrics.computeTravelInterest() 전에 실행시키면 metrics는 이미 할당 해제된 traveler를 관찰하고 있기 때문에 카테고리는 nil이 될 거예요. 

의도치 않은 동작으로 버그 상황이에요!

 

그럼 버그를 해결하는 방법에 대해 살펴볼게요!

 

위에서 봤던 withExtenedLifetime을 통해 metrics의 연산이 끝날 때까지 release 시키지 않게 해 줬어요.

 

여기에 더해 위에서 강조했던 Redesign을 생각해볼게요!

이 문제 자체가 외부에서 다른 객체(TravelMetrics)가 이 객체(Traveler)의 프로퍼티(travelMetrics)에 직접 접근하여 동작했기 때문에 발생한 문제라 아예 이 기능을 Traveler로 옮겨와 줬어요!

더 좋은 해법이라고 볼 수 있을 것 같아요 :)

 

아예 deinit자체를 가능할 경우만 publish 하도록 설계할 수도 있어요!

지역변수를 통해 metrics 기능을 실행해주는 것이 아니라 defer로 마지막에 동작하게 설정해주고, deinit에서는 assert를 통해 publish 되었는지 확인할 수 있어요 :)

 

컴파일러 최적화

Xcode 13에서 새롭게 추가된 기능이 있어요!!

컴파일러의 최적화에 따라 어떤 결과가 나올지 예측이 어렵기 때문에 가능한 객체 사용이 종료된 즉시 deallocate 하게 만들 수 있어요.

 

Optimize Object Lifetimes로 들어가면 Yes로 설정이 되어있는데 이런 경우 객체의 보장된 minimum 생명주기에 가깝게 deallocate 할 수 있어요 :)

 

조금 헷갈리는 부분은 위에서 설명할 때에는 사용 완료 즉시 해제가 아니라 메서드 마지막까지 갈 수 있는 이유가 Optimize 때문이라고 했는데 설정에서 Optimize를 켜야 사용 완료 즉시에 가깝게 해제된다는 게.. 반대로 읽혀서 어렵네요 ㅎㅎ

더 고민해봐야 할 것 같아요!

 

엄청나게 긴 글이 되었습니다 ☺️

반응형
댓글