전체 페이지뷰

2017년 1월 20일 금요일

Observer Pattern

Oberver pattern은 어떤 이벤트가 생겼을 때 객체들에게 소식을 알려줄 수 있는 일대다의 느슨한 관계의 패턴입니다. 이게 무슨 말인지 알아가 보도록 합시다.



기상청에서 데이터를 받아다 출력해주는 프로그램을 만들고자 합니다.
WeatherData라는 이름의 객체를 기상청에서 보내준다고 가정할 때, 우리는 그것으로 현재조건(온도, 습도, 기압), 기상 통계, 기상 예보의 세 가지를 디스플레이할 수 있습니다. 기상데이터가 갱신될때마다 우리는 디스플레이 내용도 역시 갱신해야 합니다.

WeatherData는 다음과 같이 구성되어 있습니다.

Get...()는 가장 최근에 측정된 온도, 습도, 기압을 리턴하는 getter 메소드이며,
새로운 정보가 나올때마다 MeasurementChanged()가 호출됩니다.

WheatherData.cs 코드를 보니 우리가 작성해야 할 부분을 남겨놓은 것이 보입니다.

/*
 * 기상 관측값이 갱신될 때마다 
 * 알려주기 위한 메소드
 */

public void MeasurementChanged()
{
    //코드 들어갈 자리
}

만들어야 할 애플리케이션에 대한 조건은 이렇습니다.
1. 새 개의 기상 정보 디스플레이를 구현. WeatherData가 갱신될 때마다 갱신
2. 시스템이 확장 가능해야 함
   - 다른 개발자들이 별도의 디스플레이 항목을 만들 수 있어야 하고, 애플리케이션에
    마음대로 디스플레이 항목을 추가/제거할 수 있어야 함.

이런 조건하에 대강 코드를 작성해 봅니다.
public class WeatherData
{
    // 필드, 속성 등 선언
    public void MeasurementChanged()
    {
        float temperature = GetTemperature();
        float humidity = GetHumidity();
        float pressure = GetPressure();
        currentConditionsDisplay(temperature, humidity, pressure);
        statisticsDisplay.update(temperature, humidity, pressure);
        forecastDisplay.update(temperature, humidity, pressure);
    }
    // 기타 메소드
}
cs

이런 코드의 문제점은 무엇일까요?

인터페이스가 아닌 구체적 구현을 바탕으로 코딩하고 있으며,
새로운 디스플레이가 추가될 때마다 코드를 바꿔야 하며,
실행중에 동적으로 디스플레이 항목을 추가나 제거할 수도 없고,
바뀌는 부분인 디스플레이 부분이 캡슐화되지도 않았습니다.
다만 다행인 것은 update라는 공통의 메소드를 가져서 같은 인터페이스를 구현하고 있는것 같다는 점입니다.

Subject, Observer

주제(subject)와 구독자(observer)의 관계는 마치 신문사와 독자의 관계와 같습니다.
신문사(Subject객체)는 신문(데이터)을 관리합니다. 새 신문이 나오면 독자(oberver)들에게 배달하죠.  독자는 자신의 의사에 따라 신문을 구독할 수도 있고, 더 이상 보고싶지 않으면 탈퇴할 수도 있습니다. 물론 재가입도 가능합니다.

Subject 객체 하나가 여러 Oberver들과 관련이 되어 있는 이런 관계를 일대다(one-to-many) 관계라고 합니다.


주제역할을 하는 구상클래스인 ConcreteSubject는 언제나 Subject 인터페이스를 구현해야 합니다. 구현해야할 메소드로 옵저버등록에 쓰이는 RegisterObserver(), 탈퇴에 쓰이는 RemoveObserver(), 그리고 옵저버들에게 연락하기 위한 NotifyObservers() 메소드가 있고,
추가적으로 상태를 설정하고 알려주는 GetState(), SetState()메소드가 포함될수 있습니다.

Observer가 될 가능성이 있는 객체는 역시 Observer 인터페이스를 구현해야 합니다.
거기에는 주제가 바뀌었을 때 호출되는 Update() 메소드만 있을 뿐입니다.


Loose Coupling

두 객체가 느슨하게 연결되어 있다는 것은 서로 상호작용은 하나 서로에 대해 잘 모른다는 것을 의미합니다. Oberver pattern에서는 subject와 oberver가 loose coupling 되어 있습니다. 그 둘은 서로 데이터를 주고받지만, 한편으로는 독립적으로 재활용, 수정될 수 있고 얼마든지 추가될 수도 있고 그 과정에 문제를 일으키지 않습니다.

디자인 패턴: 서로 상호작용을 하는 객체 사이는 가능하면 Loose coupling 디자인을 사용한다.


그래서 전체 애플리케이션은 위와 같이 디자인 될 수 있습니다.
Subject 인터페이스와 Observer 인터페이스가 있고, 추가로 향후 디스플레이 요소가 덧붙여질 것에 대비하여 DisplayElement 인터페이스가 있습니다.

그리고 WeatherData는 Subject를 구현하고, 각각의 Display는 Observer와  DisplayElement 인터페이스를 구현합니다.

이제서야 힘들게 공부했지만 뜬구름 잡는 것 같았던 Interface가 이해되기 시작합니다. 상속이라는 강력한 방법과 달리 인터페이스는 최소한의 공통점만을 가지고서 각 객체를 연결시켜 주므로 loose coupling 구현에 탁월한 존재라 할 수 있다 생각됩니다.


기상스테이션 인터페이스 구현

사실 C#에는 이벤트 핸들러라는 것이 있어 옵저버 패턴과 사실상 동일하다고 생각되긴 합니다만 EventHandler 없이 따라가 보겠습니다.

public interface Subject
{
    void RegisterObserver(Observer o);  //옵저버 등록
    void RemoveObserver(Observer o);  //옵저버 제거
    void NotifyObservers();  //상태변경시 호출
}

public interface Observer
{
    void Update(float temp, float humidity, float pressure);
}

public interface DisplayElement
{
    void Display();
}

WeatherData에서 Subject 인터페이스 구현

public class WeatherData : Subject
{
    private List<Observer> observers;
    private float temperature;
    private float humidity;
    private float pressure;
  
    public WeatherData()
    {
        observers = new List<Observer>();
    }
    public void RegisterObserver(Observer o)
    {
        observers.Add(o);
    }
   
    public void RemoveObserver(Observer o)
    {
        int i = observers.IndexOf(o);
        if ( i>= 0 )
            observers.RemoveAt(i);
    }
    public void NotifyObservers()
    {
        foreach (Observer o in observers)
            o.update(temperature, humidity, pressure);
    }
    public void MeasurementChanged()
    {
        NotifyObservers();
    }
    public void SetMeasurements(float temperature, float humidity, float pressure)
    {
        this.temperature = temperature;
        this.humidity = humidity;
        this.pressure = pressure;
        MeasurementChanged();
    }
    // 기타 메소드
}
cs



디스플레이 구현

이제 디스플레이 항목을 만들어 봅니다.
UML설계에서 보듯 Observer와 DisplayElement의 두 가지 interface를 구현해야 합니다.
모두 세가지 Display 클래스를 만들어야 하는데 그 중 CurrentConditionDisplay를 우선 만들어 봅니다.

public class CurrentConditionDisplay : Observer, DisplayElement
    {
    private float temperature;
    private float humidity;
    private float pressure;
    private Subject weatherData;
 
    public CurrentConditionDisplay(Subject weatherData)
    {
        this.weatherData = weatherData;
        weatherData.RegisterObserver(this);
    }
 
    public void Update(float temperature, float humidity, float pressure)
    {
        this.temperature = temperature;
        this.humidity = humidity;
        this.pressure = pressure;
        Display();
    }
 
    public void Display()
    {
        Console.WriteLine("Current conditions:\n{0} C Degrees\n{1} % humidity\n{2} hPa\n"
            , temperature, humidity, pressure);
    }
}
cs


아직 모든 디스플레이를 만들지는 않았지만 테스트를 돌려봅시다.

class Program
{
    static void Main(string[] args)
    {
        WeatherData weatherData = new WeatherData();
        CurrentConditionDisplay currentDisplay = new CurrentConditionDisplay(weatherData);
 
        weatherData.SetMeasurements(306530.4f);
        weatherData.SetMeasurements(164829.2f);
        weatherData.SetMeasurements(-12.5f, 5529.2f);
    }
}
cs

결과)
Current conditions:
30 C Degrees
65 % humidity
30.4 hPa

Current conditions:
16 C Degrees
48 % humidity
29.2 hPa

Current conditions:
-12.5 C Degrees
55 % humidity
29.2 hPa

잘 작동하는 것 같습니다.
ForecastDisplay, StatisticsDisplay도 모두 더해서 구현해 보겠습니다.

using System;
using System.Collections.Generic;
 
namespace DesignPattern
{
    public interface Subject
    {
        void RegisterObserver(Observer o);  //옵저버 등록
        void RemoveObserver(Observer o);  //옵저버 제거
        void NotifyObservers();  //상태변경시 호출
    }
 
    public interface Observer
    {
        void Update(float temp, float humidity, float pressure);
    }
 
    public interface DisplayElement
    {
        void Display();
    }
    public class WeatherData : Subject
    {
        private List<Observer> observers;
        private float temperature;
        private float humidity;
        private float pressure;
 
        public WeatherData()
        {
            observers = new List<Observer>();
        }
 
        public void RegisterObserver(Observer o)
        {
            observers.Add(o);
        }
 
        public void RemoveObserver(Observer o)
        {
            int i = observers.IndexOf(o);
            if (i >= 0)
                observers.RemoveAt(i);
        }
 
        public void NotifyObservers()
        {
            foreach (Observer o in observers)
                o.Update(temperature, humidity, pressure);
        }
 
        public void MeasurementChanged()
        {
            NotifyObservers();
        }
 
        public void SetMeasurements(float temperature, float humidity, float pressure)
        {
            this.temperature = temperature;
            this.humidity = humidity;
            this.pressure = pressure;
            MeasurementChanged();
        }
 
        // 기타 메소드
    }
 
    // CurrentCondition 디스플레이 클래스들 구현    
    public class CurrentConditionDisplay : Observer, DisplayElement
    {
        private float temperature;
        private float humidity;
        private float pressure;
        private Subject weatherData;
 
        public CurrentConditionDisplay(Subject weatherData)
        {
            this.weatherData = weatherData;
            weatherData.RegisterObserver(this);
        }
 
        public void Update(float temperature, float humidity, float pressure)
        {
            this.temperature = temperature;
            this.humidity = humidity;
            this.pressure = pressure;
            Display();
        }
 
        public void Display()
        {
            Console.WriteLine("Current conditions:\n{0} C Degrees\n{1} % humidity\n{2} hPa\n"
                , temperature, humidity, pressure);
        }
    }
 
    // Forecast디스플레이 구현
    public class ForecastDisplay : Observer, DisplayElement
    {
        private float currentPressure = 29.92f;
        private float lastPressure;
        private WeatherData weatherData;
 
        public ForecastDisplay(WeatherData weatherData)
        {
            this.weatherData = weatherData;
            weatherData.RegisterObserver(this);
        }
 
        public void Update(float temp, float humidity, float pressure)
        {
            lastPressure = currentPressure;
            currentPressure = pressure;
 
            Display();
        }
 
        public void Display()
        {
            Console.WriteLine("Forecast: ");
            if (currentPressure > lastPressure)
            {
                Console.WriteLine("날씨가 좋아지고 있습니다.\n");
            }
            else if (currentPressure == lastPressure)
            {
                Console.WriteLine("날씨에 큰 변화는 없습니다.\n");
            }
            else if (currentPressure < lastPressure)
            {
                Console.WriteLine("기온하락과 비에 주의하세요.\n");
            }
        }
    }
 
    // Statistics 디스플레이 구현
    public class StatisticsDisplay : Observer, DisplayElement
    {
        private float maxTemp = 0.0f;
        private float minTemp = 200;
        private float tempSum = 0.0f;
        private int numReadings;
        private WeatherData weatherData;
 
        public StatisticsDisplay(WeatherData weatherData)
        {
            this.weatherData = weatherData;
            weatherData.RegisterObserver(this);
        }
 
        public void Update(float temp, float humidity, float pressure)
        {
            tempSum += temp;
            numReadings++;
 
            if (temp > maxTemp)
            {
                maxTemp = temp;
            }
 
            if (temp < minTemp)
            {
                minTemp = temp;
            }
 
            Display();
        }
 
        public void Display()
        {
            Console.WriteLine("Avg/Max/Min temperature = {0} / {1} / {2}\n"
                , tempSum / numReadings, maxTemp, minTemp);
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            WeatherData weatherData = new WeatherData();
            CurrentConditionDisplay currentDisplay = new CurrentConditionDisplay(weatherData);
            ForecastDisplay forecastDisplay = new ForecastDisplay(weatherData);
            StatisticsDisplay statisticsDisplay = new StatisticsDisplay(weatherData);
 
            weatherData.SetMeasurements(306530.4f);
            weatherData.SetMeasurements(164829.2f);
            weatherData.SetMeasurements(-12.5f, 5529.2f);
        }
    }
}
cs

결과)
Current conditions:
30 C Degrees
65 % humidity
30.4 hPa

Forecast:
날씨가 좋아지고 있습니다.

Avg/Max/Min temperature = 30 / 30 / 30

Current conditions:
16 C Degrees
48 % humidity
29.2 hPa

Forecast:
기온하락과 비에 주의하세요.

Avg/Max/Min temperature = 23 / 30 / 16

Current conditions:
-12.5 C Degrees
55 % humidity
29.2 hPa

Forecast:
날씨에 큰 변화는 없습니다.

Avg/Max/Min temperature = 11.16667 / 30 / -12.5

세 개의 디스플레이가 모두 잘 작동합니다.

갑작스레 체감온도 항목을 추가해야할 필요가 생겼습니다.
체감온도 식은 상당히 복잡한데...스스로 구현할 필요가 없고, 헤드퍼스트에는 화씨로 나와있는 것 같아서 어딘가에서 본 간단 공식을 사용하여 대략 구현했습니다.

// 체감온도 디스플레이
public class HeatIndexDisplay : Observer, DisplayElement
{
    float heatIndex = 0.0f;
    private WeatherData weatherData;
 
    public HeatIndexDisplay(WeatherData weatherData)
    {
        this.weatherData = weatherData;
        weatherData.RegisterObserver(this);
    }
    public void Update(float t, float rh, float pressure)
    {
        heatIndex = computeHeatIndex(t, rh);
        Display();
    }
    private float computeHeatIndex(float t, float rh)
    {
        float index = (float)(t - 0.4 * (t - 10* (1 - rh / 100)); 
        return index;
    }
 
    public void Display()
    {
        Console.WriteLine("체감 온도: {0}\n " , heatIndex);
    }
}
cs

그리고 메인에 
HeatIndexDisplay heatIndexDisplay = new HeatIndexDisplay(weatherData);

를 추가하고 다시 실행하여 보면...
결과)
Current conditions:
30 C Degrees
65 % humidity
30.4 hPa

Forecast:
날씨가 좋아지고 있습니다.

Avg/Max/Min temperature = 30 / 30 / 30

체감 온도: 27.2

Current conditions:
16 C Degrees
48 % humidity
29.2 hPa

Forecast:
기온하락과 비에 주의하세요.

Avg/Max/Min temperature = 23 / 30 / 16

체감 온도: 14.752

Current conditions:
-12.5 C Degrees
55 % humidity
29.2 hPa

Forecast:
날씨에 큰 변화는 없습니다.

Avg/Max/Min temperature = 11.16667 / 30 / -12.5

체감 온도: -8.45

이것으로 옵저버 패턴을 마칩니다.

댓글 1개:

  1. 좋은 글 잘 보고 있습니다.
    닷넷이 인기가 많았더라면 더 많은 사람들이 봤을 텐데..하는 아쉬움이 있네요..

    위 예제에서 질문이 있습니다.
    1. WeatherData는 추상 관찰자에게 의존하고 있는데, 구상 관찰자들은 구상 주제에 의존하고 있습니다.
    구상 관찰자들도 추상 주제에 의존해야 되는 것 아닐까요?
    WeatherData weatherData; -> Subject subject;

    2. NotifyObservers()는 주제가 갖춰야 할 행위라서, 인터페이스로 정의했습니다. 인터페이스에서 정의하면 public 이 되겠지요. 그러나, 이 행위의 다른 이면은 외부에 노출이 되어서는 안되는 행위입니다. 주제가 관리하는 데이터의 변경이 있을 때만 (주제 내부에서) 호출되어야 하지 외부에서 임의로 호출되면 안되는 행위입니다. 따라서 구상 객체에서는 private한 행위가 되어야 합니다. 즉, public 해야 하고 private해야 하는 모순이 발생합니다. 인터페이스에서 정의는 하되 구상 객체에서는 외부에 노출이 안되게 하는 방법이 있을까요?

    답글삭제