김경록의 앱 개발 여정

[Swift] coordinator 패턴 사용시 memory leak 주의점 본문

TIL

[Swift] coordinator 패턴 사용시 memory leak 주의점

Kim Roks 2025. 1. 8. 19:44

코디네이터 패턴은 iOS 애플리케이션에서 화면 전환 및 흐름 제어를 관리하는 데 매우 유용한 패턴입니다.
다양한 형태로 구현할 수 있지만, 여기서는 제가 사용하는 형태를 공유하고, 코디네이터 패턴을 구현하면서 발생했던 실수와 그에 따른 주의사항을 정리해 보았습니다.

코디네이터의 기본 형태

public protocol Coordinator: AnyObject {
    var parentCoordinator: Coordinator? { get set }
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }
    
    func start()
    func didFinish()
}

위와 같은 구조를 바탕으로 코디네이터 간의 부모-자식 관계를 정의하고, 화면 흐름을 명확히 제어할 수 있습니다.

코디네이터 패턴 사용 시 주의해야 할 점

1. 뷰 컨트롤러는 코디네이터를 약한 참조로 가져야 한다

아래는 잘못된 구현 예입니다:

final class TestViewController: UIViewController {
    let coordinator = DefaultTestCoordinator()
}

이 코드에서 TestViewController는 DefaultTestCoordinator(구현체)를 강한 참조로 가지고 있습니다. 이는 두 가지 문제를 일으킬 수 있습니다:

메모리 누수: TestViewController와 DefaultTestCoordinator 간에 강한 참조 순환이 발생하여 메모리에서 해제되지 않습니다.

구조적 문제: 뷰 컨트롤러가 코디네이터 구현체에 의존하고 있어 의존성 역전 원칙(DIP)을 위배합니다.
아래는 개선된 코드입니다.

// 코디네이터 프로토콜
protocol TestCoordinator: Coordinator {}

// 코디네이터 구현체
final class DefaultTestCoordinator: TestCoordinator {
    // 코디네이터 구현...
}

// 뷰 컨트롤러
final class TestViewController: UIViewController {
    // 구현체가 아닌 추상체에 약한 참조로 의존
    weak var coordinator: TestCoordinator?
}

추상화 의존: TestViewController는 DefaultTestCoordinator 구현체가 아닌 TestCoordinator 프로토콜에 의존합니다.
약한 참조: weak 키워드를 사용하여 강한 참조 순환을 방지합니다.

2. 뷰 컨트롤러 인스턴스 변수 선언 시 주의점

코디네이터의 start() 메서드에서 뷰 컨트롤러를 생성하는 방법 또한 주의가 필요합니다.

잘못된 예시

final class DefaultTestCoordinator: TestCoordinator {
    let testViewController = TestViewController()
    
    func start() {
        testViewController.coordinator = self
        navigationController.pushViewController(testViewController, animated: true)
    }
}

문제점:
testViewController가 DefaultTestCoordinator의 전역 변수로 선언되어 있습니다.
여러번 사용되는 경우가 있었고, 코드의 중복을 줄이기 위해 무의식적으로 해당 방식을 선택한 적이 있습니다.
이 경우, 뷰 컨트롤러가 pop되더라도 여전히 DefaultTestCoordinator에 의해 강한 참조가 유지되므로 메모리에서 해제되지 않습니다.
올바른 개선 방법
1. 뷰 컨트롤러를 지역 변수로 생성

final class DefaultTestCoordinator: TestCoordinator {
    func start() {
        let testViewController = TestViewController()
        testViewController.coordinator = self
        navigationController.pushViewController(testViewController, animated: true)
    }
}

testViewController는 start() 메서드 내에서만 존재하며, 뷰 컨트롤러가 pop되면 자동으로 메모리에서 해제됩니다.

  1. 옵셔널 타입으로 선언 후 초기화
final class DefaultTestCoordinator: TestCoordinator {
    var testViewController: TestViewController?

    func start() {
        let testViewController = TestViewController()
        testViewController.coordinator = self
        navigationController.pushViewController(testViewController, animated: true)
        self.testViewController = testViewController
    }

    func didFinish() {
        testViewController = nil
    }
}

장점: testViewController를 코디네이터의 속성으로 저장해도, didFinish()에서 명시적으로 nil로 설정하여 강한 참조를 끊을 수 있습니다.

마치며..

뷰 컨트롤러는 코디네이터를 weak 참조로 가져야 한다
이를 통해 강한 참조 순환을 방지하고, 의존성 역전 원칙을 준수할 수 있습니다.

뷰 컨트롤러 인스턴스 생성은 지역 변수 또는 관리 가능한 방식으로 처리해야 한다
전역 변수로 뷰 컨트롤러를 선언하면 메모리 누수가 발생할 수 있으므로 지역 변수로 선언하거나, 필요 시 옵셔널로 선언하여 수동으로 참조를 해제합니다.

메모리 누수를 예방하는 습관을 들이자
메모리 디버거를 사용하여 참조 관계를 주기적으로 점검하고, 코디네이터와 뷰 컨트롤러 간 강한 참조가 없는지 확인하는 것이 중요합니다.

사실 모두 기본적인 강한 순환 참조의 예시지만,
이러한 점을 준수하면 더욱 안전하고 효율적인 코디네이터 패턴을 구현할 수 있습니다! 😊