iOS 프로젝트/프로젝트 경진대회 (LSLP)

RxSwift) 이메일 검증 네트워크 요청하기 (feat. withLatestFrom & flatMap)

Dev.Andy 2023. 11. 18. 23:59

머리말

구현 화면

이번에 구현한 화면은 아래와 같다. 이메일을 입력하여 확인 버튼을 누르면 해당 이메일이 사용가능한지 아닌지에 대한 반응형 화면이다.

 

RxSwift Observable Flow

이번에 적용한 Observable에 대한 흐름을 아래처럼 그려보았다. 본문에서 주제 별로 끊어서 설명할 예정이다.

RxSwift Observable Flow
확대화면 더 크게 볼 수 있습니다

GitHub PR 링크

회원 가입 - 이메일 검증 요청과 응답 메시지에 따른 반응형 UI 구현 #5

아마 아직 Private일테지만, 나중에 Public 전환 시 볼 수 있을 것 같다 :)

본문

0) ViewModel 구조

입력 값으로는 텍스트(이메일)과 버튼 클릭(확인 버튼 클릭)을 받고, 출력 값으로 해당 텍스트가 적절한지, 응답 메시지는 무엇인지를 다시 화면에 보여준다.

final class UserJoinViewModel: ViewModelType {
    
    struct Input {
        let text: ControlProperty<String>
        let nextButtonClicked: ControlEvent<Void>
    }
    
    struct Output {
        let isTextValid: Observable<Bool>
        let responseMessage: BehaviorRelay<String>
    }
    
    private let disposeBag = DisposeBag()
    
    func transform(input: Input) -> Output {
        //...
    }
}

1) 화면의 입력 값으로 클릭 이벤트 발생

`input.nextButtonClicked`

final class UserJoinViewModel: ViewModelType {
    func transform(input: Input) -> Output {
        let validationMessage = BehaviorRelay(value: String())
        
        let isTextValid = input
        //...
        
        input
            .nextButtonClicked
            .throttle(.seconds(1), scheduler: MainScheduler.instance)
            .withLatestFrom(input.text) { _, query in
                return query
            }
            .flatMap { query in
                APIManager.shared.validateEmail(email: query)
            }
            .subscribe(with: self) { owner, response in
                validationMessage.accept(response.message)
            }
            .disposed(by: disposeBag)
        }
        
        return Output(isTextValid: isTextValid, responseMessage: validationMessage)
}

2) 클릭 이벤트 발생 시,  최근 텍스트와 결합하다 - `withLatestFrom`

2-1) `withLatestFrom`의 흐름

단어에서 이미 의미를 유추할 수 있듯이, A withLatestFrom B이라 하면, "A와 B로부터의 최근 값"을 결합한 값을 의미한다.

따라서 "클릭 시 최근 텍스트 값"에 활용할 수 있다.

 

withLatestFrom()에 대한 Flow

2-2) 버튼 클릭 시 최근의 텍스트(String) → `Observable<String>`

버튼 이벤트(nextButtonClicked)가 발생하면 해당 이벤트의 최근 텍스트(input.text)를 가져온다. 원래 클로저의 인자는 2가지(클릭과 텍스트)이지만, 이 중에서 텍스트만 필요하기에 `_ , query`로 하였다. 반환 타입은 `Observable<String>`.

final class UserJoinViewModel: ViewModelType {
    func transform(input: Input) -> Output {
        //...
        
        input
            .nextButtonClicked
            .throttle(.seconds(1), scheduler: MainScheduler.instance)
            .withLatestFrom(input.text) { _, query in  // 📌 `버튼을 클릭했을 때 가장 최근의 input.text를 결합`
                return query
            } // 📌 -> Observable<String>
        //...
}

3) 클릭 시 최근 텍스트를 네트워크 요청을 보내기 - `flatMap`

3-1) `flatMap`의 흐름

이제 결합된 `Observable<String>`의 요소인 String마다 각각 네트워크 요청을 보내야 한다. ⭐️ 하지만 각 요소(String)의 네트워크 요청은 단순한 요소가 아니라, 그 안의 Observable Stream을 가지고 있다.

flatMap()에 대한 Flow

(+) flatMap에 대한 공식 문서 설명

(2023-11-19 추가)

transform the items emitted by an Observable into Observables, then flatten the emissions from those into a single Observable
Observable의 각 요소(여기서는 텍스트)를 각각의 Observable로 변환(여기서는 네트워크 요청)하고, 그 이후 그것 (내부의 Observable)으로부터 방출된 것을 단일한 (1차원) Observable로 평탄화한다.

(영문은 공식 문서, 한글은 필자 번역)

공식 문서의 정의가 처음에는 이해가 잘 되지 않았는데, 실제로 적용하면서 하나하나 되짚어 보니 좀 더 깊게 이해할 수 있었다. 😆

ReactiveX - FlatMap operator

3-2) 네트워크 요청 메서드의 반환 타입은 `Observable<EmailValidationResponse>`

아래의 네트워크 메서드를 보면 반환 타입이 `Observable`인 것을 알 수 있다. 따라서 저마다의 String은 `Observable<EmailValidationResponse>`로 반환이 된다. 따라서 1차원의 Observable<String>은 2차원의 Observable<Observable<EmailValidationResponse>>`를 반환한다.

class APIManager {
    
    static let shared = APIManager()
    
    private init() { }
    
    private let provider = MoyaProvider<MementoAPI>()
    
    func validateEmail(email: String) -> Observable<EmailValidationResponse> {
        //...
    }
}

3-3) 평탄화(flatten) 작업 - `Observable<Observable<EmailValidationResponse>>` → `Observable<EmailValidationResponse>`

하지만 우리가 원하는 것은 2차원의 `Observable<Observable<EmailValidationResponse>>`이 아니라, 각 요소에 모델 데이터가 담긴 1차원의 `ObservableEmailValidationResponse<>`을 원한다. 따라서 평탄화 작업이 있는 flatMap을 써야 한다.

⭐️ 이것이 우리가 map이 아닌 flatMap을 쓰는 이유다.

final class UserJoinViewModel: ViewModelType {
    func transform(input: Input) -> Output {
        //...
        
        input
            .nextButtonClicked
            .throttle(.seconds(1), scheduler: MainScheduler.instance)
            .withLatestFrom(input.text) { _, query in
                return query
            } // 📌 -> Observable<String>
            .flatMap { query in
                APIManager.shared.validateEmail(email: query) // 📌 -> Observable<Observable<EmailValidationResponse>>
            } // 📌 -> Observable<EmailValidationResponse>

        //...
}

4) 네트워크 요청에 대한 응답 데이터를 출력값으로 보내기

UI에 특화된 Relay, 그 중에서 기본 값을 가져야 하는 BehaviorRelay

이제 받은 데이터를 다시 출력값으로 보내야 한다. UI에 특화가 되어 있기에 Relay를 썼고, 기본 값이 있어야 하기 때문에 Behavior를 활용했다. 이제 BehaviorRelay로 받은(accept) 이벤트를 subscribe로 보내주면 된다.

final class UserJoinViewModel: ViewModelType {
    func transform(input: Input) -> Output {
        let validationMessage = BehaviorRelay(value: String())
        
        let isTextValid = input
        //...
        
        input
            .nextButtonClicked
            .throttle(.seconds(1), scheduler: MainScheduler.instance)
            .withLatestFrom(input.text) { _, query in
                return query
            }
            .flatMap { query in
                APIManager.shared.validateEmail(email: query)
            } // 📌 -> Observable<EmailValidationResponse>
            .subscribe(with: self) { owner, response in
                validationMessage.accept(response.message) // Relay를 accept (기본값은 빈 배열)
            }
            .disposed(by: disposeBag)
        }
        
        return Output(isTextValid: isTextValid, responseMessage: validationMessage)
}

5) Output(출력값)을 View(ViewController)의 UI에 나타내기

transform 된 Output의 인스턴스 output의 흐름

이제 transform으로 입력값을 가공한 출력값을 화면에 나타낼 차례이다.

View (ViewController)의 구조

아래처럼 output으로 받은 응답 메시지에 따라 UILabel의 text 값을 바꾸고, 여러 Color에 대한 로직 처리가 들어가 있다.

final class UserJoinViewController: BaseViewController {

    //...
    
    private func bind() {
        let input = UserJoinViewModel.Input(text: emailTextField.rx.text.orEmpty, nextButtonClicked: nextButton.rx.tap)
        let output = viewModel.transform(input: input)
                
        output
            .responseMessage
            .asDriver()
            .drive(with: self) { owner, value in
                let color = value == Constant.NetworkResponse.EmailValidation.Message.validEmail ? Constant.Color.Label.valid : Constant.Color.Label.notValid
                owner.emailValidationLabel.text = value // 📌 응답 메시지를 화면에 나타내기
                owner.emailValidationLabel.textColor = color // 📌 응답 메시지마다 Color에 대한 로직 처리
                if !value.isEmpty {
                    owner.emailTextField.layer.borderColor = color.cgColor  // 📌 응답 메시지마다 Color에 대한 로직 처리
                }
            }
            .disposed(by: disposeBag)
    }
}

꼬리말

1) UI에 특화된 RxCocoa의 Traits 중 하나인 Driver

에러를 방출하지 않는 Observable

에러를 방출하지 않기에 에러(onError)에 의한 구독 중지가 일어나지 않는다. 따라서 disposed에 의해 메모리가 해제되지 않는 이상 계속 남아 있다

백그라운드 스레드가 아닌 메인 스레드에서 작업

UI 변경에 특화되어 있기 때문에 백그라운드가 아닌 메인 스레드에서 로직을 처리한다.

2) ViewController의 bind에서 color에 대한 로직을 output으로 놓아야 할까

찝찝하게 남은 VC 안의 로직

원칙 상 MVVM에 해당하는 V인 View (ViewController; VC)에서는 화면만 보여주어야 하나, 컬러에 대한 로직을 VC에 남겨두었다... 이것을 다시 MVVM에 작업하기에는 번거로울 것 같아 일단 VC에 두었다 🥲 MVVM에 정답은 없다고 하지만, 다시 고려해보면 좋을 것 같다.