무엇인가

하나의 기능을 셋으로 가른다.

  • 모델 — 데이터와 규칙. 화면을 모른다.
  • — 모델을 보여 준다. 데이터를 바꾸지 않는다.
  • 컨트롤러 — 입력을 받아 모델을 바꾼다.

핵심은 모델이 뷰를 모른다는 것이다. 모델이 화면을 직접 그리기 시작하면 같은 데이터를 다른 방식으로 보여 줄 수 없게 된다.

구조

graph LR
    U([사용자]) -->|입력| C[컨트롤러]
    C -->|상태 변경| M[모델]
    M -->|변경 알림| V[뷰]
    V -->|표시| U
    M -.->|조회| V

C++로 보기

모델이 뷰를 직접 부르지 않도록 관찰자 콜백으로만 알린다. 그래서 뷰를 둘 붙여도 모델은 그대로다.

#include <functional>
#include <iostream>
#include <string>
#include <vector>
 
// ── 모델: 데이터와 규칙만. 화면을 모른다 ──
class Counter {
    int value_ = 0;
    std::vector<std::function<void(int)>> observers_;
 
  public:
    void onChanged(std::function<void(int)> fn) { observers_.push_back(std::move(fn)); }
 
    int value() const { return value_; }
 
    void add(int delta) {
        int next = value_ + delta;
        if (next < 0) return;              // 규칙: 음수가 안 된다
        value_ = next;
        for (const auto& fn : observers_) fn(value_);
    }
};
 
// ── 뷰: 보여 주기만. 모델을 바꾸지 않는다 ──
class NumberView {
  public:
    void render(int v) const { std::cout << "[숫자] " << v << '\n'; }
};
 
class BarView {
  public:
    void render(int v) const { std::cout << "[막대] " << std::string(v, '#') << '\n'; }
};
 
// ── 컨트롤러: 입력을 모델의 동작으로 옮긴다 ──
class CounterController {
    Counter& model_;
 
  public:
    explicit CounterController(Counter& m) : model_(m) {}
 
    void handle(char key) {
        switch (key) {
            case '+': model_.add(1); break;
            case '-': model_.add(-1); break;
            default:  std::cout << "(무시: " << key << ")\n";
        }
    }
};
 
int main() {
    Counter model;
    NumberView numbers;
    BarView bars;
 
    // 뷰가 모델을 구독한다. 모델은 누가 듣는지 모른다.
    model.onChanged([&](int v) { numbers.render(v); });
    model.onChanged([&](int v) { bars.render(v); });
 
    CounterController controller{model};
    for (char key : {'+', '+', '+', '-', 'x', '-', '-', '-'}) controller.handle(key);
 
    std::cout << "최종: " << model.value() << '\n';   // 규칙 덕에 음수로 안 내려간다
}

뷰가 둘인데 모델 코드에 뷰라는 단어가 없다. 이것이 분리가 됐다는 신호다. 반대로 모델 안에 std::cout이 있으면 이미 뷰와 붙은 것이다.

언제 쓰나

  • 같은 데이터를 여러 방식으로 보여 줘야 할 때.
  • 화면이 자주 바뀌고 규칙은 그대로일 때(또는 그 반대).
  • 업무 규칙을 화면 없이 테스트하고 싶을 때. 위 예제의 Counter는 단독으로 테스트된다.

대가

  • 작은 기능에는 과하다. 파일 셋이 생기고 오가는 코드가 는다.
  • 컨트롤러가 비대해지기 쉽다. 갈 곳 없는 코드가 여기 쌓인다.
  • 뷰와 모델의 경계가 실제로는 흐릿하다. “표시용 서식”이 어느 쪽인지 늘 논쟁거리다.
  • 파생형이 많다(MVP, MVVM). 이름만 같고 책임 배분이 달라 팀 안에서 말이 엇갈린다.

실제로 만나는 곳

거의 모든 GUI 툴킷과 웹 프레임워크. Qt의 모델/뷰, 웹의 라우터-컨트롤러 구조, 그리고 MVVM으로 변형된 형태가 오늘날 프런트엔드의 기본형이다.