[디자인패턴] 9. 옵저버 패턴 ( Observer Pattern )

1. 옵서버 패턴이란?

옵서버 패턴은 데이터의 변경이 발생했을 경우 상대 클래스나 객체에 의존하지 않으면서 데이터 변경을 통보하고자 할 때 유용하다.

예를 들어 새로운 파일이 추가되거나, 기존 파일이 삭제되었을 때 탐색기는 이를 즉시 표시할 필요가 있다. 탐색기를 복수 개 실행하는 상황이나 하나의 탐색기에서 시스템을 변경하였을 때는 다른 탐색기에게 즉각적으로 이를 통보해야한다.

옵서버 패턴은 통보 대상 객체의 관리를 Subject클래스와 Observer인터페이스로 일반화한다. 그러면 데이터 변경을 통보하는 클래스(ConcreteSubject)는 통보 대상 클래스나 객체(ConcreteObserver)에 대한 의존성을 없앨 수 있다. 결과적으로 옵서버 패턴은 통보 대상 클래스나 대상 객체의 변경에도 ConcreteSubject클래스를 수정없이 그대로 사용할 수 있도록 한다.

observer-pattern-collaboration

옵서버 패턴에서 나타나는 역할이 수행하는 작업은 아래와 같다.

아래는 옵서버 패턴의 순차 다이어그램이다.

observer-pattern-sequence

ConcreteSubject가 자신의 상태, 즉 데이터 변경을 통보하려면 ConcreteObserver가 미리 등록되어 있어야 하는데 위의 순차 다이어그램을 살펴보면 ConcreteSubjectConcreteObserver1ConcreteObserver2가 등록되어 있는 상태이다.

  1. 이때 ConcreteObserver1 ConcreteSubject의 상태를 변경하면 ConcreteSubject는 등록된 모든 ConcreteObserver에게 자신이 변경되었음을 통보한다.
  2. 변경통보는 실제로 ConcreteSubject의 상위 클래스인 Subject클래스의 notifyObservers() 메서드를 호출해 이루어진다.
  3. 그러면 notifyObservers()메서드는 등록된 각 ConcreteObserverupdate()메서드를 호출한다.
  4. 마지막으로 통보받은 ConcreteObserver1ConcreteObserver2ConcreteSubjectgetState()메서드를 호출함으로써 변경된 상태나 데이터를 구한다.

2. 옵서버 패턴 예제 : 성적 출력 기능

성적을 출력하는 기능을 구현하면서 옵서버 패턴을 이해해보자.

2.1 성적출력기능

아래는 성적을 출력하는 기능을 구현하기 위해 필요한 클래스 다이어그램과 설명이다.

observer-pattern-score-class-diagram

아래의 그림은 ScoreRecordDataSheetView 클래스 사이의 상호작용을 순차다이어그램으로 표현한 것이다.

observer-pattern-score-sequence

위에서 설명한 것을 바탕으로 작성된 코드는 아래와 같다.

public class ScoreRecord {

    private List<Integer> scores = new ArrayList<>();   // 점수 저장
    private DataSheetView dataSheetView;                // 목록 형태로 점수를 출력하는 클래스 참조 변수

    public void setDataSheetView(DataSheetView dataSheetView) {
        this.dataSheetView = dataSheetView;
    }

    // 새로운 점수 추가
    public void addScore(int score) {
        scores.add(score);  // scores 목록에 주어진 점수를 추가
        dataSheetView.update(); // scores 변경 통보
    }

    // 점수 목록 가져오기
    public List<Integer> getScoreRecord() {
        return scores;
    }

}
public class DataSheetView {

    private ScoreRecord scoreRecord;    // 점수 저장 클래스 참조변수
    private int viewCount;              // 저장된 점수의 갯수

    // 생성자
    public DataSheetView(ScoreRecord scoreRecord, int viewCount) {
        this.scoreRecord = scoreRecord;
        this.viewCount = viewCount;
    }

    // 점수의 변경을 통보받아 갱신하는 메서드
    public void update() {
        List<Integer> record = scoreRecord.getScoreRecord(); // 점수 조회
        displayScores(record, viewCount);
    }

    // 점수 출력 메서드
    private void displayScores(List<Integer> record, int viewCount) {
        System.out.println("List of " + viewCount + " entries ");
        for (int i = 0; i < viewCount &&  i < record.size(); i++) {
            System.out.println(record.get(i));
        }
        System.out.println();
    }

}
public class Client {
    public static void main(String[] args) {

        ScoreRecord scoreRecord = new ScoreRecord();    // 점수 저장 객체 생성
        DataSheetView dataSheetView = new DataSheetView(scoreRecord, 3);    // 3개까지 점수만 출력

        scoreRecord.setDataSheetView(dataSheetView);    // 점수 시트 설정

        for (int i = 1; i <= 5; i++) {
            int score = i * 10;
            System.out.println("adding " + score);  // 점수추가
            scoreRecord.addScore(score);    // 저장된 점수목록 출력
        }

    }
}
adding 10
List of 3 entries 
10

adding 20
List of 3 entries 
10
20

adding 30
List of 3 entries 
10
20
30

adding 40
List of 3 entries 
10
20
30

adding 50
List of 3 entries 
10
20
30

2.2 성적출력기능의 문제점

성적 출력 기능을 위와 같이 구현했지만 만약 기능이 추가되거나, 요구사항이 바뀐다면 어떻게 될까? 구체적인 변경사항이나 문제점에 대해 알아보자.

일단 먼저 성적을 다른 형태로 출력하는 경우에 대해 알아보자.

점수 목록 출력대신 최소/최대 값만을 출력하려면 기존의 DataSheetView클래스 대신 MinMaxView클래스를 추가할 필요가 있다. 그리고 ScoreRecord 클래스는 DataSheetView 클래스가 아니라 MinMaxView 클래스에 성적 변경을 통보할 필요가 있다.

아래는 위에서 설명한 것과 같이 성적의 최소/최대 값을 출력하는 코드이다.

public class ScoreRecord {

    private List<Integer> scores = new ArrayList<>();   // 점수 저장
    private MinMaxView minMaxView;  // MinMaxView 클래스 객체 참조 변수
    
    // MinMaxView 설정 추가
    public void setMinMaxView(MinMaxView minMaxView) {
        this.minMaxView = minMaxView;
    }

    // 새로운 점수 추가
    public void addScore(int score) {
        scores.add(score);  // scores 목록에 주어진 점수를 추가
        //dataSheetView.update(); // scores 변경 통보
        minMaxView.update();    // scores 변경 통보 변경
    }

    // 점수 목록 가져오기
    public List<Integer> getScoreRecord() {
        return scores;
    }

}
public class MinMaxView {

    private ScoreRecord scoreRecord; // 점수 저장 객체 참조 변수

    public MinMaxView(ScoreRecord scoreRecord) {
        this.scoreRecord = scoreRecord;
    }

    public void update() {
        List<Integer> record = scoreRecord.getScoreRecord(); // 점수 조회
        displayMinMax(record);  // 최소 / 최대 값 출력
    }

    private void displayMinMax(List<Integer> record) {
        int min = Collections.min(record, null);
        int max = Collections.max(record, null);
        System.out.println("Min : " + min + ", Max : " + max);
    }
}
public class Client {
    public static void main(String[] args) {

        ScoreRecord scoreRecord = new ScoreRecord();    // 점수 저장 객체 생성
        MinMaxView minMaxView = new MinMaxView(scoreRecord);

        scoreRecord.setMinMaxView(minMaxView);

        for (int i = 1; i <= 5; i++) {
            int score = i * 10;
            System.out.println("adding " + score);  // 점수추가
            scoreRecord.addScore(score);    // 저장된 점수목록 출력
        }
    }
}
adding 10
Min : 10, Max : 10
adding 20
Min : 10, Max : 20
adding 30
Min : 10, Max : 30
adding 40
Min : 10, Max : 40
adding 50
Min : 10, Max : 50

코드는 원하던 결과대로 출력이 되었다. 하지만 이는 OCP법칙에 위반된다. 그 이유는 점수가 입력되었을 때 지정된 특정 대상 클래스(DataSheetView)에게 고정적으로 통보하도록 코드가 짜여있었는데 다른 대상 클래스(MinMaxView)에게 점수가 입력되었음을 통보하려면 ScoreRecord 클래스의 변경이 불가피하기 때문이다.

이제 두번째 변경된 요구사항에 대해서 알아보자. 성적이 입력되었을 때 최대 3개 목록, 최대 5개의 목록, 최소/최대 값을 동시에 출력하거나 처음에는 목록으로 출력하고, 나중에는 최소/최대 값을 출력하려면 어떻게 해야할지 알아보자.

목록을 출력하는 것은 DataSheetView 클래스를 활용하고, 최소/최대 값을 출력하는 것은 MinMaxView 클래스를 활용할 수 있다. 그래서 ScoreRecord 클래스는 2개의 DataSheetView 객체(3개, 5개의 목록)와 1개의 MinMaxView 객체에게 성적 추가를 통보할 필요가 있다. 이를 위해 ScoreRecord 클래스는 다시 변경되어야한다.

아래의 코드는 위의 설명을 반영한 내용으로 작성되었다.

public class ScoreRecord {

    private List<Integer> scores = new ArrayList<>();                   // 점수 저장
    private List<DataSheetView> dataSheetViews = new ArrayList<>();     // 목록 형태로 점수 출력 리스트 참조변수
    private MinMaxView minMaxView;                                      // 최소/최대값 출력 참조변수

    public void addDataSheetView(DataSheetView dataSheetView) {
        dataSheetViews.add(dataSheetView);
    }

    // MinMaxView 설정 추가
    public void setMinMaxView(MinMaxView minMaxView) {
        this.minMaxView = minMaxView;
    }

    // 새로운 점수 추가
    public void addScore(int score) {
        scores.add(score);  // scores 목록에 주어진 점수를 추가
        // 각 dataSheetView에 값의 변경을 통보
        for (DataSheetView dataSheetView : dataSheetViews) {
            dataSheetView.update();
        }
        minMaxView.update();    // MinMaxView에 값의 변경 통보 변경
    }

    // 점수 목록 가져오기
    public List<Integer> getScoreRecord() {
        return scores;
    }

}
public class MinMaxView {

    private ScoreRecord scoreRecord;

    public MinMaxView(ScoreRecord scoreRecord) {
        this.scoreRecord = scoreRecord;
    }

    public void update() {
        List<Integer> record = scoreRecord.getScoreRecord();
        displayMinMax(record);
    }

    private void displayMinMax(List<Integer> record) {
        int min = Collections.min(record, null);
        int max = Collections.max(record, null);
        System.out.println("Min : " + min + ", Max : " + max);
        System.out.println();
        System.out.println("===============================");
    }
}
public class DataSheetView {
    
    private ScoreRecord scoreRecord;    // 점수 저장 클래스 참조변수
    private int viewCount;              // 저장된 점수의 갯수

    // 생성자
    public DataSheetView(ScoreRecord scoreRecord, int viewCount) {
        this.scoreRecord = scoreRecord;
        this.viewCount = viewCount;
    }

    // 점수의 변경을 통보받아 갱신하는 메서드
    public void update() {
        List<Integer> record = scoreRecord.getScoreRecord(); // 점수 조회
        displayScores(record, viewCount);
    }

    // 점수 출력 메서드
    private void displayScores(List<Integer> record, int viewCount) {
        System.out.println("List of " + viewCount + " entries ");
        for (int i = 0; i < viewCount &&  i < record.size(); i++) {
            System.out.println(record.get(i));
        }
        System.out.println();
    }

}
public class Client {
    public static void main(String[] args) {

        ScoreRecord scoreRecord = new ScoreRecord();    // 점수 저장 객체 생성

        DataSheetView dataSheetView3 = new DataSheetView(scoreRecord, 3);

        DataSheetView dataSheetView5 = new DataSheetView(scoreRecord, 5);
        MinMaxView minMaxView = new MinMaxView(scoreRecord);

        scoreRecord.addDataSheetView(dataSheetView3);
        scoreRecord.addDataSheetView(dataSheetView5);
        scoreRecord.setMinMaxView(minMaxView);

        for (int i = 1; i <= 5; i++) {
            int score = i * 10;
            System.out.println("adding " + score);
            scoreRecord.addScore(score);
        }
    }
}
adding 10
List of 3 entries 
10

List of 5 entries 
10

Min : 10, Max : 10

===============================
adding 20
List of 3 entries 
10
20

List of 5 entries 
10
20

Min : 10, Max : 20

===============================
adding 30
List of 3 entries 
10
20
30

List of 5 entries 
10
20
30

Min : 10, Max : 30

===============================
adding 40
List of 3 entries 
10
20
30

List of 5 entries 
10
20
30
40

Min : 10, Max : 40

===============================
adding 50
List of 3 entries 
10
20
30

List of 5 entries 
10
20
30
40
50

Min : 10, Max : 50

===============================

일단은 요구사항의 변경에 따라 코드를 변경하여 원하는 결과를 얻을 수 있었다. 하지만 성적의 통보 대상이 변경된 것을 반영하려고 ScoreRecord 클래스의 코드를 수정하게 됨에 따라 이것도 역시 OCP를 위배하게 되었다. 이러한 문제를 옵서버 패턴을 통해 해결하기 위한 해결책을 아래에서 알아보자.

2.3 해결책

문제 해결의 핵심은 성적 통보 대상이 변경되더라도 ScoreRecord 클래스를 그대로 재사용할 수 있어야한다는 점이다. 따라서 ScoreRecord 클래스에서 변화되는 부분을 식별하고 일반화시켜야 한다.

ScoreRecord 클래스에서는 통보 대상인 객체를 참조하는 것을 관리해야하며 addScore()메서드는 통보 대상인 객체의 update() 메서드를 호출할 필요가 있다. 이런 통보 대상 객체의 관리와 각 겍체에 update()메서드를 호출하는 기능은 성적 변경뿐만 아니라 임의의 데이터가 변경되었을 때 이에 관심을 가지는 모든 대상 객체에 통보하는 경우에도 동일하게 발생하는 기능이다. 따라서 이러한 공통 기능을 상위 클래스 및 인터페이스로 일반화하고, 이를 활용해 ScoreRecord를 구현하는 방식으로 설계를 변경하는 편이 좋다.

아래는 DataSheetViewMinMaxView 클래스에게 성적 변경을 통보할 수 있도록 개선한 ScoreRecord 클래스 다이어그램과 이에 대한 설명이다.

observer-pattern-enhanced-score-class-diagram

아래의 코드는 위와 같은 방식으로 설계한 내용이다.

// 추상화된 통보대상
public interface Observer {

    // 데이터의 변경을 통보했을 때 처리하는 메서드
    public abstract void update();

}
// 추상화된 변경 관심 데이터
public abstract class Subject {

    // 추상화된 통보 대상 목록
    private List<Observer> observers = new ArrayList<>();

    // 옵서버(통보 대상) 추가
    public void attach(Observer observer) {
        observers.add(observer);
    }

    // 옵서버(통보 대상) 제거
    public void detach(Observer observer) {
        observers.remove(observer);
    }

    // 통보대상 목록에서 각 옵서버에게 변경을 통보
    public void notifyObservers() {
        for (Observer observer : observers) {
            observer.update();
        }
    }

}
// 구체적인 변경 감시 대상 데이터
public class ScoreRecord extends Subject {

    private List<Integer> scores = new ArrayList<>();

    // 점수 추가
    public void addScore(int score) {
        scores.add(score);
        notifyObservers();  // 데이터의 변경을 각 옵서버에게 통지
    }

    public List<Integer> getScoreRecord() {
        return scores;
    }

}
// 통보대상
public class DataSheetView implements Observer {

    private ScoreRecord scoreRecord;    // 점수 저장 클래스 참조변수
    private int viewCount;              // 저장된 점수의 갯수

    // 생성자
    public DataSheetView(ScoreRecord scoreRecord, int viewCount) {
        this.scoreRecord = scoreRecord;
        this.viewCount = viewCount;
    }

    // 점수의 변경을 통보받아 갱신하는 메서드
    @Override
    public void update() {
        List<Integer> record = scoreRecord.getScoreRecord(); // 점수 조회
        displayScores(record, viewCount);
    }

    // 점수 출력 메서드
    private void displayScores(List<Integer> record, int viewCount) {
        System.out.println("List of " + viewCount + " entries ");
        for (int i = 0; i < viewCount &&  i < record.size(); i++) {
            System.out.println(record.get(i));
        }
        System.out.println();
    }

}
// 통보대상 클래스
public class MinMaxView implements Observer {

    private ScoreRecord scoreRecord;

    public MinMaxView(ScoreRecord scoreRecord) {
        this.scoreRecord = scoreRecord;
    }

    @Override
    public void update() {
        List<Integer> record = scoreRecord.getScoreRecord();
        displayMinMax(record);
    }

    private void displayMinMax(List<Integer> record) {
        int min = Collections.min(record, null);
        int max = Collections.max(record, null);
        System.out.println("Min : " + min + ", Max : " + max);
        System.out.println();
        System.out.println("==============================");
    }

}
public class Client {
    public static void main(String[] args) {
        ScoreRecord scoreRecord = new ScoreRecord();
        DataSheetView dataSheetView3 = new DataSheetView(scoreRecord, 3);
        DataSheetView dataSheetView5 = new DataSheetView(scoreRecord, 5);
        MinMaxView minMaxView = new MinMaxView(scoreRecord);

        scoreRecord.attach(dataSheetView3);

        scoreRecord.attach(dataSheetView5);

        scoreRecord.attach(minMaxView);

        for (int i = 1 ; i <= 5 ; i ++ ) {
            int score = i * 10 ;
            System.out.println("adding " + score) ;
            scoreRecord.addScore(score) ;
        }
    }
}
adding 10
List of 3 entries 
10

List of 5 entries 
10

Min : 10, Max : 10

==============================
adding 20
List of 3 entries 
10
20

List of 5 entries 
10
20

Min : 10, Max : 20

==============================
adding 30
List of 3 entries 
10
20
30

List of 5 entries 
10
20
30

Min : 10, Max : 30

==============================
adding 40
List of 3 entries 
10
20
30

List of 5 entries 
10
20
30
40

Min : 10, Max : 40

==============================
adding 50
List of 3 entries 
10
20
30

List of 5 entries 
10
20
30
40
50

Min : 10, Max : 50

==============================

위와 같이 코드를 작성하게 되면 성적 변경에 관심이 있는 대상 객체들의 관리는 Subject클래스에서 구현하고 ScoreRecord클래스는 Subject클래스를 상속받게 함으로써 ScoreRecord클래스는 이제 DataSheetViewMinMaxView를 직접 참조할 필요가 없게 되었다. 그러므로 ScoreRecord클래스의 코드를 변경하지 않고도 새로운 관심 클래스 및 객체를 추가/제거하는 것이 가능해졌다.

이제 추가적으로 합계/평균을 출력하는 StatisticsView클래스를 작성하고, 새로운 관심 객체를 추가 시켜보자.

public class StatisticsView implements Observer {

    private ScoreRecord scoreRecord;

    public StatisticsView(ScoreRecord scoreRecord) {
        this.scoreRecord = scoreRecord;
    }

    @Override
    public void update() {
        List<Integer> record = scoreRecord.getScoreRecord();
        displayStatistics(record);
    }

    private void displayStatistics(List<Integer> record) {

        int sum = 0;
        for (int score : record) {
            sum += score;
        }
        float average = (float) sum / record.size();
        System.out.println("sum " + sum + ", average " + average);

    }
}
public class Client {
    public static void main(String[] args) {

        ScoreRecord scoreRecord = new ScoreRecord();
        DataSheetView dataSheetView3 = new DataSheetView(scoreRecord, 3);
        scoreRecord.attach(dataSheetView3);
        MinMaxView minMaxView = new MinMaxView(scoreRecord);
        scoreRecord.attach(minMaxView);

        for (int i = 1; i <= 5; i++) {
            int score = i * 10;
            System.out.println("adding " + score);
            scoreRecord.addScore(score);
        }

        scoreRecord.detach(dataSheetView3);
        StatisticsView statisticsView = new StatisticsView(scoreRecord);
        scoreRecord.attach(statisticsView);

        for (int i = 1; i <= 5; i++) {
            int score = i * 10;
            System.out.println("adding " + score);
            scoreRecord.addScore(score);
        }
    }
}
adding 10
List of 3 entries 
10

Min : 10, Max : 10

==============================
adding 20
List of 3 entries 
10
20

Min : 10, Max : 20

==============================
adding 30
List of 3 entries 
10
20
30

Min : 10, Max : 30

==============================
adding 40
List of 3 entries 
10
20
30

Min : 10, Max : 40

==============================
adding 50
List of 3 entries 
10
20
30

Min : 10, Max : 50

==============================
adding 10
Min : 10, Max : 50

==============================
sum 160, average 26.666666
adding 20
Min : 10, Max : 50

==============================
sum 180, average 25.714285
adding 30
Min : 10, Max : 50

==============================
sum 210, average 26.25
adding 40
Min : 10, Max : 50

==============================
sum 250, average 27.777779
adding 50
Min : 10, Max : 50

==============================
sum 300, average 30.0

이전 코드(ScoreRecord)를 수정하지 않고 클래스(StatisticsView) 하나만을 추가함으로써 합계/평균을 구할 수 있게 되었다.