[iOS] unowned는 왜 있는건지를 모르겠어서 직접 알아봤습니다

Youth·2024년 5월 14일
3

고찰 및 분석

목록 보기
17/21

안녕하세요 킴스캐슬입니다:)
오늘은 memory leak이슈를 해결하다가 지금까지 weak self만 써봤지 unowned self를 써본적이없어서 갑자기 unowned는 왜 있는거지?라는 의문이 들어서 공부한 내용들을 가져와봤습니다 ㅎㅎ
늘 그랬지만 오늘은 내용이 좀 길어질거같아서 거두절미하고 바로 내용으로 들어가보죠!

weak self? unowned self?

iOS개발을 하다보면 객체간의 참조를 고려해야할때가 많죠
대표적으로 delegate를 쓸때 weak를 가지고 객체간의 레퍼런츠를 선언해주는것처럼 말이죠

iOS는 ARC를 통해서 메모리를 관리하는데 ARC는 자동적으로 강한순환참조로인한 memory leak을 방지해주거나 알려주지 않기때문에 iOS개발자는 이런 문제를 에방하기위해서 weak나 unowned를 사용해서 객체간의 강한순환참조를 예방해줘야합니다

근데 실질적으로 많은 사람들의 코드를 보면 생각보다 unowned self를 사용한 코드를 찾아보기 쉽지않습니다 “왜일까?”라는 의문점이 들었습니다

weak를 쓰면 캡쳐한 객체가 메모리에서 할당해제되었을때 nil이 나와서 안전하다라는건 알죠
만약에 할당해제된 객체에 unowned로 접근하면 crash가나면서 앱이 터져버린다는것도 압니다
결국 이러한 안정성을 포기하면서까지 이 기능이 존재하는 이유가 있지않을까라는 의문이 들었던거죠

앱이 터진다는건 앱개발자로서 절대 용납하면 안되니까요
누군가는 이걸 weak는 null safety하고 unowned는 dangling pointer가 될 가능성이있다라고 하더라고요

이런 의문점을 해결하기 위해서는 swift4 이전에 무슨 문제가 있었는지를 이해하는과정이 필요했습니다

swift4이전의 메모리 관리 방식

예를들어서 이름과 나이를 속성으로 가지는 User라는 객체가 있다고 해보겠습니다
swift4이전에는 strong reference count(이하 strong RC)와 weak reference count(이하 weak RC)가 존재했습니다

객체가 처음 생성될때 각각의 RC는 1로 시작합니다(이유는 아래에 있습니다, 특별한 이유라기보다는 애초에 그렇게 설정된다고 되어있기때문에 받아들이시면됩니다!)

하지만 이때 객체의 강한참조가 0이 된다고 하더라도 weak RC가 여전히 1이기때문에 객체는 해제되었지만 heap 메모리에는 남아있는 좀비오브젝트가 되어버립니다

만약에 여기서 weak로 접근한다면 컴파일러가 "음 이건 strong이 0인걸보니 좀비오브젝트군"하고 weak에 nil을 할당해주고 그제서야 User객체가 메모리에서 완전히 해제되는 deallocation됩니다

그리고 weak로 접근하는것 자체가 객체에 바로 접근하기때문에 실제로는 thread safe 하지 않는 문제도 존재했다고 합니다(실제 애플 pr에 올라왔던 글이있더라고요 ㅎㅎ...)

swift4이후의 메모리 관리 방식

결국 아래의 두가지 문제를 해결해야했을 겁니다

  1. weak로 접근하기 전에 좀비 오브젝트가 존재할 가능성(접근 안하면 계속 메모리 낭비)
  2. weak로 객체에 접근할때 thread safe하지 않는 문제

swift4이후에는 이러한 문제를 해결하기 위해서 RC에 대한 방식을 새로 정의합니다
그 정의를 먼저 보고 실제로 어떻게 이런 문제를 해결했는지를 살펴보겠습니다

Swfit공식레포의 RefCount.h에 레퍼런스카운팅에 대한 설명이 자세하게 주석으로 적혀있습니다(주석까지 다니까 너무 글이 길어질거같아서 주석 자체는 제외하고 중요한 내용만 남겨보겠습니다)

An object conceptually has three refcounts.

핵심은 “객체는 세 종류의 래퍼런스 카운트를 가지고 있다”입니다

1. 강한 참조 카운트(Strong Reference Count)

The strong RC counts strong references to the object.

말그대로 기본적으로 객체를 참조하는 객체의 갯수를 카운팅합니다
우리가 알고있는 대로 강한참조가 0이되면 객체는 deinit되고(deallocation이 아닙니다! 주의!) 이 객체를 미소유참조(unowned)로 읽으려고 하면 error, 약한참조(weak)로 읽으려고 한다면 nil이 된다고합니다

2. 미소유 참조 카운트(Unowned Reference Count)

미소유 참조 카운트는 미소유 참조로 객체를 참조하는 개수를 카운팅 합니다. 미소유 참조의 카운트는 강한 참조에 대해 1의 추가적인 카운트를 가지며, 이 추가적인 카운트는 deinit이 완료되면 줄어든다고 합니다. 만약 미소유 참고 카운트가 0이 되면 객체의 할당이 해제(freed)된다고 하는데 freed를 deallocation이라고 생각해주시면 될것같습니다

3. 약한 참조 카운트(Weak Reference Count)

약한 참조 카운트는 객체에 대한 약한 참조의 개수를 저장합니다. 약한 참조는 미소유 참조 카운트에 대해 1의 추가적인 카운트를 가지는데, 이 카운트는 객체가 메모리에서 해제되면 줄어들게 됩니다. 그리고 약한 참조가 0이 되면 객체의 side table entry가 해제됩니다.이번에도 다른 부분이 생겼죠? 뭔지는 모르겠지만 약한 참조 카운트가 0이되면 사이드 테이블 엔트리라는 것이 메모리에서 해제된다고 합니다.

4. 사이드테이블(side table)

Objects initially start with no side table. They can gain a side table when:

  1. a weak reference is formed and pending future implementation:
    ...생략...

Gaining a side table entry is a one-way operation; an object with a side table entry never loses it. This prevents some thread races.

객체가 처음 생성되었을 때는 사이드 테이블이 없다고 합니다. 객체에 대해 사이드 테이블이 생성되는 경우 중 약한참조가 발생했을때 side table이 생긴다는걸 우선 이번글에선 알고있으면 됩니다

one-way-operation이 어떻게 data race를 예방하는지는 모르곘으나 어쨌든 여기서는 앞에 내용과 연관지어서 swift 4이전에는 weak로 접근할때 data race가발생했는데 그걸 방지하기위한 목적으로도 side table을 사용한다 정도로 알고계시면 됩니다

그리고 추가적으로 중요한 부분이 있다면 strong과 unowned는 객체를 직접 참조하고 weak는 객체의 side table을 참조한다(=객체를 직접참조하지않는다)고 합니다

swift4이후의 메모리 동작 방식

네...꼭 필요한 개념과 용어들을 정리해봤는데요...대체 이 개념들이 어떻게 상호작용하는지 전혀 그려지지 않으실겁니다...제가 그래서 정말 이해하는데 오래걸렸거든요...이제부턴 그림과 함께 side table이 어떻게 동작하고 그로인해 swift4에서의 문제가 어떻게 해결되었는지를 이야기해보겠습니다

이전에 side table은 늘있는것이아니라 처음 객체가 생성되었을때는 없다가 약한참조로 접근하는순간 생성된다고 이야기했었는데요 그러면 결국 side table이있을때와 없을때를 나눠서 봐야합니다

1. Side table이 없을때

side table이 없다는 뜻은 weak로 접근을하지않았다는 뜻이고 swift에서는 그럴때는 strong RC와 unowned RC만 존재합니다 처음엔 둘다 0이라고 생각하실수도있겠지만 미소유 참조의 카운트는 강한 참조에 대해 1의 추가적인 카운트를 가지며때문에 strong RC가 0이라 1로 시작합니다. 그리고 객체가 생성되고 변수에 할당되면 strong RC는 1이 되겠죠 그래서 객체가 처음 생성되면 두 count모두 1로 시작됩니다

그리고 strong과 unowned의 경우 side table이 없을때는 객체자체에 접근합니다

이런 그림의 상태가 될거고 이상태를 우리는 Live상태라고 합니다(생명주기같은겁니다)
만약에 여기서 객체의 strong RC가 1줄어들면 어떻게될까요

강한참조가 0이되면 객체는 deinit되고(deallocation이 아닙니다! 주의!)

결국 deinit이 되는시간동안에는 deinited라기보다는 deiniting이될겁니다
만약에 deiniting동안에 unowned가 0인 상태라면 객체를 deallocation시키는 과정으로 바로 넘어가게됩니다

그런데 unowned에서 추가적인 카운트는 deinit이 완료되면 줄어든다고 합니다라서 deiniting동안에 여전히 1로 남아있다면 deinit이 완료된 deinited시점에 비로소 unowned RC가 1줄어들어 0이될겁니다
(deinited시점에는 deallocation까지는 진행되지 않은상태였다가 deinited가되고 unowned가 0이되면 그제서야 deallocation이 될겁니다)

기존에 weak를 사용했을때는 직접 weak로 접근을 해야만 deallocation을 할수있을지를 알수있었습니다. 접근을 하지않으면 그동안에는 메모리에 불필요한 공간을 사용하게되죠. 하지만 지금 unowned를 사용하면서 자동으로 strong RC와 unowned RC를 통해서 자동으로 메모리에서 deallocaion을 해줍니다

즉 weak없이도 unowned를 통해서 자동으로 객체를 메모리에서 적절한 순간에 자동으로 deallocation할수있게되었습니다

2. Side table이 있을때

side table이 있을때는 객체는 메모리에서 deallocation되었지만 side table은 메모리에 남아있는 Freed상태와 객체와 side table모두 메모리에서 deallocation된 Dead상태로 나눌수 있습니다

우선 weak로 접근을 해서 side table이 생기는 경우를 그림으로 표현해보겠습니다
객체를 처음생성하면 strong과 unowned 그리고 weak가 모두 1로 초기화가 될거고 weak로 접근을 하기때문에 weak RC가 1이 추가되어있을겁니다
(그리고 위에서 잠깐 언급햇던것처럼 side table을 만드는 방식이 weak로 접근할때 thread safe을 보장하게 해준다고 했습니다)

만약에 Strong RC가 1줄어들어서 0이되고 deiniting을 거쳐 deinited가되어서 unowned까지 0이 되었다고 해봅시다

그러면 당연히 deinited상태에서 unowned까지 0이 되어서 User객체는 메모리에서 deallocation될거고 sidetable이 가리키던 객체는 nil로 할당될겁니다

weak으로 객체에 접근하면 nil을 반환받게됩니다 하지만 여전히 weak RC가 0이 아니기에 객체는 deallocation되었지만 side table은 메모리에 남아있는 Freed상태가 지속되고

weak RC가 0이 되면 객체와 side table모두 deallocation되어 Dead상태가 되게됩니다

더이상 weak로 접근하지 않아도 strong RC와 unowned RC로 자동으로 deallocation이 되어 메모리 누수를 예방할수있게됩니다

좋아지기만 한건가?

물론 weak를 객체에 직접참조하지 않기위해서 side table이라는 개념을 도입하고 메모리에서 할당해제시점을 정하기위해서 unowned라는 새로운 참조카운트개념을 도입했지만 과연 weak를 사용하는것이 무조건 좋은건지에대해서는 고민을 해봐야합니다

만약에 strong RC와 unowned RC가 0이 아닌상태라서 메모리에 객체가 올라가있다고 해봅시다

그런 상태일때 weak로 User라는 객체에 접근 해서 name을 읽어오는 상황을 가정해보겠습니다

  1. side table을 만들어야합니다
  2. side table과 user객체를 서로 할당해줘야합니다
  3. 그렇게 만들어진 side table에 접근해서 실제 user객체에 접근해서 name을 읽어옵니다

그리고 추가로 객체가 메모리에서 할당해제 된다면 side table의 객체에 nil을 할당해줘야합니다(zeroing weak라고도 한다고 하네요) 아무튼 weak로 접근한다면 얻을수있는 장점들은 속도를 포기하고 얻게되는 장점들이라는걸 알 수 있습니다

side table없이 unowned로 바로접근한다면 dangling point가 될수있지만 그렇지 않은 상황이라면 side table을 만들 필요도 그 side table을 할당할 필요도 side table을 거쳐서 객체에 접근할필요도없습니다

결국은 unowned가 dangling point가 되지않는 확실한상황이라면 weak로 접근하는게 성능적으로 손해다라고할수있습니다

정리

정말 복잡하고 대체 어떻게 정리해야할지 감도안왔던 개념정리를 마무리했습니다...

지금까지는 strong RC만 count를 하고 단순히 weak과 unowned는 RC를 count하지 않는줄알았는데 실제로는 각각의 RC를 카운팅 해주며 객체의 상태를 결정했고 결국 unowned와 weak의 차이점은 객체자체를 직접 참조하는지 혹은 side table이라는 새로운 메모리공간을 할당받아서 간접적으로 참조하는지가 가장 큰 차이였네요

위의 내용을 나름대로 요약을 해본다면
기존의 strong과 weak만으로 RC를 관리할때는 메모리에 좀비 오브젝트가 남거나 weak으로 접근할때 data race가 발생하는 문제가 발생했고 이를 해결하기 위해서 weak자체를 직접 참조하지 않는 방식을 채택헀습니다. 객체를 직접 참조하지 않기 위해서 side table이라는 메모리를 할당받아서 data race로부터 안전해졌으며 weak으로 접근하지 않아도 unowned를 통해서 객체가 메모리에서 자동으로 deallocation될수있도록 관리를 하는 방식으로 변경되었습니다

약한참조(weak)는 객체가 메모리에서 deallocation된다면 side table이 객체를 nil로 할당 받기에 null safety하다는 장점이 있지만 처음 weak로 접근시에 side table 생성 및 할당, 객체를 side table을 거쳐서 접근해야하며, 객체를 nil로 할당하는 zeroing weak로 인해 속도라는 성능 측면에서는 손해를 볼수있습니다

하지만 미소유참조(unowned)는 객체 자체에 바로 참조하기에 dangling point가 되었을때는 치명적인 오류를 발생시킬수있지만 직접참조방식이기에 속도가 weak에 비해 빠르고 메모리를 조금이라도 더 아낄수있다는 장점이있습니다

결국 객체 접근시에 객체가 메모리에서 해제되지 않음이 보장이된다면 unowned을 사용해야합니다

하지만 언제나 100%라는건 없으며 dangling point로 인해서 앱자체가 꺼지는 오류가 발생하는것은 매우 치명적이기때문에 weak를 사용하는것을 선호한다는 생각이듭니다

그리고 개발자가 사실 메모리 해제 시점을 정확하게 예측하기가 어렵다는것도 weak을 주로쓰는 큰 이유이지 않을까싶습니다

예를들어서 rxswift를 사용할때 당연히 viewmodel이 deinit되는시점이 dispose되는시점보다 늦을것같지만 실제로 print를 찍어보면 미묘하게 deinit이 dispose보다 먼저 출력된다는 뜻은 미묘한 차이라도 deinit되어서 객체가 deinit된다음에 dispose로 접근한다는 의미이기때문에 unowned를 사용했을때 문제가 발생할가능성이 존재합니다...

unowned와 weak의 차이가 뭔가요?

이제는 이런 질문에 조금은 이야기할 지식들이 많아진 느낌이네요 ㅎㅎ
혹시 여러분들은 unowned를 사용해보신 경험이있는지 궁금하네요 댓글로 알려주세요!

저는 그럼 수업을 들으러 가보겠습니다 ㅠ

그럼 20000!

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

1개의 댓글

comment-user-thumbnail
2024년 5월 16일

이전에는 weak 참조를 관리하던 객체의 역할을 Side Entry라는 녀석에게 넘겨줬다고 볼 수 있겠군요
이것도 SRP를 지키기 위해 수정된 것이라고 할 수 있으려나요
Udemy에 있는 외국 분들 강의에서는 클로저에 unowned self를 쓰는 경우도 있더라고요
이런 부분도 국가별로 차이가 있을지도 모르겠네요

답글 달기