project/개발 업무
iOS Combine을 활용해 로그인 상태 관리 기능 구현
lgvv
2024. 11. 27. 00:12
iOS Combine을 활용해 로그인 상태 관리 기능 구현
멀티캐스트 딜리게이트 패턴을 활용해 구현했던 코드를 개선하고자 함.
- 멀티캐스트 딜리게이트 패턴 활용한 로그인 상태 관리 구현 : https://rldd.tistory.com/706
- Combine을 활용해 로그인 상태 관리 기능 구현 : https://rldd.tistory.com/707
목차
- 아이디어
- 데모 샘플 구현 코드
- 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를 채택해서 간편하게 사용 가능
- 멀티캐스트 딜리게이트 패턴에 비해 코드 복잡도가 낮아짐
단점
- 아직까지는 잘 모르겠음
(참고)