[타입스크립트로 살펴보는 디자인패턴 2] 옵저버 패턴(Observer Pattern)
🙋♂️ 디자인패턴 구현코드 깃헙
옵저버 패턴(Observer Pattern)이란?
옵저버 패턴은 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체에게 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다(one-to-many) 의존성을 정의한다.
옵저버 패턴은 일종의 구독서비스를 구현한 것이다. 뉴스레터와 같은 케이스로, 정보를 보내는 주체는 주제(subject), 정보를 받는 구독자는 옵저버(observer)라고 부른다. 주제와 옵저버는 일대다 관계를 맺고 있으며, 주제의 상태가 변경되면 옵저버 객체들에게 변경되었다는 연락이 간다.
옵저버 패턴은 보통 주제 인터페이스와 옵저버 인터페이스를 구현하는 방식으로 클래스를 디자인 한다. 주제 인터페이스를 구현한 주제 클래스는 옵저버 등록과 해제, 알림 메서드가 필수적으로 들어가고, 옵저버 인터페이스를 구현한 옵저버 클래스는 변화된 주제의 상태를 업데이트하는 메서드가 필수적이다.
# 여기서 잠깐
클래스 다이어그램 일반화 관계(Generalization)에서 점선과 실선의 차이
- 점선 화살표: 인터페이스 구현 관계를 나타냄
- 실선 화살표: 클래스 상속 관계를 나타냄
연관 관계 화살표 방향의 차이
- Subject → Observer:
- Subject의 책임을 강조
- 여러 옵저버를 관리하는 주체로서 observer를 참조
- ConcreteObserver → ConcreteSubject:
- 옵저버의 데이터 의존성 강조
- 데이터를 받기 위해 종속적으로 subject를 참조
기상 관측 시스템
이어서 옵저버 패턴을 적용한 기상 관측 시스템의 클래스 다이어그램을 살펴보자. 이 시스템은 온도, 습도, 기압에 대한 데이터를 실시간으로 측정하고, 관측된 데이터를 WeatherData 라는 구현 클래스를 통해 옵저버인 디스플레이 기기에 전달하는 구조이다.
디스플레이는 1) 현재 상태를 보여주는 화면과 2) 날씨를 예측해주는 화면을 구성했다.
코드
observer.interface.ts
앞서 옵저버 패턴에 대해 설명한 것처럼 옵저버를 등록 및 제거하고, 상태 변경을 보내는 메서드를 Subject 인터페이스에 구성했다. Observer 인터페이스는 기상 데이터를 받아서 업데이트하는 메서드를, DisplayElement 인터페이스는 화면에 출력하는 메서드를 구성했다.
export interface Subject {
registerObserver(observer: Observer): void;
removeObserver(observer: Observer): void;
notifyObserver(): void;
}
export interface Observer {
update(temp: number, humidity: number, pressure: number): void;
}
export interface DisplayElement {
display(): void;
}
weather.type.ts
기상 예측을 보여줄 화면에서 사용할 enum을 정리했다.
export enum ForecastCase {
RAIN = "rain",
SNOW = "snow",
SUNNY = "sunny",
}
weather.date.ts
WeatherData 클래스는 Subject를 구현한다. 옵저버의 등록, 제거 및 등록된 옵저버의 업데이트 로직을 실행하여 상태 변화를 알려준다. 기상 관측 데이터가 측정되면 변화된 값들을 옵저버에게 알린다.
import { Observer, Subject } from "./observer.interface";
export class WeatherData implements Subject {
private observers: Observer[];
private temperature: number;
private humidity: number;
private pressure: number;
constructor() {
this.observers = [];
}
registerObserver(observer: Observer): void {
this.observers.push(observer);
}
removeObserver(observer: Observer): void {
this.observers = this.observers.filter((el) => el !== observer);
}
notifyObserver(): void {
for (const observer of this.observers) {
observer.update(this.temperature, this.humidity, this.pressure);
}
}
measurementsChanged(): void {
this.notifyObserver();
}
setMeasurement(temp: number, humidity: number, pressure: number): void {
this.temperature = temp;
this.humidity = humidity;
this.pressure = pressure;
this.measurementsChanged();
}
getTemperature(): number {
return this.temperature;
}
getHumidity(): number {
return this.humidity;
}
getPressure(): number {
return this.pressure;
}
}
weather.data.display.ts
CurrentConditionsDisplay
- 생성자에서 해당 디스플레이를 weatherData의 옵저버로 등록한다.
- update 메서드에서 현재 측정값을 속성에 할당하고, display 메서드로 출력까지 한다.
ForecastDisplay
- 생성자에서 해당 디스플레이를 weatherData의 옵저버로 등록한다.
- 업데이트된 측정값을 기반으로 날씨를 예측하는 forecast 메서드를 추가했다.
- isWet 게터 메서드를 설정하여 공통된 판단 기준을 묶어줬다.
- switch-case 문법과 enum을 활용하여 예측된 날씨를 출력했다.
import { DisplayElement, Observer } from "./observer.interface";
import { WeatherData } from "./weather.date";
import { ForecastCase } from "./weather.type";
export class CurrentConditionsDisplay implements Observer, DisplayElement {
private temperature: number;
private humidity: number;
private pressure: number;
constructor(weatherData: WeatherData) {
weatherData.registerObserver(this);
}
update(temp: number, humidity: number, pressure: number): void {
this.temperature = temp;
this.humidity = humidity;
this.pressure = pressure;
this.display();
}
display(): void {
console.log(
`현재 상태: 온도 ${this.temperature}℃, 습도: ${this.humidity}%, 기압: ${this.pressure}hPa`,
);
}
}
export class ForecastDisplay implements Observer, DisplayElement {
private temperature: number;
private humidity: number;
private pressure: number;
constructor(weatherData: WeatherData) {
weatherData.registerObserver(this);
}
update(temp: number, humidity: number, pressure: number): void {
this.temperature = temp;
this.humidity = humidity;
this.pressure = pressure;
this.display();
}
display(): void {
switch (this.forecast()) {
case ForecastCase.RAIN:
console.log("내일은 비가 옵니다");
break;
case ForecastCase.SNOW:
console.log("내일은 눈이 옵니다");
break;
case ForecastCase.SUNNY:
console.log("내일은 화창합니다");
break;
default:
console.log("내일은 저도 모릅니다");
break;
}
}
private forecast(): string {
if (this.temperature > 20 && this.isWet) {
return ForecastCase.RAIN;
} else if (this.temperature < 5 && this.isWet) {
return ForecastCase.SNOW;
} else {
return ForecastCase.SUNNY;
}
}
get isWet(): boolean {
return this.humidity > 70 && this.pressure < 1000;
}
}
weather.station.ts
생성자에서 weatherData를 디스플레이 클래스의 new 연산자에 파라미터로 전달하면 옵저버로 등록이 된다. 이후 관측된 데이터값을 세팅하는 메서드를 호출하면 옵저버들의 화면에 아래와 같이 출력된다.
import { CurrentConditionsDisplay, ForecastDisplay } from "./weather.data.display";
import { WeatherData } from "./weather.date";
class WeatherStation {
private weatherData = new WeatherData();
private currentDisplay: CurrentConditionsDisplay;
private forecastDisplay: ForecastDisplay;
constructor() {
this.currentDisplay = new CurrentConditionsDisplay(this.weatherData);
this.forecastDisplay = new ForecastDisplay(this.weatherData);
this.weatherData.setMeasurement(21, 71, 997);
this.weatherData.setMeasurement(3, 75, 999);
this.weatherData.setMeasurement(20, 69, 1014);
}
}
new WeatherStation();
출력값
현재 상태: 온도 21℃, 습도: 71%, 기압: 997hPa
내일은 비가 옵니다
현재 상태: 온도 3℃, 습도: 75%, 기압: 999hPa
내일은 눈이 옵니다
현재 상태: 온도 20℃, 습도: 69%, 기압: 1014hPa
내일은 화창합니다
풀 방식으로 변경하기
앞서 구현했던 코드는 푸시(push) 방식으로 Subject가 모든 옵저버의 update 메서드를 호출해서 데이터를 보내는 방식이다. 이럴 경우, 값이 세팅될 때마다 모든 디스플레이의 update 메서드를 호출해야 하고 특정 디스플레이에서 사용하지 않는 데이터까지 보내야 한다는 단점이 있다.
풀(pull) 방식은 옵저버가 필요할 때마다 주제로부터 필요한 데이터만 당겨오는 방식이다. 풀 방식을 사용하면 주제를 구현한 클래스에 속성이 추가 되거나, 옵저버를 구현하는 클래스가 추가 되는 상황에 보다 유연하게 대처할 수 있다.
observer.interface.ts
Observer 인터페이스는 update 메서드에서 파라미터를 받을 필요가 없다.
export interface Observer {
update(): void;
}
weather.data.display.ts
옵저버 인터페이스를 구현한 디스플레이 클래스에 weatherData 속성을 추가하고, 생성자에서 받은 파라미터를 할당한다. 그리고 기존에 update 메서드를 통해 받았던 데이터를 weatherData의 get 메서드로 가져와서 할당한다.
export class CurrentConditionsDisplay implements Observer, DisplayElement {
private weatherData: WeatherData;
constructor(weatherData: WeatherData) {
this.weatherData = weatherData;
weatherData.registerObserver(this);
}
update(): void {
this.temperature = this.weatherData.getTemperature();
this.humidity = this.weatherData.getHumidity();
this.pressure = this.weatherData.getPressure();
this.display();
}
}
weather.data.ts
옵저버들의 update 메서드를 실행할 때도, 데이터를 파라미터로 전달할 필요가 없다.
notifyObserver(): void {
for (const observer of this.observers) {
observer.update();
}
}
객체 디자인 원칙
상호작용하는 객체 사이에는 가능하면 느슨한 결합을 사용해야 한다.
느슨한 결합(Loose Coupling)
느슨한 결합은 객체 사이의 상호의존성을 최소화하는 것으로, 변경 사항에 유연한 대처를 가능하게 한다. 지금까지 살펴본 옵저버 패턴은 느슨한 결합의 좋은 예시다.
- 주제는 옵저버가 특정 인터페이스만 구현한다는 사실을 알 뿐이다. 어떻게 구현하는지 구체적인 것은 알 필요가 없다.
- 그렇기 때문에 주제나 옵저버 모두 인터페이스의 구현만 따른다면, 세부사항이 변경되더라도 서로에게 영향을 주지 않는다.
참고문서
헤드퍼스트 디자인패턴, 한빛미디어