project/개발 업무

iOS Combine을 활용해 로그인 상태 관리 기능 구현

lgvv 2024. 11. 27. 00:12

iOS Combine을 활용해 로그인 상태 관리 기능 구현

 

멀티캐스트 딜리게이트 패턴을 활용해 구현했던 코드를 개선하고자 함.

 

MulticastDelegateExample.zip
0.15MB

 


목차

  • 아이디어
  • 데모 샘플 구현 코드
    • Combine 기반 기능 모듈 구현
    • 로그인 기능 구현체
    • 사용 예제
  • 구현 후 분석


아이디어

이전에 키보드 상태를 감지하기 위해서 Combine을 활용한 적이 있었는데, 이번에도 해당 형태로 구현하면 별도의 주입 없이 더 간편하게 사용할 수 있다고 생각

 

 

데모 샘플 구현 코드 (Combine 기반 기능 모듈 구현)

멀티캐스트 딜리게이트 패턴을 Combine 기반으로 수정

 

import UIKit
import Combine

enum NotificationType {
    case login
    case logout
    
    var name: NSNotification.Name {
        switch self {
        case .login: return NSNotification.Name("login")
        case .logout: return NSNotification.Name("logout")
        }
    }
}

/// 이벤트를 받기 위한 Delegate
public protocol AuthenticationDelegate {
    var userDidLogin: AnyPublisher<User, Never> { get }
    var userDidLogout: AnyPublisher<Void, Never> { get }
}

public extension AuthenticationDelegate {
    var userDidLogin: AnyPublisher<User, Never> {
        NotificationCenter.default
            .publisher(for: NotificationType.login.name)
            .compactMap { notification -> User? in
                if let user = notification.object as? User {
                    return user
                } else {
                    return nil
                }
            }
            .eraseToAnyPublisher()
    }
    
    var userDidLogout: AnyPublisher<Void, Never> {
        NotificationCenter.default
            .publisher(for: NotificationType.logout.name)
            .map { _ in () }
            .eraseToAnyPublisher()
    }
}

 

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

Swift 6를 기준으로 구현했기 때문에 동시성과 관련한 오류를 해결하고자 함

  • Task의 내부의 캡쳐되는 값을 inout으로 클로저 내부에서 상태 값을 변경할 수가 없음
  • 외부에 값을 전달해서 처리하기 위해 Action에 fetchLogin(User)를 추가

 

 

import SwiftUI
import Combine

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

public struct DemoCombineExampleView: View {
    @StateObject var viewModel = DemoCombineExampleViewModel()
    
    public init() {}
    
    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
    
    init() {
        self.featureC = .init()
        self.featureD = .init()
        
        super.init(nibName: nil, bundle: nil)
        
        configureUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - UIComponents
    
    private let combineDemoView = UIHostingController(rootView: DemoCombineExampleView()).view!
    
    private func configureUI() {
        view.addSubview(combineDemoView)
        combineDemoView.backgroundColor = .red.withAlphaComponent(0.5)
        combineDemoView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            combineDemoView.topAnchor.constraint(equalTo: view.topAnchor),
            combineDemoView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            combineDemoView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            combineDemoView.bottomAnchor.constraint(equalTo: view.centerYAnchor)
        ])        
    }
}

// MARK: - Feature Module

class FeatureC: AuthenticationDelegate {
    private var cancellables: Set<AnyCancellable>

    private func bind() {
        print("FeatureC: bind")
        
        userDidLogin.sink { completion in
            print("FeatureC uerrDidLogin", completion)
        } receiveValue: { user in
            print("FeatureC: uerrDidLogin: \(user)")
        }.store(in: &cancellables)
        
        userDidLogout.sink { completion in
            print("FeatureC userDidLogout", completion)
        } receiveValue: {
            print("FeatureC userDidLogout")
        }.store(in: &cancellables)
    }
    
    init() {
        print("FeatureC: init")
        cancellables = .init()
        
        bind()
    }
    
    deinit {
        print("FeatureC: deinit")
    }
}

class FeatureD: AuthenticationDelegate {
    private var cancellables: Set<AnyCancellable>

    private func bind() {
        userDidLogin.sink { completion in
            print("FeatureD uerrDidLogin", completion)
        } receiveValue: { user in
            print("FeatureD: uerrDidLogin: \(user)")
        }.store(in: &cancellables)
        
        userDidLogout.sink { completion in
            print("FeatureD userDidLogout", completion)
        } receiveValue: {
            print("FeatureD userDidLogout")
        }.store(in: &cancellables)
    }
    
    init() {
        print("FeatureD: init")
        cancellables = .init()
        
        bind()
    }
    
    deinit {
        print("FeatureD: deinit")
    }
}

결과

 

 

구현 후 분석

장점

  • 클라이언트 코드가 더 깔끔해짐
  • source of truth를 클라이언트 영역에서 관리할 필요 없이 필요한 영역에서 Delegate를 채택해서 간편하게 사용 가능
  • 멀티캐스트 딜리게이트 패턴에 비해 코드 복잡도가 낮아짐

단점

  • 아직까지는 잘 모르겠음

 

 

 

 

(참고)

https://rldd.tistory.com/596

 

SwiftUI keyboard 이벤트 감지하기

SwiftUI keyboard 이벤트 감지하기앱 개발에 있어서 키보드 상태에 따라서 뷰의 다른 컴포넌트들의 높이가 조정되는 등 키보드와 관련해서는 꽤나 까다로움 UIKit을 사용한다면  - iOS 15 이상: keybo

rldd.tistory.com

https://rldd.tistory.com/689

 

Swift Mixin and Trait

Swift Mixin and Trait iOS 프로그래밍에서 주로 사용되는 언어는 Swift로 다중 상속을 지원하지 않음.Swift에서는 인터페이스(Interface)를 프로토콜(protocol)로 사용하고 있어서 프로토콜이라는 용어와 인

rldd.tistory.com