project/개발 업무

iOS 멀티캐스트 딜리게이트 패턴 활용한 로그인 상태 관리 구현

lgvv 2024. 11. 27. 00:08

iOS 멀티캐스트 딜리게이트 패턴 활용한 로그인 상태 관리 구현

 

멀티캐스트 패턴을 활용해 로그인 기능을 구현

 

 

MulticastDelegateExample.zip
0.15MB

 

 

목차

  • 배경
  • 고려사항
  • 데모 샘플 구현 코드
    • 멀티캐스트 기능 구현 모듈
    • 로그인 기능 구현체
    • 이벤트 수신(사용 예제)
  • 구현 후 분석

 

배경

 

앱 내에서 카카오, 네이버, 페이스북 등 여러 소셜 로그인을 지원하고 있음.

로그인이 되지 않은 상태에서도 로그인 페이지가 나타나는 것이 아닌, 각 페이지마다 별도의 정책이 적용되며 다른 UI가 나타남.

웹뷰, 웹 등 로그아웃 버튼이 아니더라도 로그아웃 처리시킬 수 있음.

기존 구현은 NotificationCenter를 통해서 addObserver 하는 형태로 변경사항을 수신받은 객체가 자신에게 맞는 행동을 취함

 

고려사항

개발하기 전 고려사항 정리

  • 앱을 사용하는 도중, 웹에서 기기 로그아웃을 진행할 수 있음
    • 앱을 사용하는 페이지에 위치에서 로그아웃 상태로 변경되며, 이에 따라 로그아웃 상태의 정책이 적용
    • UI가 변경되어 나타날 수 있음
  • 로그인을 실제로 구현해 사용하는 곳에서 구현하는 인터페이스와 로그인 상태만 수신받아 사용하는 인터페이스는 다름.
    • 필요하지 않는 메서드는 사용할 수 없게 프로토콜로 분리
  • 각 페이지에 맞는 정책 자유도를 위해 각 Feature마다 수신해서 스스로 처리할 수 있도록
  • Swift 6 적용
  • 객체지향 잘 지키면서 만들어 볼 것 (클래스 다이어그램 문서화)

 

데모 샘플 코드 구현 (멀티 캐스트 기능 구현 모듈)

데모 기능을 멀티캐스트 딜리게이트 패턴을 활용해 샘플 형태로 구현

import UIKit
import SwiftUI

/// 멀티캐스트 딜리게이트 패턴을 위한 관리 객체
final class MulticastDelegate<T> {
    
    private var delegates = NSHashTable<AnyObject>.weakObjects()

    func addDelegate(_ delegate: T) {
        delegates.add(delegate as AnyObject)
    }

    func removeDelegate(_ delegate: T) {
        delegates.remove(delegate as AnyObject)
    }

    func invokeDelegates(_ closure: (T) -> Void) {
        for delegate in delegates.allObjects {
            if let delegate = delegate as? T {
                closure(delegate)
            }
        }
    }
}

/// 로그인과 관련한 이벤트를 전달할 Delegate
public protocol AuthEventDelegate: AnyObject {
    func userDidLogin(user: User)
    func userDidLogout()
}

/// 이벤트를 수신받기 위해 Delegate 등록 및 삭제를 위한 인터페이스
public protocol AuthDispatchSystem {
    func addDelegate(_ delegate: AuthEventDelegate)
    func removeDelegate(_ delegate: AuthEventDelegate)
}

/// 로그인과 관련한 기능을 `구현`하는 모듈에서 사용할 인터페이스
protocol AuthCommand {
    func configure(authenticationService: AuthenticationService)
    
    func login(id: String, password: String) async -> Result<User, Error>
    func logout() async -> Result<Void, Error>
}

/// Auth와 관련한 실제 구현체
public final class DefaultAuthDispatchSystem: AuthCommand, AuthDispatchSystem {
    private let delegates = MulticastDelegate<AuthEventDelegate>()
    
    private let authenticationService: AuthenticationService?
    
    public init(authenticationService: AuthenticationService? = nil) {
        self.authenticationService = authenticationService
    }

    public func addDelegate(_ delegate: AuthEventDelegate) {
        delegates.addDelegate(delegate)
    }
    
    public func removeDelegate(_ delegate: AuthEventDelegate) {
        delegates.removeDelegate(delegate)
    }
    
    public func login(id: String, password: String) async -> Result<User, Error> {
        guard let authenticationService else { return .success(.init(platform: "임시 에러")) }
        
        let result = await authenticationService.login(id: id, password: password)
        
        if let user = try? result.get() {
            delegates.invokeDelegates {
                $0.userDidLogin(user: user)
            }
        }
        
        return result
    }
    
    public func logout() async -> Result<Void, Error> {
        guard let authenticationService else { return .success(()) }
        
        let result = await authenticationService.logout()
        
        if (try? result.get()) != nil {
            delegates.invokeDelegates { $0.userDidLogout() }
        }
        
        return result
    }
}

/// 임시로 사용할 모델
public struct User: @unchecked Sendable {
    var platform: String
}

 

 

데모 샘플 코드 구현 (로그인 기능 구현체)

로그인 기능을 실제로 구현한 UI

  • 여러 소셜 로그인 및 로그아웃 기능을 제공하며 테스트
import UIKit
import SwiftUI
import Combine

@MainActor
public final class MulticastDemoViewModel: ObservableObject {
    
    enum Action {
        case kakao
        case naver
        case facebook
        
        case fetchLogin(User)
        
        case logout
        
        case fetchLogout
    }
   
    func send(action: Action) {
        switch action {
        case .kakao:
            authDispatchSystem?.configure(authenticationService: KakaoAuth())
            login()
            
        case .naver:
            authDispatchSystem?.configure(authenticationService: NaverAuth())
            login()
            
        case .facebook:
            authDispatchSystem?.configure(authenticationService: FacebookAuth())
            login()
        
        case let .fetchLogin(user):
            print("Demo App Update User State: ", user)
            break
            
        case .logout:
            logout()
            
        case .fetchLogout:
            self.authDispatchSystem = nil
            
        }
    }
    
    private func login() {
        Task { [authDispatchSystem] in
            guard let authDispatchSystem else { return }
            let result = await authDispatchSystem.login(id: "id", password: "password")
            switch result {
            case let .success(user):
                send(action: .fetchLogin(user))
                
            case .failure:
                break
            }
            
        }
    }
    
    private func logout() {
        Task { [authDispatchSystem] in
            guard let authDispatchSystem else { return }
            let result = await authDispatchSystem.logout()
            switch result {
            case .success:
                send(action: .fetchLogout)
            case .failure:
                break
            }
        }
    }
    
    private var _authDispatchSystem: AuthDispatchSystem?
    private var authDispatchSystem: AuthCommand? {
        get { _authDispatchSystem as? AuthCommand }
        set { _authDispatchSystem = newValue as? AuthDispatchSystem }
    }
    
    public init(authDispatchSystem: AuthDispatchSystem?) {
        self._authDispatchSystem = authDispatchSystem
    }
}

public struct MulticastDemoView: View {
    @StateObject var viewModel: MulticastDemoViewModel
    
    public init(viewModel: MulticastDemoViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }
    
    public var body: some View {
        VStack(spacing: 10) {
            Button("로그인 - 카카오") {
                viewModel.send(action: .kakao)
            }
            
            Button("로그인 - 네이버") {
                viewModel.send(action: .naver)
            }
            
            Button("로그인 - 페이스북") {
                viewModel.send(action: .facebook)
            }
            
            Button("로그아웃") {
                viewModel.send(action: .logout)
            }
        }
    }
}

 

 

데모 샘플 코드 구현 (사용 예제)

로그인 기능을 실제로 구현한 UI

  • 여러 소셜 로그인 및 로그아웃 기능을 제공하며 테스트
import UIKit
import SwiftUI
import Combine
import AuthKit

final class ViewController: UIViewController {
    
    private let featureA: FeatureA
    private let featureB: FeatureB
    
    private let authDispatchSystem: AuthDispatchSystem
    
    init(authDispatchSystem: AuthDispatchSystem) {
        self.authDispatchSystem = authDispatchSystem
        
        self.featureA = .init(authDispatchSystem: authDispatchSystem)
        self.featureB = .init(authDispatchSystem: authDispatchSystem)
        
        super.init(nibName: nil, bundle: nil)
        
        configureUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - UIComponents
    
    private lazy var mulicastExampleView: UIView = {
        let viewModel = MulticastDemoViewModel(authDispatchSystem: authDispatchSystem)
        let rootView = MulticastDemoView(viewModel: viewModel)
        let hostingController = UIHostingController(rootView: rootView)
        return hostingController.view!
    }()
    
    private func configureUI() {
        view.addSubview(mulicastExampleView)
        mulicastExampleView.backgroundColor = .green.withAlphaComponent(0.5)
        mulicastExampleView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            mulicastExampleView.topAnchor.constraint(equalTo: view.centerYAnchor),
            mulicastExampleView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            mulicastExampleView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            mulicastExampleView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        
    }
}

// MARK: - Feature Module

class FeatureA: AuthEventDelegate {
    private let authDispatchSystem: AuthDispatchSystem
    
    func userDidLogin(user: AuthKit.User) {
        print("FeatureA uerrDidLogin", user)
    }
    
    func userDidLogout() {
        print("FeatureB userDidLogout")
    }
    
    init(authDispatchSystem: AuthDispatchSystem) {
        print("FeatureA: init")
        
        self.authDispatchSystem = authDispatchSystem
        authDispatchSystem.addDelegate(self)
    }
    
    deinit {
        print("FeatureA: deinit")
        
        authDispatchSystem.removeDelegate(self)
    }
}

class FeatureB: AuthEventDelegate {
    private let authDispatchSystem: AuthDispatchSystem
    
    func userDidLogin(user: AuthKit.User) {
        print("FeatureB uerrDidLogin", user)
    }
    
    func userDidLogout() {
        print("FeatureB userDidLogout")
    }
    
    init(authDispatchSystem: AuthDispatchSystem) {
        print("FeatureB: init")
        
        self.authDispatchSystem = authDispatchSystem
        authDispatchSystem.addDelegate(self)
    }
    
    deinit {
        print("FeatureB: deinit")
        authDispatchSystem.removeDelegate(self)
    }
}

 

이벤트 처리 후 응답 받음

 

구현 후 분석

장점

  • 기존 addObserver 및 removeObserver 하는 형태에서 authDispatchSystem을 통해 add와 remove만 해주니 더 깔끔해짐

단점

  • source of truth를 위해 DefaultAuthDispatchSystem 객체를 최상단부터 필요한 Feature마다 주입해주어야 함
  • 의존성 역전을 통해 다른 모듈에 해당 객체를 만들고 거기서 참조할 수도 있지만, 사람이 인지하기에 의존성이 복잡해짐
  • 로그인 동작을 하는 AuthCommand랑 딜리게이트를 등록하는 AuthDispatchSystem를 인터페이스를 분리해 두었는데 내부에 프로퍼티를 두어 캐스팅하는 로직이 존재하여 수정에 취약
  • 결국 add랑 remove하고 있어서 개발자 오류 발생 가능성 존재

추가 개선 포인트

  • Combine + NotificationCenter 활용해서 클라이언트에서 더 손쉽게 사용할 수 있을 것 같음

 

(참고)

https://rldd.tistory.com/447

 

Swift 디자인패턴 Multicast Delegate Pattern (멀티캐스트 딜리게이트 패턴)

Swift 디자인패턴 Multicast Delegate Pattern (멀티캐스트 딜리게이트 패턴) 멀티캐스트 델리게이트 패턴은 델리게이트 패턴을 확장하여, 하나의 객체가 여러 개의 델리게이트에게 알림을 전송. 히스

rldd.tistory.com