Skip to content

How to Use: Combine CombineCocoa MVVM

Jin-won Choi edited this page Oct 4, 2022 · 2 revisions
  • Issue에 Noah(PJunyeong)가 작성한 글을 Wiki로 옮긴 글입니다. (issue #14)
  • 데이터 서비스 클래스 + 뷰 모델 + 뷰 ( 뷰 컨트롤러) 패턴의 MVVM 구현 완료했습니다
  • Combine 프레임워크를 통해 퍼블리셔/섭스크라이버 구현에 완료했고, transform, bind 등 전형적인 반응형 프로그래밍 방법으로 작성했습니다
  • 파이어베이스 회원가입 및 로그인 기능을 담당하는 매니저 클래스, 해당 뷰를 그려주는 컨트롤러, 뷰와 실제 서비스 간을 이어주고 데이터를 관리하는 뷰 모델 작성 방법을 공유합니다

데이터 서비스 클래스

  • 실제 파이어베이스 회원가입을 위한 Auth를 호출하고 관련 메소드를 호출하는 매니저 클래스입니다.
  • 현재 파이어베이스를 사용하고 있으나 인증 작업의 추상화를 위해 프로토콜로 작성했습니다
import Foundation
import FirebaseAuth
import Combine

protocol AuthManager {
    func signIn(email: String, password: String) -> AnyPublisher<Bool, Error>
    func signUp(email: String, password: String, name: String) -> AnyPublisher<Bool, Error>
}

final class FirebaseAuthManager: AuthManager {
    private let auth = FirebaseAuth.Auth.auth()
    
    func signUp(email: String, password: String, name: String) -> AnyPublisher<Bool, Error> {
        return Future<Bool, Error> { [weak self] promise in
            self?.auth.createUser(withEmail: email, password: password, completion: { _, error in
                if let error = error {
                    promise(.failure(error))
                } else {
                    promise(.success(true))
                }
            })
        }
        .eraseToAnyPublisher()
    }
                                  
    func signIn(email: String, password: String) -> AnyPublisher<Bool, Error> {
        return Future<Bool, Error> { [weak self] promise in
            self?.auth.signIn(withEmail: email, password: password) { _, error in
                if let error = error {
                    promise(.failure(error))
                } else {
                    promise(.success(true))
                }
            }
        }
        .eraseToAnyPublisher()
    }
}
  • 즉 뷰 모델 등 데이터 서비스를 사용하는 곳에서 입력받은 클래스는 FirenbaseAuthManager 클래스 자체가 아니라 AuthManager 프로토콜입니다.

뷰 모델

import Foundation
import Combine

final class SignUpViewModel {
    var email = CurrentValueSubject<String, Never>("")
    var password = CurrentValueSubject<String, Never>("")
    var name = CurrentValueSubject<String, Never>("")
    private let authManager: AuthManager
    private let output: PassthroughSubject<Output, Never> = .init()
    private var cancellables = Set<AnyCancellable>()
    
    enum Input {
        case signUpButtonDidTap
    }
    enum Output {
        case signInDidFail(error: Error)
        case signUpDidFail(error: Error)
        case emailDidMiss
        case passwordDidMiss
        case ninknameDidMiss
        case signUpDidSuccess
    }
    
    init(authManager: AuthManager = FirebaseAuthManager()) {
        self.authManager = authManager
    }
    
    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
        input
            .sink { [weak self] receivedValue in
                    guard let self = self else { return }
                switch receivedValue {
                case .signUpButtonDidTap: self.handleSignUp()
                }
            }
            .store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }
    
    private func handleSignUp() {
        email
            .combineLatest(password, name)
            .map { email, password, name in
                // TODO: Validate email, password, name
                // if not valiadated, then output.send(errors)
                return (email, password, name)
            }
            .map { email, password, name in
                self.authManager
                    .signUp(email: email, password: password, name: name)
            }
            .switchToLatest()
            .receive(on: DispatchQueue.global(qos: .background))
            .sink { [weak self] completion in
                switch completion {
                case .finished: print("Successfully signed up")
                case .failure(let error): self?.output.send(.signUpDidFail(error: error))
                }
            } receiveValue: { [weak self] success in
                guard let self = self else { return }
                if success {
                    self.output.send(.signUpDidSuccess)
                }
            }
            .store(in: &cancellables)
    }
}
  • 회원가입 뷰 UI에 사용하는 데이터(이메일, 비밀번호, 이름 정보)
  • Combine 데이터 스트림에 전형적으로 사용되는 input-output 매칭을 원활하게 하기 위해 클래스의 타입 내 이넘 형식으로 작성했습니다.

import UIKit
import Combine
import CombineCocoa
import SnapKit

class SignUpViewController: UIViewController {
    ...UI Components
    private let viewModel = SignUpViewModel()
    private let input: PassthroughSubject<SignUpViewModel.Input, Never> = .init()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setSignUpViewUI()
        bind()
    }
    
    private func setSignUpViewUI() {
        ...
    }
    
    private func bind() {
        let output = viewModel.transform(input: input.eraseToAnyPublisher())
        output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] receivedValue in
                guard self != nil else { return }
                switch receivedValue {
                case .signInDidFail(error: let error):
                    print(error.localizedDescription)
                case .signUpDidFail(error: let error):
                    print(error.localizedDescription)
                case .emailDidMiss:
                    break
                case .passwordDidMiss:
                    break
                case .ninknameDidMiss:
                    break
                    // alert -> give info to user
                case .signUpDidSuccess:
                    print("Successfully Signed Up")
                    // success -> switch to current view (navigation dismiss, etc...)
                }
            }
            .store(in: &cancellables)
        signUpButton
            .tapPublisher
            .sink { [weak self] _ in
                guard let self = self else { return }
                self.input.send(.signUpButtonDidTap)
            }
            .store(in: &cancellables)
        
        emailTextField
            .textPublisher
            .compactMap({ $0 })
            .sink { [weak self] email in
                guard let self = self else { return }
                self.viewModel.email.send(email)
            }
            .store(in: &cancellables)
        passwordTextField
            .textPublisher
            .compactMap({ $0 })
            .sink { [weak self] password in
                guard let self = self else { return }
                self.viewModel.password.send(password)
            }
            .store(in: &cancellables)
        nameTextField
            .textPublisher
            .compactMap({ $0 })
            .sink { [weak self] name in
                guard let self = self else { return }
                self.viewModel.name.send(name)
            }
            .store(in: &cancellables)
    }
}
  • 뷰 컨트롤러가 그리고 있는 뷰는 대체적으로 두 가지 부분으로 나뉩니다.
  • (1). 뷰를 그리기 (현재 코드 베이스 형식으로 구현했기 때문에 클로저 형태로 컴포넌트를 그린 뒤 오토 레이아웃을 코드로 설정했습니다)
  • (2). 뷰와 데이터를 바인딩: 기존 UIKit 작성 관습으로는 targetAction을 더해준 뒤 델리게이트를 호출해 특정 이벤트에 대한 데이터 변경을 감지/체크해주었습니다만, Combine을 통해 데이터 퍼블리셔를 구독한 뒤 sink 단 내부에서 이벤트를 감지할 수 있기 때문에(즉 구독을 달았기 때문에) 보다 SwiftUI 스타일과 유사한 프로그래밍이 가능해졌습니다. 물론 위의 tapPublisher 등 컴포넌트 하단부에 달려 있는 데이터 퍼블리셔는 CombineCocoa라는 서드파티 라이브러리가 커스텀 익스텐션으로 넣어준 퍼블리셔 형식입니다.

  • 그렇다면 전체적인 데이터 흐름을 살펴보겠습니다. 유저의 입장에서 보자면 흐름은 간단합니다: 이메일, 패스워드, 이름 텍스트 필드에 작성 → 버튼 클릭 → 회원 가입 완료! 총 세 가지 단계입니다. 현재 MVVM 단계로 나누어진 상황에서 위 과정을 나눠서 생각해봅시다. 그렇다면 해야 할 일은 다음과 같습니다.

(1). 뷰 UI에 유저가 작성하는 데이터를 계속 가지고 있어야 한다. 텍스트 필드에 입력된 데이터를 바탕으로 회원가입해주어야 하기 때문이다! (2). 회원 가입 버튼 클릭을 감지해서 회원 가입을 시도해야 한다! (3). 회원 가입 결과를 리턴받고 이를 감지해야 한다!

바인딩

emailTextField
            .textPublisher
            .compactMap({ $0 })
            .sink { [weak self] email in
                guard let self = self else { return }
                self.viewModel.email.send(email)
            }
            .store(in: &cancellables)
    var email = CurrentValueSubject<String, Never>("")

위의 뷰 컨트롤러 상에서 뷰 모델 클래스를 가지고 있는데, 바인딩 목적은 이메일 텍스트 필드에 작성되는 데이터를 뷰 모델의 email이라는 곳이 계속 가지고 있도록 하는 것입니다. CurrentValueSubject는 데이터 퍼블리셔의 일종으로 초깃값을 가지고 있는 친구인데요, 이메일 텍스트 필드의 내용이 변화할 때 sink를 타고 내려가 이 이메일 변수에 담겨지게 됩니다. 이러한 바인딩을 이메일, 패스워드, 이름 텍스트 필드 - 뷰 모델의 각 퍼블리셔와 연동하면 현재 유저가 작성하는 데이터를 계속 간직할 수 있습니다.

자, 유저가 텍스트 필드 내에 데이터를 모두 작성했다고 가정해봅시다. 유저는 회원가입을 위해 버튼을 누르게 됩니다. 일반적인 UIKit의 target-action에서는 addTarget을 통해 @objc 함수를 추가해주어야 했습니다만, Combine의 가호 아래 더 이상 그럴 필요가 없게 되었습니다.

signUpButton
            .tapPublisher
            .sink { [weak self] _ in
                guard let self = self else { return }
                self.input.send(.signUpButtonDidTap)
            }
            .store(in: &cancellables)

CombineCocoa가 익스텐션을 통해 던져주는 탭 퍼블리셔를 통해 이 번트의 탭을 감지할 수 있고, sink 단에서 어떤 행동을 할지 (또는 흘러오는 데이터를 가지고 어떤 조작을 할지) 결정합니다. 즉 회원가입을 시도하는 곳입니다. 회원가입을 위해서는 뷰 컨트롤러 단이 아니라 뷰 컨트롤러의 뷰 모델이 관리하고 있는 회원가입 매니저의 특정 함수를 사용해야 하는데요, 뷰 컨트롤러가 하는 일은 그저 signUpButtonDidTap이라는 사건이 일어났다! 즉 이벤트만 알려주는 것입니다.

    private let input: PassthroughSubject<SignUpViewModel.Input, Never> = .init()

뷰 컨트롤러 단에서는 이미 SingUpViewModel 타입 내에 구현된 인풋 이넘 데이터 퍼블리셔를 가지고 있습니다!

    enum Input {
        case signUpButtonDidTap
    }
    enum Output {
        case signInDidFail(error: Error)
        case signUpDidFail(error: Error)
        case emailDidMiss
        case passwordDidMiss
        case ninknameDidMiss
        case signUpDidSuccess
    }
  • 뷰 모델 안에서 인풋/아웃풋을 정형화한 이넘으로 관리하고 있기 때문에 들어오는 모든 종류의 인풋과 아웃풋을 체계적으로 관리할 수 있습니다.
    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
        input
            .sink { [weak self] receivedValue in
                    guard let self = self else { return }
                switch receivedValue {
                case .signUpButtonDidTap: self.handleSignUp()
                }
            }
            .store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }

뷰 모델 클래스는 이니셜라이즈 단에서 뷰 컨트롤러로 들어오는 인풋 타입에 따라 어떤 아웃풋을 내놓을 것인지 미리 결정해두었습니다. 현재 들어올 인풋의 종류는 회원가입 버튼이 눌렀다는 입력 하나뿐이겠네요. 이 경우 handleSignUp() 함수가 실행되는데, 실질적인 회원가입 매니저 클래스의 함수를 사용하는 부분입니다.

    private func handleSignUp() {
        email
            .combineLatest(password, name)
            .map { email, password, name in
                // TODO: Validate email, password, name
                // if not valiadated, then output.send(errors)
                return (email, password, name)
            }
            .map { email, password, name in
                self.authManager
                    .signUp(email: email, password: password, name: name)
            }
            .switchToLatest()
            .receive(on: DispatchQueue.global(qos: .background))
            .sink { [weak self] completion in
                switch completion {
                case .finished: print("Successfully signed up")
                case .failure(let error): self?.output.send(.signUpDidFail(error: error))
                }
            } receiveValue: { [weak self] success in
                guard let self = self else { return }
                if success {
                    self.output.send(.signUpDidSuccess)
                }
            }
            .store(in: &cancellables)
    }

회원가입에 사용하는 데 필요한 데이터는 뷰 모델이 데이터 퍼블리셔로 계속 UI 상의 데이터를 가지고 있었던 것들입니다. 즉 이메일, 패스워드, 이름 정보입니다. 이때 combineLastest 메소드를 통해 여러 개의 퍼블리셔를 동시에 그루핑할 수 있습니다. 만일 올바르지 않은 형식이 들어온다면 이 map에서 한 번 걸러줍니다. 이후 통과가 된다면 바로 회원가입 함수를 사용합시다. 회원가입 함수의 아웃풋 결과는 곧 성공했는지의 여부를 불리언으로 알려주는 AnyPublisher<Bool, Error>입니다(자세한 설명은 하단부에 있습니다). 그런데 데이터 스트림이 중첩되어 있고, 우리가 사용하고자 하는 데이터는 최초에 달린 이메일/패스워드/이름 데이터 퍼블리셔 그룹이 아니라 내부에서 호출한 bool 형식의 데이터 퍼블리셔이기 때문에 switch로 바꿔줍니다. 이후 일반적인 컴바인 구독 방법과 마찬가지로 sink를 통해 이 회원가입 이벤트의 결과가 어땠는지 output으로 보내줍시다.

    func signUp(email: String, password: String, name: String) -> AnyPublisher<Bool, Error> {
        return Future<Bool, Error> { [weak self] promise in
            self?.auth.createUser(withEmail: email, password: password, completion: { _, error in
                if let error = error {
                    promise(.failure(error))
                } else {
                    promise(.success(true))
                }
            })
        }
        .eraseToAnyPublisher()
    }

파이어베이스 회원가입 함수를 통해 회원가입 성공 여부를 데이터 퍼블리셔 형식으로 만들어 보내주는 함수입니다. 다른 애플의 프레임워크처럼 컴플리션 핸들러 + 퍼블리셔 타입 두 개 모두 함수로 제공하면 좋겠지만, 컴플리션 핸들러밖에 없네요. 어쩔 수 없이 커스텀으로 API 결과를 데이터 퍼블리셔로 만들어주기 위해 Future/promise로 감싸서 리턴해줍니다.

let output = viewModel.transform(input: input.eraseToAnyPublisher())
        output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] receivedValue in
                guard self != nil else { return }
                switch receivedValue {
                case .signInDidFail(error: let error):
                    print(error.localizedDescription)
                case .signUpDidFail(error: let error):
                    print(error.localizedDescription)
                case .emailDidMiss:
                    break
                case .passwordDidMiss:
                    break
                case .ninknameDidMiss:
                    break
                    // alert -> give info to user
                case .signUpDidSuccess:
                    print("Successfully Signed Up")
                    // success -> switch to current view (navigation dismiss, etc...)
                }
            }
            .store(in: &cancellables)

위 뷰 모델의 transform에서는 뷰 컨트롤러에서 들어오는 input에 따라 output의 종류가 달라졌습니다. 그렇다면 이 결과에 따라서 어떤 결과를 결과물로 보여주어야 할지 컨트롤러 상에서 구독을 해줘야 합니다. 마지막 아웃풋의 종류를 이넘으로 관리하고 있기 때문에 되게 간편하게 종류를 확인할 수 있는데요. 이 경우 뷰 컨트롤러에서 할 일은 "회원 가입이 성공했기 때문에 다른 뷰로 이동하기" 이거나 "회원 가입이 실패했기 때문에 알러트를 띄워주기" 둘 중 하나이겠네요. 물론 실제 UI 패치 이벤트가 발생하는 단이기 때문에 receive로 메인 스레드를 받고, 이동하는 부분은 디스패치 메인 큐에 넣어서 실행해주면 될 것 같습니다.

  • 이상으로 간단하게 제가 구현한 AuthManager 사용 과정에 대해 이야기해보았습니다. 감사합니다~

💻 기술

Study
Trouble Shooting

🎥 기록

Daily Standup
Retrospect
Minutes
User

Clone this wiki locally