project/개발 업무
iOS 멀티캐스트 딜리게이트 패턴 활용한 로그인 상태 관리 구현
lgvv
2024. 11. 27. 00:08
iOS 멀티캐스트 딜리게이트 패턴 활용한 로그인 상태 관리 구현
멀티캐스트 패턴을 활용해 로그인 기능을 구현
- 멀티캐스트 딜리게이트 패턴 활용한 로그인 상태 관리 구현 : https://rldd.tistory.com/706
- Combine을 활용해 로그인 상태 관리 기능 구현 : https://rldd.tistory.com/707
목차
- 배경
- 고려사항
- 데모 샘플 구현 코드
- 멀티캐스트 기능 구현 모듈
- 로그인 기능 구현체
- 이벤트 수신(사용 예제)
- 구현 후 분석
배경
앱 내에서 카카오, 네이버, 페이스북 등 여러 소셜 로그인을 지원하고 있음.
로그인이 되지 않은 상태에서도 로그인 페이지가 나타나는 것이 아닌, 각 페이지마다 별도의 정책이 적용되며 다른 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 활용해서 클라이언트에서 더 손쉽게 사용할 수 있을 것 같음
(참고)