[Combine] Combine 진짜 알기 쉽게 정리해서 올려줄게 (2) - 실전 코드와 함께 Publisher, Operator 심화 개념 알아가기

2024. 8. 10. 23:00Framework, Library

 

[Combine] Combine 진짜 알기 쉽게 정리해서 올려줄게 (1) - Combine 기초 개념 이해하기

⚠️ 이번 글은 Combine Framework가 처음 소개되었던 WWDC19의 두 세션 Introducing Combine과 Combine in Practice의 예제 을 직접 구현하면서 사용된 Combine 개념들에 대해 집중적으로 소개합니다.이 글에서 소개

mini-min-dev.tistory.com

지난 Combine 진짜 알기 쉽게 정리해서 올려줄게 1탄 글에서는
Combine이란 무엇이고, 왜 쓰는 것이고, 핵심 개념 3가지 (Publisher, Subscriber, Operator)와 이것들이 서로 어떤 식으로 메서드를 호출하면서 작동이 이루어지는지까지 살펴봤다.

오늘 쓰는 2탄 글에서는 설명한 Combine 개념을 실제 코드에 적용한 예제와 함께
Publisher, Operator는 어떤 종류가 있고 / 각각은 어떤 특징이 있어 / 어떻게 사용할 수 있는지까지 조금 더 심화적인 내용을 다뤄보고자 한다. (Subscriber와 관련된 내용은 글이 너무 길어져서 3탄에서 다룰 예정 ^__^)


실제 Combine을 사용해서 구현한 화면은 아래와 같다.
WWDC19에서 Combine을 처음 소개했을 때 사용했던 <Wizard School Signup>이라는 예제이고, (내가 직접 구현하면서 조금 수정했다) 사용자로부터 wizardName과 password, repeat password 값을 입력받아 비동기적으로 값의 유효성을 체크하고 User Interface의 변화를 주는 게 목표다.
조금 더 구체적으로는 아래 세 가지 목표를 달성하는 것이 이 예제의 목표라고 볼 수 있다.

  1. password와 repeat password 두 값의 일치 여부와 함께 특정 글자수 이상으로 비밀번호가 설정되었는지 체크한다. (image tintColor 변환)
  2. Network를 호출해서 사용할 수 있는 Wizard name인지 값을 체크한다. (image tintColor 변환 & activatorIndicator 상태 처리)
  3. Wizard name과 password, repeat password가 모두 유효한지 체크한다. (button의 isEnabled 상태 처리)

지금부터 해당 예제와 Combine의 심화적인 내용까지 알아보도록 하자!

오늘 Combine을 설명하며 구현할 화면!

 

1️⃣ Convenience Publishers - 다양한 Publisher 알아보기

Combine 프레임워크에서의 Publisher 기본 개념은 아래와 같았다.

💡 Publishers declare that a type can transmit a sequence of values over time. = Publisher는 시간이 경과함에 따른 전송 가능한 일련의 값을 정의합니다.

내가 만들고자 하는 앱에서 우선적으로 필요했던 Publisher의 동작 플로우는
사용자가 3개의 텍스트필드(name, password, repeat password)에 새로운 값을 입력할 때마다(.editingChanged) 해당 텍스트필드에 입력된 String 타입의 text 값을 개별적으로 방출(publish)하는 것이었다.
*시간이 경과함에 따른 = 텍스트 필드의 값이 수정될 때마다 / 전송 가능한 일련의 값 = 텍스트 필드에 들어온 String 타입의 text

Swift 5.1에 생긴 @Published 프로퍼티 래퍼(property wrapper)를 사용하면 해당 프로퍼티 값을 Publisher로 추가할 수 있었다.

@Published해당 프로퍼티가 변경될 때마다 자동으로 Publisher가 value를 publish하도록 만드는 어노테이션이다.
물론, @Published라고 선언되었더라도 기본적인 속성은 "프로퍼티(property)"이기 때문에, 일반적인 프로퍼티처럼 값을 저장하거나 / 값을 가져올 수도 있다.

실제 코드의 동작 흐름을 살펴볼까?

  1. 3개의 텍스트 필드는 textFieldChanged(_:)라는 objc 함수가 타깃 메서드로 연결되어 있다.
  2. textFieldChanged(_:) 메서드의 구현부는 텍스트 필드 값이 editingChanged될 때마다 @Published로 선언된 프로퍼티에 새로운 text값이 계속 저장되도록 한다.
  3. @Published 프로퍼티는 새로운 값이 들어올 때마다 해당 String 값을 계속 방출(publish)한다.
// MARK: - Publisher
@Published private var userName: String = ""
@Published private var password: String = ""
@Published private var passwordAgain: String = ""

// MARK: - Setup Target
[userNameTextField, passwordTextField, confirmPasswordTextField].forEach {
    $0.addTarget(self, action: #selector(textFieldChanged), for: .editingChanged)
}

@objc func textFieldChanged(_ sender: UITextField) {
    switch sender {
    case userNameTextField: 
        userName = sender.text ?? ""
    case passwordTextField:
        password = sender.text ?? ""
    case confirmPasswordTextField:
        passwordAgain = sender.text ?? ""
    }
}

내가 사용한 @Published는 매우 단순한 형태에 Publisher에 불과하고,
사실 Combine에서는 이 외에도 더 다양한 종류의 Publisher를 <Convenience Publishers>라는 이름으로 지원하고 있다.

이 부분에서 다양한 Publisher의 종류에 대해 배우고 넘어가보도록 하자.

💡 Convenience Publishers

1. Future : 비동기 작업의 단일 결과를 나타내는 경우 사용하는 Publisher. 성공적(Output)으로 값을 배출할 수도/실패(Failure)할 수도 있다.
2. Just : 각 Subscriber (each)에게 딱 한 번만 값(Ouput)을 방출하고 완료되는 Publisher
3. Deffered : Publisher의 생성을 지연시키는 Publisher - 주로 새로운 Subscriber가 생겼을 때마다 동일한 작업을 반복적으로 수행시킬 때 사용한다.
4. Empty : 아무 값도 방출하지 않고, 선택적(Output, Failure)으로 즉시 완료시키는 Publisher
5. Fail : 지정된 오류를 즉시 방출하고 바로 종료시키는 Publisher
6. Record : 미리 정의된 일련의 값과 완료 또는 오류를 방출하는 Publisher

+ 이 외에도 Publisher의 기능을 갖고 있는 PassthroughSubject나 CurrentValueSubject도 사용할 수 있지만, 이 두 녀석은 Subject를 별도로 다룰 때 더 자세하게 설명하도록 하겠다!

다시 돌아와서,
지금까지는 사용자로부터 텍스트 값을 입력받고, 그 입력받은 값을 들어올 때마다 계속 방출하는 기본적인 Publisher를 완성했다.

하지만 나에게 필요한 Publisher는 아직 본격적으로 구현되지 않은 상황이다.
이 글 처음에서 내가 구현해야할 목표는 아래 세 가지라고 언급했었다. (password와 repeat password 두 값의 일치 여부와 함께 특정 글자수 체크 / Network를 호출해서 사용할 수 있는 Wizard name인지 체크 / Wizard name과 password, repeat password가 모두 유효한지 체크)

지금부터 순서대로 세 가지 Publisher를 구현해 보며 이와 함께 사용되는 Operator도 함께 알아보고자 한다.

<Wizard School Signup> 예제에서 사용할 Publisher 세 가지

 

2️⃣ Advanced Publisher/Operator (1) - 비밀번호 값을 검증하는 Publisher 만들기

첫 번째로 만들 녀석은 두 개의 @Published 프로퍼티 (password & passwordAgain)을 결합하고, 비밀번호의 유효성을 체크하는 조건 로직을 지나, 유효한 비밀번호 값을 publish 하는 AnyPublisher<String?, Never> 타입의 Publisher다.

여기서 살펴보게 될 Publisher와 Operator의 자세한 개념을 설명을 듣기 전에, 먼저 간단한 내용만 정리해보고 코드를 살펴보자.

  • combineLatest : 두 Publisher를 결합할 때 사용된 Operator
  • map : Publisher가 방출하는 값을 다른 값으로 변환(유효성이 보장된 password)해서 방출할 때 사용된 Operator
  • AnyPublisher, eraseToAnyPublisher : 타입 소거(type-erased)된 Publisher 타입으로 선언해 내부에 구현된 구체적인 Publisher 타입을 숨기는 역할
// First Publisher
// Passwords must matched & > N Characters
var validatedPassword: AnyPublisher<String?, Never> {
    return $password
        .combineLatest($passwordAgain)
        .map { password, passwordAgain in
            guard password == passwordAgain, password.count > N else { return nil }
            return password
        }
        .eraseToAnyPublisher()
}

이제 자세하게 하나씩 설명을 해보겠다.

우선, 비밀번호의 유효성을 체크하기 전에 해줘야 하는 작업은 위에서 만들었던 두 개의 @Published 프로퍼티에서 방출되고 있는 password와 passwordAgain의 값을 결합해서 새로운 Publisher를 만드는 작업이다. (두 password가 서로 일치하는지를 확인해야 하기 때문!)
이때 사용할 수 있는 두 가지 연산자(Combining Operator)는 Zip과 CombineLatest이다.

💡 Combining Publishers Operator 
: Zip와 CombineLatest는 모두 두 개 이상의 Publisher를 결합하여 새로운 값을 방출하는 Publisher를 만들 수 있다!


- Zip : "When/And" 연산 -> 각 Publisher가 새 값을 방출할 때까지 기다렸다가 - 방출된 값들의 쌍이 모두 맞춰지면 단일 튜플로 변환해 publish한다.
- CombineLatest : "When/Or" 연산 -> Publisher 중 새 값이 한 곳에서라도 방출되면 단일 값으로 변환해 publish한다.

password와 passwordAgain 구현부에서는 Zip이 아닌 CombineLatest를 사용했다.
-> CombineLatest를 사용한다는 것은 두 password 값 중, 어느 하나라도 publish될 때 바로 단일 값을 만들겠다는 의미인 거다!

@Published로 선언된 프로퍼티를 받아올 때는 $ 키워드를 붙여서 사용하는 것까지 알아두자! ($password.combineLatest($passwordAgain))


두 번째로 password와 passwordAgain 값이 하나의 단일 값으로 방출되던 것을 - 두 값의 일치 여부와 특정 글자수 이상인지를 확인해서 - password 값만 방출되도록 변환해 주는 작업을 수행했다.

- map : 고차함수 map 함수와 유사하게, Publisher가 방출하는 값을 다른 값으로 변환한다.

guard문을 사용해 CombineLatest로부터 넘어온 두 값 password와 passwordAgain 값의 일치(==)를 확인하고 / password의 글자수(count)가 N개 이상인지를 함께 확인해서 
유효한 비밀번호라고 확인되는 경우는 password를 return, 조건에 맞지 않는 경우에는 nil을 return 하도록 했다.


마지막으로 위에서 다양한 종류의 Publisher를 살펴봤지만, 여기서는 Publisher를 AnyPublisher<String?, Never> 타입으로 선언했다.
*AnyPublisher의 Value값인 String은 password 또는 nil이 return되는 위 map에서 비롯된 것이고, Failure값인 Never는 절대 에러가 발생하지 않는다는 것을 의미한다고 보면 된다.

- eraseToAnyPublisher() : 기존 Publisher의 구체적인 타입을 숨기고 AnyPublisher 타입으로 변환시킨다.

그럼 굳이 왜 AnyPublisher 타입으로 변환시키냐고?

AnyPublisher는 "어느 타입의 Publisher든 수용할 수 있도록 해주는 타입"이다.
조금 더 구체적으로 설명해서 이 Publisher가 지금까지 설명했던 것처럼 이렇게 저렇게 구현되어 있다는 것을 숨기고 그저 "<String?, Never>를 방출하는 Publisher이다"만 노출시키게 되는 셈인 거다.
-> 특정한 함수나 프로퍼티에서는 Publisher의 타입을 노출하는 것보다는 / 반환되는 Publisher를 숨기는 것이 더 유연하게 Publisher를 설계할 수 있게 만드는데 도움이 된다고 한다! (이 부분은 무슨 말인지 더 알아봐야 할 필요가..)

Publisher #1. Passwords must match & > N Characters Publisher의 동작 흐름

 

3️⃣ Advanced Publisher/Operator (2) - 서버 통신을 통해 이름값을 검증하는 Publisher 만들기

두 번째로는 서버로부터 입력받은 usename 값이 유효한지를 검증하는 (처음 만든 Publisher와 동일한) AnyPublisher<String?, Never> 타입의 Publisher를 만들어볼 것이다.
잠깐! 해당 Publisher는 AnyPublisher 타입으로 선언되어 구체적인 어떤 Publisher인지를 숨기도록 되어있지만 / 실제로는 비동기 작업의 단일 결과를 배출하는 Future로 정의되어 있는 것을 확인할 수 있다.

이번 Publisher에서 설명할 내용은 불필요한 네트워크 요청을 방지하기 위해 사용된 operator인 debounce, removeDuplicates / 그리고 비동기 작업을 처리할 때 사용할 flatMap 정도이다.

특히, Combine의 핵심은 비동기 작업을 유용하게 처리할 수 있도록 만드는 프레임워크라는 점이기 때문에 특히 이번 챕터를 집중해서 잘 살펴보자!

// Second Publisher
// User name is valid according to server
var validatedUsername: AnyPublisher<String?, Never> {
    return $username
        .debounce(for: 0.5, scheduler: RunLoop.main)
        .removeDuplicates()
        .flatMap { userName in
            return Future { promise in
                self.userNameAvailable(username) { available in
                    promise(.success(available ? username : nil))
                }
            }
        }
        .eraseToAnyPublisher()
}

지금 선언한 validatedUsername Publisher는 텍스트 필드의 text 값이 변경될 때마다 방출되는 @Published var username 프로퍼티로부터 값을 받아 ($username 사용) - 네트워크 통신을 보내 유효성을 검증하고 - 검증된 username 값을 보내도록 로직이 만들어졌다.

여기서의 일차적인 문제는 @Published 프로퍼티의 내용이 변경될 때마다 매번 값을 방출한다는 점이다.

= 네트워크 통신을 보내야 하는 이 Publisher 입장에서 - 사용자가 텍스트필드를 수정 한번 한번 할 때마다 매번 통신을 보내는, 불필요한 네트워크 요청이 발생하게 되는 상황이 발생하게 되는 것!
나는 debounce와 removeDuplicates 두 연산자를 사용해서 이 불필요한 네트워크 요청을 줄이는 방법을 선택했다.

- debounce : 연속적인 이벤트 스트림에서 불필요한 방출(publish)을 피하고, 지정된 시간(for:) 동안 사용자에게 추가 이벤트가 발생하지 않았을 때만 방출하도록 만든다.
- removeDuplicates : 연속적인 이벤트 스트림에서 이전 값과 동일한 값을 방출하는 경우 (= min 글자를 publish하고-n을 지웠다가 다시 n을 쓰는 경우-min이 또 방출되는 상황) 해당 이벤트의 방출을 막는다.

지금까지 작성한 코드는 그저 방출되는 값을 보다 부드럽게 방출될 수 있도록 만든 점이 전부이다.
이제부터 서버로의 요청을 비동기적인 코드로 작성해서 부드럽게 방출되는 username이 유효한지를 체크할 수 있도록 만들어보겠다.

var validatedUsername: AnyPublisher<String?, Never> {
    return $username
        .debounce(for: 0.5, scheduler: RunLoop.main)
        .removeDuplicates()
        .eraseToAnyPublisher()
}

네트워크를 호출하는 userNameAvailable(_:, completion:)이라는 메서드가 있다고 가정해 보자.
*이 글에서 자세하게 언급하지는 않았지만 completion으로 값을 전달하는 별도의 메서드로 구현하지 않고, dataTaskPublisher(for:)라는 URLSession의 Publisher 메서드를 사용한다면 네트워크 통신 결과를 바로 방출할 수 있는 방법도 있다.

func userNameAvailable(_ userName: String, completion: @escaping (Bool) -> Void) {
    // 네트워크 통신 부분 어쩌구.. 부분 생략하고 요약.. 
    let result = try JSONDecoder().decode(Bool.self, from: data)
    completion(result)
}

위 메서드에서 값을 Publisher로 가져오기 위해 flatMap과 Future를 사용해 보겠다.

- flatMap : 특정 Publisher에서 방출되는 값에 대해 새로운 Publisher를 생성한다. 여기서 새롭게 생성한 Publisher는 기존 Publisher의 이벤트 흐름에서 이어지도록 만든다.

flatMap과 Future 클로저 블록 내부에서 이 네트워크 함수를 호출할 거다.

특히, Future 클로저는 비동기 작업이 완료된 후 호출되며, 성공(.success) 또는 실패(.failure)의 결과를 받게 되는데
성공 상황에서 위의 네트워크 함수 userNameAvailable(_:, completion:)로부터 유효성 결과를 받으면 - name의 유효함에 따라 name 값 혹은 nil 값을 비동기적으로 방출하도록 로직을 작성할 수 있었다.

var validatedUsername: AnyPublisher<String?, Never> {
    return $username
    ...
        .flatMap { userName in
            return Future { promise in
                self.userNameAvailable(username) { available in
                    promise(.success(available ? username : nil))
                }
            }
        }
    ...
}

마지막으로, eraseToAnyPublisher()를 사용해서 위에서 언급한 AnyPublisher 타입으로 만들었던 것까지 작성하게 되면, 두 번째 Publisher까지 성공적으로 구현이 완료된다.
아래 이미지를 보면서 두 번째 Publisher의 동작 흐름을 리마인드 해보자!

Publisher #2. User name is valid according to Server Publisher의 동작 흐름

 

4️⃣ Advanced Publisher/Operator (3) - 두 검증된 publisher를 합치는 또 하나의 Publisher 만들기

마지막으로 구현해야 할 Publisher는 단순하다.
위에서 만들었던 두 가지 Publisher(validatedUsername, validatedPassword)를 결합해서 모두 유효한 값이 넘어올 경우 (userName, password) 단일 튜플 쌍을 방출하도록 만드는 것이 목표다.

복습!
두 개 이상의 Publisher를 결합할 수 있는 방법은 Zip과 CombineLatest가 있었다.
여기서 CombineLatest를 사용해서 결합한다는 것은 두 Publisher 중 한 값이라도 새롭게 방출하면 새로운 결합을 수행하도록 만들겠다는 의미였다는 것 기억할 것이다.

복습 2!
map Operator를 사용하면 Publisher가 방출하는 값을 다른 값으로 변환할 수 있었다.
해당 코드에서는 userNamer과 password가 단일 값으로 넘어오던 것을 조건을 확인한 후, 하나의 단일 튜플로 묶어서 방출하도록 변환했다.

// Third Publisher
// Enabled if username and password valid
var validatedCredentials: AnyPublisher<(String, String)?, Never> {
    return validatedUsername
        .combineLatest(validatedPassword)
        .map { userName, password in
            guard let userName, let password else { return nil }
            return (userName, password)
        }
        .eraseToAnyPublisher()
}

이 마지막 Publisher까지 구현되면 전체 애플리케이션에서 사용하는 Publisher는 모두 정복했다고 봐도 된다!

아래 마지막 Publisher의 흐름을 보면서, 지금까지 설명한 모든 Publisher들의 동작까지 전체적으로 살펴볼 수 있다.
*Publisher 부분은 다른 색으로 표시했다.

Publisher #3. Enable if username and passwords valid Publisher의 동작 흐름

 

5️⃣ 잠깐! 이것보다 더 많은 Operator가 있다고?

💡 Operators describe a behavior for changing values = Operator는 값의 변화와 관련된 행동을 설명합니다.

이 예제에서 Publisher를 만들면서 다양한 Publisher 종류를 알아봄과 동시에, 다양한 Operator까지 살펴볼 수 있었다.
하지만, 아직까지도 다루지 못한 많은 종류의 Operator들이 남아있기에 몇몇 Operator들에 대해서 더 살펴보고 2탄 글을 마무리해보고자 한다.

Apple은 Combine에서 제공하는 Operator의 종류(= Declarative Operator API라고 부른다)를 크게 다섯 가지로 나누어서 설명하고 있다.
위에서 설명하지 않은 연산자들만 빠르게 살펴보자!

  • Functional transformations : Publisher에서 방출된 값을 변환하거나 필터링하는 등의 변형을 주는 데 사용
    • compactMap : 옵셔널 요소를 처리해 nil이 아닌 값만 방출되도록 할 때 사용
    • filter : 조건에 맞는 값만 방출하고자 할 때 사용. 고차함수의 filter와 동일한 역할!
    • reduce : 모든 값을 하나의 결과로 축약할 때 사용
    • scan : reduce와 비슷한 역할을 하지만, 중간 결과를 방출하고자 할 때 사용

  • List operations : 리스트와 같이 순서가 있는 값을 다루거나, 데이터 스트림에서의 여러 값을 다루는 데 사용
    • collect : 여러 값을 배열로 묶어서 방출되도록 할 때 사용
    • first / last : 스트림의 첫 번째 값을 방출하고 완료 / 스트림이 완료될 때까지 기다렸다가 마지막 값을 방출
    • prepend / append : 데이터 스트림의 가장 앞, 또는 가장 뒤에 값을 추가
  • Error handling : Publisher에서 발생한 오류를 처리하는 데 사용
    • catch : 오류가 발생하면, 대체 Publisher를 반환하여 데이터 스트림을 지속할 수 있도록 함
    • retry : 오류가 발생할 경우 지정된 횟수만큼 재시도할 수 있도록 함
    • replaceError : 오류가 발생하면, 지정된 값을 방출하고 완료
    • assertNoFailure : 오류가 발생하면 런타임 오류를 발생 / <Failure: Never> 타입의 디버깅 용도로 사용된다.

  • Thread or queue movement : 데이터 스트림의 처리를 다른 스레드나 큐에서 사용할 수 있도록 옮기는 데 사용 (ex. 무거운 작업을 처리할 때 background thread에서 비동기적으로 처리할 수 있도록 하거나 / UI 업데이트를 main thread에서 처리할 수 있도록 하거나 등)
    • subscribe(on:) : on 파라미터로 작업을 시작할 때 사용할 스레드를 결정한다.
    • receive(on:) : 마찬가지 on 파라미터로 작업을 받았을 때 사용할 스레드를 결정한다.

  • Scheduling and time : Publisher를 시간 기반으로 방출하도록 만드는 데 사용 (ex. 일정 시간 간격으로 반복해서 방출하거나 / 딜레이 타임아웃 같은 제약을 걸거나 등)
    • delay : Publisher에서 방출되는 값을 일정 시간만큼 지연시킬 때 사용
    • timeout : 지정된 시간 내에 이벤트가 발생하지 않는 경우 오류를 발생
    • timer(= interval) : 일정한 시간 간격으로 이벤트를 방출하는 Publisher를 생성할 때 사용

위에서 나름 자주 사용하는 Operator의 종류를 축약해서 정리한 것에도 불구하고, 종류가 매우 다양하다.

"이 많은 것을 다 어떻게 알고 어떻게 사용하지?"라는 의문을 던지는 우리들에게 Apple은 "Combine의 핵심 디자인 원칙인 <Composition>으로 돌아가라"라는 말을 던진다.

엥, 이게 무슨 말이지?

우와..이 많은 거 다 어떻게 알고 사용해 -> Apple 왈, Composion으로 돌아가라?!

💡 많은 기능을 수행할 수 있는 소수의 Operator가 아니라, 작은 작업을 수행하는 다수의 Operator를 제공한다.

Combine의 핵심 디자인 원칙이라 설명하는 <Composition><여러 개의 작은 기능을 결합하여 더 큰 기능을 구성하는 것>을 의미한다. 
이 점은 함수형 프로그래밍의 특징이라고 볼 수도 있는데,
작은 작업을 수행하는 많은 Operator들을 결합함으로써 / 복잡한 데이터 처리 과정을 더 작은 단계로 나눌 수 있게 되고 / 이로 인해 더 간결하고 직관적으로 사용하는 것을 Apple이 의도했다는 점이다!

즉 Combine을 처음 접할 때 많은 Operator의 양이 주는 압도감이 있을 수 있지만,
그 Operator 하나하나가 제공하는 기능보다 < Operator 여러 개가 조합했을 때 나오는 시너지 + 데이터 처리의 흐름을 이해하는 데 더 학습을 기울이라는 말로 설명하고 있다고 받아들이면 좋겠다.


글이 너무 길어져서 3탄으로 이어서 돌아오겠다.
3탄에서는 Subscriber의 구체적인 구현부와 종류, 사용법 / 마지막으로 Subject라는 Publisher와 Subscriber의 특징을 모두 갖고 있는 조금 특별한 녀석까지 살펴볼 것이다!

오늘은 여기까지 :)

+ 3탄 글 작성 완료!

 

[Combine] Combine 진짜 알기 쉽게 정리해서 올려줄게 (3) - 실전 코드와 함께 Subscriber, Subject 심화 개념

[Combine] Combine 진짜 알기 쉽게 정리해서 올려줄게 (1) - Combine 기초 개념 이해하기⚠️ 이번 글은 Combine Framework가 처음 소개되었던 WWDC19의 두 세션 Introducing Combine과 Combine in Practice의 예제 을 직접

mini-min-dev.tistory.com