RxSwift / Combine 입문 : 옵저버 패턴의 메커니즘

bono·2023년 8월 13일
10
post-thumbnail

안녕하세요 보노(해언)입니다!

최근 겪은 몇몇 상황과 책을 읽던 중 얻은 깨달음을 토대로 포스팅을 작성합니다. 본래는 고차함수에 대한 포스팅을 기획했으나, 개념을 확장하여 옵저버 패턴을 다루려 합니다.

  1. 옵저버 패턴의 기초
  2. 고차함수를 통한 옵저버 패턴의 응용

로 진행되는 서술입니다.

개인적인 생각이지만 옵저버 패턴에 고차함수가 적용된 구간을 이해하고나면 옵저버 패턴의 구성을 이해할 수 있으리라 생각합니다.

본 포스팅을 통해 아래와 같은 코드의 작동 메커니즘을 추측할 수 있습니다

let subject = PublishSubject<String>()

// 구독
subject
    .subscribe(onNext: { message in
        print("Received event with message: \(message)")
    })
    .disposed(by: disposeBag)

// 이벤트 발행
subject.onNext("Hello, RxSwift!")

실질적인 코드나 사용법을 다루기 보다는 그를 이해하여 풀어놓은 해설에 가까운 글입니다. (옵저버 패턴의 사용 예를 다루지는 않습니다)

RxSwift, CombineReactive programing을 접하며 옵저버 패턴에 대해 관심이 생겼으나, 아직 이해가 부족한 분들께 도움이 되는 글이길 바랍니다


옵저버 패턴?

옵서버 패턴(observer pattern)은 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다.

주로 분산 이벤트 핸들링 시스템을 구현하는 데 사용된다. 발행/구독 모델로 알려져 있기도 하다.
출처 위키백과

(역시 근본 개념은 눈에 잘 안들어온다)
간단히 말해, 옵저버 패턴은 1:N으로 상태 변화 전달이 가능한 디자인 패턴이다

위의 그림은 옵저버 패턴에 대한 이해를 돕기위한 그림이다.

신문 이라는 이벤트Publisher에 의해 발행되고, 그걸 Pubilser를 구독한 Observer들이 수신하는 상황이다.

이처럼 발행할 이벤트(신문)을 가진 객체를 발행자라고 부르고, 해당 객체의 변화를 전달 받기를 희망하는 객체들을 구독자라고 부른다. (꽤나 직관적인 명명이라고 생각한다)

더불어 발행자구독자를 부르는 명칭은 예제 코드마다 다르다.

본 포스팅에서는 발행자Publisher, 구독자Observer 라 부르려 한다. 포스팅 마다 발행자를 Obserable, 구독자를 Subsriber 라 부르는 등 용어에 차이가 있다.


+)

RxSwift / Combine에는 발행자와 구독자의 역할을 세분화하거나, 혼합하여 만든 추가적인 개념이 있다. (이벤트 또한 발행 형태가 다양하다)

그러나 근간은 발행자구독자이다.

무엇이 되었건 발행자구독자를 지칭하는 명칭이 무엇인지 확인하고 읽으면 코드를 읽을 수 있다.


옵저버 패턴에 대한 서론은 끝났다.

그래서 옵저버 패턴은 어떻게 구성되어 있길래 1:N 관계를 어떻게 성립시킨 걸까?

뜯어 볼 예시 코드를 ChatGPT에게 요청해 보자

아래는 ChatGPT의 답변에 해당한다

당장은 이해보다 등장하는 키워드와 구성에 집중해 보자

예제 코드 (1)

// Observer 프로토콜 정의
protocol Observer: AnyObject {
    func update(_ message: String)
}

// Publisher 클래스
class Publisher {
    private var observers: [Observer] = []
    private var state: String = "" {
        didSet {
            notify()
        }
    }

    // 상태를 변경하고 관찰자들에게 알림
    func changeState(_ newState: String) {
        self.state = newState
    }

    func add(observer: Observer) {
        observers.append(observer)
    }

    func remove(observer: Observer) {
        if let index = observers.firstIndex(where: { $0 === observer }) {
            observers.remove(at: index)
        }
    }

    private func notify() {
        observers.forEach { $0.update(state) }
    }
}

// Observer를 구현한 ConcreteObserver 클래스
class ConcreteObserver: Observer {
    let name: String

    init(name: String) {
        self.name = name
    }

    func update(_ message: String) {
        print("\(name) received state update: \(message)")
    }
}

실행 코드 (2)


// 예제 실행
let publisher = Publisher()

let observer1 = ConcreteObserver(name: "Observer1")
let observer2 = ConcreteObserver(name: "Observer2")

publisher.add(observer: observer1)
publisher.add(observer: observer2)

publisher.changeState("New State!")

// 출력:
// Observer1 received state update: New State!
// Observer2 received state update: New State!

ConcreteObserver이란 구독자 클래스는 Observer 프로토콜을 채택하여 생성되었다.

Observer 프로토콜이 Publisher와 ConcreteObserver 인스턴스의 연결다리(추상화)인 셈이다

Publisheradd 메서드를 통해 자신이 발행할 이벤트 수신을 원하는 Observer 인스턴스를 저장해 두었다가 이벤트가 변경되면 (changeState를 통해 이벤트가 수신되면) 해당 이벤트를 observers의 update 메서드를 통해 구독자들(observers)에게 알린다.

순서를 그림으로 그리자면 아래와 같다

코드와 함께 보면 아래와 같다

그럼 발행자인 Publisher는 언제 observers를 가지게 되었을까?

Publisher는 add 메서드를 통해 observer를 전달받아 자신의 observers 배열에 추가하였다

func add(observer: Observer) {
        observers.append(observer)
    }

실제 구현에 해당하는 구간을 다시한번 살펴보자

let publisher = Publisher() // 나는 발행자

let observer1 = ConcreteObserver(name: "Observer1") // 나는 옵저버1
let observer2 = ConcreteObserver(name: "Observer2") // 나는 옵저버2

publisher.add(observer: observer1) // 옵저버1은 발행자의 observers에 추가될게
publisher.add(observer: observer2) // 옵저버2는 발행자의 observers에 추가될게

publisher.changeState("New State!") // 상태가 변경된다면 ...

1) Publisher는 Observer 타입의 배열에 구독자들을 가지고,
2) 이벤트 변경(수신) 시
3) Observer들의 update 메서드를 호출하여 이벤트를 전달한다

let publisher = Publisher() // 나는 발행자

중요한 점은 발행자(Publisher)는 자기 자신만으로는 이벤트 수신 시 실행시킬 update 메서드를 알지 못한다는 것이다.

실행할 update 메서드를 알게 되는 시점은

publisher.add(observer: observer1)
publisher.add(observer: observer2)

구독자가 생긴 시점 이후이다.

즉, 옵저버 패턴의 핵심은 아래와 같다.

⭐️ Publisher가 자신의 observers(구독자들)에게 state(이벤트)를 전달해 주기 위해 실행하는 update 함수의 구현 내용을 모른다 **

Publisher가 실행시킬 update 메서드는 Observer가 정의한 것이다.

왜 Publisher가 Observer의 메서드를 실행해야 할까?

그건 Publisher가 이벤트의 수신 시점(생성 시점)을 알고, 해당 이벤트를 원하는 Observer는 그 시점을 모르기 때문이다.

그러니 옵저버 패턴의 작동 메커니즘은

이벤트 발생 시점을 아는 Publisher가 Observer들이 자신의 이벤트를 전달 받아 수행하고자 하는 행동을 가지고 있다가, 이벤트 생성 시점에 대신 실행해주는 것이다.

좀 더 구체적인 이해를 위해 다른 코드를 들어보자

구독자(Observer)가 하고 싶은 일

이전에 설명한 코드는 모든 옵저버들이 이벤트를 전달 받았을 때

func update(_ message: String) {
        print("\(name) received state update: \(message)")
    }

(내 이름) received state update: (전달 받은 이벤트)

지정된 양식으로만 행동할 수 있었다

하지만 아래와 같은 상황을 고려해 보자.

Observer1 : 너 신문 발행해 주는 애지? 나 너 구독할테니까 신문 나오면 줘.
나 신문 나오면 부모님께 드려야 하거든

Observer2 : 너 신문 발행해 주는 애지? 나 너 구독할테니까 신문 나오면 줘.
나 신문 보고 학교 숙제해야 하거든

두 옵저버가 같은 이벤트를 전달받았을 때 해주고 싶은 일이 다르다.

그러니 지금처럼 update 함수 내용을 ConcreteObserver class 내부에 고정할 수가 없다.

그렇다면 어떻게 해야할까.

우선 발행자와 구독자의 관계를 이해해보자.

Publisher : 데이터 생성 시점을 앎. 그거 가지고 뭐 할 지를 모름
Observer : 데이터를 가지고 하고 싶은 게 있음. 그 데이터 생성 시점을 모름

역시나 중요한 것은 데이터(이벤트)의 수신 시점을 아는 것이 Publisher란 것이다.

그러니 Publisher는 Observer에게 행동을 전달받아, 적절한 시점에 대신 실행해 주고자 한다.

Publisher는 이벤트 생성 시점에 빈 칸을 뚫어 옵저버가 원하는 행동을 실행시킬 공간을 마련해 둔다.

위 내용을 코드로 살펴보자.

예제 코드 (2)

class Publisher {
    private var observers: [(String) -> Void] = []
    private var state: String = "" {
        didSet {
            notify()
        }
    }

    func changeState(_ newState: String) {
        self.state = newState
    }

    func subscribe(_ closure: @escaping (String) -> Void) {
        observers.append(closure)
    }

    private func notify() {
        observers.forEach { $0(state) } //여기가 빈 칸. 
        //Publisher 입장에서는 외부에서 주입 받을 옵저버의 행동 실행 공간
    }
}

Publisher는 subscribe 메서드를 통해 이벤트 생성 시 처리해 줄 구독자의 행동을 외부로부터 주입받는다.

Publisher는 이벤트가 생성 될 때 notify()를 실행하여 저장해 둔 구독자들의 행동에 이벤트를 넣어 실행시킨다

실행 코드 (2)


// 예제 실행
let publisher = Publisher()

publisher.subscribe { message in
    print("Observer1 received state update: \(message)")
}

publisher.subscribe { message in
    print("Observer2 received state update: \(message)")
}

publisher.changeState("New State!")

// 출력:
// Observer1 received state update: New State!
// Observer2 received state update: New State!

Publisherfunc subscribe(_ closure: @escaping (String) -> Void) 함수를 통해 이벤트 수신 시 실행되길 원하는 동작을 클로저 형태로 전달 받는다.

클로저Publisher 내부에 배열 형태로 저장되며, 이벤트 발생 시 실행된다.

클로저를 함수의 인자로 전달하고, 배열에 저장한 후, 필요한 시점에 실행하는 이러한 접근법은 클로저일급 객체 특성과 함수가 클로저 형태의 파라미터를 받는 고차 함수의 장점을 잘 반영한다.

그래서 발행자의 역할은,

위 그림은 내 나름의 유머로, 저 자체가 정답이다.

발행자가 이벤트 수신 시 실행할 메서드의 내용을 모른 채 정의되었다는 점을 강조하고 싶었다.

굳이 정답을 적는다면 이렇게 적을 수 있을 것 같다

객체지향적 특징

옵저버 패턴은 객체지향 디자인 패턴 중 하나로, 객체 간의 의존성을 줄이면서 하나의 객체의 상태 변경에 대해 다른 객체들에게 알릴 수 있는 방식을 제공한다.

나는 아직 객체지향적 이라는 말이 와닿지 않아 옵저버 패턴이 객체지향적인 특징을 갖는 부분을 정리하며 글을 마무리하려 한다.

캡슐화 : 옵저버 패턴에서 Publisher는 자신의 상태와 상태 변경 로직을 캡슐화한다. Observer는 이벤트에 반응하는 로직만을 캡슐화한다.

즉, 객체 간 책임이 명확해 진다.

다형성 : Observer 인터페이스 (또는 추상 클래스)를 구현한 여러 구체적인 클래스들은 다양한 방식으로 이벤트를 처리할 수 있다.

이로 인해 여러 타입의 옵저버가 하나의 주제에 대해 다양한 동작을 수행할 수 있다. (첫번째 예시에서 Observer 프로토콜을 채택하는 또 다른 클래스를 생성해주면 된다)

의존성 역전 : PublisherObserver 인터페이스에만 의존하며, 구체적인 Observer 구현에는 의존하지 않는다.
즉 고수준의 모듈 Publisher와 저수준의 모듈 Observer 간의 직접적인 의존성이 제거된다.

재사용성 : 옵저버 패턴을 사용하면, PublisherObserver 사이의 결합도가 낮아져 각각 독립적으로 재사용될 수 있다.

확장성 : Observer를 추가하거나 기존의 Observer를 삭제하는 게 Publisher에 영향을 주지 않는다.

이러한 특징들은 객체지향 프로그래밍의 핵심 원칙들을 잘 반영하며, 객체 간의 의존성을 최소화하고 변경에 유연하게 대응할 수 있게 해준다.

정리

Publisher는 배열 형태로 Observer의 행동을 관리하므로 발신, 수신 관계를 1:N으로 형성할 수 있습니다.

참고로, RxSwift와 Combine에서 각각을 지칭하는 명칭은 아래와 같습니다.

RxSwift
발행자 : Obserable (구독가능한)
구독자 : Observer

Combine
발행자 : Publisher
구독자 : Subscriber

이번엔 정말 짧게 쓰고 싶었는데 마음처럼 잘 되지 않아 아쉽습니다...

글이 길어질 수록 간단히 자주 쓰자는 다짐과는 점점 더 멀어지는 기분이 들지만... 누군가에게 도움이 되는 글이길 바랍니다.

댓글 다 읽고 있으니 궁금한 점이나 오류에 대한 피드백이 있다면 남겨주세요!

감사합니다! 다들 즐거운 개발 되세요!

profile
iOS 개발자 보노

2개의 댓글

comment-user-thumbnail
2023년 8월 13일

글 잘 봤습니다.

답글 달기
comment-user-thumbnail
2023년 8월 13일

대단합니다. 글 잘봤습니다.

답글 달기