RxSwift를 이용한 공통 UITextField 리팩토링

파편화된 로직을 RxSwift로 통합해보자

유현지
2021.12.06

안녕하세요. 딜리셔스 모바일 팀 iOS 파트 유현지입니다.

최근 파트 내에서 RxSwift 스터디를 진행했고, 저는 이를 이용해 평소 필요성을 느꼈던 공통 UITextField 리팩토링 작업을 진행하고 있습니다.

이 글에서는 기존 공통 UITextField에 대한 요구사항들과 이를 RxSwift를 이용해 어떤식으로 개선했는지 기술하고자 합니다.

(※ 리팩토링 중 RxSwift와 관련 없는 부분은 생략하며, 편의를 위해 기존 공통 UITextFieldCMTextField로 표기합니다.)

개요

RxSwift를 이용해 리팩토링을 진행한 목적은 다음과 같습니다.

  1. 스터디 학습 적용
  2. CMTextField를 사용하면서 느꼈던 불편사항 개선
  3. delegate, rx, addtarget 으로 파편화된 ViewController에서의 CMTextField 사용을 하나의 방식으로 통일
  4. 완성된 이후의 텍스트를 받아서 처리해야 하는 delegate 사용의 번거로움, 부정확함 개선

요구사항

리팩토링 시작 전 iOS파트에서 기존 CMTextField에 대한 요구사항들을 취합했고, 그 중 RxSwift를 이용해 개선할 수 있는것을 정리했습니다.

  1. 생성할 때 아래 값들을 세팅하면 CMTextField 내부에서 처리되었으면 좋겠다.
    • maxLength
    • TextField 상태에 따른 font, border, background 색상
    • 입력제한 타입(ex. 이모지, 특수문자 등)
    • 포맷 타입(ex. 휴대폰, 이메일 등)
      • 포맷 설정 시 입력한 텍스트에 자동으로 포맷에 맞는 형식이 적용
  2. 파편화된 사용법이 하나로 통일됐으면 좋겠다.
    • ViewController에서의 CMTextField 사용 방식 통합
      • delegate, addTarget, rx로 파편화되어 있는 방식을 rx로 통합

RxSwift를 이용한 개선

요구사항을 개선하기 위해 CMTextField엔 3개의 Stream이 필요합니다.

  • text가 변경될 때 그 String을 방출하는 ControlProperty
  • editing 시작, 종료 시 ControlEvent를 방출하는 Observable
  • CMTextField의 상태(normal, editing, error)를 방출하는 Observable

차례대로 살펴보겠습니다.

rx.changedText

정의

text가 변경될 때만 그 String을 방출하는 ControlProperty

기능

  • 방출된 string을 요구사항 1번의 설정값을 적용한 Operator로 연산하여 최종 string 방출
  • 최종 text를 방출하는 Driver를 구독해서 UI Binding 처리

→ 완성된 이후의 string을 delegate에서 받아 처리하는 번거로움 개선

설명

RxSwift에서 제공하는 UITextField.rx.text

controlPropertyWithDefaultEvents 이벤트가 발생할 때 방출되기 때문에

.editingChanged 이벤트 + 최초 초기화 1회 + 포커싱 + 언포커싱 될 때 모두 방출됩니다.

이는 우리의 CMTextField에 맞지 않기 때문에 controlPropertyWithDefaultEvents 중에서 값이 변경될 때 발생하는 이벤트인 .editingChanged, .valueChanged 가 발생할 때만 방출되는 ControlProperty를 새로 정의했습니다.

public var changedText: ControlProperty<String?> {
        return base.rx.controlProperty(editingEvents: [.editingChanged, .valueChanged], getter: { textField in
            textField.text
        }, setter: { textField, value in
            if textField.text != value {
                textField.text = value
            }
        })
    }

사용은 rx.changedText에서 방출한 String → Operator 과정 → 최종 String 방출 Driver 구독 → UI Binding 순으로 이루어집니다.

Flow Chart

입력된 text가 최초 방출되고 초기화 시 설정한 값으로 만든 Operator들에 의해 연산되는 과정을 그린 플로우 차트 입니다.

각 Operator에서는 다음과 같은 기능을 수행합니다.

  1. orEmpty 연산
    • 역할
      • optional 해제를 위해 추가합니다.
  2. scan 연산
    • 역할
      • scan 연산자는 이전에 방출된 값과 새로 방출된 값을 결합해 방출할 아이템을 생성할 수 있습니다.
      • 주로 새로 방출된 값을 검사하는 로직을 넣어 통과하지 못하면 이전에 방출된 값을 방출시킵니다.
    • 기능
      • maxLength 검사
      • 입력 제한 타입 검사
    • 사용
     let test: Driver<String?> = self.rx.changedText
                 .orEmpty
                 .scan(self.text) { [weak self] (prev, new) -> String? in
                     guard let s = self else { return prev }
    
                     // 1. maxLength 검사
                     // 2. 입력 제한 타입 검사
                     // 그 밖의 검사 로직
    
                     // 위 검사를 통과하지 못하면 이전 값인 prev를 방출합니다.
    
                     return new
                 }
                 .asDriver(onErrorJustReturn: "")
    
  3. map 연산
    • 역할
      • map 연산자는 수식에 맞게 변환한 값을 방출시킵니다.
    • 기능
      • 포맷 타입(ex. 전화번호, 이메일 등)에 맞게 변환
    • 사용

        .map { [weak self] str -> String? in
                        guard let s = self else { return "" }
      
                        // 포맷에 맞는 변환 처리
      
                        return str
                    }
      
  4. asDriver
    • 역할
      • 최종적으로 변환한 값을 방출하는 Driver로 변환합니다.
      • CMTextField의 rx.text에 이 Driver를 binding 하여 UI를 나타냅니다.
let rxChangedTextDriver: Driver<String?> = self.rx.changedText
            .orEmpty
            .scan(self.text) { [weak self] (prev, new) -> String? in
                guard let s = self else { return prev }

                // 1. maxLength 검사
                // 2. 입력 제한 타입 검사
                // 위 검사를 통과하지 못하면 이전 값인 prev를 방출합니다.

                return new
            }
            .map { [weak self] str -> String? in
                guard let s = self else { return "" }

                // 포맷에 맞는 변환
                // Ex. 전화번호인 경우
                if s.format == .tel {
                    return str?.insertHyphen()
                }

                return str
            }
            .asDriver(onErrorJustReturn: "")

// 최종 UI Binding
rxChangedTextDriver
	.drive(self.rx.text)
  .disposed(by: self.disposeBag)

2. rx.editingAction

정의

editing 시작, 종료 시 발생한 TargetedControlEvent를 방출하는 Observable

(TargetedControlEvent은 Rx+Control에 정의된 제네릭 타입)

struct TargetedControlEvent<T> {
    var event: UIControl.Event
    var target: T
}

기능

editingDidBegin, editingDidEnd, editingDidEndOnExit 이벤트가 불릴 때 그 event를 방출합니다. editingEvent를 하나의 Stream에서 관리할 수 있어 각각의 event 함수에서 처리해야 하는 delegate 사용 불편함을 개선할 수 있었습니다.

설명

UIControl.event에 따라 별도 처리를 수행해야 하는 TextField를 위해 정의된 Observable입니다.

예시)

온라인몰 주소 변경의 CMTextField는 UIControl.Event마다 다른 처리가 필요합니다.

  • editingDidBegin
    • editing이 시작 시 editing 상태의 color 적용
  • editingDidEnd, editingDidEndOnExit
    • editing 종료 후 입력된 text의 validation 검증
      • Y: normal 상태의 color 적용
      • N: error 상태의 color 적용, 온라인몰 주소 변경 UILabel의 textColor도 변경

예시처럼 UIControl.Event 마다 별도 처리를 하기 위해서는 CMTextField를 사용하는 ViewController에서 textField에 어떤 이벤트가 불렸는지 알아야 합니다.

RxSwift에서 제공하는 rx.controlEvent 를 사용해도 되지만 editingDidBegin, .editingDidEnd, .editingDidEndOnExit 각각 구독해 처리해야 되기 때문에 불편합니다.

따라서 필요한 Event가 불릴 때 그 TargetedControlEvent를 방출하는 Observable을 정의했습니다.

var editingAction: Observable<TargetedControlEvent<Base>> {

        let events: [UIControl.Event] = [
            .editingDidBegin,
            .editingDidEnd,
            .editingDidEndOnExit
        ]

        let eventObservables = events.map({ controlEvent(event: $0) })
        return Observable.merge(eventObservables)
    }

사용

TargetedControlEvent을 방출하기 때문에 event만 사용하기 위해서 map 연산자로 가공해 사용합니다.

ViewController에서 사용하기 위해 UIControl.Event를 방출하는 PublishRelay를 생성하고 rx.editingAction이 방출할 때 map 연산자로 뽑은 UIControl.event를 accept 해줍니다.

self.rx.editingAction
            .map { $0.event }
            .subscribe(onNext: { [weak self] in
                guard let s = self else { return }

				// VC에서 사용할 PublishRelay에 방출된 event값을 accept.
                s.rxEditingAction.accept($0)
            })
            .disposed(by: self.disposeBag)

ViewController에서의 사용은 다음과 같습니다.

self.testTextField.rxEditingAction.subscribe(onNext: { [weak self] event in
            guard let s = self else { return }

            switch event {
            case .editingDidBegin:
                // editing 시작 시 처리해야할 로직
            case .editingDidEnd, .editingDidEndOnExit:
                // editing 종료 시 처리해야할 로직
            default:
                break
            }
        }).disposed(by: self.disposedBag)

3. rxStatus

정의

CMTextField의 상태(normal, editing, error)를 방출하는 Observable

기능

  • 상태에 따른 변경을 처리해주기 위한 Observable 입니다.
  • CMTextField의 상태 Enum(normal, editing, error)을 방출하는 PublishRelay 입니다.

사용

rxEditingAction을 subscribe할 때 rxStatus의 값을 accept해주는 코드 입니다.

self.testTextField.rxEditingAction.subscribe(onNext: { [weak self] event in
            guard let s = self else { return }

            switch event {
            case .editingDidBegin:
                // editing 상태 accept
                s.testTextField.rxStatus.accept(.editing)

            case .editingDidEnd, .editingDidEndOnExit:                
                // editing 종료 후 normal/error 판별 로직 수행
                if 텍스트 validation == true {
                    s.testTextField.rxStatus.accept(.normal)
                } else {
                    s.testTextField.rxStatus.accept(.error)
                }
            default:
                break
            }
        }).disposed(by: self.disposedBag)

ViewController에서는 rxStatus를 subscribe해서 각 상태별로 별도 처리 할 수 있습니다.

self.testTextField.rxStatus.subscribe(onNext: { [weak self] status in

            switch status {
            case .normal:
                // normal 상태의 경우
            case .editing:
                // editing 상태의 경우
            case .error:
                // error 상태의 경우
            }
        }).disposed(by: self.disposedBag)

위 3개의 Stream으로 파트내에서 취합한 요구사항들을 RxSwift를 이용해 개선할 수 있었습니다.

고민사항

작업하며 어떤 식으로 처리해야 할지 고민 중인 부분들을 정리했습니다.

  1. 코드에서 값 대입 시 처리

    rx.changedText는 ControlProperty이기 때문에 사용자가 직접 text를 입력&붙여넣기 하는 경우 방출됩니다. 따라서 개발자가 코드에서 textField.text = "new"와 같이 직접 대입할 때는 불리지 않습니다. 이것에 대한 처리를 CMTextField에 넣어줘야 할지, 특수한 경우니 사용자가 VC에서 별도 처리하게 해야 할지 고민 중입니다.

  2. Delegate, Rx 파편화

    현재 한 ViewController에 여러 개의 UITextField가 있는 경우 UITextFieldDelegate 함수 안에서 모든 TextField를 관리하고 있습니다.

     func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    
             if textField == self.tf1 {
                 // 처리
             } else if textField == self.tf2 {
                 // 처리
             } else if textField == self.tf3 {
                 // 처리
             }
    
             return true
         }
    

    Rx로 방법을 통일할 경우 각 textField마다 subscribe를 해줘야 해서, 오히려 코드가 늘어나는 경우가 생깁니다.

    따라서 textField가 많은 VC인 경우엔 delegate를 사용해서 한 함수에서 모두 관리하도록 하는 게 좋을지에 대한 고민이 있습니다.

    하지만 이는 리팩토링 하는 사람이 강제해야 할 부분인 것 같아 Rx를 사용하는 방식으로 통일하도록 할 생각입니다.

마무리

리팩토링 진행은 현재 중간 피드백을 받고 신상마켓 내부에 있는 기존 CMTextField를 교체하는 식으로 진행하고 있습니다.

팀원 모두가 써야 하는 공통 컴포넌트인 만큼 사용하는 입장에서 편하게 사용할 수 있는지가 제일 큰 고민이었습니다.

하지만 올해 진행했던 RxSwift 스터디를 실제 프로젝트에 녹인다는 것은 즐거운 경험이었습니다.

아직 완전한 상태의 작업은 아니지만, 앞으로도 중간중간 팀원분들의 피드백을 받으며 깔끔하게 기존 CMTextField를 리팩토링 버전으로 교체하는 것이 목표입니다.

유현지

신상마켓 iOS 개발자

"야망있게 살고싶은 개발자 입니다."