포스트

RxSwift (3)

Filtering Operators

이전글에서 살펴봤듯 Observable에 적용하는 모든 메서드는 연산자(Operator)라고 부른다. 이번 편부터는 그중에서도 Observable이 방출하는 이벤트 중 원하는 것만 골라서 통과시키거나 걸러내는 연산자들, 즉 Filtering Operator를 다룬다. ignoreElements, elementAt, filter부터 시작해서 skip, take, distinct, share까지 차례로 이어진다.


1. Ignore Operator

특정 이벤트를 걸러서 무시하는 오퍼레이터 세트. ignoreElements, elementAt, filter 세 가지를 다룬다.

  • ignoreElements: 모든 next 이벤트를 무시하고 completed/error 이벤트만 전달받는다. 진행률처럼 중간값이 중요하지 않고 “끝났는지 아닌지”만 알면 되는 경우에 쓴다
  • elementAt: 지정한 인덱스의 next 이벤트 하나만 받고, 그 즉시 자동으로 completed 처리된다. 로그인 3회 실패 시 계정을 잠그는 것 같은 케이스에 적합하다
  • filter: Combine의 filter와 동일하게, 조건을 만족하는 이벤트만 통과시킨다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
func ignoreAllElements() {

    let bag = DisposeBag()

    let progress = PublishSubject<Float>()

    // next 이벤트(0.2, 0.6, 0.9)는 전부 무시되고
    // completed 또는 error 이벤트만 구독자에게 전달됨
    progress.ignoreElements().subscribe({ print($0) }).disposed(by: bag)

    progress.onNext(0.2)
    progress.onNext(0.6)
    progress.onNext(0.9)

    // 여기서 completed 또는 error를 호출해야 실제로 뭔가 출력됨
    // progress.onCompleted()
}

func loginAttempts() {

    let bag = DisposeBag()

    let login = PublishSubject<String>()

    // 세 번째(index 2) next 이벤트만 받고 자동으로 completed 처리
    // 로그인 3회 시도 제한 같은 로직에 쓸 수 있음
    login.elementAt(2).subscribe({ print($0) }).disposed(by: bag)

    login.onNext("One")
    login.onNext("Two")
    login.onNext("Three")  // 이 값만 출력되고 스트림 종료
    login.onNext("Four")   // 이미 completed라 무시됨
}

func evenNumberFilter() {

    let bag = DisposeBag()

    let evenNum = PublishSubject<Int>()

    // 짝수 조건을 만족하는 이벤트만 통과
    evenNum.filter({ $0 % 2 == 0 }).subscribe({ print($0) }).disposed(by: bag)

    for num in 0...10 {
        evenNum.onNext(num)
    }
}

핵심 요약

  • ignoreElements: next 값 자체는 필요 없고 완료/에러 시점만 알고 싶을 때 사용
  • elementAt: 특정 순번의 이벤트 하나만 받고 자동 completed - 반복 시도 제한 같은 로직에 적합
  • filter: 조건에 맞는 이벤트만 통과, Combine의 filter와 동일한 개념

2. Skip Operator

Ignore Operator가 이벤트를 아예 버리는 거였다면, Skip Operator는 정해진 조건까지만 이벤트를 건너뛰고 그 이후로는 정상적으로 흘려보낸다는 점에서 다르다. skip(count), skipWhile, skipUntil 세 가지를 다룬다.

  • skip(count): 앞에서부터 지정한 개수만큼의 next 이벤트를 건너뛰고, 그 다음 이벤트부터 정상적으로 전달한다
  • skipWhile: 조건이 true인 동안에는 계속 건너뛰다가, 조건이 처음 false가 되는 순간부터는 이후 값을 전부 통과시킨다. 한 번 조건이 깨지면 그 뒤로 다시 조건이 true가 돼도 건너뛰지 않는다
  • skipUntil: 다른 Observable(트리거)이 첫 이벤트를 방출하기 전까지 원본 Observable의 이벤트를 전부 건너뛴다. 트리거가 한 번이라도 이벤트를 내보내면 그 이후 값은 전부 통과된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func skipCount() {

    let bag = DisposeBag()

    // 앞에서 7개(1~7)는 건너뛰고 8부터 전달
    Observable.from(Array(1...10)).skip(7).subscribe({ print($0) }).disposed(by: bag)
}

func skipWhile() {

    let bag = DisposeBag()

    // 짝수인 동안 계속 skip, 처음 홀수(7)를 만나는 순간부터는
    // 뒤에 다시 짝수(8)가 와도 더 이상 건너뛰지 않음
    Observable.from([2, 4, 6, 7, 8, 9]).skipWhile({ $0 % 2 == 0 }).subscribe({ print($0) }).disposed(by: bag)
}

func skipUntil() {

    let bag = DisposeBag()

    let subject = PublishSubject<Int>()

    // 트리거를 Void 타입으로 - 값 자체는 필요 없고
    // "이벤트가 한 번 발생했는지" 여부만 중요하기 때문
    let trigger = PublishSubject<Void>()

    subject.skipUntil(trigger).subscribe({ print($0) }).disposed(by: bag)

    subject.onNext(1)  // 트리거 전이라 skip됨
    subject.onNext(2)  // 트리거 전이라 skip됨

    trigger.onNext(())  // 이 시점부터 subject 값이 통과되기 시작

    subject.onNext(3)  // 출력됨
    subject.onNext(4)  // 출력됨
}

핵심 요약

  • skip(count): 앞에서부터 지정한 개수만 건너뛰고 이후는 전부 통과
  • skipWhile: 조건이 true인 동안만 건너뛰고, 한 번 false가 되면 그 뒤로는 계속 통과 (조건이 다시 true가 돼도 무관)
  • skipUntil: 트리거 Observable이 첫 이벤트를 내보내기 전까지는 전부 건너뛰고, 이후로는 전부 통과. 트리거 값 자체가 필요 없다면 PublishSubject<Void>로 선언하는 게 의도를 더 명확히 드러냄

3. Take Operator

Skip Operator와 정반대 동작을 한다. Skip이 지정한 조건까지 이벤트를 건너뛰고 그 뒤를 흘려보낸다면, Take는 지정한 조건까지만 이벤트를 가져오고 그 뒤를 잘라낸다. take(count), takeWhile, takeUntil 세 가지를 다룬다.

  • take(count): 앞에서부터 지정한 개수만큼의 next 이벤트만 받고, 그 즉시 completed 처리된다
  • takeWhile: 조건이 true인 동안에는 이벤트를 받다가, 조건이 처음 false가 되는 순간 완전히 completed되고 그 뒤로는 어떤 이벤트도 받지 않는다. skipWhile과 달리 조건이 한 번 깨지면 스트림 자체가 끝난다는 점이 다르다
  • takeUntil: 다른 Observable(트리거)이 첫 이벤트를 방출하기 전까지 원본 Observable의 이벤트를 받는다. 트리거가 이벤트를 내보내는 순간 즉시 completed되고 그 뒤로는 어떤 값도 받지 않는다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func takeCount() {

    let bag = DisposeBag()

    // 앞에서 4개(0~3)만 받고 즉시 completed
    Observable.from(Array(0...10)).take(4).subscribe({ print($0) }).disposed(by: bag)
}

func takeWhile() {

    let bag = DisposeBag()

    // 짝수인 동안만 받다가 처음 홀수(7)를 만나는 순간 completed
    // skipWhile과 달리 조건이 깨지면 스트림 자체가 끝나버림
    Observable.from([2, 4, 6, 7, 8, 9]).takeWhile({ $0 % 2 == 0 }).subscribe({ print($0) }).disposed(by: bag)
}

func takeUntil() {

    let bag = DisposeBag()

    let subject = PublishSubject<Int>()

    let trigger = PublishSubject<Void>()

    subject.takeUntil(trigger).subscribe({ print($0) }).disposed(by: bag)

    subject.onNext(1)  // 출력됨
    subject.onNext(2)  // 출력됨

    trigger.onNext(())  // 이 시점에 즉시 completed

    subject.onNext(3)  // completed 이후라 무시됨
    subject.onNext(4)  // completed 이후라 무시됨
}

핵심 요약

  • take(count): 앞에서부터 지정한 개수만 받고 즉시 completed
  • takeWhile: 조건이 true인 동안만 받다가, 처음 false가 되는 순간 completed - skipWhile은 조건이 깨져도 스트림이 계속 살아있지만, takeWhile은 조건이 깨지면 스트림 자체가 끝난다
  • takeUntil: 트리거 Observable이 첫 이벤트를 내보내는 순간 즉시 completed. skipUntil과 정확히 대칭되는 동작

4. Distinct Operator

이전에 방출된 값과 비교해서, 조건에 맞지 않는 값은 걸러내고 원하는 값만 통과시키는 연산자다. 기본 제공되는 distinctUntilChanged()와 커스텀 비교 로직을 직접 짜는 distinctUntilChanged { } 두 가지 형태를 다룬다.

  • distinctUntilChanged(): 바로 직전에 방출된 값과 같은 값이 연속으로 오면 무시한다. 값 자체가 달라지지 않는 이상 계속 무시되기 때문에, 연속된 중복만 걸러진다는 점이 핵심이다 (전체 시퀀스에서 중복 제거가 아님)
  • distinctUntilChanged { first, second in … }: 비교 로직을 직접 정의할 수 있다. 클로저가 true를 반환하면 “중복(무시)”으로 처리되고, false를 반환하면 값이 그대로 통과된다. 첫 번째 값은 비교 대상이 없어서 조건 없이 무조건 통과된다
  • 클로저의 first는 마지막으로 통과된(방출된) 값이고, second는 이번에 들어온 새 값이다. 무시된 값은 다음 비교에 쓰이지 않는다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func distinctInARow() {

    let bag = DisposeBag()

    // 바로 직전 값과 같은 연속된 중복만 제거 -> 0, 1, 2, 3
    Observable.from([0, 0, 1, 1, 1, 2, 3, 3, 3, 3]).distinctUntilChanged().subscribe({ print($0) }).disposed(by: bag)
}

func customDistinct() {

    let bag = DisposeBag()

    // first: 마지막으로 통과된 값 / second: 새로 들어온 값
    // first > second가 true면 무시 -> 즉 "이전 통과값보다 작거나 같은 값"은 다 걸러지고
    // 계속 커지는 값만 통과됨
    Observable.from([0, 1, 0, 2, 1, 0, 3, 2, 0, 4]).distinctUntilChanged { (first, second) -> Bool in

        print(first, second, first > second)

        return first > second

    }.subscribe({ print($0) }).disposed(by: bag)
    // 결과: 0, 1, 2, 3, 4
}

핵심 요약

  • distinctUntilChanged(): 바로 직전 값과 같은 연속 중복만 제거하는 편의 메서드
  • distinctUntilChanged { }: 클로저가 true를 반환하면 무시, false를 반환하면 통과 - 직접 비교 조건을 정의할 수 있다
  • 클로저의 first는 마지막으로 통과된 값을 기준으로 계속 갱신되며, 무시된 값은 다음 비교에 영향을 주지 않는다
  • 프로그레스 바처럼 “값이 줄어들면 무시하고 커질 때만 갱신” 같은 케이스에 실전 활용 가능

5. Share Operator

같은 Observable을 여러 구독자가 구독할 때, 기본적으로는 구독할 때마다 Observable이 통째로 새로 복사되어 독립적으로 동작한다. 구독자마다 다른 값을 받게 되고, 메모리도 그만큼 더 쓰인다. Share Operator는 이 복사를 막고 단일 인스턴스를 여러 구독자가 공유하도록 만든다.

  • share가 없을 때: 구독자마다 Observable의 복사본이 새로 생성된다. Observable.create 클로저 내부 로직이 구독 시점마다 다시 실행되기 때문에, 구독 시점에 따라 각 구독자가 받는 값이 달라진다
  • share()만 사용: 단일 인스턴스를 공유하지만, 그 인스턴스가 completed되면 그대로 폐기된다. 이후 새로 구독하면 완전히 새로운 복사본이 다시 만들어지므로, Observable이 completed되지 않고 계속 살아있어야 share의 의미가 있다
  • share(replay:scope:): 지정한 개수만큼 이전 값을 버퍼에 저장해뒀다가, 뒤늦게 구독한 구독자에게도 그 값을 재생(replay)해준다. scope: .forever로 주면 구독자가 모두 사라져도 인스턴스와 버퍼를 계속 유지한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func share() {

    var num = 0

    func getFirst() -> Int {

        num += 1

        return num
    }

    let observable = Observable<Int>.create { (observer) -> Disposable in

        // share가 없다면 이 클로저 전체가 구독마다 다시 실행됨
        // -> getFirst()가 구독자마다 따로 호출되어 서로 다른 값을 받게 됨
        let first = getFirst()

        observer.onNext(first)
        observer.onNext(first + 1)
        observer.onNext(first + 2)
        // onCompleted 없음 -> share()만 썼다면 스트림이 끝나지 않아야
        // 두 번째 구독자가 같은 인스턴스를 공유받을 수 있음

        return Disposables.create {

            print("Disposed")
        }
    }
    // replay: 3 -> 지금까지 방출된 최근 3개 값을 버퍼에 저장해뒀다가
    //              늦게 구독한 두 번째 구독자에게도 동일하게 재생
    // scope: .forever -> 구독자가 모두 사라져도 인스턴스와 버퍼를 계속 유지
    .share(replay: 3, scope: .forever)

    let bag = DisposeBag()

    observable.subscribe({ print("first", $0) }).disposed(by: bag)

    // 첫 구독이 끝난 뒤 구독해도, replay 버퍼 덕분에 first와 동일한 값을 받음
    observable.subscribe({ print("second", $0) }).disposed(by: bag)
}

핵심 요약

  • share 없음: 구독마다 Observable이 통째로 복사되어 독립 실행 - 구독자마다 다른 값을 받고 메모리도 더 쓴다
  • share(): 단일 인스턴스를 공유하지만, completed되는 순간 폐기된다 - completed되지 않는 스트림에만 의미가 있다
  • share(replay:scope:): 지정한 개수만큼 과거 값을 버퍼에 저장해 늦은 구독자에게도 재생하고, scope: .forever면 구독자가 없어도 인스턴스를 유지한다
  • 여러 구독자가 같은 값을 받아야 하고, 매 구독마다 로직이 중복 실행되는 게 부담스러운 경우(네트워크 요청 등)에 실전 활용 가치가 크다

6. Ignore in Practice

지금까지 배운 Ignore/Skip/Filter/Take 계열 연산자를 조합해서 실전 예제를 만들어본다. 앱 회원가입에서 이메일 대신 전화번호를 아이디로 쓰는 경우, 입력값에서 유효한 숫자만 걸러내 10자리 전화번호를 완성하는 로직이다.

  • 이메일을 아이디로 쓰면 가짜 계정 생성이 쉽고, 비밀번호나 이메일 자체를 잊어버리는 사용자도 많다. 전화번호를 아이디로 쓰면 이 두 문제를 어느 정도 줄일 수 있다
  • 사용자가 숫자를 하나씩 입력한다고 가정하고, 그 입력 스트림에서 앞의 불필요한 0과 두 자릿수 이상 값을 걸러내면서 정확히 10자리만 뽑아내는 것이 목표다
  • skipWhile로 맨 앞의 0들을 건너뛰고, filter로 한 자릿수(0~9)가 아닌 값을 걸러내고, take(10)으로 10개까지만 받은 다음, toArray()로 한 번에 배열로 묶어서 문자열로 합친다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func phoneNumberFormatter() {

    let bag = DisposeBag()

    let subject = PublishSubject<Int>()

    subject
        .skipWhile({ $0 == 0 })   // 맨 앞에 연속으로 오는 0만 건너뜀 - 0이 아닌 값이 한 번 나오면 이후로는 skip 중단
        .filter({ $0 < 10 })      // 10 이상인 값(두 자릿수 이상 입력 실수 등)은 걸러냄
        .take(10)                 // 유효한 값이 10개 모이면 즉시 completed
        .toArray()                // completed 시점에 지금까지 모은 값을 배열 하나로 묶어 방출
        .subscribe { (event) in

            // toArray는 배열 next 이벤트 한 번, 그리고 completed 이벤트가 옴
            // element가 nil이면 next가 아니라는 뜻이라 이벤트 자체를 출력
            guard let element = event.element else { print(event); return }

            print(element.map({ String($0) }).joined())

        }.disposed(by: bag)

    subject.onNext(0)   // 맨 앞 0 -> skipWhile에서 건너뜀
    subject.onNext(14)  // skipWhile은 통과되지만 filter에서 걸러짐 (10 이상)
    subject.onNext(8)   // 첫 번째로 살아남는 값
    subject.onNext(0)   // 14 이후로는 skipWhile이 더 이상 skip하지 않으므로 이 0은 통과

    "0555888823498234928".forEach { (num) in

        guard let number = Int(String(num)) else { return }

        subject.onNext(number)
    }
    // 최종적으로 살아남는 값: 8, 0, 0, 5, 5, 5, 8, 8, 8, 8 (10개)
    // take(10)이 여기서 completed되고 나머지 숫자는 무시됨
    // 출력: "8005558888"
}

핵심 요약

  • skipWhile은 맨 앞의 불필요한 값(연속된 0)만 걸러내고, 조건이 한 번 깨지면 그 뒤로는 관여하지 않는다
  • filter는 스트림 전체에 걸쳐 계속 조건을 검사해서 원치 않는 값(두 자릿수)을 지속적으로 제거한다
  • take(10)은 원하는 개수가 모이는 즉시 스트림을 끊어서, 뒤에 더 들어오는 값은 아예 무시하게 만든다
  • toArray()는 개별 next 이벤트들을 하나로 모아 배열 형태로 한 번에 받고 싶을 때 사용 - completed 시점에 지금까지의 값을 배열로 방출한다
  • 여러 필터링 연산자를 체이닝하면 “앞부분 무시 -> 조건에 안 맞는 값 제거 -> 개수 제한”처럼 단계별 파이프라인을 자연스럽게 구성할 수 있다
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.