본문 바로가기
OOP/<헤드 퍼스트 디자인 패턴>, 에릭 프리먼 외

MVC (아키텍처 패턴)

by 민휘 2023. 2. 24.

핵심 의도

모델-뷰-컨트롤러는 사용자에게 화면을 통해 입력을 받아 애플리케이션의 기능을 실행하는 프로그램에 적용할 수 있는 패턴이다. MVC는 옵저버, 전략, 컴포지트 패턴으로 이루어진 복합 패턴이다. 복합 패턴을 통해 서로 느슨하게 결합되므로 깔끔하면서 유연하고 재사용 가능한 구현이 가능하다.

 

적용 상황

클라이언트에게 화면을 보여주고 뒷단에서 처리하는 대부분의 프로그램에 적용 가능하다. 웹, 특히 클라이언트-서버 애플리케이션 구조에 MVC를 적응시켜주는 다양한 웹 MVC 프레임워크가 있다.

 

솔루션의 구조와 각 요소의 역할

객체에게 책임을 분할하기

사용자에게 화면을 보여주고 명령을 받아 실행하는 애플리케이션의 책임을 나열해보자. 우선 사용자에게 화면을 만들어 보여주는 책임(뷰), 애플리케이션 기능을 구현하는 책임(모델), 사용자에게 입력을 받아 애플리케이션 기능을 호출하는 책임(컨트롤러)으로 나눌 수 있다. 사실 화면에서 입력을 받아 바로 애플리케이션 기능을 호출해도 되지만, 뷰나 애플리케이션 기능을 재사용하기 위해 컨트롤러를 도입해 결합도를 낮추었다.

 

모델, 뷰, 컨트롤러는 다음과 같이 상호작용한다.

  1. 사용자는 뷰와 상호작용한다. 사용자가 뷰에서 버튼을 누른다든가 동작을 하면 뷰는 무슨 일이 일어났는지 컨트롤러에게 알려준다. 그러면 컨트롤러가 상황에 맞게 작업을 처리한다.
  2. 컨트롤러가 모델에게 상태를 변경하라고 요청한다. 컨트롤러는 사용자의 행동을 받아서 해석한다. 사용자가 버튼을 클릭하면 컨트롤러는 그것이 무엇을 의미하는지 해석하고, 모델을 어떤 식으로 조작해야하는지 결정한다.
  3. 컨트롤러가 뷰를 변경해달라고 요청할 수도 있다. 예를 들어 컨트롤러는 인터페이스에 있는 어떤 버튼이나 메뉴를 활성화하거나 비활성화도록 요청할 수 있다.
  4. 상태가 변경되면 모델이 뷰에게 그 사실을 알린다. 사용자가 한 행동이나 다른 내부적인 변화 등으로 모델에서 무언가가 바뀌면 모델은 뷰에서 상태가 변경되었다고 알린다.
  5. 뷰가 모델에게 상태를 요청한다. 뷰는 화면에 표시할 상태를 모델로부터 직접 가져온다. 모델로부터 상태 변화가 생겼다는 연락을 받거나, 컨트롤러가 뷰에게 무언가를 바꾸라고 요청했을 때 뷰는 모델에게 상태를 알려달라고 요청한다.

 

구현 포인트

모델 - 옵저버 패턴

모델은 상태가 변경될 때마다 모델과 연관된 객체들에게 연락한다. 옵저버 패턴을 사용하면 모델을 뷰와 컨트롤러로부터 완전히 독립시킬 수 있다. 한 모델에서 서로 다른 뷰를 사용할 수도 있다.

 

뷰 - 컴포지트 패턴

디스플레이는 여러 단계로 겹쳐 있는 윈도우, 패널, 버튼, 텍스트 레이블 등으로 구성된다. 각 디스플레이 항목은 복합 객체(윈도우)나 잎(버튼)이 될 수 있다. 컨트롤러가 뷰에게 화면을 갱신해달라고 요청하면 최상위 뷰 구성 요소에게만 화면을 갱신하라고 얘기하면 된다. 나머지는 컴포지트 패턴이 알아서 처리해준다.

 

컨트롤러 - 전략 패턴

뷰와 컨트롤러는 고전적인 전략 패턴으로 구현되어 있다. 컨트롤러는 여러 전략을 제공하고, 뷰는 이 전략을 써서 화면을 구성한다. 뷰는 애플리케이션의 겉모습에만 신경 쓰고, 인터페이스의 행동을 결정하는 일은 모두 컨트롤러에게 맡긴다. 전략 패턴을 사용하면 뷰를 모델로부터 분리하는데 도움이 된다. 사용자의 요청 처리를 위해 모델과 상호작용하는 부분은 컨트롤러만 알고 뷰는 모르기 때문이다.

 

적용 예시

요구사항

음악을 재생하고 비트를 만들고 BPM을 조절하는 DJ 프로그램을 만든다. 뷰는 자바 스윙으로 구현한다. 뷰는 다음과 같은 명령을 받을 수 있다.

  • 실시간 BPM을 보여준다. BPM이 바뀌면 표시된 내용도 자동으로 바뀐다.
  • BPM을 직접 입력하고 set 버튼을 눌러서 BPM을 설정하고, 증가 감소 버튼을 눌러 BPM을 조절할 수 있다.
  • DJ Control 메뉴에서 start를 선택하면 연주를 선택할 수 있고, stop을 선택해 연주를 중지할 수 있다. start를 선택해 연주를 시작하기 전까지는 stop가 비활성화되고, 연주가 시작되면 start는 비활성화된다.

 

코드

BeatModelInterface

```java
public interface BeatModelInterface {
    void initialize();

    void on();

    void off();

    void setBPM(int bpm);

    int getBPM();

    void registerObserver(BeatObserver o);

    void removeObserver(BeatObserver o);

    void registerObserver(BPMObserver o);

    void removeObserver(BPMObserver o);
}
```

BeatModel

```java
import java.util.*;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip;
import java.io.*;
import javax.sound.sampled.Line;

public class BeatModel implements BeatModelInterface, Runnable {
    List<BeatObserver> beatObservers = new ArrayList<BeatObserver>();
    List<BPMObserver> bpmObservers = new ArrayList<BPMObserver>();
    int bpm = 90;
    Thread thread;
    boolean stop = false;
    Clip clip;

    public void initialize() {
        try {
            File resource = new File("clap.wav");
            clip = (Clip) AudioSystem.getLine(new Line.Info(Clip.class));
            clip.open(AudioSystem.getAudioInputStream(resource));
        }
        catch(Exception ex) {
            System.out.println("Error: Can't load clip");
            System.out.println(ex);
        }
    }

    public void on() {
        bpm = 90;
        //notifyBPMObservers();
        thread = new Thread(this);
        stop = false;
        thread.start();
    }

    public void off() {
        stopBeat();
        stop = true;
    }

    public void run() {
        while (!stop) {
            playBeat();
            notifyBeatObservers();
            try {
                Thread.sleep(60000/getBPM());
            } catch (Exception e) {}
        }
    }

    public void setBPM(int bpm) {
        this.bpm = bpm;
        notifyBPMObservers();
    }

    public int getBPM() {
        return bpm;
    }

    public void registerObserver(BeatObserver o) {
        beatObservers.add(o);
    }

    public void notifyBeatObservers() {
        for(int i = 0; i < beatObservers.size(); i++) {
            BeatObserver observer = (BeatObserver)beatObservers.get(i);
            observer.updateBeat();
        }
    }

    public void registerObserver(BPMObserver o) {
        bpmObservers.add(o);
    }

    public void notifyBPMObservers() {
        for(int i = 0; i < bpmObservers.size(); i++) {
            BPMObserver observer = (BPMObserver)bpmObservers.get(i);
            observer.updateBPM();
        }
    }

    public void removeObserver(BeatObserver o) {
        int i = beatObservers.indexOf(o);
        if (i >= 0) {
            beatObservers.remove(i);
        }
    }

    public void removeObserver(BPMObserver o) {
        int i = bpmObservers.indexOf(o);
        if (i >= 0) {
            bpmObservers.remove(i);
        }
    }

    public void playBeat() {
        clip.setFramePosition(0);
        clip.start();
    }
    public void stopBeat() {
        clip.setFramePosition(0);
        clip.stop();
    }

}
```

DJView

```java
import java.awt.*;
import java.awt.event.*;
import javax.swing.*;

public class DJView implements ActionListener,  BeatObserver, BPMObserver {
    BeatModelInterface model;
    ControllerInterface controller;
    JFrame viewFrame;
    JPanel viewPanel;
    BeatBar beatBar;
    JLabel bpmOutputLabel;
    JFrame controlFrame;
    JPanel controlPanel;
    JLabel bpmLabel;
    JTextField bpmTextField;
    JButton setBPMButton;
    JButton increaseBPMButton;
    JButton decreaseBPMButton;
    JMenuBar menuBar;
    JMenu menu;
    JMenuItem startMenuItem;
    JMenuItem stopMenuItem;

    public DJView(ControllerInterface controller, BeatModelInterface model) {    
        this.controller = controller;
        this.model = model;
        model.registerObserver((BeatObserver)this);
        model.registerObserver((BPMObserver)this);
    }

    public void createView() {
        // Create all Swing components here
        viewPanel = new JPanel(new GridLayout(1, 2));
        viewFrame = new JFrame("View");
        viewFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        viewFrame.setSize(new Dimension(100, 80));
        bpmOutputLabel = new JLabel("offline", SwingConstants.CENTER);
        beatBar = new BeatBar();
        beatBar.setValue(0);
        JPanel bpmPanel = new JPanel(new GridLayout(2, 1));
        bpmPanel.add(beatBar);
        bpmPanel.add(bpmOutputLabel);
        viewPanel.add(bpmPanel);
        viewFrame.getContentPane().add(viewPanel, BorderLayout.CENTER);
        viewFrame.pack();
        viewFrame.setVisible(true);
    }


    public void createControls() {
        // Create all Swing components here
        JFrame.setDefaultLookAndFeelDecorated(true);
        controlFrame = new JFrame("Control");
        controlFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        controlFrame.setSize(new Dimension(100, 80));

        controlPanel = new JPanel(new GridLayout(1, 2));

        menuBar = new JMenuBar();
        menu = new JMenu("DJ Control");
        startMenuItem = new JMenuItem("Start");
        menu.add(startMenuItem);
        startMenuItem.addActionListener((event) -> controller.start());
        // was....
        /*
        startMenuItem.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent event) {
                controller.start();
            }
        });
        */
        stopMenuItem = new JMenuItem("Stop");
        menu.add(stopMenuItem); 
        stopMenuItem.addActionListener((event) -> controller.stop());
        // was...
        /*
        stopMenuItem.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent event) {
                controller.stop();
            }
        });
        */
        JMenuItem exit = new JMenuItem("Quit");
        exit.addActionListener((event) -> System.exit(0));
        // was...
        /*
        exit.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent event) {
                System.exit(0);
            }
        });
        */

        menu.add(exit);
        menuBar.add(menu);
        controlFrame.setJMenuBar(menuBar);

        bpmTextField = new JTextField(2);
        bpmLabel = new JLabel("Enter BPM:", SwingConstants.RIGHT);
        setBPMButton = new JButton("Set");
        setBPMButton.setSize(new Dimension(10,40));
        increaseBPMButton = new JButton(">>");
        decreaseBPMButton = new JButton("<<");
        setBPMButton.addActionListener(this);
        increaseBPMButton.addActionListener(this);
        decreaseBPMButton.addActionListener(this);

        JPanel buttonPanel = new JPanel(new GridLayout(1, 2));

        buttonPanel.add(decreaseBPMButton);
        buttonPanel.add(increaseBPMButton);

        JPanel enterPanel = new JPanel(new GridLayout(1, 2));
        enterPanel.add(bpmLabel);
        enterPanel.add(bpmTextField);
        JPanel insideControlPanel = new JPanel(new GridLayout(3, 1));
        insideControlPanel.add(enterPanel);
        insideControlPanel.add(setBPMButton);
        insideControlPanel.add(buttonPanel);
        controlPanel.add(insideControlPanel);

        bpmLabel.setBorder(BorderFactory.createEmptyBorder(5,5,5,5));
        bpmOutputLabel.setBorder(BorderFactory.createEmptyBorder(5,5,5,5));

        controlFrame.getRootPane().setDefaultButton(setBPMButton);
        controlFrame.getContentPane().add(controlPanel, BorderLayout.CENTER);

        controlFrame.pack();
        controlFrame.setVisible(true);
    }

    public void enableStopMenuItem() {
        stopMenuItem.setEnabled(true);
    }

    public void disableStopMenuItem() {
        stopMenuItem.setEnabled(false);
    }

    public void enableStartMenuItem() {
        startMenuItem.setEnabled(true);
    }

    public void disableStartMenuItem() {
        startMenuItem.setEnabled(false);
    }

    public void actionPerformed(ActionEvent event) {
        if (event.getSource() == setBPMButton) {
            int bpm = 90;
            String bpmText = bpmTextField.getText();
            if (bpmText == null || bpmText.contentEquals("")) {
                bpm = 90;
            } else {
                bpm = Integer.parseInt(bpmTextField.getText());
            }
            controller.setBPM(bpm);
        } else if (event.getSource() == increaseBPMButton) {
            controller.increaseBPM();
        } else if (event.getSource() == decreaseBPMButton) {
            controller.decreaseBPM();
        }
    }

    public void updateBPM() {
        if (model != null) {
            int bpm = model.getBPM();
            if (bpm == 0) {
                if (bpmOutputLabel != null) {
                    bpmOutputLabel.setText("offline");
                }
            } else {
                if (bpmOutputLabel != null) {
                    bpmOutputLabel.setText("Current BPM: " + model.getBPM());
                }
            }
        }
    }

    public void updateBeat() {
        if (beatBar != null) {
             beatBar.setValue(100);
        }
    }
}
```

ControllerInterface

```java
public interface ControllerInterface {
    void start();
    void stop();
    void increaseBPM();
    void decreaseBPM();
     void setBPM(int bpm);
}
```

BeatController

```java
public class BeatController implements ControllerInterface {
    BeatModelInterface model;
    DJView view;

    public BeatController(BeatModelInterface model) {
        this.model = model;
        view = new DJView(this, model);
        view.createView();
        view.createControls();
        view.disableStopMenuItem();
        view.enableStartMenuItem();
        model.initialize();
    }

    public void start() {
        model.on();
        view.disableStartMenuItem();
        view.enableStopMenuItem();
    }

    public void stop() {
        model.off();
        view.disableStopMenuItem();
        view.enableStartMenuItem();
    }

    public void increaseBPM() {
        int bpm = model.getBPM();
        model.setBPM(bpm + 1);
    }

    public void decreaseBPM() {
        int bpm = model.getBPM();
        model.setBPM(bpm - 1);
      }

     public void setBPM(int bpm) {
        model.setBPM(bpm);
    }
}
```

DJTestDrive

```java
public class DJTestDrive {

    public static void main (String[] args) {
        BeatModelInterface model = new BeatModel();
        ControllerInterface controller = new BeatController(model);
    }
}
```

어댑터, 전략 패턴을 이용해 뷰 재활용하기

DJ 프로그램의 뷰는 BPM을 보여주고 비트 막대의 움직임을 보여줬다. 이번에는 음악 재생 모델이 아니라 심장 박동을 보여주는 모델의 뷰로 재활용해보려고 한다. 뷰와 컨트롤러에는 전략 패턴이 적용되어있으므로 뷰와 컨트롤러를 쉽게 재사용할 수 있다. 뷰는 그대로 두고, DJ 컨트롤러가 아니라 심장 박동 컨트롤러를 생성해 뷰에 주입한다.

현재 뷰는 DJ 모델의 인터페이스를 참조하고 있으므로, 심장 박동 모델도 DJ 모델 인터페이스로 사용하려면 어댑터를 만들어 뷰에 주입해야 한다.

HeartModel

```java
import java.util.*;

public class HeartModel implements HeartModelInterface, Runnable {
    ArrayList<BeatObserver> beatObservers = new ArrayList<BeatObserver>();
    ArrayList<BPMObserver> bpmObservers = new ArrayList<BPMObserver>();
    int time = 1000;
    int bpm = 90;
    Random random = new Random(System.currentTimeMillis());
    Thread thread;

    public HeartModel() {
        thread = new Thread(this);
        thread.start();
    }

    public void run() {
        int lastrate = -1;

        for(;;) {
            int change = random.nextInt(10);
            if (random.nextInt(2) == 0) {
                change = 0 - change;
            }
            int rate = 60000/(time + change);
            if (rate < 120 && rate > 50) {
                time += change;
                notifyBeatObservers();
                if (rate != lastrate) {
                    lastrate = rate;
                    notifyBPMObservers();
                }
            }
            try {
                Thread.sleep(time);
            } catch (Exception e) {}
        }
    }
    public int getHeartRate() {
        return 60000/time;
    }

    public void registerObserver(BeatObserver o) {
        beatObservers.add(o);
    }

    public void removeObserver(BeatObserver o) {
        int i = beatObservers.indexOf(o);
        if (i >= 0) {
            beatObservers.remove(i);
        }
    }

    public void notifyBeatObservers() {
        for(int i = 0; i < beatObservers.size(); i++) {
            BeatObserver observer = (BeatObserver)beatObservers.get(i);
            observer.updateBeat();
        }
    }

    public void registerObserver(BPMObserver o) {
        bpmObservers.add(o);
    }

    public void removeObserver(BPMObserver o) {
        int i = bpmObservers.indexOf(o);
        if (i >= 0) {
            bpmObservers.remove(i);
        }
    }

    public void notifyBPMObservers() {
        for(int i = 0; i < bpmObservers.size(); i++) {
            BPMObserver observer = (BPMObserver)bpmObservers.get(i);
            observer.updateBPM();
        }
    }
}
```

HeartAdapter

```java
public class HeartAdapter implements BeatModelInterface {
    HeartModelInterface heart;

    public HeartAdapter(HeartModelInterface heart) {
        this.heart = heart;
    }

    public void initialize() {}

    public void on() {}

    public void off() {}

    public int getBPM() {
        return heart.getHeartRate();
    }

    public void setBPM(int bpm) {}

    public void registerObserver(BeatObserver o) {
        heart.registerObserver(o);
    }

    public void removeObserver(BeatObserver o) {
        heart.removeObserver(o);
    }

    public void registerObserver(BPMObserver o) {
        heart.registerObserver(o);
    }

    public void removeObserver(BPMObserver o) {
        heart.removeObserver(o);
    }
}
```

HeartController

```java
public class HeartController implements ControllerInterface {
    HeartModelInterface model;
    DJView view;

    public HeartController(HeartModelInterface model) {
        this.model = model;
        view = new DJView(this, new HeartAdapter(model));
        view.createView();
        view.createControls();
        view.disableStopMenuItem();
        view.disableStartMenuItem();
    }

    public void start() {}

    public void stop() {}

    public void increaseBPM() {}

    public void decreaseBPM() {}

     public void setBPM(int bpm) {}
}
```

HeartTestDrive

```java
public class HeartTestDrive {

    public static void main (String[] args) {
                HeartModel heartModel = new HeartModel();
        ControllerInterface model = new HeartController(heartModel);
    }
}
```

코멘트

스프링의 MVC 패턴을 이미 공부했으므로 MVC는 알고 있는 것이라고 생각했다. 이번 예제에서 프레임워크의 큰 개입 없이 코드로 모델과 컨트롤러, 뷰가 상호작용하는 것을 보니 신기하기도 하고, 프레임워크에 적용된 복잡한 MVC도 이렇게 생겼을 거라고 생각하니 이해에 더 도움이 된다. 스프링 MVC 패턴을 다시 보면 감회가 새로울 것 같다.

'OOP > <헤드 퍼스트 디자인 패턴>, 에릭 프리먼 외' 카테고리의 다른 글

헤드 퍼스트 디자인 패턴 서평  (0) 2023.02.28
Proxy  (1) 2023.02.24
State  (0) 2023.02.24
Composite  (0) 2023.02.24
Iterator  (0) 2023.02.24