Trouble Shooting - ARC와 Capture List, Memory Leak

YesCoach·2022년 8월 12일
0

트러블 슈팅

목록 보기
2/2

졸업 프로젝트 진행 중 발생한 Trouble Shooting 입니다.

🥏 트러블 슈팅 배경

한창 ARC와 메모리에 대해서 공부하던 중, 졸업 프로젝트에서 메모리 누수가 발생하진 않는지 궁금해졌습니다.
매번 콜백, 비동기 클로저에서 참조 객체 사이에 강한 순환참조를 방지하려고 [weak self] 를 명시해왔는데요.
과연 정말로 순환참조가 발생하지 않는가, 제대로 알고 쓰고 있는지 확인할 필요가 있다고 생각했습니다.

코딩하면서 메모리 영역에 어떻게 할당이 되고 있는지 직접 확인해본 적이 없었기 때문에, 검색을 통해 Xcode의 Instruments 를 사용해보려고 했는데요,,,
메모리 leak를 확인하는데 Objective-C를 포함한 모든 레퍼런스 타입의 메모리 할당이 체크되는 듯 하였고 그 방대한 양에 밀려서 다음으로 미루게 되었습니다,,,

이번에는 Xcode에서 제공하는 debug Navigator, 그중 memory report 영역을 참고로 하게 되었습니다.

또, class의 deinit 메서드를 통해 인스턴스가 메모리로부터 해제되는지 확인해 볼 수 있습니다. 메모리 누수가 의심되는 경우 deinit을 통해 정상적으로 해제되는지 확인하는 것도 좋을 듯 합니다.

문제 확인

전반적인 프로젝트의 flow를 따라서 메모리 누수를 확인해야 하는데, 일단 제가 구현한 부분인 classItem 상세보기 파트를 확인해보기로 했습니다.

앱의 메인 화면

유저가 classItem을 등록하면, 해당 지역에 있는 다른 유저들의 메인 화면에 보여주는 기능을 하고 있습니다.
여기서 셀을 누르면 해당 classItem의 상세화면으로 넘어가게 됩니다.

ClassDetailViewController

 

ClassDetailViewController는 TableView와 StackView, 각 항목을 구성하는 View 객체들로 구성되어 있습니다.

메모리 누수가 발생한다면 메모리에서 해제되지 않고 계속 쌓일 것이기 때문에, ClassDetailViewController를 생성하고 해제하는 동작을 반복해보았습니다. 과연,,?

역시나,, 점점 느려지더니 마지막에는 경고를 띄우며 강제종료되었습니다ㅜ

debug navigator에서 확인해본 메모리 영역 과부화입니다,, ClassDetailViewController를 pop했지만 해제되지 않고 계속 쌓이는 모습입니다. ClassDetailViewController의 `deinit`에 breakpoint를 걸었지만 통과되는 것을 보고 확신했습니다.

이런 메모리 관련 이슈를 확인해보려는 생각을 한번도 못했었는데, 바로 이렇게 확인하게 되어서 그래도 기분이 좋았습니다
그간 모르는 사이에 강한순환참조가 얼마나 많이 발생했을까.. 앞으로 신경써야겠다 생각이 드네요

트러블 슈팅

문제의 부분 찾기

문제가 발생하는 구간을 찾아보고, 리팩토링을 진행해보았습니다.

다소 지저분,,,

ClassDetailViewController는 Main 화면에서 이미 패칭해온 ClassItem을 프로퍼티로 받는데요, 그럼에도 ClassItem의 이미지 배열을 서버에서 받아와야 하기 때문에 비동기 로직이 필요합니다. 또한 일부 Cell(유저정보 등)의 경우 클릭했을때 상세화면으로 더 들어가는 경우가 있는데, Cell의 데이터를 가지고 상세화면을 push 하기 위해 cell의 내부에서 viewcontroller로 콜백을 주고 있습니다.

모든 콜백, delegate 프로퍼티를 살펴보다가 의심되는 부분을 확인하고 리팩토링을 진행했습니다.

1. 캡쳐 리스트 missing

		guard let cell = tableView.dequeueReusableCell(
        	withIdentifier: DetailUserCell.identifier,
            for: indexPath) as? DetailUserCell else {
			return UITableViewCell()
        }
        cell.configure(with: classItem.writer) {
            self.navigationController?.pushViewController(
            	ProfileDetailViewController(user: $0),
                animated: true)
        }
		return cell

여기는 확실하네요,, 콜백을 통해 self을 참조하고 있습니다.
강하게 참조하므로 self(ClassDetaiViewController)의 Reference Count가 1 증가했습니다.
따라서, ClassDetailViewController를 dismiss해도, Reference Count가 0이 되지 못해 메모리에 남게됩니다ㅠ...

2.중첩 클로저...? 이중 클로저?

// dataSource 구현부
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    switch indexPath.section {
    case 0:
    	guard let cell = tableView.dequeueReusableCell(
        	withIdentifier: DetailImageCell.identifier,
            for: indexPath) as? DetailImageCell else {
            return UITableViewCell()
        }
        cell.delegate = self
        classItem.fetchedImages { images in
            DispatchQueue.main.async { [weak self] in
                guard let self = self else { return }
                self.activityIndicator.stopAnimating()
                cell.configureWith(images: images)
            }
        }
        return cell
    .
   	.
    .
    }
}

위 코드는 다음의 흐름으로 진행됩니다.

  1. classItem.fetchedImages : classItem의 이미지 주소 배열을 가지고, 서버에서 이미지를 받아옵니다.(콜백)
  2. 받아온 images를 가지고 셀을 구성합니다. 이때, indicator도 중지합니다.

UI와 관련된 업데이트는 main thread에서 수행해야 하므로, DispatchQueue.main.async를 통해 인디케이터를 멈추고, 비동기적으로 이미 반환한 셀에 이미지를 채우는 작업을 진행했는데요.

항상 클로저를 사용할때, 강한순환참조를 막기 위해 캡쳐리스트를 사용해라, [weak self] 를 사용하라는 내용을 봐왔고, 이번에도 self(ClassDetailViewController)에 접근하는 클로저(여기서는 메인스레드)에서 [weak self] 를 사용했습니다.
그런데 직접 메모리 누수를 겪으니까 이제서야 의문이 들더라구요,,(메모리 누수는 1번 때문에 발생했습니다^^,,)

📮 궁금증

내부 클로저(mainThread)에서는 self의 프로퍼티들을 참조하니까 캡쳐리스트로 weak를 걸어줬어
그로 인해 ARC는 증가하지 않고, 클로저가 끝나기 전에 메모리에서 해제되면 자동으로 nil을 할당해 같이 메모리에서 해제되겠구나

그럼

외부 클로저(fetchedImages)에서는 직접 self에 참조하지 않는건가?
아니면 내부 외부 둘다 캡쳐리스트를 통해 강한순환참조에 대비해야하나?

+추가
메모리 누수는 1번(캡쳐리스트의 missing) 때문에 발생했습니다,, 다만 ARC와 캡쳐리스트, 특히 중첩 클로저에서 어떻게 해야할까에 대한 고민 정리로 남겨두었습니다ㅠㅠ

 

✏️ 해결책

그에 대한 해결책은 스택오버플로우에서 확인할 수 있었습니다.

1. 외부 클로저에서 weak로 캡쳐리스트를 설정한 경우, 내부 클로저에서는 해당 캡쳐리스트를 사용하면 된다.
즉,, 외부에서 weak로 캡쳐했다면 내부에서는 따로 캡쳐할 필요가 없다.

2. 만약 내부 클로저에서만 캡쳐리스트를 설정한 경우, 이는 에러를 발생시킨다(강한순환참조가 발생한다).
많이 하는 실수라고 합니다. 저도 내부에서만 접근하니까 외부에서는 안해줘도 된다고 생각했어요;;
외부에서 캡쳐를 안하면, Strong하게 참조한다고 합니다...

// EX2_B
fn {
  fn2 { [weak self] in
    self.foo()
  }
}
// fn retains self (this is a common, hard-to-spot mistake)

출처: https://stackoverflow.com/questions/38739129/do-capture-lists-of-inner-closures-need-to-redeclare-self-as-weak-or-unowne/62352667#62352667

+추가
외부 클로저에서 참조를 안하는 경우에는 괜찮은 듯 합니다! 직접적으로 객체에 참조하는게 아니기 때문에 Reference Count가 올라갈 일이 없죠!

3. 외부 클로저에서 weak로 캡쳐한 객체를 옵셔널 바인딩 해버리면, 내부에서 다시 weak로 캡쳐해야한다.
저는 습관적으로 weak으로 캡쳐한 객체를 옵셔널 바인딩해주고 사용해왔는데요,,

bbb.closure { [weak self] in
	guard let self = self else { return }
    self.aaa = "aaa"
}

이런식으로, self를 옵셔널 체이닝 안하고 사용하는 방식인데, 이게 결국 self를 strong하게 다시 참조하는 거라고 하네요,,?!

https://yagom.net/forums/topic/closure-capture-capture-list/
야곰닷넷에서 잘 정리해주신 글을 참고해서 정리해보면,,

weak로 참조하던 인스턴스가 해제되면, 캡쳐당한 객체도 자동으로 nil을 넣어주면서 같이 해제되게 하는건데,
옵셔널 바인딩을 하게되면 해당 블록에서는 바인딩시 nil일 경우 알아서 해제되고 수행 종료하고 알아서 다 하겠지만...!
만약 그게 중첩 클로저의 외부였고, 내부 클로저에서 옵셔널 바인딩한 self를 사용한다면, Strong한 참조가 발생합니다.

3번에서 특히 캡쳐리스트, ARC에 대해서 다시 생각해보게 된 것 같아요.

[weak self] 를 왜 하는건지, weak로 참조할 경우 ARC는 어떻게 동작하고, 메모리에서 해제되면 캡쳐한 인스턴스는 어떻게 되는지, weak 타입을 옵셔널 바인딩하는 것은 무엇을 의미하는지,,,

한번 제대로 생각해보게 된 계기가 되었습니다.

테스트

ClassDetailViewController가 메모리에 추가되고 해제되는 것을 확인할 수 있습니다.
deinit 또한 인스턴스가 메모리에서 해제됨에 따라 정상적으로 호출됩니다.

개선된 코드

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        switch indexPath.section {
        case 0:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: DetailImageCell.identifier, for: indexPath) as? DetailImageCell else {
                return UITableViewCell()
            }
            cell.delegate = self
            classItem.fetchedImages { [weak self] images in // 외부 클로저에서 weak 캡쳐 걸기
                DispatchQueue.main.async {
                    guard let self = self else { return }
                    self.activityIndicator.stopAnimating()
                    cell.configureWith(images: images)
                }
            }
            return cell
        case 1:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: DetailUserCell.identifier, for: indexPath) as? DetailUserCell else {
                return UITableViewCell()
            }
            cell.configure(with: classItem.writer) { [weak self] in // 콜백에서 인스턴스에 접근할 경우 캡쳐리스트로 RC 관리하기
                self?.navigationController?.pushViewController(ProfileDetailViewController(user: $0), animated: true)
            }
            return cell

결론

  1. 콜백, 비동기 클로저에서 다른 인스턴스를 참조할 경우, 강한순환참조가 발생하지 않도록 weak 하게 캡쳐리스트를 지정하자.
  2. 외부 클로저에서 이미 캡쳐한 경우, 내부 클로저도 동일하게 해당 인스턴스를 참조할 수 있다.(캡쳐된)
  3. weak로 지정한 인스턴스는 옵셔널 타입이고, 이를 옵셔널 바인딩할 경우 바인딩한 인스턴스는 Strong 한 상태가 된다.
    옵셔널 바인딩한 인스턴스를 내부 클로저에서 참조할 경우 Strong이므로 Reference Count가 증가한다.
  4. 강한순환참조를 고려하지 않으면, 메모리 누수가 발생할 수 있다. 성능은 물론 앱이 강제 종료될 수 있다.

참고한 내용

https://stackoverflow.com/questions/38739129/do-capture-lists-of-inner-closures-need-to-redeclare-self-as-weak-or-unowne/62352667#62352667
https://yagom.net/forums/topic/closure-capture-capture-list/
https://zeddios.tistory.com/522
https://seizze.github.io/2019/12/20/iOS-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%9C%AF%EC%96%B4%EB%B3%B4%EA%B8%B0,-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%9D%B4%EC%8A%88-%EB%94%94%EB%B2%84%EA%B9%85%ED%95%98%EA%B8%B0,-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%A6%AD-%EC%B0%BE%EA%B8%B0.html

profile
iOS dev / Japanese with Computer Science

0개의 댓글