[헤드 퍼스트 디자인 패턴] 02. 옵저버 패턴

akim·2023년 4월 11일
0
post-thumbnail

뭔가 재미있는 일이 생겼을 때 모르고 지나치면 슬프겠죠?
뭔가 중요한 일이 일어났을 때 객체에게 새 소식을 알려 줄 수 있는 패턴이 하나 있습니다. 바로 옵저버 패턴입니다. 자주 사용되는 패턴 중 하나로 엄청나게 쓸모가 있습니다.
2장에서는 일대다 관계나 느슨한 결합같이 옵저버 패턴에 관한 내용을 두루두루 배워 볼 겁니다.

1. 기상 모니터링 애플리케이션 알아보기

이 시스템은

  • 기상 스테이션(실제 기상 정보를 수집하는 물리 장비)
  • WeatherData 객체(기상 스테이션으로부터 오는 정보를 추적하는 객체)
  • 사용자에게 현재 기상 조건을 보여주는 디스플레이 장비

이 세 가지 요소로 이루어진다.


각각에 대한 요구사항은 아래와 같다.

  • WeatherData 객체는 현재 기상 조건인 온도, 습도, 기압 을 추적한다.
  • 해당 객체를 바탕으로 화면에는 현재 조건,기상 통계,기상 예보 를 표시한다.
  • 화면의 모든 항목들은 WeatherData 객체에서 최신 측정치를 수집할 때 마다 실시간으로 갱신된다.
  • 이 스테이션은 다른 개발자가 직접 날씨 디스플레이를 만들어서 바로 넣을 수 있도록 확장 가능해야 한다.

WeatherData 클래스 살펴보기

WeatherData 클래스의 메소드는 아래와 같이 구성되어 있다.

  • getTemperature()
  • getHumidity()
  • getPressure()

이 세가지 메소드는 각각 최근에 측정된 온도, 습도, 기압 값을 리턴하는 메소드다.

  • measurementsChanged()

이 메소드는 WeatherData 에서 갱신된 값을 가져올 때 마다 호출된다.

이 메소드에 디스플레이를 업데이트하는 코드를 넣어 현재 조건,기상 통계,기상 예보 를 보여줄 수 있도록 해보자.


구현 목표

  1. WeatherData 클래스에는 3가지 측정값(온도, 습도, 기압)의 게터 메소드가 있다.
  2. 새로운 기상 측정 데이터가 들어올 때마다 measurementsChanged() 메소드가 호출된다.
  3. 기상 데이터를 사용하는 디스플레이 요소 3가지(현재 조건,기상 통계,기상 예보 ) 를 구현해야 한다.
  4. 디스플레이를 업데이트하도록 measurementsChanged() 메소드에 코드를 추가해야 한다.

추가 목표

  • 확장성: 지금은 3가지 디스플레이만을 구현하지만 나중에 새로운 디스플레이를 추가할 수 있도록 한다.

기상 스테이션용 코드 추가하기

public class WeatherData{
	// 인스턴스 변수 선언
    
    public void measurementsChanged(){
    	// WeatherData에 있는 게터 메소드를 호출해서 최신 측정값을 가져오고 
        // 각 값을 적당한 변수에 저장한다.
    	float temp = getTemperature();
        float humidity = getHumidity();
        float pressure = getPressure();
        
        // 각 디스플레이를 갱신한다.
        // 최신 측정값을 전달하면서 각 디스플레이 항목의 update 메소드를 호출한다.
        currentConditionsDisplay.update(temp, humidity, pressure);
        statisticsDisplay.update(temp, humidity, pressure);
        forecastDisplay.update(temp, humidity, pressure);
    }
    
    // 기타 메소드
}

위 코드의 특징은 아래와 같다.

  • 인터페이스가 아닌 구체적인 구현을 바탕으로 코딩하고 있다.
  • 새로운 디스플레이 항목이 추가될 때마다 코드를 변경해야 한다.
  • 실행 중에 디스플레이 항목을 추가하거나 제거할 수 없다.
  • 바뀌는 부분을 캡슐화하지 않았다.

해당 특징들이 드러나는 부분은 전체 코드 중 아래 부분이다.

currentConditionsDisplay.update(temp, humidity, pressure);
statisticsDisplay.update(temp, humidity, pressure);
forecastDisplay.update(temp, humidity, pressure);

바뀌어야 하는 부분이지만 캡슐화되어 있지 않으며, 구체적인 구현에 맞춰 코딩했으므로 프로그램을 고치지 않고는 다른 디스플레이 항목을 추가하거나 제거할 수 없다.

이러한 단점들을 어떻게 보완할 수 있을까?

바로 오늘의 주제인 옵저버 패턴을 적용해볼 때다.


2. 옵저버 패턴

옵저버 패턴 이해하기

신문이나 잡지를 구독하는 매커니즘에 대해 생각해보자.

  1. 신문사가 사업을 시작하고 신문을 찍어내기 시작한다.
  2. 독자가 특정 신문사에 구독 신청을 하면 매번 새로운 신문이 나올 때마다 배달을 받을 수 있다.
    2-1. 구독을 해지하기 전까지 신문을 계속 받을 수 있다.
  3. 신문을 더이상 보고 싶지 않으면 구독 해지 신청을 한다. 그러면 더이상 신문이 오지 않는다.
  4. 신문사가 망하지 않는 이상, 개인/호텔/항공사 등 모든 소비자는 꾸준하게 신문을 구독하거나 해지한다.

여기서 한 가지 결론을 얻을 수 있다.

💡 신문사(subject) + 구독자(observer) = 옵저버 패턴

위 문장에 대해 자세히 알아보도록 하자.

  1. 주제 객체가 되는 신문사는 중요한 데이터를 관리한다.

  2. 만약 주제 데이터가 바뀌면 옵저버 객체에게 그 소식이 전해지며, 새로운 데이터 값은 어떤 방법으로든 옵저버 객체에 전달된다.

  3. 옵저버 객체들은 주제를 구독하고 있으며(주제 객체에 등록되어 있으며) 주제 데이터가 바뀌면 갱신 내용을 전달받는다.

  4. 옵저버가 아닌 객체는 주제 데이터가 바뀌어도 아무 연락도 받지 못한다.


옵저버 패턴의 작동 원리

  • 새로운 객체가 등장하여 주제에게 본인도 옵저버가 되고 싶다고 말한다.
    새 객체는 정말로 옵저버가 되고 싶다! 주제에서 상태가 바뀔 때마다 보내주는 int 값에 정말 관심이 많았기 때문이다!
  • 새 객체도 이제 정식 옵저버가 되었다.
    새 객체는 매우 신난 상태다! 구독자 목록에 이름을 올리고 다음에 전달될 int 값을 애타게 기다리고 있다!

  • 주제 값이 바뀌었다.
    이제 새 객체를 비롯한 모든 옵저버가 주제 값이 바뀌었다는 연락을 받게 되었다!

  • 기존 옵저버 객체중 하나가 옵저버 목록에서 탈퇴하고 싶다는 요청을 한다.
    배가 부른 한 객체는 한참 전부터 int 값을 받아왔으나 이제 지겨워졌는지 옵저버를 그만두기로 하고 해지 요청을 한다.

  • 이제 그 객체는 빠졌다.
    주제가 요청을 받아들여 해당 객체를 옵저버 집합에서 제거했다.

  • 주제에 새로운 int 값이 들어왔다.
    모든 옵저버가 값이 바뀌었다는 연락을 받는다. 하지만 탈퇴한 객체는 더이상 연락을 받지 못하기에 새로운 int 값이 무엇인지 알 수 없다. 새로운 int 값이 무엇인지 알고 싶다면 다시 옵저버 등록 요청을 하면 된다.

이 흐름을 쭉 읽어보면 마치 IT 기업 헤드헌터(주제)에게 개발자(일반 객체)가 본인의 포트폴리오를 어필하여 리크루팅 목록(옵저버)에 이름을 올려두고, 채용 기회가 있을 때마다 헤드헌터(주제)개발자(옵저버의 객체)에게 이를 알리는 것과 비슷한 맥락이라고 볼 수 있을 것이다.


옵저버 패턴의 정의

옵저버 패턴이 무엇인지 어느정도 이해했다면 이제 정확한 정의를 알아보자.

옵저버 패턴(ObserverPattern)은 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체에게 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다(one-to-many) 의존성을 정의한다.

옵저버 패턴은 일련의 객체 사이에서 일대다 관계를 정의하기 때문에 한 객체의 상태가 변경되면 그 객체에 의존하는 모든 객체에 연락이 간다.

즉, 옵저버는 주제에 딸려 있으며 주제의 상태가 바뀌면 옵저버에게 정보가 전달된다.

옵저버는 데이터가 변경되었을 때 주제에서 갱신해주기를 기다리는 입장이기에 의존성을 가진다고 할 수 있으며, 이런 방법을 사용하면 여러 객체가 동일한 데이터를 제어하는 방법보다 더 깔끔한 객체지향 디자인을 만들 수 있다.


옵저버 패턴의 구조

옵저버 패턴은 여러 가지 방법으로 구현할 수 있지만, 보통은 주제 인터페이스옵저버 인터페이스 가 들어있는 클래스 디자인으로 구현한다.

Subject

  • 주제를 나타내는 인터페이스
  • 객체에서 옵저버로 등록할 때
  • 옵저버 목록에서 탈퇴하고 싶을 때
  • 상태가 바뀔 때마다 이를 알릴 때

Observer

  • 옵저버가 될 가능성이 있는 객체가 반드시 구현해야 하는 인터페이스
  • 주제의 상태가 바뀌었을 때
  • 각 주제마다 여러 개의 옵저버가 있을 수 있음

ConcreteSubject

  • 주제 역할을 하는 구상 클래스(클래스의 모든 메서드를 완벽하게 구현한, 오퍼레이션의 실체가 존재하는 클래스)가 반드시 구현해야 하는 인터페이스
  • 상태를 설정하고 알아내는 세터/게터를 포함

ConcreteObserver

  • Observer 인터페이스만 구현한다면 무엇이든 옵저버 클래스가 될 수 있음
  • 특정 주제에 등록하여 연락을 받을 수 있음

느슨한 결합

느슨한 결합에 대해 다루기에 앞서 바구니를 예시로 들어 한번 생각해보자.

단단하게 엮인 바구니와 느슨하게 엮인 바구니가 있다. 둘 중 어느 것이 덜 찢어지거나 부서질까? 바로 느슨하게 엮여 유연한 바구니다.

소프트웨어에서도 마찬가지다. 객체가 서로 덜 단단하게 결합되어 있다면 객체들이 부서질 확률이 낮아진다.

모든 생명체가 다른 생명체에 의존하는 것처럼 모든 객체는 다른 객체에 의존한다. 따라서 완전히 의존하지 않을 수는 없지만, 느슨한 결합을 위해 의존은 하되 다른 객체의 세세한 부분까지 다 알 필요는 없는 것이다.

다른 객체를 잘 모르면 변화에 더 잘 대응할 수 있는 디자인을 만들 수 있다. 덜 단단하게 짠 바구니처럼 말이다.


느슨한 결합(Loose Coupling)은 객체들이 상호작용할 수는 있지만, 서로를 잘 모르는 관계를 의미한다.

앞서 바구니를 예시로 든 것 처럼, 느슨한 결합을 활용하면 유연성이 아주 좋아진다. 옵저버 패턴은 이 느슨한 결합을 보여주는 훌륭한 예시다.

주제는 옵저버가 특정 인터페이스(Observer 인터페이스)를 구현한다는 사실만 안다.

  • 옵저버의 구상 클래스가 무엇인지, 옵저버가 무엇을 하는지는 알 필요도 없다.

옵저버는 언제든지 새로 추가할 수 있다.

  • 주제는 옵저버 인터페이스를 구현하는 객체의 목록에만 의존하므로 언제든지 새로운 옵저버를 추가할 수 있다.
  • 실행 중에 옵저버를 바꿔도 주제는 계속해서 데이터를 보낼 수 있다.
  • 마찬가지로 아무 때나 옵저버를 제거해도 된다.

새로운 형식의 옵저버를 추가할 때도 주제를 변경할 필요가 전혀 없다.

  • 새로운 클래스에서 옵저버 인터페이스를 구현하고 옵저버로 등록하면 된다.
  • 옵저버 인터페이스만 구현하면 어떤 객체에도 연락할 수 있다.

주제와 옵저버는 서로 독립적으로 재사용할 수 있다.

  • 주제와 옵저버를 다른 용도로 활용할 일이 있다고 해도 손쉽게 재사용할 수 있다. (단단하게 결합되어 있지 x)

주제나 옵저버가 달라져도 서로에게 영향을 미치지 않는다.

  • 주제나 옵저버 인터페이스를 구현한다는 조건만 만족하면 어떻게 고쳐도 상관없다.

디자인 원칙

상호작용하는 객체 사이에는 가능하면 느슨한 결합을 사용해야 한다.

느슨하게 결합하는 디자인을 사용하면 변경 사항이 생겨도 무난히 처리할 수 있는 객체지향 시스템을 구축할 수 있다. 객체 사이의 상호의존성을 최소화할 수 있기 때문이다.


3. 기상 스테이션 프로젝트 구현

기상 스테이션 설계하기

Subject

Observer

  • 모든 기상 구성 요소에 Observer 인터페이스를 구현
  • 주제에서 옵저버에 갱신된 정보를 전달하는 방법을 제공

DisplayElement

  • 모든 디스플레이 요소에 구현 인터페이스를 하나 더 만듦

WeatherData

  • Subject 인터페이스를 구현

CurrentConditionsDisplay

  • WeatherData 객체로부터 얻은 현재 측정값을 보여줌

StatisticsDisplay

  • 측정치의 최솟값, 평균, 최댓값을 추적하고 화면에 표시함

ForecastDisplay

  • 측정치를 바탕으로 기상 예보를 화면에 표시함

ThirdPartyDisplay

  • 다른 개발자들도 Observer와 DisplayElement 인터페이스를 구현해서 새로운 디스플레이 요소를 만들 수 있음

기상 스테이션 구현하기

pubilc interface Subject{
	// Observer를 인자로 받아 옵저버를 등록하거나 제거함
	public void registerObserver(Observer o); 
    public void removeObserver(Observer o);
    // 주제의 상태가 변경되었을 때 모든 옵저버에게 변경 내용을 알림
    public void notifyObservers();
}

// 모든 옵저버 클래스에서 구현해야 함
// 따라서 모든 옵저버는 update() 메소드를 구현해야 함
public interface Observer{
	public void update(float temp, float humidity, float pressure);
}

// 디스플레이 항목을 화면에 표시해야 할 때 메소드를 호출함
public interface DisplayElement{
	public void display();
}

Subject 인터페이스 구현하기

public class WeatherData implements Subject { // WeahterData에서 Subject 인터페이스를 구현
	private List<Observer> observers; 
	private float temperature;
	private float humidity;
	private float pressure;
	
	public WeatherData() {
		observers = new ArrayList<Observer>(); // 생성자가 a의 객체 생성
	}
	
    // 여기서부터 notifyObservers() 까지는 Subject 인터페이스를 구현하는 부분
    // 옵저버가 등록을 요청하면 목록 맨 뒤에 추가
	public void registerObserver(Observer o) {
		observers.add(o);
	}
	
    // 옵저버가 탈퇴를 요청하면 목록에서 뺌
	public void removeObserver(Observer o) {
		observers.remove(o);
	}
	
    // ✨중요✨ 모든 옵저버에게 상태 변화를 알려주는 부분
    // 모두 Observer 인터페이스를 구현하는, update() 메소드가 있는 객체들이므로 손쉽게 상태 변화를 알려줄 수 있음
	public void notifyObservers() {
		for (Observer observer : observers) {
			observer.update(temperature, humidity, pressure);
		}
	}
	
    // 기상 스테이션으로부터 갱신된 측정값을 받으면 옵저버들에게 알림
	public void measurementsChanged() {
		notifyObservers();
	}
	
	public void setMeasurements(float temperature, float humidity, float pressure) {
		this.temperature = temperature;
		this.humidity = humidity;
		this.pressure = pressure;
		measurementsChanged(); // 실제 장비에서 기상 데이터를 가져오는 대신 메소드를 활용해 디스플레이 항목을 테스트
	}
    
	// 기타 WeatherData 메소드
	public float getTemperature() {
		return temperature;
	}
	
	public float getHumidity() {
		return humidity;
	}
	
	public float getPressure() {
		return pressure;
	}

}

}

디스플레이 요소 구현하기

// WeatherData 객체로부터 변경 사항을 받으려면 Observer를 구현해야
// 모든 디스플레이 항목에서 DisplayElement를 구현하기로 했으므로 이것도 구현
public class CurrentConditionsDisplay implements Observer, DisplayElement {
	private float temperature;
	private float humidity;
	private WeatherData weatherData;
	
    // 생성자에 weatherData라는 주제가 전달되며, 그 객체를 써서 디스플레이를 옵저버로 등록
	public CurrentConditionsDisplay(WeatherData weatherData) {
		this.weatherData = weatherData;
		weatherData.registerObserver(this);
	}
	
     // update()가 호출되면 온도와 습도를 저장하고 display()를 호출
	public void update(float temperature, float humidity, float pressure) {
		this.temperature = temperature;
		this.humidity = humidity;
		display();
	}
	
    // 가장 최근에 받은 온도와 습도를 출력
	public void display() {
		System.out.println("Current conditions: " + temperature 
			+ "F degrees and " + humidity + "% humidity");
	}
}

기상 스테이션 테스트

public class WeatherStation {

	public static void main(String[] args) {
		WeatherData weatherData = new WeatherData(); // WeatherData 객체를 생성
	
    	// 3개의 디스플레이를 생성하고 WeatherData 객체를 인자로 전달
		CurrentConditionsDisplay currentDisplay = 
			new CurrentConditionsDisplay(weatherData);
		StatisticsDisplay statisticsDisplay = new StatisticsDisplay(weatherData);
		ForecastDisplay forecastDisplay = new ForecastDisplay(weatherData);

		// 새로운 기상 측정 값이 들어왔다고 가정
		weatherData.setMeasurements(80, 65, 30.4f);
		weatherData.setMeasurements(82, 70, 29.2f);
		weatherData.setMeasurements(78, 90, 29.2f);
	}
}

푸시와 풀 방식

옵저버를 구현하는 방식에는 크게 두 가지가 있다.

  • Push : 주제가 옵저버로 데이터를 보냄
  • Pull : 옵저버가 주제로부터 데이터를 당겨옴

지금 만들어 놓은 WeatherData 디자인은 전자의 방식으로, 하나의 데이터만 갱신해도 되는 상황에서도 update() 메소드에 모든 데이터를 보내도록 되어 있다.

그래도 별 문제는 없지만, 풍속 같은 새로운 데이터 값을 추가한다면 대부분의 update() 메소드에서 풍속 데이터를 쓰지 않더라도 모든 디스플레이에 있는 update()의 값을 바꿔야 할 것이다.

이러한 이유 등으로, 대체로 옵저버가 필요한 데이터를 골라서 가져가도록(Pull) 만드는 방법이 더 좋다.

주제가 자신의 데이터에 관한 게터 메소드를 가지게 만들고, 필요한 데이터를 당겨올 때 해당 메소드를 호출할 수 있도록 옵저버를 고쳐주면 위와 같은 방법으로 구현이 가능하다.


4. 오늘의 디자인 원칙

  1. 애플리케이션에서 달라지는 부분을 찾아내고, 달라지지 않는 부분과 분리한다

-> 옵저버 패턴에서 변하는 것은 주제의 상태와 옵저버의 개수, 형식이다. 옵저버에서는 주제를 바꾸지 않고도 주제의 상태에 의존하는 객체들을 바꿀 수 있다. 나중에 바뀔 것을 대비해 두면 편하게 작업할 수 있다.

  1. 구현보다는 인터페이스에 맞춰서 프로그래밍한다.

-> 주제와 옵저버에서 모두 인터페이스를 사용한다.
주제는 Subject 인터페이스로 Observer 인터페이스를 구현하는 객체들의 등록과 탈퇴를 관리하고, 그런 객체들에게 연락을 돌린다. 이렇게 하면 느슨한 결합도 만들 수 있다.

  1. 상속보다는 구성을 활용한다.

-> 옵저버 패턴에서는 구성을 활용해서 옵저버들을 관리한다. 주제와 옵저버 사이의 관계는 상속이 아니라 구성이다. 게다가 실행 중에 구성되는 방식을 사용하므로 더욱 좋다.

  1. 상호작용하는 객체 사이에는 가능하면 느슨한 결합을 사용해야 한다. << new!!


본 포스팅에 쓰인 이미지와 내용의 모든 출처는 책 '헤드 퍼스트 디자인 패턴' 에 있습니다.

profile
학교 다니는 개발자

0개의 댓글