Search

[발표 정리] 마다가스카 기술 공유 컨퍼런스 - 메모리를 지켜라(ARC) 발표

마다가스카 기술 공유 컨퍼런스.pdf.zip
4207.4KB
1.
안녕하세요. 오늘 메모리를 지켜라라는 주제로 발표를 하게된 마다가스카 팀플수장 신윤아입니다. iOS 개발을 하면서 많은 분들이 메모리에 크게 관심을 가지신 적이 없을거예요. 아 이건 메모리에 부담이 되는구나. 이렇게 코드를 짜지 않다가 메모리에 조금씩 관심을 가지게 되면서 메모리를 안전하게 사용하면서 코드짜는 법을 알아가는 거 같아요. 저도 메모리에 하등 관심이 없었는데, 그런 제가 이번에 메모리를 지켜라 라는 주제로 발표를 하게 된 이유가 있어요. 제가 Xcode를 실행하던 중에 끔찍한 일을 당했습니다. 처음 실행했을 때 15.4MB를 가지고 있던 메모리 사용률이 제가 ImagePicker를 켜니깐 45.3MB가 되더라구요? 아 그래 그래 ImagePicker를 열면 사진을 가져오니깐 메모리 게이지가 오르겠다 라고 생각을 했습니다. 하지만 Picker를 끄고 나서도 45.3MB 밑으로 내려가지 않는거예요. 그리고 제가 ImagePicker를 3번정도 열고 닫기를 반복했더니 101MB까지 오르게 됐어요. 누군가는 보고서 저 정도는 괜찮지 않아? 고작 101MB잖아라고 생각할 수 있어요. 하지만 실제 기기에서, 사람들이 사용하는 기기에서 동일한 일이 발생했다면? 어떻게 될까요? 시뮬레이터는 아시다시피 기본 6장의 사진을 가지고 있어서 피커를 키면 6장의 사진을 불러오는데 그런 큰 용량을 사용했을 겁니다. 실제 기기는 6장만 가지고 있는게 아니죠? 6천장, 6만장, 60만장일 수도 있어요. 물론 pagination를 사용해서 가져오는 사진들을 조절하면서 가져오겠지만 그래도 그렇게 사진이 많은 피커를 몇 번 왔다 갔다 하면 우리 앱이 다 죽을겁니다. 제가 저런 문제가 생긴 이유는 제가 사용했던 ImagePicker에서 사진이 가져오는 과정에서 알아서 dismiss해준 것처럼 보였기 때문에 제가 Picker를 dismiss해주지 않아서 계속 ImagePicker가 사라지지 않고 메모리 어딘가에 쌓였기 때문이에요. 이런 앱이 실제로 출시되게 된다면 사람들의 핸드폰에서 메모리를 다 잡아먹겠져? 저는 어떻게 하면 메모리 영역을 안전하게 사용할 수 있을까 생각을 하게 됐습니다. 메모리 영역이 잘 관리되고 있다는 건 곧 안전하고 좋은 앱이라는 것이니깐요. 그러면 우리같이 안전하고 좋은 앱을 어떻게 하면 만들 수 있을 지 알아봅시다.
2.
목차는 정말 간단해요. 첫 번째는 ARC를 알아보는 것이고, 2번째는 메모리 누수를 어떻게 막을지 알아보는 겁니다. 원래 3번째 이미지 다운사이징까지 있었는데 내용이 너무 길어져서 나중에 지켜라2를 통해서 해볼 생각입니다. 오늘은 기본적인 메모리에 대한 내용들 그리고 메모리 누수만 다루도록 하겠습니다.
3.
여러분은 ARC가 뭔지 아시나요? ARC는 Automatic Reference Counting, 참조 메모리 관리를 자동으로 해주는 기능입니다. ARC는 메모리 영역 중에 힙 영역을 관리합니다. 인스턴스, 클로저 등등의 참조 타입은 자동으로 힙에 할당이 되요. 얘기만 들으면 무슨 소리인가 싶을 거 같아서 예시를 가지고 왔습니다.
4.
지금 Dog라는 클래스를 하나 만들고 buddy라는 지역변수를 하나 만들었어요. 전역 변수로 보이지만 지역변수라고 생각을 해주세요. buddy는 Dog인스턴스를 참조하고 있는 변수입니다. 지역 변수인 buddy는 스택에 할당이 되고 실제 Dog 인스턴스는 힙에 할당이 돼요. 스택에 있는 buddy는 힙 영역에 있는 인스턴스를 참조하고 있는 형태입니다. 따라서 안에 보시다시피 힙에 할당된 인스턴스 주소값이 들어가 있는 걸 보실 수 있습니다.
이번에는 buddy를 clone에 대입시켜볼게요. 이렇게 했을 때 옆에 그림처럼 스택에 clone이 들어가고 buddy와 동일하게 힙에 있는 Dog인스턴스의 주소값을 가지고 있는 걸 볼 수 있죠. 이 두개의 변수는 Dog인스턴스를 참조하고 있는겁니다. 근데 이 메모리를 어떻게 해제하지? 이런 생각 해보신 적 있나요? 우린 지금껏 인스턴스를 마음대로 할당하고 사용해 왔지만 단 한번도 인스턴스에서 직접 메모리를 해제해준 적은 없었을 거예요. 그럼 이렇게 된다는 걸까요? 힙에 인스턴스가 남아있다면 너무 위험하잖아요?
근데 다행이게도 우리에겐 ARC맨이 있었습니다. 힙에 있던 메모리는 ARC맨이 우리가 직접 해제해주지 않아도 처리를 해줄거예요. ARC는 인스턴스가 더이상 필요가 없을 때 클래스 인스턴스에 사용된 메모리를 자동적으로 해제해줍니다. 너무 좋죠? 그 밖에 RC형식의 기능들은 이런 특징들이 있어요. 컴파일 시점 ~ 장점은 개발자가 참조 해제 시점을 파악 가능, RunTime 시점에 추가 리소스가 발행하지 않음. 하지만 큰 단점이 하나 있는데 그게 바로 순환 참조가 발생할 시 영구적으로 메모리가 해제되지 않을 수 있다는 점이에요. 아직까지는 순환 참조를 제대로 배우지 않았기 때문에 순환 참조가 뭔지는 모르겠지만 영구적으로 메모리가 해제되지 않을 수 있다? 일단 끔찍하죠? 근데 우리가 순환 참조를 배우기 위해서는 먼저 ARC가 어떤 방식으로 메모리를 관리하는지 알아봐야 합니다. ARC는 Reference Count를 사용해서 메모리의 참조 횟수를 계산해서 메모리를 관리 합니다. 참조 횟수가 0이 되면 ARC는 자동적으로 해당 인스턴스를 사용하지 않는 인스턴스라고 보고 힙에서 내리는거죠. 휴지통으로 해제시켜주는겁니다. 그럼 참조 횟수가 +, - 될 수 있다는 건데, 어떤 식으로 되는 걸까요?
Reference count가 증가할 때는 인스턴스를 새로 생성할 때, 아까 buddy를 생성했을 때, 기존 인스턴스를 다른 변수에 대입할 때, clone이 그 예시겠죠. 이름 그대로 해당 인스턴스를 참조하는 변수들이 생기면 RC가 올라가는 겁니다.
반대로 내려가는 경우는 뭘까요. 당연하지만 인스턴스를 가리키던 변수가 메모리에서 해제되었을 때 RC는 참조하던 변수가 없어졌으니깐 당연히 -가 되겠죠. nil이 지정되면 해당 변수가 사라지기 때문에 RC가 동일하게 - 됩니다. 변수에 다른 값을 대입한 경우? 이 경우는 변수에 다른 인스턴스를 넣어준거예요. 변수는 두가지의 인스턴스를 동시에 가질 수 없기 때문에 하나의 인스턴스만 채택하게 되겠죠? 결과적으로 전에 쓰던 인스턴스는 버려지면서 RC가 -됩니다. 그리고 마지막 경우는 프로퍼티에 해당하는데 우리가 클래스안에 클래스 인스턴스를 가진 변수를 선언할 수 있잖아요? 그랬을 때 속해 잇는 클래스 인스턴스가 메모리 해제가 되면 동시에 프로퍼티도 해제가 되면서 참조하고 있던 인스턴스의 RC가 -되는 겁니다.
보시다시피 이렇게 buddy가 Dog인스턴스를 참조하면 RC가 증가합니다. clone이 참조를 하면 2로 증가하겠죠? 근데 이렇게 인스턴스의 주소값이 변수에 할당이 될 때 RC가 증가하면 이게 바로 강한 참조입니다. 누군가는 이렇게 말할 수 있어요. 아 나는 Strong으로 선언한적이 없어서 강참은 아니지 ㅎㅎ 어쩔티비 ㅎㅎ 하지만 우리가 지금껏 자연스럽게 인스턴스를 생성하고 사용했던게 강함 참조예요. 왜냐면 default가 강한 참조입니다. internal를 쓰지 않아도 우리의 접근 제한자가 internal인 것처럼 우리의 기본 참조도 strong입니다. 근데 이런 strong의 가장 큰 문제점이 순환 참조가 발생한다는 점이에요. 그럼 순환참조가 뭔지 알아봅시다.
순환참조는 영구적으로 메모리가 해제되지 않을 수 있다는 문제가 있어요, 예를 하나들어볼게요. 보시다시피 두개의 class가 있습니다. 그리고 저는 bestfriend라는 클래스 인스턴스를 하나씩 만들어두고 메모리가 완전히 해제되면 실행되는 deinit에다가 print를 넣어뒀어요. 완전히 해제되면 저 프린트문이 실행될겁니다. 옆에 있는 그림처럼 우리의 stack과 heap이 구성되어 있겠죠? RC가 1일겁니다.
그리고 우리가 bestfriend에다가 seawater와 heerucan를 각각 넣어준다면 현재 우리가 사용한 변수는 강한 참조였기 때문에 그림처럼 서로를 참조하는 형태가 될겁니다. RC가 각각 2가 되는거예요. 이게 바로 두 개의 객체가 서로가 서로를 참조하고 있는 형태인 순환 참조입니다. 우리가 nil를 각각 넣어줄게요. 아마 이렇게 생각을 할게에요. ARC가 해주겠지. 하지만 아닙니다. deinit메서드도 호출되지 않아요. 왜냐 힙에서 사라지지 않고 계속 메모리를 먹고 있기 때문입니다. 왜죠! 왜냐면 해제를 해도 RC가 -1밖에 되지 않기 때문에 결국에는 1이 남아서 힙에 그대로 남게 되는거죠. 이 상황은 해당 인스턴스를 가리키는 변수도 사라진 상태라서 없앨 수가 없어지는겁니다. 메모리 누수가 발행하는 거죠. 이렇게 strong를 사용해서 순환참조에 문제가 생긴 경우를 강한 순환 참조라고 합니다.
4.
그럼 어떤식으로 막을 수 있을까요? weak과 unowned가 해당 문제를 해결할 수 있을 거 같네요. weak은 인스턴스를 참조할 시, RC를 증가시키지 않습니다. 참조하던 인스턴스가 메모리에서 해제된 경우, 자동으로 nil이 할당되어 메모리가 해제됩니다. nil이 할당이 되어야 하기 때문에 weak는 무조건 옵셔널 타입의 변수여야 한다는걸 알 수 있죠?
이번에는 Seawater에서 weak변수를 선언해줄게요. 그리고 동일하게 bf를 지정해줍니다. 하지만 아까와는 다르게 seawater의 weak변수때문에 참조가 일어나지 않습니다 즉 RC값이 1로 있는거죠. 그리고 나서 nil값을 변수에 넣어주겠습니다. 그러면 weak인 seawater는 메모리 해제가 일어날 거예요. 그럼 Heerucan은 어떡하죠? 아까 우리가 들었다시피 프로퍼티가 다른 클래스를 참조하고 있다면 해당 클래스가 인스턴스에서 해제가 될 때 참조가 -된다고 들었죠. 그렇게 때문에 둘 다 0이 되어서 해제가 됩니다. 즉 deinit 절교하겠다는 프린트가 나오겠죠? 이제 바로 약한 순환 참조입니다.
근데 이렇게 생각할 수 있어요 누구한테 weak를 선언해줘야 하나... 예제에서는 둘 다 수명이 동일하다고 생각이 들기 때문에 아무나 weak로 선언했지만 강한 순환 참조가 난 경우에는 둘 중 수명이 더 짧은 인스턴스를 가리키는 애를 약한 참조로 선언해주면 됩니다. 후리가 먼저 절교선언을 할 거 같다면 혜수의 BF가 nil이 될 수 있다는 것이기 때문에 bf를 weak로 선언해주면 된다 라고 생각해주시면 됩니다.
5.
unowned는 무엇인가 싶을거예요. 저도 한 번도 생각해본 적이 없습니다. 그냥 눈으로 본 적만 있어요. 이 친구는 weak과 비슷하지만 인스턴스를 참조하는 도중에 해당 인스턴스가 메모리에서 사라질 일이 없다고 확신을 할 수 있다는 점이에요. 참조하던 인스턴스가 만약 메모리에서 해제된 경우에 nil을 할당받지 못하고 해제된 메모리 주소값을 계속 들고 있다는 겁니다. 너무 별론데?
혜수보다 "후리가 더 오래 베프를 유지한다는 가정"하에 unowned를 설정해줬습니다. 그리고 아까와 모든 것이 동일해요. 그러면 Seawater 인스턴스는 unowned된 상태로 받을 겁니다. 아까랑은 반대로 RC=1인곳은 후릐죠? 근데 우리가 알아야 할 점이 있어요, unowned가 붙은 후릐의 bestfriend가 가리키는 혜수 인스턴스는, 후릐 인스턴스가 메모리에서 해제되기 전까진 절대 절대 먼저 해제되어선 안됨. heerucan인스턴스보다 seawater가 메모리 해제를 먼저하게 되면 문제가 생겨요. heerucan에 있는 bf에 아무것도 없기 때문이죠. 이미 메모리가 해제된 포인터 값에 접근하려해서 에러가 발생합니다. unowned는 에러를 발생시킬 위험이 커요. 웬만해서 weak를 사용하는 것을 권장합니다. 그리고 둘 중에 수명이 더 긴 인스턴스를 가리키는 애를 unowned로 선언해주는 게 좋습니다.
6.
그럼 뭐가 좋은가요? 우리가 이렇게 [weak self]를 쓰는 경우가 있어요. weak의 경우에는 nil를 할당받을 가능성이 있기 때문에 옵셔널 타입으로 self에 대한 옵셔널 바인딩을 해줘야 하지만 unowned의 경우에는 넌옵셔널 타입으로 self에 대한 옵셔널 바인딩 없이 사용할 수 있습니다. Swift 5.0부터는 unowned도 옵셔널 타입이 되긴 한다네요. 옵셔널 바인딩을 하지 않아도 되기 때문에 코드가 깔끔해지겠죠? 그리고 우리가 자주 쓰지만 weak 시켜주지 않는 친구가 있어요 바로 delegate입니다. delegate는 다른 얘를 참조하고 있지만 우리가 약한 참조 시켜주지 않아서 메모리에 떠돌게 되는 일이 발생할 수 있어요. 우리 앱안에 delegate들이 얼키설키 엉켜있는거죠. 그렇기 때문에 weak를 사용해서 해당 VC가 deinit되고 나면 같이 사라질 수 있도록 nil이 될 수 있도록 weak를 사용해줘야 해요.
7.
해당 경우말고도 제가 겪은것인 제대로 dismiss해주지 않은 VC, 너무 많은 에셋, 너무 큰 이미지로 인한 메모리 공간 부족, in-out 파라미터를 사용해서 메모리들에 충돌이 일어날 수도 있고 클로저 정의 시점에 [weak self]를 올바른 부분에 사용하지 않아서 누수가 생기는 등... 이렇게 많은 이유로 메모리에 문제가 생길 수 있습니다. 정말 사소한 부분이기 때문에 메모리를 지키기 위해서 조금만 신경을 써준다면 우리 앱이 건강하게 누수없이 세상에 나올 수 있겠죠? 오늘 이런 누추한 발표를 들어주셔서 감사하고... 얼른 세미나 가세요..