본문 바로가기

iOS/Deep dive!!!

[iOS] 뷰 컨트롤러 강한 참조 발생 경험와 해결 방법. Strong reference closure | ARC 진짜 뿌수기 #1

728x90

 

안녕하세요: ]

 

이번 포스트는 ARC와 RC(retain count), strong reference cycle에 대한 간단한 개념을 정리할 것입니다.

포스트의 후반부에는 제가 실제로 개발하며 때 런타임 때 viewController, coordinator와 navigation controller, closuer의 관계에서 발생된 strong referency cycle! 이슈관련해서 메모리가 증가됬던 경험과 해결 방법을 소개하려고 합니다 +_+

ARC와 RC란?!

ARC와 RC를 탐구하기 전에 간단히 값 타입(Value type)과 참조 타입(Reference type)을 비교해보려고 합니다.

Value type vs Reference type

Swift는 크게 값(value)타입과 참조(reference)타입으로 분류되어 있습니다.

 

값 타입은 스택 영역에 저장되고 복사를 통해 데이터가 전달됩니다. Immutable state이기 때문에 그래서 여러 스레드에서 안전하게 사용됩니다.

참조 타입은 힙 영역에 할당된 인스턴스를 여러 객체가 참조(대입)할 경우 복사를 통해 전달되는게 아니라 한 인스턴스의 데이터를 여러 객체가 참조해서 공유하게 됩니다. Mutable state이기 때문에 동시성 문제가 발생될 수 있습니다.

 

 

값 타입은 복사(copy)본을 생성해서 전달해줍니다. 값 타입의 한 객체를 다른 스코프의 객체에게 대입한다면 해당 인스턴스는 해당 범위(스코프)를 벗어나면 소멸됩니다. cf. 일부 값 타입은 경우에 따라 최적화를 위해 힙에서 참조되는 CoW 기법 덕분에 새로운 인스턴스의 수정이 없다면 복사본이 만들어지지 않습니다(크으.. 대단한 성능 관리 기술입니다).

 

 

참조 타입은 힙 영역에 할당된 객체를 다른 스코프의 객체에게 대입할 경우 다른 객체 또한 한 인스턴스를 참조하게 됩니다(이때 참조는 strong reference). 이 경우 다른 스코프의 객체가 해당 스코프를 벗어나도 힙 영역을 참조하는 또 다른 객체가 있다면 힙 영역의 인스턴스는 소멸되지 않습니다. 그 이유는 retain count가 0으로 감소되지 않았기 때문입니다: ]

 

ARC(AutomaticReferenceCounting)에서는 기본적으로 weak, unowned로 선언하지 않는 인스턴스를 강한 참조와 retain count의 증가, 감소를 통해 할당과 해제를 관리합니다.

ARC와 Retain Count

Objective-C에서는 C++, C처럼 개발자가 직접 동적으로 인스턴스를 할당(alloc)하고, 해제(deallocate) 해야합니다. (retain, release and autorelease) 메모리를 관리 기법인 MRC에선 직접 할당 해제를 해야했습니다.

이 경우 개발자가 참조 타입의 인스턴스를 release하지 않게 된다면 해당 인스턴스를 사용하지 않는데도 데이터가 가상 메모리 힙 영역에 유지될 것이고... 의도치 않은 메모리 누수가 발생됩니다. "메모리 사용량이 왜 이렇게 높지..?"를 경험할 수 있습니다.

 

ARC의 장점은 클래스 타입의 객체, 클로저 등 참조(reference) 타입의 메모리 할당, 해제를 자동으로 다룹니다.

이는 참조 타입의 인스턴스가 메모리에 로드되고 참조를 통해 여러 객체가 한 인스턴스를 가리킬 때, ARC weak, unowned로 선언되지 않는 참조 객체들에 대해서 기본적으로 strong reference와 referencing(retain) count를 통해 인스턴스들의 lifecycle에 따른 생성, 소멸을 관리해준다는 것입니다. 자세한 내용은 다음 포스트에서 다뤄볼 예정입니다.


힙 영역에서 할당됬던 특정 인스턴스의 strong reference가 없는 경우. 즉 힙 영역의 인스턴스를 참조하는 retain count가 0으로 감소한다면 ARC는 해당 인스턴스 메모리를 자동으로 할당 해제합니다. 그 전에  deinit을 호출시킵니다.

 

여기서 중요한 것은 힙 영역에 할당된 인스턴스의 retain count!가 0이 될 때 deinit이 호출된다는 점입니다.

Strong reference cycle 문제를 간단히 소개합니다.

ARC가 자동으로 retain count를 관리한다지만, 그럼에도 개발자가 반드시 관심을 갖고 다뤄야합니다. 그렇지 않을 경우 사용하지 않는 인스턴스가 여전히 힙 영역에 할당되어 strong reference로 메모리에 남아있을 수 있습니다. 그 경우 중 하나가 강한 참조 순환(Strong reference cycle)입니다.

class A {
  let name: String
  var b: B? = nil
  deinit { print("A deinit") }
}

class B {
  let name: String
  var a: A? = nil
  deinit{ print("B deinit") }
}

 

이 두 인스턴스가 있고

 

override func viewDidLoad() {
  super.viewDidLoad()
  let a = A(name: "hi")
  let b = B(name: "hello")
  a.b = b
  b.a = a 
}

 

이렇게 두 인스턴스를 생성하고 내 부 프로퍼티들을 초기화 한다면, viewDidLoad() 호출이후 이 함수 스코프 이후에도 결론적으로 a의 retain count = 1, b의 retain count = 1로 힙 영역에 a, b인스턴스가 여전히 할당되어있습니다.

 

이 경우가 메모리 누수(memory leak: 컴퓨터 프로그램이 필요하지 않은 메모리를 계속 점유하고 있는 현상)가 발생되는 경우입니다!! viewDidLoad()함수 호출 이후에 a, b인스턴스는 사용 할 수도 없고 사용도 안하지만 메모리에 여전히 로드되어 있다는 점입니다.

 

 

  ...
  a.b = b
  b.a = a 
  a.b = nil
}

 

 

이 경우 strong reference를 해결하는 방법은 A 인스턴스 내부의 b 변수 타입을 옵셔널로 선언하고, 한쪽의 인스턴스에서 strong referencing 중인 내부 프로퍼티 (예를들면 a인스턴스의 내부 프로퍼티 b)의 인스턴스를 명시적으로 nil을 대입함으로 retain count를 직접 감소시키는 것입니다. 또는 weak나 unowned를 선언하는 방법이 있는데, 이때 weak는 var타입으로해야 런타임때 컴파일러가 참조 중인 값을 nil로 처리할 수 있기 때문입니다.

 

지금은 단순한 문자열과 클래스를 갖는 인스턴스의 예시지만, 실제로 Service, 이미지, UI컴포넌트, 뷰모델 등이 있는 뷰컨트롤러 인스턴스에서 메모리 누수가 발생된다면? 점유중인 메모리가 상당히 커질 것입니다. 

프로젝트 runtime 때 strong reference로 발생한 메모리 관련 이슈와 해결과정!

정말 평범한 화면인데,,,,, 내부적으로는 평범하지 않게 메모리 점유가 증가되는 제 프로젝트의 상황을 통해, strong reference cycle 발생한 원인과 해결 과정을 소개하려고 합니다. 

 

 

이 화면은 관심있는 여행지, 포스트를 찜한 리스트를 볼 수 있는 화면입니다. 

 

 

FavoriteViewController에서 특정 cell을 클릭할 때 상세 뷰 컨트롤러로 이동합니다.

 

FavoriteDetailVIewController(상세 찜 화면)에선 여행 리뷰, 장소에 따라 서로 다른 데이터를 보여줘야 합니다. 그리고 스크롤 할 때마다 메뉴 영역의 뷰가 사라졌다 올라와야 합니다. 

 

 

위 동영상은 문제가 없어보이지만,,

 

 

상세 화면 전환 -> 네비게이션의 popViewController를 통해 이전 화면으로 벗어나도 오른쪽 Memory 사용량은 계속해서 늘어납니다. 네비게이션 스택에서 favoriteDetailViewController가 pop되도 계속해서 메모리 할당이 되어있기 때문입니다.

 

결론부터 말하자면 클로저 때문입니다.

favoriteDetailViewController는 내부적으로 strong reference 중인 클로저 때문에 맨 위에서 소개한 경우처럼 viewDidLoad() 시점 이 후 a, b 프로퍼티는 사라지지만 계속해서 A, B 를 strong reference 중인 상황이 발생된 것입니다.

 

 

지금부터 제 코드 상황을 기반으로 strong reference 클로저를 사용할 때 메모리 누수가 발생된 경험을 소개하려고 합니다.

 

 

이 사진은 뷰 컨트롤러의 화면 전환과 생성을 담당할 코디네이터입니다

 

 

FavoriteCoordinator인스턴스에서 showFavoriteDetailPage()를 호출하게 된다면, 자식 코디네이터(FavoriteDetailCoordinator)를 생성 후 favoriteCoordinator.child에 추가합니다. 그리고 자식 코디네이터의 start()함수를 호출합니다. 자식 코디네이터 init 시점에선 내부 프로퍼티 변수인 view controller의 인스턴스를 생성합니다.

 

final class FavoriteDetailCoordiantor {
  ...
  func start() {
    presenter.pushViewController(viewController, animated: true)
  }
}

 

FavoriteDetailCoordinator의 start()를 호출시킬 때, 네비게이션 컨트롤러를 통해 FavoriteDetailViewController인스턴스로 할당된 FavoriteDetailCoordinator의 viewController를 화면에 보여줍니다.

 

FavoriteDetailViewController(상세 찜 화면)

그렇게 이 화면이 보여진다면! 객체간 참조 관계는 다음과 같습니다.

 

 

결과적으로 FavoriteDetailViewController는 presenter라는 네비게이션 컨트롤러를 통해 네비게이션 스택에 push된 상황입니다. 추가로 favoriteCoordinator 인스턴스의 child 프로퍼티에서 favoriteDetailCoordinator인스턴스를 참조 중 입니다. 그러기에 favorite detail coordinator 내부 프로퍼티인 viewController 또한 FavoriteDetailViewController(retain count == 2)를 강한 참조 중 입니다.

 

Favorite detail view controller를 메모리로부터 해제하기 위해선 retain count를 2 -> 0으로 만들면 됩니다. 

 

1. presenter의 네비게이션 스택에서 popViewController호출( retain count -= 1 )

2. favorite detail coordinator의 인스턴스를 favoriteCoordinator.child로부터 삭제( detail coordinator의 viewController인스턴스도 사라지기에 retain count -= 1 )

 

이렇게 상세 찜 화면에서 뒤로가기를 누를 때 네비게이션 스택에선 pop을, 현재 detail 코디네이터의 상위로부터 child 프로퍼티로 참조중인 detailCoordiantor를 제거하면 favoriteDetailViewController는 점유중인 메모리 자원을 반환할 것입니다.

 

favoriteDetailViewController가 메모리에서 자원을 반환하는지 알 수 있는 방법은 deinit이 호출되는지를 파악하면 됩니다. Deinit은 메모리에 로드된 인스턴스의 retain count가 0이 될 때 호출되기 때문입니다.

 

 

 

저는 상세 찜 화면에서 뒤로가기를 누를 때 1,2 과정을 처리했습니다. 그럼에도 위 동영상에서는 메모리가 증가되고 있었습니다. Deinit이 호출이 되지 않았습니다.

 

 

그 이유는 상세 찜 화면의 subivew인 favorite detail menu area view에 있는 여행리뷰, 장소 버튼을 누르 때마다 호출되는 클로저.

 

이 클로저를 구현할 때 favorite detail view controller에서 strong reference 로 캡쳐하도록 구현하는 코드가 문제였습니다.

 

 

이렇게 클로저를 구현후 실행해서 클로저가 context를 캡쳐하게 된다면 참조 타입인 favorite detail view controller는 두 개의 클로저가 추가로 strong reference하게 됩니다.

 

 

따라서 parent coordinator에서 favorite detail coordinator 제거 + presenter에서 favorite detail view controller 제거를 해도 결국 favorite detail view controller생성되 때 호출한 bind()함수를 통해 strong reference로 context를 캡처한 두 클로저 때문에 retain count는 0이 되지 않는 것입니다. == 메모리 누수.

 

그럼 어떻게 해결해야 할까요?..

 

첫 번째 방법은 favorite detail view controller를 strong reference 인 두 클로저를 명시적으로 해제하는 것입니다: ) 

 

 

이렇게 명시적으로 self를 캡처중인 두 클로저의 해제를 호출해주면 됩니다!!

 

 

그럼 retain count == 0이 되고, deinit이 호출됩니다.

 

그런데 이렇게 할 경우 매번 선언한 클로저의 nil을 호출해 야하는 작업이 번거롭고, 하나라도 nil처리하지 않으면 결론적으로 strong reference는 살아있고 retain count != 0이 됩니다. 더 쉬운 방법은 weakunowned를 사용하는 것입니다.

 

 

이렇게 weak, unowned로 클로저 외부의 context를 캡처하게 되면 retain count는 증가되지 않습니다: ) 그럼 일일이 클로저 체크후 nil처리하지 않아도 됩니다. 그 이유는 런타임시 release가 되야하는상황에 nil 처리를 해주고 불필요한 참조를 방지하게 됩니다.

 

 

deinit이 호출될 때 메모리 사용량,,

 

 

 

결론. 클로저 사용할 때 weak, unowned를 사용해야 좋습니다. 그리고 뷰 컨트롤러를 개발했다면 deinit 한 번 체크해보는 것도 메모리 누수 체크로 좋은 것 같습니다: ]

 

이상하거나 틀린 내용 발견시 댓글로 알려주신다면 정말 감사합니다!!

 

 

 

참고로...

menuView.travelReviewTapHandler { [weak self] in
  guard let self = self else { return }
  // 로직
}

 

weak self로 선언해서 이 경우 클로저는 favorite detail view controller를 약한 참조로 하지만, 클로저 내 gaurd let self = self else { return } 코드가 스레드에서 실행된 이후의 로직이 실행되는 과정에선 self를 strong reference하게 됩니다. 반면 guard let self else { return }을 하지 않고 self?.로 옵셔널 체이닝을 통해 클로저 내 내부 로직들을 호출한다면 self는 여전히 약한 참조로 작동될 것입니다.

728x90