본문 바로가기

CS/디자인패턴

[디자인패턴] Observer 패턴

일반적인 observer 패턴을 설명하는 그림
옵저버가 여러개의 서브젝트를 구독하는 상황. 값이 변경된 subject에서 자신을 파라미터로 넘긴다.

설명

객체 사이에 1 대 N 의존 관계를 정의하여, 대상(subject) 객체의 상태 변화가 의존 객체(observers)에게 통지되고, 상태를 자동으로 업데이트한다.

 객체 사이의 데이터 일관성을 유지하고 싶지만 결합도를 높이고 싶지는 않다. 예를 들어 데이터를 관리하는 클래스와 표를 보여주는 클래스(바, 파 차트, 표 등)들이 있을 때, 데이터가 변동될 때마다 표의 모습을 바꾸고 싶다고 생각해보자. 일단 데이터와 표를 담당하는 클래스가 분리되어 있으므로, 데이터를 시각화하기 위해서는 데이터 클래스가 표 클래스에게 자신의 데이터를 지속적으로 제공할 필요가 있다.

 이때 각 표를 위한 클래스들의 인터페이스가 동일하다는 보장은 없으므로 단순히 구현한다면 데이터 클래스 내부에 업데이트를 요구하는 클래스들의 레퍼런스를 가지고 있다가, 실제로 데이터가 갱신될 때마다 변동된 데이터를 전송해주는 방식을 고려할 수 있다.

이 방식의 문제는 데이터 클래스가 너무 구체적인 대상을 참조한다는 점이다. 미래에 얼마나 더 많은 객체와 연관될지 모르는 상황에서 클래스 각각의 레퍼런스를 기반으로 요구하는 인터페이스에 따라 데이터를 제공하기 시작하면 점점 데이터 클래스와 표 클래스 사이의 결합도가 높아져서 재사용과는 거리가 멀어지는 문제가 나타난다. 이를 해결하기 위해서는 데이터 클래스가 참조할 대상 클래스들을 보다 추상적인 형태로 변경해야 한다.

 다른 측면의 문제도 있다. 현재 구조에서는 데이터 클래스가 직접 데이터를 전달하므로 지나치게 많은 역할을 맡게 된다. 만약 원본 데이터를 전송하기 위한 일련의 작업이 꽤 긴 시간을 요구하는 경우, 해당 데이터를 보내는 시간동안 다른 작업 요청이 무시되거나 미뤄질 수  있다. 예를 들어 for문을 통해 1000개의 타겟에 큰 용량의 데이터를 보내야 한다면, 해당 시간동안은 데이터 클래스를 호출할 수 없다. 이를 해결하기 위해서는 데이터가 변할 때 데이터 클래스가 직접 데이터를 전송하는 대신, 변동 사항만을 알리고 각 클래스들에게 데이터가 변했다는 사실을 통지하는 것을 고려한다.

 옵저버 패턴은 대상(서브젝트)의 상태 변화가 다른 객체(옵저버)에게 전달되는 상황에서 서브젝트의 pull / push 책임을 각 옵저버에게 분산한다. 이 과정에서 옵저버 및 서브젝트에 대한 인터페이스를 설정하고 구체적인 객체들이 해당 인터페이스를 구현하도록 추상화하여 결합도를 낮춘다.

 현재 패턴의 동작 방식은 다음과 같다.

notify가 동작하는 모습

  1. 서브젝트에 옵저버를 등록( attach/subscribe )한다.
  2. 서브젝트의 상태가 변경되면 등록된 옵저버들에게 알린다 ( notify/publish, update 호출 ).
  3. 알림을 받은 옵저버들은 나름대로 필요한 데이터를 서브젝트로부터 가져온다 ( update ).
  4. 더 이상 호출이 필요하지 않다면 옵저버를 제거한다( detach/unsubscribe ).

옵저버 패턴 자체가 상당히 많이 사용되는 패턴일 뿐더러 환경마다 표현도 달라진다. pub/sub 모델로 부르는 경우도 있고, eventListener 같은 형식으로 나타내기도 한다. 하지만 이름이 아무리 달라져도 위와 같은 동작 방식이 기본이 된다.

 참고로 서브젝트의 상태를 옵저버에서 변경할 수도 있다. gof 책에서 나오는 표 - 데이터 예시에서는 특정 표(옵저버)에서 데이터를 변경하여( subject.setState( ) ) 다른 표에 변경 사항을 반영하는 경우를 설명한다. 옵저버가 단순 구독 역할만 해야한다고 생각할 필요는 없다.

사용하는 경우

  • 추상 개념이 2가지로 나뉘고 종속적인 관계로 나타나는 경우, 각각을 추상화하여 재사용한다.
  • 한 객체에 대한 변경이 다른 객체에 영향을 주는 상황에서 프로그래머는 옵저버가 얼마나 바뀌는지 알 필요가 없을 때.
  • 서브젝트 객체에서 변화를 통보할 때 누가 이 변화에 관심 있는지 알 필요 없을 때.

위 3가지 경우 중 하나라면 옵저버 패턴을 사용한다고 한다.

사용 결과

  1. 서브젝트와 옵저버 둘 다 추상적인 형태를 띄므로 결합도가 낮게 연결되므로 독립적인 변형이 쉽다. 또한 서브젝트 또는 옵저버의 코드 수정 없이도 새로운 옵저버를 추가할 수 있다. 
  2. 브로드캐스트 형식의 교류가 가능하다. 옵저버 객체들을 Observer라는 추상적인 형태로 관리할 수 있으므로 대상이 구체적으로 누구인지, 얼마나 많은지 등을 고려하지 않고도 변경을 알릴 수 있다.
  3. (단점) 서브젝트의 연속적인 수정이 발생하는 경우 불필요한 갱신이 계속될 수 있다. 옵저버들이 서브젝트가 가진 모든 데이터의 변경에 관심이 있는 것은 아님에도 서브젝트는 각 옵저버의 관심 영역을 모르기 때문에 일단 변경사항을 알린다. 이 경우 옵저버 입장에서 서브젝트의 어떤 상태가 변했는지 알 방법이 없다면 상태가 변경되었다는 안전한 가정 하에 동작해야 하므로 쓸모 없는 갱신을 수행하게 된다. 따라서 복잡한 시스템에서는 서브젝트의 기존 상태를 파악하는 기능이 요구될 수 있다.

구현 고려사항

  1. 서브젝트가 옵저버에 대한 레퍼런스 ( 옵저버 배열 등 )를 가지는게 가장 간단하지만, 서브젝트는 많고 옵저버는 적은 상황인 경우 해시 테이블을 둬서 서브젝트와 옵저버 사이 관계를 정리할 수도 있다. 이 경우 옵저버가 없는 서브젝트에게 레퍼런스를 위한 공간을 할당하는 낭비를 막을 수 있다. ( 테이블 통해 옵저버를 얻어야 하는 단점이 존재. ) 
  2. 하나의 옵저버가 여러 서브젝트를 구독한다면 옵저버 내부에 서브젝트에 대한 레퍼런스를 두는 대신 update메서드의 파라미터로 서브젝트의 레퍼런스를 받도록 구현하는 편이 효율적이다. ( 서브젝트 각각 레퍼런스는 메모리 손해 )
  3. notify을 누가 호출해야 하는지 지정할 수 있다.
    1. 서브젝트 객체가 호출: 서브젝트의 상태가 변할 때마다 notify을 호출하여 변경을 반영한다. 사용자가 호출할 필요가 없어서 편리하지만, 비효율적인 없는 변동을 유발할 수도 있다.
    2. 사용자가 적절한 시점에 호출: 서브젝트 외부에서 notify을 호출한다. 일련의 상태변화 후 실제로 반영이 필요한 시점에 갱신을 수행할 수 있어 효율적인 동작이 가능한 장점이 있지만, 사용자가 이런 동작을 수행해야한다는 사실 자체를 잊을 가능성이 높다.
  4.  삭제한 서브젝트에 대한 댕글링 포인터 참조는 위험하다. 따라서 서브젝트가 제거되기 전에 각 옵저버에게 자신을 참조 목록에서 제거하라는 메시지를 전달할 수 있다. ( C++처럼 소멸자가 있다면 편리할 듯? )
  5. 통보하기 전에 서브젝트의 상태가 자체 일관성(self-consistency)을 가지도록 해야 한다. 상속한 서브 클래스에서 오버라이딩한 메서드 op 내에서 값을 업데이트할 때 super 클래스의 op를 호출할 수 있다. 이때 super 클래스의 메서드 내에 notify가 존재한다면, 자식 클래스에 변화가 반영되기도 전에 notify을 호출함으로써 자식의 변화는 반영되지 않는 문제가 발생할 가능성이 존재한다.
     이를 회피하기 위해 여러 값을 설정하는 오퍼레이션을 추상적인 메서드 ( 템플릿 메서드 ) 로 설정하여 구체적인 값의 변경은 서브 클래스에서 정의하도록 지정할 수 있다.
  6. push model vs pull model
    1. push model: 서브젝트가 자신의 상세한 정보를 직접 전달하는 방식. 옵저버마다 다른 요청에 대응하기 위해 옵저버의 타입이 무엇인지 정확히 알아야 하므로 재사용성이 떨어진다.
    2. pull model: 서브젝트가 업데이트되었다는 사실만 알리고 데이터는 옵저버가 가져가게 한다. 재사용성이 높아지지만, 각 옵저버가 요구하지 않는 값에 의해 업데이트가 발생할 수 있다. 이 부분은 감시하고 싶은 속성을 함께 등록하여 특정 상황에서만 notify가 동작하도록 구성하여 어느 정도 보완할 수 있다. ( MutationObserver 참고 )
//자기 일관성 관련 코드
//01. 자기 일관성이 깨지는 경우
function op(int lastAdded) {
    super.op(lastAdded);
    this.sum += lastAdded; // 이 값은 notify 호출 이후에 변하므로 반영되지 않음.
}

//02. 자기 일관성이 동작하는 경우

// in subject class
abstract class Subject {
    abstract function something(int lastAdded): void; // 세부 클래스에서 정의
    // ... notify 등 정의

    function op(int lastAdded) {
        something(lastAdded);
        this.notify();
    }

}

참여자

  1. Subject: 옵저버를 알고 있는 객체, 옵저버를 추가, 제거하고 변경을 알리기 위한 인터페이스를 정의한다.
  2. Observer: 서브젝트의 변화에 관심이 있는 객체. 갱신하는데 필요한 인터페이스(update)를 정의한다.
  3. ConcreteSubject: 구체적인 서브젝트. 옵저버가 요구하는 데이터를 가지고 있으며, 상태 변경을 통보한다.
  4. ConcreteObserver: 구체적인 옵저버. 서브젝트의 상태와 일관성을 유지하는데 필요한 인터페이스(update)을 구현한다. 하나의 옵저버가 구독하는 서브젝트의 개수에 따라 레퍼런스를 유지하지 않을 수도 있다.

예시 코드

interface Observer {
    update(): void;
}

interface Subject {
    attach(o: Observer): void;
    detach(o: Observer): void;
    notify(): void;
}

class TextTyper implements Subject {
    observers: Observer[];
    private _str: string;
    private _lastTyped: string;

    constructor() {
        this._str = '';
        this._lastTyped = '';
        this.observers = [];
    }

    get str() {
        return this._str;
    }

    get lastTyped() {
        return this._lastTyped;
    }

    clear() {
        this._str = '';
        this._lastTyped = '';
        this.notify();
    }

    stroke(ch: string) {
        this._str += ch;
        this._lastTyped = ch;
        this.notify();
    }

    backspace() {
        if(this._str.length >= 0) {
            this._str = this._str.slice(0,-1);
        }
        this._lastTyped = '[backspace]';
        this.notify();
    }

    attach(o: Observer): void {
        this.observers.push(o);
    }
    detach(o: Observer): void {
        const idx = this.observers.indexOf(o);
        if(idx >= 0) {
            this.observers.splice(idx, 1);
        }
    }

    notify(): void {
        for(const ob of this.observers) {
            ob.update();
        }
    }
}

class ConsolePrinter implements Observer {
    private sub?: TextTyper;

    update() {
        console.log(this.sub!.str);
    }
}

const format = new Intl.DateTimeFormat('ko-kr', {
    year:"numeric",
    month: "2-digit",
    day: "2-digit",
    hour:"2-digit",
    minute: "2-digit",
    second: "2-digit"
});

class Logger implements Observer {
    private loggedList: string[];
    private sub?: TextTyper;

    constructor() {
        this.loggedList = [];
    }

    set setSub(sub: TextTyper) {
        this.sub = sub;
    }

    update() {
        const clicked = this.sub!.lastTyped;
        if(clicked != '') { // clear에는 반응 안함
            this.loggedList.push(`${format.format(new Date())}: Typed ${clicked}`);
        }
    }

    get getLog(): readonly string[] {
        return this.loggedList;
    } 
}

 간단한 텍스트 입력에 대한 로깅 및 출력을 진행하는 상황을 가정했다. ConsolePrinter과 Logger은 Observer 인터페이 스를 구현하여 subject인 TextTyper이 업데이트될 때마다 update을 통해 반응한다.

 TextTyper은 subject을 구현하며, 내부적으로 옵저버들을 관리할 수 있는 리스트를 가지고 있다. 수행할 수 있는 동작은 옵저버와 관련된 메서드인 attach, detach, notify가 있다. 텍스트와 관련된 동작에는 일반적인 텍스트 키를 입력하는 stroke, 백스페이스를 누르는 backspace 가 있고, 전체 내용을 지우는 clear이 있다.

 코드를 대략 작성하기는 했지만, 실제 예시를 보는게 훨씬 좋다. 웹의 경우 직접적으로는 MutationObserver 같은 옵저버 API를 지원하고, EventListener들도 일종의 구독 개념으로 동작한다고 볼 수 있다. 이외에도 redis의 pub / sub 모델, 백엔드 프레임워크에서 보이는 observable 객체 등 상당히 자주 사용되는 패턴이므로 사용해보면 현재 패턴을 이해할 것이다.