Today I Learn

Multicast Delegate Pattern을 활용한 위치 정보 공유 (with NSHashTable)

Goniii 2025. 5. 2. 09:21

클린 아키텍처 기반 iOS 프로젝트에서의 적용기

문제 상황

이번 프로젝트에서는 홈 화면에서는 현재 위치 기반으로 정보를 보여주고, 킥보드 등록 화면에서는 사용자의 위치를 기반으로 등록 주소를 설정하기 위해 홈 화면(HomeView)킥보드 등록 화면(RegisterView) 에서 동시에 사용자의 현재 위치 정보를 필요로 했다

iOS의 CLLocationManager는 보통 하나의 객체로 관리되고, 위치 업데이트를 받기 위해 CLLocationManagerDelegate를 채택하게 된다

클린 아키텍처 구조를 따르면서, CLLocationManager 관련 기능은 LocationManagerRepository라는 Repository 계층 내부에서 관리했다

(사실 클린 아키텍처에서 LocationManager를 굳이 레포지토리로 분리하는 게 맞는지는 의심 중이다)

 

 

기존 Delegate 패턴의 한계❗ 

사용자의 위치가 업데이트 될 때마다 ViewModel(HomeViewModel, RegisterViewModel)이 동시에 위치 정보를 받아야 하기 때문에 이를 딜리게이트 패턴을 이용해 레포지토리에서 ViewModel로 위치 정보를 전달해주도록 구현하려고 했지만, 기존 delegate 패턴은 단일 객체만을 지정할 수 있다는 문제가 있었다

locationManager.delegate = self

이렇게 지정하면 self에만 콜백이 오기 때문에, 하나의 ViewModel만 위치 정보를 받을 수 있고 나머지는 무시된다.

두 ViewModel이 각각 LocationManagerRepositoryDelegate를 채택하더라도 결국 마지막에 등록된 한 쪽만 콜백을 받는 구조였다.

 

해결 방법: Multicast Delegate Pattern

이 문제를 해결하기 위해 선택한 방법은 Multicast Delegate Pattern이다.

여러 객체에게 동시에 delegate 메서드를 호출하려면 단일 delegate가 아니라 delegate들을 배열처럼 모아놓고 순회하면서 각각 호출해주면 된다.

여기서 중요한 건 강한 참조로 리스트를 유지하면 메모리 누수가 생기기 때문에, NSHashTable<AnyObject>.weakObjects()를 사용해서 약한 참조(weak) 로 delegate들을 안전하게 관리한다는 점이다.

 

핵심 코드 요약

protocol LocationManagerRepositoryDelegate: AnyObject {
    func didUpdateLocation(_ location: CLLocation)
}

final class LocationManagerRepository: NSObject, LocationManagerRepositoryProtocol {
    private let locationManager: CLLocationManager
    private var delegates = NSHashTable<AnyObject>.weakObjects()

    init(locationManager: CLLocationManager) {
        self.locationManager = locationManager
        super.init()
    }

    func addDelegate(_ delegate: any LocationManagerRepositoryDelegate) {
        if !delegates.allObjects.contains(where: { $0 === delegate }) {
            delegates.add(delegate)
        }
    }
    
    func removeDelegate(_ delegate: LocationManagerRepositoryDelegate) {
         elegates.removeDelegate(delegate)
    }
}

extension LocationManagerRepository: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let currentLocation = locations.last else { return }

        for case let delegate as LocationManagerRepositoryDelegate in delegates.allObjects {
            delegate.didUpdateLocation(currentLocation)
        }
    }

이 방식 덕분에 HomeViewModel과 RegisterViewModel은 addDelegate(self)를 통해 위치 정보를 동시에 안전하게 받을 수 있다

 

NSHashTable<AnyObject>.weakObjects()란?

ARC 기반의 iOS에서 메모리 누수를 방지하면서 여러 객체를 참조할 수 있는 방법

  • NSHashTable은 중복 없이 객체를 저장하는 Set 비슷한 구조
  • .weakObjects()는 저장된 객체들을 weak 참조로 관리해서, 해당 객체가 메모리에서 해제되면 자동으로 리스트에서 제거됨
  • 단, AnyObject만 저장 가능하므로 클래스 타입만 사용 가능
  • 순서는 보장되지 않음

 

왜 사용하는가?

1. 메모리 누수 방지 (retain cycle 차단)

  • delegate들을 강하게 잡으면 ViewModel이나 ViewController가 해제되지 않아 누수 발생
  • weakObjects()를 쓰면 delegate가 해제될 때 자동으로 nil되고, 컬렉션에서도 빠져서 메모리 걱정 없이 쓸 수 있다.

2. 여러 delegate 등록 가능

  • 기존 delegate 패턴은 단일 객체만 연결 가능하지만, NSHashTable을 쓰면 여러 객체를 등록해서 동시에 이벤트를 보낼 수 있음.

 

중복 없이 저장하는데 왜 직접 비교(===)로 중복 체크를 하나?

NSHashTable은 ==가 아닌 hash 값 + isEqual(_:) 을 기준으로 중복을 판단 함

  • 기본적으로 NSObject의 isEqual 구현이 ==로 동작
  • 하지만 AnyObject로 delegate를 저장하고 비교할 때, 의도한 delegate가 정확히 같은 인스턴스인지 (===) 확인하려면 명시적으로 체크해야 한다
  • 특히 protocol type (any LocationManagerRepositoryDelegate)은 직접 비교가 까다롭기 때문에, NSHashTable 입장에서는 같은 인스턴스로 못 알아보는 경우가 있다고 한다

 

여러 객체에 동시에 이벤트를 전달하면 Notification랑 뭐가 다른가?

Notification과 Multicast Delegate 모두 한 객체가 여러 객체에게 이벤트를 전달해야 할 때 사용하는 대표적인 방법이지만, 의도, 사용 방식, 제어 수준, 타입 안전성 등이 다르다

  • Notification: 시스템 전역적인 이벤트 브로드캐스팅
  • Multicast Delegate: 앱 내부에서 여러 객체에게 정해진 프로토콜 방식으로 이벤트 전달
항목 Notification Multicast Delegate
기반 구조 옵저버 패턴 (Pub/Sub) delegate + weak list
타입 안전성 없음 (userInfo로 딕셔너리 전달) 있음 (컴파일 타임 프로토콜)
사용 범위 전역적으로 브로드캐스팅 명시적으로 등록한 객체에만
디버깅 난이도 누가 받는지 추적 어려움 등록된 객체가 명확함
메모리 관리 iOS 9 이상에서 자동 제거 일부 가능 NSHashTable로 weak 참조
사용 시점 시스템 이벤트, loosely coupled 상황 명확한 delegate 관계가 필요한 상황

 

 

 

https://developer.apple.com/documentation/foundation/nshashtable

https://developer.apple.com/documentation/foundation/nshashtable/weakobjects()

https://medium.com/@yury.buslovsky/multicast-delegate-to-the-rescue-b7f0092dddfd

728x90