본문 바로가기

국비 교육

2020.10.12 일자 수업 : Observer 디자인 패턴

 Observer Design Pattern 

 

git/eomcs-java-basic/src/main com.eomcs.design-pattern.observer

Observer Design Pattern (= Publish-Subscribe Pattern)

Observer 디자인 패턴은 특정 객체의 상태 변화에 따라 수행해야 하는 작업이 있을 경우, 기존 코드를 손대지 않고 손쉽게 기능을 추가하거나 제거할 수 있는 설계 기법으로 발행(publish)/구독(subscribe) 모델 이라고 부르기도 한다. 구독 객체를 리스너(listener) 또는 관찰자(observer) 라 부르기도 한다.

 

어떤 객체의 상태에 변화가 생기면, 즉 이벤트가 생기면, 그것에 대한 소식을 받아  작업을 수행하는 객체가 listener이다. 비록 관찰자라는 의미의 Observer라고 하지만 작업 수행자는 객체를 계속 관찰하는 역할보다는 이벤트에 대한 소식을 받는, 좀 더 수동적인 listener 역할을 수행하고 있다.

 

Observer 디자인 패턴의 UML 패턴은 다음과 같다.

 

 

  • Subject 인터페이스
    • Collection에 옵저버를 추가하고 삭제하는 메서드를 갖는다.
    • 옵저버에게 Subject의 상태를 알릴 메서드를 갖는다.
  • Subject 구현체
    • Subject의 상태에 따라 어떤 기능을 수행할 옵저버 객체를 저장할 Collection을 갖는다.
    • Subject 인터페이스를 구현하며 그 객체의 상태를 알릴 필드상태를 바꾸거나 조회할 메서드를 갖는다.
  • Observer 인터페이스
    • Subject의 상태에 따라 각 기능을 수행할 메서드를 갖는다.
  • Observer 구현체
    • Observer 인터페이스의 메서드를 구현하여 실제로 각 기능을 수행한다.

 

예) 어떤 회사에 구독자들의 아이디를 등록하면 사이트에 게시글이 추가될 때마다 구독자들에게 메일을 보내는 프로그램과 같은 구조


Observer Pattern 적용

다음과 같은 클래스들이 있다. 

  • Car 클래스
    • start 메서드
    • run 메서드
    • stop 메서드
  • test01 클래스
    • main -> Car 객체 생성 후, start, run, stop 순서대로 호출
public class Car {
  public void start() {
    System.out.println("시동을 건다.");
  }

  public void run() {
    System.out.println("달린다.");
  }

  public void stop() {
    System.out.println("시동을 끈다.");
  }
}

public class Test01 {

  public static void main(String[] args) {
    Car car = new Car();

    car.start();

    car.run();

    car.stop();

  }
}

Car 클래스에 안전벨트 착용여부를 시동을 켤 때 검사하는 기능을 추가하기 위해 Car 클래스의 start 메서드에 코드를 추가하려고 한다. 새로운 코드를 기존 Car 클래스 위에 그대로 추가하면 다음과 같은 문제가 생길 수 있다.

  • 구버전을 사용해야 할 때가 있다.
  • 디버깅과 테스트가 완료된 기존 코드를 변경하면 새로운 버그가 발생할 수 있다.

따라서 기존 코드를 변경하지 않고 새로운 기능을 추가하기 위해 다음과 같이 옵저버 패턴으로 설계할 수가 있다.

  • Car 클래스 : publisher 역할
    • Car의 상태에 따라 각각의 메서드를 호출할 CarObserver 객체들을 담을 Collection을 만들고, 옵저버 객체를 Collection에 담을 addCarObserver()와 Collection에서 옵저버 객체를 삭제할 removeCarObserver() 메서드를 추가한다.
    • 각 상태를 지정하는 메서드에 옵저버의 상태메서드를 호출하여 옵저버에 상태를 통지한다(start 메서드에서 carStarted 호출, stop 메서드에서 carStopped 호출).
import java.util.ArrayList;
import java.util.List;

public class Car {

  List<CarObserver> observers = new ArrayList<>();

  public void addCarObserver(CarObserver observer) {
    observers.add(observer);
  }

  public void removeCarObserver(CarObserver observer) {
    observers.remove(observer);
  }

  public void start() {
    System.out.println("시동을 건다.");

    for (CarObserver observer : observers) {
      observer.carStarted();
    }
  }

  public void run() {
    System.out.println("달린다.");
  }

  public void stop() {
    System.out.println("시동을 끈다.");

    for (CarObserver observer : observers) {
      observer.carStopped();
    }
  }
}
  • CarObserver 인터페이스 - Car의 상태에 따라 수행할 작업을 구현할 메서드를 정의 (carStarted, carStopped)
    *상태에 따라 수행되는 메서드는 Car의 상태를 나타내므로 메서드명이 동사보다도 명사구로 표현된다.
public interface CarObserver {
  void carStarted();

  void carStopped();
}
  • 추가될 기능들을 구현할 CarObserver 구현체들 (SafeBeltCarObserver) - 각 상태에 따라 호출될 메서드에 수행할 작업을 구현한다.
public class SafeBeltCarObserver implements CarObserver {

  @Override
  public void carStarted() {
    System.out.println("안전벨트 착용 여부 검사");
  }

  @Override
  public void carStopped() {}
}
  • 메인 메서드에서 원하는 기능들을 추가하고자 할 때, 생성한 Car 객체에 대하여 addCarObserver메서드를 호출하여 기능을 가진 Observer 구현체를 Car의 Collection에 담는다.
public class Test01 {

  public static void main(String[] args) {

    Car car = new Car();

    car.addCarObserver(new SafeBeltCarObserver());

    car.start();

    car.run();

    car.stop();

  }
}
// 실행 결과 !
// 시동을 건다.
// 안전벨트 착용 검사
// 달린다.
// 시동을 끈다.

이런 방식으로 원하는 기능을 추가할 때마다 Observer 구현체를 추가하여 사용하면 기존의 코드를 손대지 않고 새로운 기능을 원하는 상태에 수행할 수 있다. 

 

다음은 안전 벨트 검사 이외에 다음과 같은 기능을 추가한 결과이다.

  • EngineOilCarObserver : 시동을 걸 때, 엔진 오일 검사하기
  • BrakeOilCarObserver : 시동을 걸 때, 브레이크 오일 검사하기
  • SunRoofCarObserver : 시동을 끌 때, 썬 루프 닫기
package com.eomcs.design_pattern.observer.test;

public class Test01 {

  public static void main(String[] args) {
    Car car = new Car();
    
    car.addCarObserver(new SafeBeltCarObserver());
    car.addCarObserver(new EngineOilCarObserver());
    car.addCarObserver(new BrakeOilCarObserver());
    car.addCarObserver(new SunRoofCarObserver());

    car.start();

    car.run();

    car.stop();

  }
}
// 실행 결과!
// 시동을 건다.
// 안전벨트 착용 검사
// 엔진 오일 검사
// 브레이크 오일 검사
// 달린다.
// 시동을 끈다.
// 썬루프 닫기

추상 클래스의 활용

다만 저번 단계에서는 Car 클래스에서 모든 메서드에서 옵저버들의 상태에 따른 작업을 수행하는 메서드를 호출해야하므로 이 작업을 하는 코드를 별도의 notify 키워드를 가진 메서드들로 추출할 것이다.

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

public class Car {

  List<CarObserver> observers = new ArrayList<>();

  public void addCarObserver(CarObserver observer) {
    observers.add(observer);
  }

  public void removeCarObserver(CarObserver observer) {
    observers.remove(observer);
  }

  private void notifyObserversOnStarted() {
    for (CarObserver observer : observers) {
      observer.carStarted();
    }
  }

  private void notifyObserversOnStopped() {
    for (CarObserver observer : observers) {
      observer.carStopped();
    }
  }

  public void start() {
    System.out.println("시동을 건다.");

    notifyObserversOnStarted();
  }


  public void run() {
    System.out.println("달린다.");
  }

  public void stop() {
    System.out.println("시동을 끈다.");

    notifyObserversOnStopped();
  }
}

한편, CarObserver의 구현체들은 실제로 구현된 메서드들(CarStarted, CarStopped)을 중에서 일부 메서드에서만 기능을 수행하고 있다. 나머지 메서드에서는 구현은 하되, 아무것도 실행하지 않고 있다. 따라서 CarObserver를 구현하는 추상 클래스를 만들어, 원하는 기능을 가진 메서드만 오버라이딩할 수 있도록 한다.

 

CarObserver를 구현하는 추상 클래스는 AbstractCarObserver으로, 이미 아무런 코드 없이 모든 메서드를 구현하고 있다.

public class AbstractCarObserver implements CarObserver {
  @Override
  public void carStarted() {}

  @Override
  public void carStopped() {}
}

이렇게 하면 구체적인 CarObserver 구현체들은 이 AbstractCarObserver 추상 클래스를 상속받음으로써 각 객체가 수행할 기능이 없는 메서드는 굳이 오버라이딩할 필요가 없다. 또한 각 클래스들이 어떤 기능을 수행하고 있는 지 더 잘 나타낼 수 있다.

public class EngineOilCarObserver extends AbstractCarObserver {
  @Override
  public void carStarted() {
    System.out.println("엔진 오일 유무 검사");
  }
}
public class SunRoofCloseCarObserver extends AbstractCarObserver {
  @Override
  public void carStopped() {
    System.out.println("썬루프를 닫는다.");
  }
}

위와 같이 추상 클래스는 대부분 인터페이스와 이를 구현하는 클래스들의 중간단계가 되어, 구현체들이 (꼭 구현해야하는 메서드를 제외하고) 원하는 기능만 골라 오버라이딩할 수 있도록 돕는다.


 실습 2 - Observer 디자인 패턴 프로젝트 적용 

 

git/eomcs-java-project/mini-pms-33-a

실무에서는 왠만하면 스태틱 멤버를 사용하지 않는다. 따라서 App에서 사용중인 모든 스태틱 멤버를 인스턴스 멤버로 바꿔줄 것이다.

  void printCommandHistory(Iterator<String> iterator) {
  .
  .
  .
  private void saveObjects(Collection<?> list, File file) {
  .
  .
  .
  private <T> void loadObjects(

이렇게 하면 스태틱 메서드인 메인 메서드에서는 인스턴스 메서드를 사용하지 못하기 때문에 메인 메서드에서 구현된 코드들을 service 라는 인스턴스 메서드안으로 옮긴다. 그리고 메인 메서드에서 App 클래스의 객체를 생성하고 그에 대해 service 메서드를 호출한다.

public class App {

  public static void main(String[] args) throws Exception {
    App app = new App();
    app.service();
  }
  
  public void service() throws Exception {
    // 스태틱 멤버들이 공유하는 변수가 아니라면 로컬 변수로 만들라.
    List<Board> boardList = new ArrayList<>();
    File boardFile = new File("./board.json"); // 게시글을 저장할 파일 정보

    List<Member> memberList = new LinkedList<>();
    File memberFile = new File("./member.json"); // 회원을 저장할 파일 정보

    List<Project> projectList = new LinkedList<>();
    File projectFile = new File("./project.json"); // 프로젝트를 저장할 파일 정보

    List<Task> taskList = new ArrayList<>();
    File taskFile = new File("./task.json"); // 작업을 저장할 파일 정보

    // 파일에서 데이터 로딩
    loadObjects(boardList, boardFile, Board[].class);
    loadObjects(memberList, memberFile, Member[].class);
    loadObjects(projectList, projectFile, Project[].class);
    loadObjects(taskList, taskFile, Task[].class);

    Map<String,Command> commandMap = new HashMap<>();

    commandMap.put("/board/add", new BoardAddCommand(boardList));
    commandMap.put("/board/list", new BoardListCommand(boardList));
    commandMap.put("/board/detail", new BoardDetailCommand(boardList));
    commandMap.put("/board/update", new BoardUpdateCommand(boardList));
    commandMap.put("/board/delete", new BoardDeleteCommand(boardList));

    MemberListCommand memberListCommand = new MemberListCommand(memberList);
    commandMap.put("/member/add", new MemberAddCommand(memberList));
    commandMap.put("/member/list", memberListCommand);
    commandMap.put("/member/detail", new MemberDetailCommand(memberList));
    commandMap.put("/member/update", new MemberUpdateCommand(memberList));
    commandMap.put("/member/delete", new MemberDeleteCommand(memberList));

    commandMap.put("/project/add", new ProjectAddCommand(projectList, memberListCommand));
    commandMap.put("/project/list", new ProjectListCommand(projectList));
    commandMap.put("/project/detail", new ProjectDetailCommand(projectList));
    commandMap.put("/project/update", new ProjectUpdateCommand(projectList, memberListCommand));
    commandMap.put("/project/delete", new ProjectDeleteCommand(projectList));

    commandMap.put("/task/add", new TaskAddCommand(taskList, memberListCommand));
    commandMap.put("/task/list", new TaskListCommand(taskList));
    commandMap.put("/task/detail", new TaskDetailCommand(taskList));
    commandMap.put("/task/update", new TaskUpdateCommand(taskList, memberListCommand));
    commandMap.put("/task/delete", new TaskDeleteCommand(taskList));

    commandMap.put("/hello", new HelloCommand());

    Deque<String> commandStack = new ArrayDeque<>();
    Queue<String> commandQueue = new LinkedList<>();

    loop:
      while (true) {
        String inputStr = Prompt.inputString("명령> ");

        if (inputStr.length() == 0) {
          continue;
        }

        commandStack.push(inputStr);
        commandQueue.offer(inputStr);

        switch (inputStr) {
          case "history": printCommandHistory(commandStack.iterator()); break;
          case "history2": printCommandHistory(commandQueue.iterator()); break;
          case "quit":
          case "exit":
            System.out.println("안녕!");
            break loop;
          default:
            Command command = commandMap.get(inputStr);
            if (command != null) {
              try {
                // 실행 중 오류가 발생할 수 있는 코드는 try 블록 안에 둔다.
                command.execute();
              } catch (Exception e) {
                // 오류가 발생하면 그 정보를 갖고 있는 객체의 클래스 이름을 출력한다.
                System.out.println("--------------------------------------------------------------");
                System.out.printf("명령어 실행 중 오류 발생: %s\n", e);
                System.out.println("--------------------------------------------------------------");
              }
            } else {
              System.out.println("실행할 수 없는 명령입니다.");
            }
        }
        System.out.println();
      }

    Prompt.close();

    // 데이터를 파일에 저장
    saveObjects(boardList, boardFile);
    saveObjects(memberList, memberFile);
    saveObjects(projectList, projectFile);
    saveObjects(taskList, taskFile);
  }
}

비록 App 클래스의 객체를 딱 한번만 만드는 것이 자명하긴 하더라도, 자바와 같은 객체 지향 프로그램들은 클래스 자체가 완전한 도구의 형태(Math)를 하지 않는 한, 스태틱이 아니라 인스턴스 멤버로 만든다. 그 이유는 다음 글을 참고한다.
unabated.tistory.com/entry/%EC%99%9C-%EC%9E%90%EB%B0%94%EC%97%90%EC%84%9C-static%EC%9D%98-%EC%82%AC%EC%9A%A9%EC%9D%84-%EC%A7%80%EC%96%91%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94%EA%B0%80

 

왜 자바에서 static의 사용을 지양해야 하는가?

자바에서 데이터를 가공하거나 특정 메서드를 수행할 때 새로운 클래스를 만들어서 이를 인스턴스화 해서 쓸건지 아니면 static 으로 쓸건지 고민하게 될 때가 있다. 사실 후자는 객체지향적 관��

unabated.tistory.com

이제 서비스의 실행을 하나의 인스턴스 메서드로 만들어 메인 메서드에서 호출했으니 우리는 본격적으로 App 클래스의 메인 메서드에서 service 메서드 호출 전 후로 안내 메시지를 띄우기 위해 옵저버 패턴을 적용할 것이다.

 

가장 먼저, App 클래스의 컨텍스트를 관찰하는 옵저버 인터페이스를 정의한다. 이것은 pms 패키지 멤버 클래스 뿐만 아니라 다른 패키지에 있는 클래스에서도 사용할 수 있는 인터페이스이므로 context라는 패키지를 따로 만들어 그 안에서 정의했다.

public interface ApplicationContextListener {
  void contextInitialized();

  void contextDestroyed();
}

App 클래스에서는 옵저버 객체를 담을 컬렉션을 필드로 준비하고, 컬렉션에 옵저버 객체를 추가하고 삭제하는 메서드도 함께 정의한다.

public class App {

  List<ApplicationContextListener> listeners = new ArrayList<>();
  
  public void addApplicationContextListener(ApplicationContextListener listener) {
    listeners.add(listener);
  }
  
  public void removeApplicationContextListener(ApplicationContextListener listener) {
    listeners.remove(listener);
  }
  .
  .
  .

App 객체의 서비스 시작과 끝의 상태를 각 옵저버에게 알리는 메서드를 정의한다.

  private void notifyApplicationContextListenerOnServiceStarted() {
    for (ApplicationContextListener listener : listeners)
      listener.contextInitialized();
  }
  
  private void notifyApplicationContextListenerOnServiceStopped() {
    for (ApplicationContextListener listener : listeners)
      listener.contextDestroyed();
  }

이제 service 메서드 안에서 처음과 끝에 이 메서드들을 호출한다.

  public void service() throws Exception {
    notifyApplicationContextListenerOnServiceStarted();
    .
    .
    .
    notifyApplicationContextListenerOnServiceStopped();
  }
}

이제 App 클래스에서 옵저버를 사용하여 안내 메시지를 띄울 준비를 마쳤다. 이제 서비스 전후에 안내 메시지를 띄울 ApplicationContextListener의 구현체를 생성하고 App 객체의 옵저버 컬렉션에 추가하기만 하면 된다.

AppInitListener는 pms 패키지에서만 사용될 것이므로 pms 패키지 밑에서 정의한다.

import com.eomcs.context.ApplicationContextListener;

public class AppInitListener implements ApplicationContextListener {

  @Override
  public void contextInitialized() {
    System.out.println("프로젝트 관리 시스템(PMS)에 오신것을 환영합니다.");
    
  }

  @Override
  public void contextDestroyed() {
    System.out.println("프로젝트 관리 시스템(PMS)을 종료합니다.");
  }
}
    App app = new App();
    
    app.addApplicationContextListener(new AppInitListener());
    
    app.service();
실행 결과!
프로젝트 관리 시스템(PMS)에 오신것을 환영합니다.
'board.json' 파일에서 총 4 개의 객체를 로딩했습니다.
'member.json' 파일에서 총 5 개의 객체를 로딩했습니다.
'project.json' 파일에서 총 2 개의 객체를 로딩했습니다.
'task.json' 파일에서 총 3 개의 객체를 로딩했습니다.
명령> exit
안녕!
총 4 개의 객체를 'board.json' 파일에 저장했습니다.
총 5 개의 객체를 'member.json' 파일에 저장했습니다.
총 2 개의 객체를 'project.json' 파일에 저장했습니다.
총 3 개의 객체를 'task.json' 파일에 저장했습니다.
프로젝트 관리 시스템(PMS)을 종료합니다.