본문 바로가기

Design Pattern

[디자인 패턴] 옵저버 패턴, 값이 바뀌면 여러 객체에 알리기

이번 세미나에서는 『헤드 퍼스트 디자인 패턴』의 기상 관측 예제로 옵저버 패턴을 공부했다.

하나의 기상 데이터가 바뀌면 현재 날씨, 통계, 예보 화면이 함께 갱신되는 예제다.

처음 코드를 보면 화면의 메서드를 각각 호출하면 될 것 같다. 실제로 그렇게 구현할 수 있다. 그렇다면 옵저버 패턴을 적용하면 무엇이 달라질까?


 

 

문제는 새로운 화면을 추가할 때 드러난다.

기상 데이터를 관리하는 WeatherData가 각 화면을 직접 갱신한다고 해 보자.

public void measurementsChanged() {
    float t = getTemperature();
    float h = getHumidity();
    float p = getPressure();

    currentConditionsDisplay.update(t, h, p);
    statisticsDisplay.update(t, h, p);
    forecastDisplay.update(t, h, p);
}

 

이 코드도 지금 필요한 기능은 수행한다. 그런데 체감 온도 화면을 추가하면 WeatherData에도 그 화면을 호출하는 코드를 넣어야 한다. 화면을 제거할 때도 같은 부분을 수정해야 한다.

데이터를 관리하는 클래스가 어떤 화면들이 있는지까지 구체적으로 알고 있는 셈이다. 화면의 종류가 바뀔 때마다 기상 데이터를 관리하는 코드도 영향을 받는다.

옵저버 패턴에서는 이 관계를 바꾼다. 각 화면이 알림을 받을 대상으로 등록하고, WeatherData는 등록된 대상들을 차례로 호출한다.


옵저버 패턴은 한 객체의 상태가 바뀌면, 그 객체에 의존하는 여러 객체에 변경을 알리는 패턴이다. 정의에서 말하는 ‘일대다 의존 관계’가 여기에 해당한다.

예제에서는 역할을 두 가지로 나눌 수 있다.

  • 주제(Subject): 기상 데이터를 관리하고, 값이 바뀌면 등록된 대상에게 알린다. WeatherData가 이 역할을 맡는다.
  • 옵저버(Observer): 변경 알림을 받아 자신의 작업을 처리한다. 현재 날씨, 통계, 예보 화면이 여기에 해당한다.

신문 구독으로 생각하면 이해하기 쉽다. 신문사는 구독자 목록을 관리하고 새 신문을 보낸다. 구독자는 신청하면 신문을 받고, 구독을 해지하면 더 이상 받지 않는다.

다만 같은 데이터를 받았다고 해서 모든 화면이 같은 결과를 보여 줄 필요는 없다. 현재 날씨 화면은 온도와 습도를 그대로 표시하고, 통계 화면은 온도의 평균이나 최댓값을 계산할 수 있다.

 

 

Java 코드에서는 구체적인 화면 대신 공통 인터페이스를 사용한다. 아래 코드는 등록과 알림 흐름에 집중해 정리한 예제다. Subject와 Observer는 직접 정의한 인터페이스다.

interface Subject {
    void registerObserver(Observer observer);
    void removeObserver(Observer observer);
    void notifyObservers();
}

interface Observer {
    void update(float temperature, float humidity, float pressure);
}

 

Subject에는 등록, 해지, 알림 기능이 있다. Observer에는 알림을 받았을 때 실행할 update()가 있다. 메서드 이름만 복잡해 보일 뿐, 구독 신청과 해지, 소식 전달을 코드로 옮긴 것이다.

WeatherData는 이 계약에 맞춰 옵저버 목록을 관리한다.

import java.util.ArrayList;
import java.util.List;

class WeatherData implements Subject {
    private final List<Observer> observers = new ArrayList<>();
    private float temperature;
    private float humidity;
    private float pressure;

    @Override
    public void registerObserver(Observer observer) {
        observers.add(observer);
    }

    @Override
    public void removeObserver(Observer observer) {
        observers.remove(observer);
    }

    public void setMeasurements(float t, float h, float p) {
        temperature = t;
        humidity = h;
        pressure = p;
        notifyObservers();
    }

    @Override
    public void notifyObservers() {
        for (Observer observer : observers) {
            observer.update(temperature, humidity, pressure);
        }
    }
}

 

기존 코드와 비교하면 핵심적인 차이는 마지막 반복문에 있다.

WeatherData는 더 이상 현재 날씨 화면이나 통계 화면을 각각 이름으로 지정해 호출하지 않는다.

목록에 들어 있는 객체가 Observer이고, update()를 호출할 수 있다는 것만 알면 된다.

알림을 받는 화면은 다음과 같이 구현할 수 있다.

class CurrentConditionsDisplay implements Observer {
    public CurrentConditionsDisplay(Subject subject) {
        subject.registerObserver(this);
    }

    @Override
    public void update(float temperature, float humidity, float pressure) {
        System.out.printf(
            "현재 온도: %.1f℃, 습도: %.1f%%%n",
            temperature, humidity
        );
    }
}

 

생성자의 registerObserver(this)는 “이 객체도 앞으로 변경 소식을 받겠다”는 등록이다. 이후 새 측정값을 넣으면 화면의 update()가 실행된다.

WeatherData weatherData = new WeatherData();
Observer currentDisplay = new CurrentConditionsDisplay(weatherData);

weatherData.setMeasurements(25.0f, 60.0f, 1013.0f);
// 현재 온도: 25.0℃, 습도: 60.0%

weatherData.removeObserver(currentDisplay);
weatherData.setMeasurements(26.0f, 55.0f, 1012.0f);
// 구독을 해지했으므로 currentDisplay에는 알림이 전달되지 않는다.

 

새로운 화면이 필요하면 Observer를 구현한 뒤 등록하면 된다. 화면을 만들고 연결하는 코드는 필요하지만, WeatherData의 알림 반복문은 그대로 사용할 수 있다.

 

이것이 이 예제에서 말하는 느슨한 결합이다. WeatherData가 개별 화면의 구현에 덜 의존하고, 공통 인터페이스를 통해 연결된다.

여기서 ‘자동으로 알린다’는 표현도 짚고 넘어갈 필요가 있다. 위 코드는 setMeasurements() 안에서 notifyObservers()를 호출하도록 작성했기 때문에 알림이 전달된다. 상태를 변경하고 이 호출을 빼먹으면 화면도 갱신되지 않는다.

또한 자동 알림이 비동기 실행을 의미하지는 않는다. 위 반복문은 같은 스레드에서 update()를 하나씩 호출한다. 한 화면의 처리가 오래 걸리면 뒤에 있는 화면도 기다린다.


변경을 알릴 때 데이터를 함께 보낼지는 선택할 수 있다. 이 차이가 푸시와 풀이다.

앞에서 작성한 코드는 푸시(Push) 방식이다. 주제가 알림을 보낼 때 온도, 습도, 기압을 함께 전달한다.

observer.update(temperature, humidity, pressure);

풀(Pull) 방식에서는 변경 사실을 알리고, 옵저버가 필요한 값을 직접 가져온다. 이 형태로 바꾸려면 Observer의 update() 선언을 바꾸고, 데이터를 읽을 수 있는 메서드도 제공해야 한다.

 

// 주제: 변경 사실을 알린다.
observer.update();

// 옵저버의 update() 내부: 필요한 값만 가져온다.
float temperature = weatherData.getTemperature();
float humidity = weatherData.getHumidity();

위 코드는 풀 방식의 흐름만 보여 주는 부분 코드다. 옵저버는 조회할 weatherData 객체의 참조를 가지고 있어야 한다.

비교푸시(Push)풀(Pull)
데이터 전달 주제가 알림과 함께 전달 옵저버가 알림을 받은 뒤 조회
예제의 호출 update(t, h, p) update() 이후 getter 호출
편리한 상황 필요한 데이터가 정해져 있을 때 옵저버마다 필요한 항목이 다를 때
고려할 점 매개변수가 바뀌면 여러 구현에 영향 조회 대상과 조회 메서드를 알아야 함

기상 예제에서는 현재 날씨 화면에 온도와 습도가 필요하고, 예보 화면에는 기압이 필요하다. 나중에 풍속 같은 항목이 추가될 수도 있다.

풀 방식을 쓰면 기존 조회 메서드를 유지하면서 새 항목을 추가할 수 있다. 풍속을 사용하지 않는 화면은 기존 코드를 그대로 둘 수 있다는 이점이 있다. 반대로 모든 화면에 같은 데이터 몇 개만 전달하면 된다면 푸시가 더 간단할 수 있다.

두 방식 모두 옵저버 패턴에서 선택할 수 있는 데이터 전달 방식이다. 누가 필요한 데이터를 선택할지, 새 항목이 생겼을 때 어디까지 수정해야 할지를 보고 결정할 수 있다.


여기서 한 가지 더 생각해 볼 문제가 있다. “옵저버 하나에서 오류가 나면, 다른 옵저버에게도 알림을 계속 보내야 할까?”

현재 날씨, 통계, 예보 화면 순서로 알리는 중에 통계 화면에서 오류가 발생했다고 가정해 보자. 생각할 수 있는 의견은 두 가지다.

  • 계속 알리자. 통계 화면 하나에 문제가 생겼다고 정상적인 예보 화면까지 최신 정보를 받지 못하게 하면 안 된다.
  • 일단 중단하자. 어떤 화면은 새 값을, 다른 화면은 이전 값을 보여 주면 사용자가 혼란스러울 수 있다. 오류 상황을 먼저 처리해야 한다.

다만 알림을 중단한다고 해서 이미 갱신한 현재 날씨 화면이나 원본 기상 데이터가 자동으로 이전 상태로 돌아가지는 않는다. 중단하는 것만으로 모든 화면의 상태를 일치시킬 수는 없다.

앞에서 작성한 단순한 반복문도 오류 처리가 필요하다. 중간의 update()가 예외를 던지고 이를 처리하지 않으면, 뒤에 있는 옵저버의 호출이 중단될 수 있다.

이 기상 화면처럼 각 화면을 독립적으로 사용할 수 있다면, 나머지 화면에는 계속 알리고 실패한 화면에는 ‘갱신 실패’를 표시하는 방법을 생각할 수 있다. 모든 화면이 반드시 같은 상태를 보여야 한다면 이미 갱신된 화면까지 어떻게 처리할지 별도의 설계가 필요하다.

옵저버 패턴은 변경을 여러 객체에 전달하는 구조를 제공한다. 오류 처리나 재시도 정책까지 정해 주지는 않는다.

장점이 분명한 만큼 관리할 부분도 생긴다. 수신자를 쉽게 추가하고 제거할 수 있지만, 어떤 옵저버가 등록돼 있는지 확인해야 전체 실행 흐름을 파악할 수 있다. 더 이상 알림을 받을 필요가 없는 객체는 구독을 해지해야 하고, 중복 등록을 허용할지도 정해야 한다.


앞서 공부한 전략 패턴과 비교하면 목적이 더 선명해진다. Memora의 퀴즈 예제에서 전략 패턴은 퀴즈를 만드는 방법을 별도 객체에 맡기고 교체할 수 있게 하는 것이었다. 옵저버 패턴은 기상 데이터가 바뀌었을 때 관심 있는 여러 객체에 알리는 것에 초점이 있다.

둘 다 인터페이스로 객체를 연결하지만, 분리하려는 책임이 다르다.

옵저버 패턴을 떠올릴 만한 상황은 단순히 객체 수가 많은 경우보다, 한 객체의 변경에 반응하는 대상들이 계속 추가되거나 제거되는 경우다. 그때 상태를 관리하는 코드와 변경에 반응하는 코드를 나누면 어떤 수정이 줄어드는지 살펴볼 수 있다.

 

참고: 『헤드 퍼스트 디자인 패턴』 개정판, Chapter 2. 코드는 등록과 알림 흐름을 설명하기 위해 간추려 작성했다.