순환참조 분석하기

Jisu·2023년 9월 7일
0

iOS

목록 보기
5/9

순환참조

두 객체가 각각 서로를 강한참조하고 있기 때문에 서로에 대한 참조가 해제되지 않아 메모리 누수가 발생하는 현상을 뜻한다.

말로만 하면 어떤 상황에서 발생할지 모른다. 바로 코드를 보자!

Men과 Women 클래스는 각각 여친과 남친을 옵셔널 타입으로 가지고 있다. (없을수도있으니까..)

tom과 andrea가 사귀었고 그에 따라 각 girlFriend와 boyFriend 자리에 서로를 할당해주었다. 바로 여기서 서로가 서로를 참조하는 순환 참조가 발생하는 것이다.

Case1 강한 순환 참조

불의의 사고로 tom이 죽게 되었고.. nil을 할당하게 되었다. 이 경우 tom이 nil이 되었음에도 Men 클래스에서 deinit이 호출되지 않는다. andrea의 남친으로 tom을 할당하면서 andrea도 Men 객체을 참조하고 있기 때문이다.

// MARK: Case1: 순환참조
tom = nil
print(andrea?.boyFriend) // Optional(__lldb_expr_7.Men)

이 경우 andrea의 boyFriend를 프린트해보면 이상한 문자가 optional로 찍히는걸 볼 수 있다... 망령이 남아있는 것 (Swift 디버깅 표현식이라고 한다)

Case2 약한 참조 - weak self

문제는 andrea의 boyFriend 변수 자리에 tom이 오기 때문에 boyFriend 변수에 weak 키워드를 주어 레퍼런스 카운트에서 제외되도록 만들었다.

final class Women: Human {
    var age: Int = 12
    var sex: Sex = .woman
    weak var boyFriend: Men?

    init(age: Int, sex: Sex) {
        self.age = age
        self.sex = sex
    }
    deinit {
        print("women deinit")
    }
}

// MARK: Case2: weak var
tom = nil // denit 호출
print(andrea?.boyFriend) // nil

이 경우에는 deinit도 정상적으로 호출되고 andrea에서 boyFriend에 접근했을 때도 nil이 나온다. tom이 정상적으로 메모리에서 할당해제된것을 볼 수 있다.

Case3 무소유 참조 - unowned self

그럼 이번에는 unowned 키워드를 사용해서 처리해보자.
unowned는 프로퍼티가 항상 값을 가지고 있다고 가정하는 예약어이다.

final class Women: Human {
    var age: Int = 12
    var sex: Sex = .woman
    unowned var boyFriend: Men?

    init(age: Int, sex: Sex) {
        self.age = age
        self.sex = sex
    }
    deinit {
        print("women deinit")
    }
}

// MARK: Case3: unowned var
tom = nil // denit 호출
print(andrea?.boyFriend) // run time error

참조 카운트를 올리기 때문에 정상적으로 deinit이 호출된다.
그런데 andrea.boyFriend에 접근하려 하면 런타임 에러가 발생한다. 왜냐면 boyFriend 값이 항상 있다고 가정했기 때문에 nil을 반환할 수 없기 때문이다. 때문에 런타임에러의 위험이 있는 unowned self는 굳이 쓸 필요가 없다고 생각한다.

자 여기까지는 누구나 검색하면 볼 수 있는 것들이었다.
조금 더 실제 코드에서 이것을 어떻게 고려하면 좋을지 분석해보자.

다음은 개인프로젝트 포항맛집의 ViewModel 코드 일부이다.

import SwiftUI
import Combine

final class FoodListVM: ObservableObject {

    @Published var foods: [Properties] = []
    @Published var isLoading: Bool = false
    
    ...
    
        URLSession.shared.dataTaskPublisher(for: request)
            .tryMap(handleOutput)
            .decode(type: NotionDTO.self, decoder: JSONDecoder())
            .map { decodedData in
                decodedData.results.map { $0.properties }
            }
            .receive(on: DispatchQueue.main)
            .sink { completion in
                self.isLoading = false
            } receiveValue: { [weak self] result in
                self?.foods = result
            }
            .store(in: &cancellables)

여기서 receiveValue 이후 부분에서 weak self를 사용한 모습이다.
클로저안에 클로저가 있기 때문에 self를 통해 이 객체 내의 프로퍼티를 사용하겠다고 명시해줘야한다.

클로저에서 이 객체의 프로퍼티를 사용하므로 Heap 영역에 저장된 FoodListVM 객체의 레퍼런스 카운트는 증가한다.

만약 이 ViewModel을 채택하는 View가 없어지고 유저가 다른 View로 넘어갔다고 해보자. 그러면 이 FoodListVM 클래스 자체는 메모리에서 없어지면서 객체의 레퍼런스 카운트가 1줄어들 것이다.
하지만 클로저에서 올려놓은 카운트 때문에 메모리에서 완전히 해제되지않을 것이고 메모리 누수가 발생할 것이다.

따라서 weak self를 통해 레퍼런스 카운트를 증가시키지 않도록 조치를 취해놓았다.

분석을 하고보니 깨달은 사실인데 여기서 FoodLisVM은 앱의 시작단계에서 탭뷰에서 인스턴스화 된다. 그래서 앱이 종료되기 전까지 메모리에서 해제되지는 않아 weak self가 크게 의미는 없는 것 같다.

profile
비즈니스에 관심많은 DevOps Engineer 장지수입니다.

0개의 댓글