개발콩블로그

[트러블슈팅] 중첩 클로저로 인한 순환 참조 및 메모리 누수 해결하기 본문

트러블슈팅

[트러블슈팅] 중첩 클로저로 인한 순환 참조 및 메모리 누수 해결하기

devBean 2025. 2. 21. 21:50

안녕하세요! 개발콩입니다.

오늘은중첩 클로저로 인한 순환 참조되어 메모리 누수가 발생되는 문제를 해결한 글에 대해서 작성하고자 합니다.

 

 

문제 상황

output.updatePersonData
    .bind(to: tableView.rx.items(
        cellIdentifier: PersonTableViewCell.identifier,
        cellType: PersonTableViewCell.self
    )) { (row, element, cell) in
        cell.configureView(element)
        cell.detailButton.rx.tap
            .map { row }
            .bind(with: self) { owner, index in
                owner.detailButtonDidTapRelay.accept(index)
            }
            .disposed(by: cell.disposeBag)
    }
    .disposed(by: disposeBag)

 

RxSwift를 사용하는 도중 중첩 클로저를 사용하게 되었습니다.

외부 클로저에서는 self를 참조하지 않고, 내부 클로저에서는 self를 참조해야하는 상황이였습니다.

따라서 내부 클로저에서만 약한 참조를 통해서 순환참조의 문제를 해결하고자 위와 같이 코드를 작성했습니다.

 

하지만, 해당 ViewController가 해제되지 않고 메모리 누수가 발생하고 있었습니다.

 

 

 

관련 지식에 관하여 다시 학습하기

클로저의 캡처란?

var age = 3
let closure = { print(age) }

 

클로저 내부에서 외부 변수인 age를 사용하기 때문에 age를 클로저 내부적으로 저장하고 있습니다.

이것을 age가 캡처되었다라고 표현합니다.

 

var age = 3

let closure = {
    print(age)
}

age = 4
closure() // 4

클로저는 값을 캡쳐할 때 타입에 관계 없이 무조건 참조 타입으로 캡처합니다.

Int 타입(값 타입)을 클로저에서 캡처한 이후에 age를 변경했지만 그것이 클로저에 반영이 됩니다.

 

 

그렇기 때문에 값 타입의 value를 복사해서 사용하고 싶을 때 캡처 리스트를 활용할 수 있습니다.

var age = 3

let closure = { [age] in
    print(age)
    age = 4 // 불가능
}

age = 4
closure() // 3

캡처되는 값은 값이 복사되었기 때문에 이전에 반영된 값을 잘 표현하고 있습니다.

또한, 캡처되는 값은 상수로 캡처되어 변경이 불가능합니다.

 

참조 타입에 대해서 캡처 리스트를 활용하면 참조 타입으로 캡처됩니다.

따라서 우리는 순환 참조의 문제를 해결하기 위해 weak, unowned 키워드를 활용하여 캡처하여 해결합니다.

 

 

원인 분석

firstClosure {
    secondClosure { [weak self] in
        self?.doSomeThing()
    }
}

 

내부 클로저에서 [weak self]를 사용하게 된다면 해당 ViewController에 ARC가 증가하지 않을 것이라고 판단했습니다.

 

하지만 내부 클로저에서 self를 캡처할 때에는 외부 클로저에서 해당 참조를 캡처하게 된다는 것 입니다.

 

 

내부 클로저에서 self를 캡처하기 위해 외부 클로저의 self 캡처합니다.

이것은 즉 외부 클로저가 self에 대해서 암묵적으로 강하게 캡처한다는 의미입니다.

 

따라서 위의 코드는 아래의 코드와 동일한 상황인 것 입니다.

firstClosure {
    self.someThingElse()
    secondClosure { [weak self] in
        self?.doSomeThing()
    }
}

 

 

해결 방안

해결 방법은 간단합니다. 

self에 대한 캡처리스트의 위치를 외부 클로저로 이동시키면 됩니다.

firstClosure { [weak self] in
    secondClosure {
        self?.doSomeThing()
    }
}

 

 

해결된 코드

output.updatePersonData
    .bind(to: tableView.rx.items(
        cellIdentifier: PersonTableViewCell.identifier,
        cellType: PersonTableViewCell.self
    )) { [weak self] (row, element, cell) in
        cell.configureView(element)
        cell.detailButton.rx.tap
            .map { row }
            .bind{ index in
                self?.detailButtonDidTapRelay.accept(index)
            }
            .disposed(by: cell.disposeBag)
    }
    .disposed(by: disposeBag)

 

 

 

추가로 학습한 내용과 주의할 점

우리는 아래와 같은 코드로 문제를 해결했습니다.

firstClosure { [weak self] in
    secondClosure {
        self?.doSomeThing()
    }
}

 

 

하지만 우리는 [weak self]를 사용할 때 습관적으로 아래와 같은 코드를 작성합니다.

firstClosure { [weak self] in
    guard let self else { return }
    secondClosure {
        self.doSomeThing()
    }
}

 

이렇게 코드를 변경하게 된다면 과연 순환 참조의 문제를 해결할 수 있을까요?

그렇지 않습니다.

 

guard let self else { return }

위의 코드는 self를 다시 강한 참조로 사용하겠다는 의미입니다.

 

우리가 스코프 내부에서 5개의 작업을 실행하던 중 2개의 작업이 진행되고 있다가

self가 해제가 된 경우 뒤의 3개의 작업을 수행하지 못하게 됩니다.

따라서 우리는 위의 코드를 클로저 실행 중에 self가 해제되지 않게하기 위해서 사용해야합니다.

 

혹은 self가 이미 해제된 경우라면 해당 스코프를 작동하지 않게 하기 위해 사용합니다.

 

즉, self를 사용하는 클로저 스코프 내에서 작업이 종료된다는 것을 보장할 때만 사용해야합니다.

 

하지만 우리의 코드에서는 강한 참조된 self가 secondClosure가 escaping closure라면

계속해서 self를 강하게 참조하고 있는 상태가 됩니다.

따라서 위와 같은 경우에는 self를 다시 강한 참조로 사용하는 코드를 사용하지 않는 것이 좋습니다.

 

혹은 아래와 같이 수정할 수 있습니다.

firstClosure { [weak self] in
    guard let self else { return }
    secondClosure { [weak self] in
        self?.doSomeThing()
    }
}