관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 2. RxSwift의 Map, FlatMap - 사이드 이펙트 처리 방법 (throw와 catch 사용) 본문

RxSwift/RxSwift 응용

[iOS - swift] 2. RxSwift의 Map, FlatMap - 사이드 이펙트 처리 방법 (throw와 catch 사용)

jake-kim 2022. 2. 1. 11:50

1. RxSwift의 Map, FlatMap - 사용하여 비동기를 순서대로 처리 방법

2. RxSwift의 Map, FlatMap - 사이드 이펙트 처리 방법 (throw와 catch 사용)

 

map과 flatMap 연산자 개념을 구분

  • 개념 구분
    • flatMap은 블록 내에서 Observable을 리턴해야하므로, API를 사용할때 응답값이 Observable일때 flatMap사용
    • map의 리턴값은 Observable이 아니고, 사이드이펙트 처리 시 throw를 리턴하여 처리 
    • 주의사항: 스트림안에서 throw가 하나라도 방출되면, 해당 stream은 dispose되므로 사용자가 계속 재시도 할 수 있는 이벤트 처리에는 부적합한것을 주의 (예제 상황도 사용자가 계속 시도할 수 있으므로 부적합하지만 설명을 위해서 구현)
  • 사용 아이디어
    1. flatMap 안에서 API 호출
    2. map 블록 안에서 값 체크 후, throw를 리턴하여 사이드 이펙트 처리

flatMap, map, throw,  catch를 이용한 사이드이펙트 구현

상황)

유저가 로그인하는 상황에서 로그인 결과, 이전에 회원가입 한 유저, block 유저인지, 가입하지 않은 유저인지 사이드 이펙트 처리 필요

  • 사이드 이펙트 정의
// UserState.swift

enum UserState: Error {
  case block
  case noUser
  case abuser
}
  • API 정의
    • 시뮬레이션을 위해 내부는 임시적으로 구현 (편의상 로그인 api가 동작한다고 가정)
//  API.swift

enum API {
  static func signIn(
    email: String?,
    password: String?
  ) -> Observable<Int> {
    Observable.just((0...2).randomElement() ?? -1)
  }
}
  • map블록 내에서 thorw로 반환하고 catch에서 처리될 사이드 이펙트 케이스 정의
    • map은 아래 throws키워드를 보면 알 수 있듯이, throw를 리턴 가능 - 처리는 catch(_:) 연산자에서 처리
  • UI 준비
    • UITextField 2개
    • UIButton 1개
    • 상태값 UILabel 1개

  • 사용한 프레임워크
# Rx
pod 'RxSwift'
pod 'RxCocoa'

# UI 
pod 'SnapKit'
pod 'Then'
  • UI 구현
import UIKit
import SnapKit
import Then
import RxSwift
import RxCocoa

class ViewController: UIViewController {
  private let emailTextField = UITextField().then {
    $0.borderStyle = .roundedRect
    $0.placeholder = "abcd@google.com"
    $0.textColor = .black
  }
  private let passwordTextField = UITextField().then {
    $0.borderStyle = .roundedRect
    $0.placeholder = "password"
    $0.isSecureTextEntry = true
  }
  private let confirmButton = UIButton().then {
    $0.setTitle("확인", for: .normal)
    $0.setTitleColor(.white, for: .normal)
    $0.setTitleColor(.blue, for: .highlighted)
    $0.setBackgroundImage(UIColor.systemBlue.asImage(), for: .normal)
    $0.setBackgroundImage(UIColor.gray.asImage(), for: .disabled)
    $0.isEnabled = false
  }
  private let resultLabel = UILabel().then {
    $0.textColor = .blue
  }
  
  private let disposeBag = DisposeBag()
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    self.view.addSubview(self.emailTextField)
    self.view.addSubview(self.confirmButton)
    self.view.addSubview(self.passwordTextField)
    self.view.addSubview(self.resultLabel)
    
    self.emailTextField.snp.makeConstraints {
      $0.top.left.equalTo(self.view.safeAreaLayoutGuide)
      $0.width.equalTo(self.view.safeAreaLayoutGuide).multipliedBy(2/3.0)
    }
    self.confirmButton.snp.makeConstraints {
      $0.top.right.equalTo(self.view.safeAreaLayoutGuide)
      $0.width.equalTo(self.view.safeAreaLayoutGuide).multipliedBy(1/3.0)
    }
    self.passwordTextField.snp.makeConstraints {
      $0.top.equalTo(self.emailTextField.snp.bottom)
      $0.left.equalTo(self.view.safeAreaLayoutGuide)
      $0.width.equalTo(self.view.safeAreaLayoutGuide).multipliedBy(2/3.0)
    }
    self.resultLabel.snp.makeConstraints {
      $0.top.equalTo(self.passwordTextField.snp.bottom).offset(12)
      $0.centerX.equalToSuperview()
    }
  }
}

extension UIColor {
  func asImage(_ width: CGFloat = UIScreen.main.bounds.width, _ height: CGFloat = 1.0) -> UIImage {
    let size: CGSize = CGSize(width: width, height: height)
    let image: UIImage = UIGraphicsImageRenderer(size: size).image { context in
      setFill()
      context.fill(CGRect(origin: .zero, size: size))
    }
    
    return image
  }
}

사이드 이펙트 처리

  • 바인딩
    • Flatmap 블록 내부에서는 api 호출
    • map 블록 내부에서는 사이드 이펙트, throw 던지기
    • catch 에서 throw 처리
  • 주의사항
    • map에서 throw가 한 번 이상 발생하면 해당 스트림은 disposed되므로, 입력 후 버튼을 클릭해도 이벤트가 들어가지 않으므로 1회용임을 주의
    • 때문에 예제와 같은, 계속 이메일/패스워드 입력 후 재시도하는 상황에서 비적합하지만, 이해를 돕기위해 구현
  • 바인딩 구현
    • 버튼이 눌린 경우, email과 password 입력값 획득 - 동시에 얻어야 하므로 zip을 사용하여
      self.confirmButton.rx.tap
        .withLatestFrom(
          Observable.combineLatest(
            self.emailTextField.rx.text.asObservable(),
            self.passwordTextField.rx.text.asObservable()
          )
        )​
    • map 안에서 어뷰저 유저인지 확인 - 이메일 입력 값에 '?'가 포함되어 있으면 어뷰저 유저라고 판단
      .map {
          guard !($0 ?? "").contains("?") else { throw UserState.abuser }
          return ($0, $1)
        }​
       
    • flatMap 안에서 Observable타입을 반환하는 API 호출 (Observable 타입을 반환하지 않으면 map 사용)
      .flatMap { API.signIn(email: $0, password: $1) }​
    • map 안에서 해당 api 응답값을 보고 해당 유저가 어떤 유저인지 판단하여 throw 혹은 정상 로그인 판단
      .map { intValue in
          switch intValue {
          case 0:
            throw UserState.block
          case 1:
            throw UserState.noUser
          default:
            break
          }
          return Void()
        }​
    • catch에서 throws 처리
      .catch { [weak self] error -> Observable<Void> in
          switch error {
          case UserState.abuser:
            self?.resultLabel.text = "어뷰져 유저"
          case UserState.block:
            self?.resultLabel.text = "block 유저"
          case UserState.noUser:
            self?.resultLabel.text = "회원가입이 필요한 유저"
          default:
            break
          }
          return .empty()
        }​
    • throw에서 걸리지 않고 밑에까지 이벤트가 넘어온 경우, 로그인 성공
      .do(onNext: { [weak self] in self?.resultLabel.text = "로그인 성공" })
        .subscribe()
        .disposed(by: self.disposeBag)​

* 바인딩 전체 코드

self.confirmButton.rx.tap
  .withLatestFrom(
    Observable.combineLatest(
      self.emailTextField.rx.text.asObservable(),
      self.passwordTextField.rx.text.asObservable()
    )
  )
  .map {
    guard !($0 ?? "").contains("?") else { throw UserState.abuser }
    return ($0, $1)
  }
  .flatMap { API.signIn(email: $0, password: $1) }
  .map { intValue in
    switch intValue {
    case 0:
      throw UserState.block
    case 1:
      throw UserState.noUser
    default:
      break
    }
    return Void()
  }
  .catch { [weak self] error -> Observable<Void> in
    switch error {
    case UserState.abuser:
      self?.resultLabel.text = "어뷰져 유저"
    case UserState.block:
      self?.resultLabel.text = "block 유저"
    case UserState.noUser:
      self?.resultLabel.text = "회원가입이 필요한 유저"
    default:
      break
    }
    return .empty()
  }
  .do(onNext: { [weak self] in self?.resultLabel.text = "로그인 성공" })
  .subscribe()
  .disposed(by: self.disposeBag)

* 전체 코드: https://github.com/JK0369/ExSideEffect

Comments