일련의 디자인 패턴들을 함께 사용하여 다양한 디자인 문제를 해결하는 것을 컴파운드 패턴이라고 부른다.
대표적인 컴파운드 패턴 중 하나가 바로 MVC(Model-View-Controller)이다.
MP3 재생 소프트웨어(ex, 아이튠즈)를 예로 MVC를 소개하자면 다음과 같다.
사용자는 소프트웨어에서 제공하는 사용자 인터페이스를 통해 "새로운 노래를 추가"하거나, "재생목록을 관리"하거나, "트랙 이름을 변경"할 수 있다.
=> 사용자가 인터페이스를 통해 제어
소프트웨어는 각 노래의 제목과 같은 다양한 데이터가 들어있는 데이터베이스를 관리하거나, 곡을 재생하거나, 재생하는 동안에 현재 곡 제목, 재생 시간과 같은 다양한 정보를 사용자 인터페이스 상에서 갱신해준다.
=> 소프트웨어가 인터페이스를 통해 다양한 정보 제공
1) 사용자가 버튼을 클릭하여 새로운 노래를 들으려한다.
2) "사용자는 뷰하고만 접촉할 수 있다"
뷰는 모델을 보여주는 창이다. 뷰에 대해서(재생 버튼을 누른다든가 하는 식으로) 무언가를 요청하면, 뷰는 컨트롤러에게 사용자가 어떤 일을 했는지(행동)을 알려준다. 그러면 컨트롤러는 상황에 맞게 작업을 처리한다.
3) "컨트롤러에서 모델한테 상태를 변경하라는 요청을 한다."
컨트롤러에서는 사용자의 행동(요청)을 받아서 해석한다. 사용자가 버튼을 클릭하면 컨트롤러에서 그것이 무엇을 의미하는지 해석(요청 해석)하고, 그 행동에 따라 모델을 어떤 식으로 조작해야 하는지 결정한다.
4) "컨트롤러에서 뷰를 변경해달라고 요청할 수 있다."
컨트롤러에서 뷰로부터 어떤 행동(요청)을 받았을 때, 그 행동의 결과로 뷰를 바꿔달라고 할 수 있다. 예를 들어 컨트롤러에서 인터페이스에 있는 어떤 버튼이나 메뉴를 활성화 또는 비활성화 시킬 수 있다.
5) "상태가 변경되면 모델에서 뷰한테 그 사실을 알린다."
사용자의 행동(요청, 버튼 클릭) 때문이든 다른 내부적인 변화(재생목록에서 다음 곡이 재생되는 것 등) 때문이든, 모델에서 무언가가 바뀌면 모델에서 뷰한테 상태가 변경되었음을 알린다.
5) "뷰에서 모델한테 상태를 요청한다."
뷰에서 화면에 표시할 상태는 모델로 부터 직접 가져온다. 예를 들어 모델에서 뷰한테 새로운 곡이 재생되기 시작했다고 알려주면 뷰에서는 모델한테 곡 제목을 요청하고, 그것을 받아서 화면에 표시한다. (4)의 경우처럼 컨트롤러에서 뷰를 바꾸라 요청을 했을 때에도 뷰에서 모델한테 상태를 알려달라고 요청할 수 있다.
컨트롤러는 그저 뷰로부터 사용자 입력을 받아오고, 모델한테 보내는 일만 하는 것이 아니다. 컨트롤러는 사용자의 입력(요청)을 해석해서, 그것을 바탕으로 모델을 조작하는 임무를 맡고 있다.
만약 컨트롤러의 임무를 뷰에 집어넣게 된다면 두 가지 정도의 이유로 좋지 않은 방법이 될 수 있다.
첫 째, 사용자 인터페이스 관리와 모델을 제어하는 로직 처리라는 두 가지 역할을 가지게 된다면 코드가 복잡해질 수 있기 때문이다.
둘 째, 뷰를 모델에 너무 밀접하게 연관시켜야 한다는 문제가 발생한다. 뷰를 다른 모델하고 연결해서 재사용하기가 힘들어진다.
결국 컨트롤러는 제어 로직을 뷰로부터 분리해내서 뷰와 모델의 결합을 끊어주는 역할을 한다.
reference: https://developer.mozilla.org/ko/docs/Glossary/MVC>
모델에서는 옵저버 패턴을 사용하여 상태가 변경되었을 때 그 모델하고 연관된 객체들에게 알린다. 옵저버 패턴을 통해 모델은 뷰와 컨트롤러부터 완전히 독립된다. 한 모델에서 서로 다른 뷰를 사용할 수도 있고, 여러 개의 뷰를 동시에 사용할 수 도 있다.
모델의 상태가 변경될 때마다 모든 옵저버들한테 연락이 전달된다(update). 모델은 뷰나 컨트롤러한테 전혀 의존하지 않는다.
모델의 상태 변화에 관심이 있는 객체라면 어떤 객체든지 모델에 옵저버로 등록(register)할 수 있다.
뷰와 컨트롤러는 고전적인 전략 패턴으로 구현되어 있다. 뷰 객체를 여러 전략을 써서 설정할 수 있고, 컨트롤러가 전략을 제공한다. 뷰에서는 애플리케이션의 겉모습에만 신경을 쓰고, 인터페이스의 행동에 대한 결정은 모두 컨트롤러한테 맡긴다.
전략 패턴을 사용하는 것은 뷰를 모델로부터 분리시키는 데에도 도움이 된다. 사용자가 요청한 내역을 처리하기 위해 모델하고 얘기를 해야 하는 부분은 컨트롤러이기 때문이다. 뷰에서는 그 방법을 전혀 알지 못한다.
뷰에서는 사용자의 행동(요청)을 처리하는 작업을 컨트롤러에게 전적으로 맡긴다. 사용자가 무언가를 하였음을 알리고, 뷰 객체의 전략 객체에 해당하는 컨트롤러가 사용자의 행동에 따라 어떤 행동을 취해야 할 지를 알고 있다.
그리고 전략 객체에 해당하는 컨트롤러를 바꾸면 뷰의 행동을 바꿀 수 있다.
결국 뷰에서는 화면 표시와 같은 겉모습만 잘 챙기면 된다. 사용자 입력에 따라 모델에 필요한 요청을 하는 일은 컨트롤러에서 처리한다.
디스플레이는 여러 단계로 겹쳐져 있을 수 있는 일련의 윈도우, 패널, 버튼, 텍스트 레이블 등으로 구성된다. 각 디스플레이 항목은 복합 객체(윈도우 등) 또는 리프(ex, 버튼)가 될 수 있다. 컨트롤러에서 뷰한테 화면을 갱신해 달라고 요청하면 최상위 뷰 구성요소한테만 화면을 갱신하라고 얘기하면 된다. 나머지는 컴포지트 패턴에 의해 자동으로 처리된다.
최상위의 구성요소에는 다른 구성 요소들이 들어있고, 그 안에는 또 다시 각각 다른 구성요소들이 들어갈 수 있다. 맨 끝에는 리프(leaf) 노드가 있다.
"MVC를 이용한 'DJ 박자 조절 SW'"
뷰는 드럼 비트를 만들고 BPM(Beat per Minute)을 조절하기 위한 용도로 쓰인다.
컨트롤러는 뷰와 모델 사이에 있다. DJ 컨트롤 메뉴에서 "Start"를 선택하는 사용자 입력을 받은 다음, 모델에 대한 행동으로 바꿔서 연주를 시작하는 것과 같은 작업을 처리한다.
모델은 뒷단에서 박자를 조절하고 미디어를 통해 스피커로 소리를 내보내는 역할을 한다.
reference: https://github.com/bethrobson
모델은 데이터, 상태, 애플리케이션 로직을 모두 관리하는 임무를 맡는다. BeatModel의 주 임무는 비트를 관리하는 것이기에 현재 분당 비트수(bpm)을 관리하기 위한 상태와 소리를 만들어내기 위한 여러 코드가 필요하다. 그리고 컨트롤러에서 비트를 조절하거나 뷰 및 컨트롤러에서 모델의 상태를 알아낼 때 사용할 수 있도록 외부에 공개된 인터페이스도 있어야 한다. 그리고 모델에서 옵저버 패턴를 등록하고 옵저들한테 연락을 돌리기 위한 메서드도 필요하다. 정리하자면 다음과 같다.
외부에서 모델에 접근할 수 있도록하는 인터페이스 소스이다.
public interface BeatModelInterface {
/**
* 컨트롤러에서 모델한테 사용자 입력을 전달할 때 사용하는 메서드들
*/
// BeatModel의 인스턴스가 만들어질 때 호출되는 메서드
void initialize();
// 비트 생성기 on/off를 위한 메서드
void on();
void off();
// BPM을 설정하기 위한 메서드
// 이 메서드가 호출되면 비트수가 바로 바뀜
void setBPM(int bpm);
/**
* 뷰와 컨트롤러에서 상태를 알아내거나 옵저버로 등록할 때 사용할 메서드
*/
// 현재 BPM을 리턴함. 비트 생성기가 꺼져있으면 0을 리턴
int getBPM();
// 매 박자마다 연락받을 옵저버 등록과 해제
void registerObserver(BeatObserver o);
void removeObserver(BeatObserver o);
// BPM이 바뀔 때마다 연락 받을 옵저버 등록과 해제
void registerObserver(BPMObserver o);
void removeObserver(BPMObserver o);
}
이 인터페이스를 구현한, 모델에 해당하는 BeatModel 클래스이다.
mport 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<>();
List<BPMObserver> bpmObservers = new ArrayList<>();
int bpm = 90; // default bpm: 90
Thread thread;
boolean stop = false;
Clip clip;
public void playBeat() {
clip.setFramePosition(0);
clip.start();
}
public void stopBeat() {
clip.setFramePosition(0);
clip.stop();
}
/*
* 쓰레드 run 함수
*/
@Override
public void run(){
while(!stop){
playBeat();
notifyBeatObservers();
try {
Thread.sleep(60000/getBPM());
} catch (Exception e) {}
}
}
public void notifyBeatObservers(){
for(int i=0; i<beatObservers.size(); i++){
BeatObserver observer = (BeatObserver)beatObservers.get(i);
observer.updateBeat();
}
}
public void notifyBPMObservers(){
for(int i=0; i<bpmObservers.size(); i++){
BPMObserver observer = (BPMObserver)bpmObservers.get(i);
observer.updateBPM();
}
}
/**
* 인터페이스 구현
*/
// 음악 파일과 클립 설정
@Override
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);
}
}
@Override
public void on() {
bpm = 90;
thread = new Thread(this);
stop = false;
thread.start();
}
@Override
public void off() {
stopBeat();
stop = true;
}
@Override
public void setBPM(int bpm) {
this.bpm = bpm;
notifyBPMObservers();
}
@Override
public int getBPM() {
return bpm;
}
@Override
public void registerObserver(BeatObserver o){
beatObservers.add(o);
}
@Override
public void registerObserver(BPMObserver o) {
bpmObservers.add(o);
}
@Override
public void removeObserver(BeatObserver o) {
int i = beatObservers.indexOf(o);
if(i >= 0)
beatObservers.remove(i);
}
@Override
public void removeObserver(BPMObserver o) {
int i = bpmObservers.indexOf(o);
if(i >= 0)
bpmObservers.indexOf(o);
}
}
사용자의 입력을 받고, 사용자에게 무언가를 보여주는 뷰이다.
DJView는 두 개의 서로 다른 창으로 표시되도록 구현하였다. 한 쪽 창에는 현재 BPM과 매 박자마다 통통 튀는 모습을 보여주는 막대가 있고, 다른 쪽에는 제어용 인터페이스가 있다.
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.removeObserver((BPMObserver) this);
}
/**
* 사용자가 버튼을 클릭했을 때 호출되는 메서드
*/
public void actionPerformed(ActionEvent event) {
// 사용자가 'Set' 버튼을 클릭하면 새로운 분당 비트수가 컨트롤러한테 전달됨
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();
}
}
/**
* 모델 상태가 변경되면 모델의 notify 메서드에서 호출하는 메서드
*/
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);
}
}
/**
* 뷰 생성
*/
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());
stopMenuItem = new JMenuItem("Stop");
menu.add(stopMenuItem);
stopMenuItem.addActionListener((event) -> controller.stop());
JMenuItem exit = new JMenuItem("Quit");
exit.addActionListener((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);
}
}
남은 것은 컨트롤러 클래스이다.
컨트롤러는 뷰에서 쓰이는 전략 객체라고도 볼 수 있다. 전략 패턴을 구현하려면 DJView에 집어넣을 전략 객체를 위한 인터페이스를 먼저 만들어야 한다. 그 코드는 아래와 같다.
// 뷰에서 컨트롤러에 대해 호출할 모든 인터페이스가 들어있다.
public interface ControllerInterface {
// 연주를 시작/중지하기 위한 메서드
void start();
void stop();
// 연주를 더 빠르게/느리게 하기 위한 메서드
void increaseBPM();
void decreaseBPM();
// 분당 비트수를 정수로 지정해줄 수 있는 메서드
void setBPM(int bpm);
}
그리고 이 인터페이스를 구현한 코드는 아래와 같다.
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();
}
// 사용자 인터페이스 메뉴에서 Start를 선택
// => 컨트롤러에서는 모델의 on() 메서드를 호출
// => 사용자 인터페이스 메뉴에서 Start 항목을 비활성 상태로, Stop 항목은 활성 상태로 바꿈
public void start() {
model.on();
view.disableStartMenuItem();
view.enableStopMenuItem();
}
// 사용자 인터페이스 메뉴에서 Stop을 선택
// Start 선택과 반대 작업
public void stop() {
model.off();
view.disableStopMenuItem();
view.enableStartMenuItem();
}
// 사용자가 >> 버튼을 클릭
// => 컨트롤러에서는 모델로부터 BPM을 알아내고, 거기에 1을 더한 다음 BPM 값을 새로 설정
public void increaseBPM() {
int bpm = model.getBPM();
model.setBPM(bpm + 1);
}
public void decreaseBPM() {
int bpm = model.getBPM();
model.setBPM(bpm - 1);
}
// 사용자가 임의의 BPM값을 설정하려는 경우
public void setBPM(int bpm) {
model.setBPM(bpm);
}
}
public class MainTest {
public static void main(String[] args) {
BeatModelInterface model = new BeatModel();
ControllerInterface controller = new BeatController(model);
}
}
MainTest 실행
-> BeadtModel 생성, 컨트롤러 생성
메뉴에서 'Start' 선택
-> 컨트롤러의 start() 호출
-> 모델의 on() 호출 -> thread run -> playBeat(), notifyBeatObservers() // 모델에 상태 변경 요구
-> view의 disableStartMenuItem(), enableStopMenuItem() 호출 // 뷰 화면 변경 요구
BPM 값을 직접 입력하거나 >>, << 버튼을 클릭해서 분당 비트 수를 바꾸기
-> 컨트롤러의 setBPM() 또는 increaseBPM(), decreaseBPM() 호출
-> 모델의 setBPM()을 호출하여 상태 변경을 요구
-> 모델은 bpm 상태를 변경하고 notify 메서드를 통해 변경된 상태를 옵저버(뷰 객체)에게 알림
-> 뷰의 updateBPM() 호출 -> 화면 갱신
메뉴에서 'Stop' 선택
-> 컨트롤러의 stop() 호출
-> 모델의 off() -> stopBeat()
-> view의 disableStopMenuItem(), enableStartMenuItem() 호출 // 뷰 화면 변경 요구