본문 바로가기

Swift/RxSwift

[RxSwift] Rx+MVVM으로 로그인 화면을 만들어보자.

728x90

Rx+MVVM 예제를 살펴보면 주로 검색에 대한 테이블 뷰 화면, 또는 로그인 화면이 많은 것을 알 수 있는데,

실시간으로 반응해서 대응하는 화면이 Rx의 가장 큰 장점(반응형)을 돋보이게 할 수 있어서 그런 것 같다.

 

그러니까 한번 가보자고 ~

 

먼저 화면부터 살펴보면 아래와 같다.

이렇게 위와 같이 이메일을 입력할 수 있는 텍스트필드와, 비밀번호를 입력할 수 있는 텍스트 필드 2개, 그리고 로그인 버튼으로 구성되어 있다.

 

 

🌱 MVVM

먼저 프로젝트 구성을 살펴보면 다음과 같다.

MVVM 패턴이므로 Model-View-ViewModel의 구조로 구성되어 있다.

 

 

#Model

Model에는 위와 같이 로그인 화면에서 필요한 데이터 모델을 만들어준다.

 

이 때 우리는 크게 두가지 모델이 필요하다.

✅ 바로 로그인을 하기 위해서 서버로 보낼 id와 password를 담은 구조체가 필요하고,

✅ 로그인이 성공적으로 이뤄졌을 때 서버로부터 다시 전달 받을, (더 많은 정보가 있을 수 있겠지만) 예를 들면 사용자의 이름과 같은 정보가 필요하다.

 

각 모델을 살펴보면 아래와 같다.

먼저 로그인할 때 필요한 아이디, 비밀번호를 담은 구조체이다.

import Foundation

struct LoginRequest {
    let email: String
    let password: String
}

 

로그인 이후 응답값을 담을 구조체이다.

import Foundation

struct LoginResponse: Codable {
    let name: String
    
    init(name:String) {
        self.name = name
    }
}

실제로 서비스에서 로그인 서버를 연결하게 되면 유저의 이름 뿐 아니라, 유저의 소셜 로그인 토큰 등의 정보도 같이 담아서 온다. 

 

 

#View

그리고 이제 화면을 만들어보자.

 

email을 입력할 수 있는 텍스트 필드, password를 입력할 수 있는 텍스트 필드, 둘 다 잘 작성되었다면 활성화를 할 로그인 버튼 .. 으로 화면이 구성되므로 아래와 같이 코드를 작성할 수 있다.

protocol BaseViewControllerAttribute {
    func configureHierarchy()
    func setAttribute()
    func bind()
}

final class LoginViewController: UIViewController {
    
    // MARK: - UI Property
    
    private var idTextField = UITextField().then {
        $0.borderStyle = .roundedRect
    }
    
    private var passwordTextField = UITextField().then {
        $0.borderStyle = .roundedRect
    }
    
    private var loginButton = UIButton().then {
        $0.backgroundColor = .systemPink
        $0.setTitle("로그인", for: .normal)
        $0.setTitleColor(.white, for: .normal)
    }
    
    // MARK: - Property
    
    private let viewModel = LoginViewModel()
    
    private let disposeBag = DisposeBag()
    
    // MARK: - Life Cycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        configureHierarchy()
        setAttribute()
        bind()
    }
}

extension LoginViewController: BaseViewControllerAttribute {
    func configureHierarchy() {
        view.addSubview(idTextField)
        view.addSubview(passwordTextField)
        view.addSubview(loginButton)
        
        idTextField.snp.makeConstraints { make in
            make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(10)
            make.height.equalTo(50)
        }
        
        passwordTextField.snp.makeConstraints { make in
            make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(10)
            make.height.equalTo(50)
        }
        
        loginButton.snp.makeConstraints { make in
            make.top.leading.trailing.equalTo(passwordTextField.snp.bottom).inset(10)
            make.height.equalTo(50)
        }
    }
    
    func setAttribute() {
        view.backgroundColor = .white
    }
    
    func bind() {
        
    }
}

 

위의 코드에서 protocol을 통해 view controller에서 필요한, 작성될 내용을 한번 제시한 것이라고 볼 수 있다.

protocol BaseViewControllerAttribute {
    func configureHierarchy()
    func setAttribute()
    func bind()
}

이렇게 기본적으로 사용할 함수들을 프로토콜로 선언한 것이다.

(이 프로토콜을 채택한 View Controller는 위의 세가지 함수 중 하나라도 작성하지 않으면 에러가 나기 때문에 휴먼 에러등을 줄일 수 있고 정의를 하여 추상화를 할 수 있다.) 

 

사용자의 인터렉션, 화면 전환 등이 아닌, 비즈니스 로직은 모두 View Model에서 처리하므로 (일단) ViewController은 위의 코드까지만 적고 ViewModel로 넘어가보자.

 

 

#ViewModel

로그인 화면의 뷰모델에서 필요한 비즈니스 로직은 

✅ email 텍스트 필드의 값이 변경되었는가? 어떤 값이 들어왔는가? 에 대한 판단

✅ password 텍스트 필드의 값이 변경되었는가? 어떤 값이 들어왔는가? 에 대한 판단

✅ 이메일과 비밀번호를 바탕으로 제대로 값이 들어왔고, 조건을 모두 만족하는가? 에 대한 판단 

✅ 이메일, 비밀번호를 바탕으로 서버에 요청 후 값을 다시 뷰에 전달 

 

 

이것들을 코드로 나타내면 아래와 같다.

import Foundation

import Alamofire
import RxCocoa
import RxSwift

enum LoginError: Error {
    case badRequest
    case serverError
}

final class LoginViewModel {
    let emailRelay = BehaviorRelay<String>(value: "이메일을 입력해주세요")
    let passwordRelay = BehaviorRelay<String>(value: "비밀번호를 입력해주세요")
    
    var isLoginSucceed = PublishSubject<LoginResponse>()
    
    var isValid: Observable<Bool> {
        return Observable
            .combineLatest(emailRelay, passwordRelay)
            .map { email, password in
                print("Email : \(email), Password : \(password)")
                return !email.isEmpty && email.contains("@") && email.contains(".") && password.count > 0 && !password.isEmpty 
            }
    }
    
    func requestLogin(_ loginRequest: LoginRequest) {
        let url = ""
        let headers : HTTPHeaders = ["Content-Type" : "application/json"]
        let params: Parameters = ["email": loginRequest.email, "password" : loginRequest.password]
        
        let request = AF.request(url,
                                 method: .post,
                                 parameters: params,
                                 encoding: JSONEncoding.default,
                                 headers: headers)
        
        request.responseDecodable(of: LoginResponse.self) { [weak self] response in
            guard let self = self else { return }
            
            switch response.result {
            case .success(let value):
                
                guard let statusCode = response.response?.statusCode else { return }
                
                if statusCode == 200 {
                    self.isLoginSucceed.onNext(value)
                } else if statusCode == 400 {
                    self.isLoginSucceed.onError(LoginError.badRequest)
                } else if statusCode  == 500 {
                    self.isLoginSucceed.onError(LoginError.serverError)
                }
                
            case .failure(let error):
                self.isLoginSucceed.onError(LoginError.badRequest)
                print(error)
            }
        }
    }
}

-> 위의 코드에서 서버통신 부분은 APIManager 파일로 따로 관리할 수 있다.

 

여기서 유효성 판단 이후로, (isVaild)

일단 정규식이 모두 true로 반환이 되었을 때, 그 값을 위에서 만든 LoginRequest에 넣어서 서버에 보내고

응답값을 LoginResponse에 담아 이를 바탕으로 처리할 수 있다. 

 

일단은 이렇게 파일 분리를 했다면,

뷰에서의 인터랙션과 그에 따른 비즈니스 로직 처리를 Rx를 이용해서 반응형으로 만들어보자 !!

 

 

🌱 RxSwift (+ RxCocoa)

위의 코드에서 View(=View Controller)에 따로 bind 코드를 작성하지 않았는데,

이제 사용자가 인터랙션을 해서 어떻게 데이터의 흐름이 이어지는지 따라가면서 코드를 작성해보자.

 

 

자 내가 사용자라고 가정해보자.

  1. 먼저, 앱을 시작하고 들어와서 이메일 텍스트 필드를 탭해서 이메일을 작성할 것이고,
  2. 그 아래에 있는 비밀번호 텍스트 필드를 탭해서 비밀 번호를 작성할 것이다.
  3. 그리고 조건에 맞게 이메일과 비밀번호를 작성했다면, 로그인 버튼이 활성화 될 것이고,
  4. 로그인 버튼을 탭해서 입력한 이메일과 비밀번호를 서버로 보내서 통신 이후
  5. 통신이 성공한 경우와, 실패한 경우로 나눠서 작업이 이뤄질 것이다.

 

이 흐름을 생각하면서 bind 로직을 짜면 된다. 그리고 MVVM 패턴을 적용했기 때문에

✅ UI와 관련된 작업 (UI 변화 또는 인터랙션 등)

✅ 로직과 관련된 작업 (이메일, 비밀번호 유효성 검사 및 이메일, 비밀번호를 기반으로 서버 통신 등)

을 나눠서 View와 ViewModel로 데이터를 전달하고-받으면 된다.

 

 

일단 뷰에서 받은 인터렉션에 대해서 뷰모델이 처리할 비즈니스 로직은 다음과 같다.

 

#이메일, 비밀번호 텍스트필드에서 받은 값에 대한 처리 

이 때, 텍스트 필드의 값이므로 일종의 placeholder와 같은 초기값이 필요하다.

그러므로 초기값이 있는, UI의 이벤트를 전달할 수도, 받을 수도 있으므로 BehaviorRelay 형태가 적합하다.

let emailRelay = BehaviorRelay<String>(value: "이메일을 입력해주세요")
let passwordRelay = BehaviorRelay<String>(value: "비밀번호를 입력해주세요")

 

 

#이메일, 비밀번호가 유효한가? 에 대한 처리 

각 텍스트필드의 값을 받고,

이메일의 경우 @와 .을 모두 입력했는지,

비밀번호의 경우의 값을 공란으로 두지 않고 입력했는지,

에 대한 판단이 필요하다.

 

이는 초기값은 필요없고, 이벤트를 방출하기만 하면 되므로 Observable의 형태로 선언할 수 있다.

var isValid: Observable<Bool> {
    return Observable
        .combineLatest(emailRelay, passwordRelay)
        .map { email, password in
            print("Email : \(email), Password : \(password)")
            return !email.isEmpty && email.contains("@") && email.contains(".") && password.count > 0 && !password.isEmpty 
        }
}

 

 

#로그인이 성공했는가?

입력받은, 전달받은 이메일/비밀번호로 서버통신을 한 다음, 그 결과에 대한 처리를 하기 위해 로그인 성공 유무에 대한 이벤트를 받고, 다시 방출할 변수가 필요하다.

이는 초기값은 필요 없는 형태이므로 PublishSubject로 선언할 수 있다.

var isLoginSucceed = PublishSubject<LoginResponse>()

 

 

그리고 전달 받은 이메일, 비밀번호로 서버통신을 하는 코드를 작성하면 아래와 같이 뷰모델을 만들 수 있다.

실제로 서버 통신 코드는 APIManager로 따로 관리할 수 있다.

import Alamofire
import RxCocoa
import RxSwift

enum LoginError: Error {
    case badRequest
    case serverError
}

final class LoginViewModel {
    let emailRelay = BehaviorRelay<String>(value: "이메일을 입력해주세요")
    let passwordRelay = BehaviorRelay<String>(value: "비밀번호를 입력해주세요")
    
    var isLoginSucceed = PublishSubject<LoginResponse>()
    
    var isValid: Observable<Bool> {
        return Observable
            .combineLatest(emailRelay, passwordRelay)
            .map { email, password in
                print("Email : \(email), Password : \(password)")
                return !email.isEmpty && email.contains("@") && email.contains(".") && password.count > 0 && !password.isEmpty 
            }
    }
    
    func requestLogin(_ loginRequest: LoginRequest) {
        let url = ""
        let headers : HTTPHeaders = ["Content-Type" : "application/json"]
        let params: Parameters = ["email": loginRequest.email, "password" : loginRequest.password]
        
        let request = AF.request(url,
                                 method: .post,
                                 parameters: params,
                                 encoding: JSONEncoding.default,
                                 headers: headers)
        
        request.responseDecodable(of: LoginResponse.self) { [weak self] response in
            guard let self = self else { return }
            
            switch response.result {
            case .success(let value):
                
                guard let statusCode = response.response?.statusCode else { return }
                
                if statusCode == 200 {
                    self.isLoginSucceed.onNext(value)
                } else if statusCode == 400 {
                    self.isLoginSucceed.onError(LoginError.badRequest)
                } else if statusCode  == 500 {
                    self.isLoginSucceed.onError(LoginError.serverError)
                }
                
            case .failure(let error):
                self.isLoginSucceed.onError(LoginError.badRequest)
                print(error)
            }
        }
    }
}

 

 

그리고 이제 뷰(= 컨트롤러)로 가서 코드를 작성하자.

여기서는 각 텍스트 필드에서 값을 받아서 뷰모델로 보내고

뷰모델의 유효성 검사에 대한 값에 따라서 UI를 업데이트 하고(= 이메일과 비밀번호를 조건에 맞게 작성했다면, 로그인 버튼이 활성화 될 것이다.)

서버 통신 이후의 값에 따라서 알림창을 띄워주면 된다.(= 로그인 버튼을 눌렀을 때, 서버 통신을 하고, 이의 결과에 따라서 다른 알림창을 띄워주면 된다.)

 

viewModel.emailRelay
    .bind(to: emailTextField.rx.text)
    .disposed(by: disposeBag)
viewModel.passwordRelay
    .bind(to: passwordTextField.rx.text)
    .disposed(by: disposeBag)

먼저, 사용자의 입력이 있기 전까지 초기값을 각 텍스트 필드의 text로 보내 일종의 placeholder 역할을 하도록 한다.

 

 

 emailTextField.rx.text.orEmpty
     .bind(to: viewModel.emailRelay)
     .disposed(by: disposeBag)
 
 passwordTextField.rx.text.orEmpty
     .bind(to: viewModel.passwordRelay)
     .disposed(by: disposeBag)

사용자가 값을 입력했다면, 이를 뷰모델의 relay로 보내서 유효성 검사를 할 수 있도록 한다.

 

 

 let isValid = viewModel.isValid
     .share()
 
 isValid
     .bind(to: loginButton.rx.isEnabled)
     .disposed(by: disposeBag)
 
 isValid
     .map { $0 == true ? UIColor.systemPink : UIColor.systemGray4 }
     .bind(to: loginButton.rx.backgroundColor)
     .disposed(by: disposeBag)

유효성 검사를 한 뒤,

이메일과 비밀번호를 조건에 맞게 작성했다면,

1. 로그인 버튼이 활성화 되도록 한다.

2. 그리고 배경색도 변화하여 UI 업데이트를 한다.

-> 이 때, isValid는 Observable의 형태로 Stream 공유가 되지 않는다. 그렇기 때문에 share을 통해서 하나의 메모리에서 관리할 수 있도록 한다.

 

 

loginButton.rx.tap
     .withUnretained(self)
     .bind { vc, _ in
         let request = LoginRequest(email: vc.viewModel.emailRelay.value, password: vc.viewModel.passwordRelay.value)
         vc.viewModel.requestLogin(request)
     }
     .disposed(by: disposeBag)

버튼이 활성화 되어 탭하면

작성한 이메일과 비밀번호로 서버 통신을 할 수 있도록, 뷰모델의 통신 함수를 호출한다.

 

 

viewModel.isLoginSucceed
    .withUnretained(self)
    .subscribe { vc, response in
        vc.presentAlert("로그인 성공", "\(response.name)님 환영합니다.")
    } onError: { [weak self] error in
        guard let self = self else { return }
        self.presentAlert("로그인 실패", "\(error)")
    } onCompleted: {
        print("완료")
    } onDisposed: {
        print("버려")
    }
    .disposed(by: disposeBag)

그리고 서버 통신 이후의 결과에 대한 이벤트를 구독하여

각 경우에 따라서 다른 처리를 한다.

 

 

이렇게 하면 .. !

위와 같이 동작하는 것을 볼 수 있다.

 

전체 코드를 보고 싶다면 .. 아래의 주소에서 Login 폴더를 확인하면 된다.

https://github.com/pcsoyeon/SSAC-SAEBBING/tree/main/WhatsNew/WhatsNew/Presentation/Login

 

GitHub - pcsoyeon/SSAC-SAEBBING: 나는 새삥. 모든게 다 새삥.

나는 새삥. 모든게 다 새삥. Contribute to pcsoyeon/SSAC-SAEBBING development by creating an account on GitHub.

github.com