MVC 패턴
개요
콘솔에서 +와 -를 입력받아 숫자를 세는 프로그램이 있다. 입력 루프 안에서 +가 들어오면 count++를 하고 곧바로 Console.WriteLine($"value: {count}")를 부른다. 값이 짝수인지 알려 주는 줄을 하나 더 띄우려면 값을 바꾸는 분기마다 출력 코드를 붙여야 하고, 입력을 키보드 대신 스크립트 파일에서 읽으려 해도 같은 루프를 고쳐야 한다. 값을 바꾸는 규칙과 값을 보여 주는 코드, 입력을 해석하는 코드가 모두 이 루프 한 곳에 섞여 있어서다.
MVC(Model-View-Controller)는 이 셋을 세 역할로 나눈다. 모델(Model)은 데이터와 그 데이터를 바꾸는 규칙을 가지며, 카운터에서는 현재 값과 Increment()가 여기에 속한다. 뷰(View)는 모델을 읽어 사용자에게 보여 주는 쪽으로, 여기서는 value: 1 같은 줄을 출력하는 객체다. 컨트롤러(Controller)는 사용자 입력을 받아 모델 호출로 바꾸며, 이 예에서는 입력 줄 +를 받으면 Increment()를 부른다.
역할을 나누고 나면 모델이 바뀐 사실을 뷰가 어떻게 아느냐가 남는다. 고전적인 MVC에서는 뷰가 모델에 자신을 등록해 두고, 모델은 상태가 바뀔 때 등록된 쪽에 변경을 알린다. 알림을 받은 뷰는 모델에서 새 값을 읽어 다시 그린다. 모델은 등록된 뷰의 구체적인 타입을 모르고 알림만 보내므로 뷰를 하나 더 붙여도 모델 코드는 바뀌지 않는다. 이 등록과 알림의 구조가 옵서버 패턴이다.
구조
범위 넓힌 예제의 타입이다. TodoController는 입력 줄을 해석해 TodoList를 호출하고, 실패 메시지는 ErrorView로 보낸다. ListView와 SummaryView는 TodoList를 필드로 들고 Changed 이벤트를 구독한다. TodoList에서 뷰로 가는 화살표가 없는 것은 모델이 뷰 타입을 참조하지 않기 때문이다. Smalltalk-80에서는 뷰마다 짝이 되는 컨트롤러가 있었다.1 이 예제는 입력이 한 줄씩 들어오므로 컨트롤러 하나가 모든 뷰의 입력을 맡는다.
입력 한 줄 done 1을 처리하는 순서다. 컨트롤러는 모델만 호출하고 뷰를 직접 부르지 않는다. 모델이 Changed를 발생시키면 등록된 순서대로 두 뷰의 Render()가 불리고, 각 뷰는 Items를 다시 읽어 출력한다.
역사
Trygve Reenskaug는 1978년 여름부터 1979년 여름까지 Xerox PARC의 Learning Research Group(LRG)에 방문 연구원으로 있었다. 그의 MVC 페이지는 MVC가 1978년에 구상되었다고 적고, 사용자가 크고 복잡한 데이터 집합을 다루는 문제의 일반적인 해법이었다고 설명한다.2
처음 쓴 용어는 1979년 5월 12일자 노트 "Thing-Model-View-Editor"의 네 가지였다. Adele Goldberg와 긴 논의를 거쳐 1979년 12월 10일자 노트 "Models-Views-Controllers"에서 모델, 뷰, 컨트롤러라는 이름이 정해졌다. 이 노트에서도 뷰의 정보를 사용자가 수정하게 해 주는 에디터(editor)는 필요할 때 컨트롤러와 뷰 사이에 끼웠다가 편집이 끝나면 버리는 보조 요소로 남았다.234
Reenskaug의 컨트롤러는 Smalltalk-80의 컨트롤러와 다르다. 12월 노트에서 컨트롤러는 관련된 뷰들을 화면에 배치하고, 사용자의 명령을 메시지로 바꾸어 뷰에 전달한다.4 그는 이 컨트롤러를 지금은 Tool이라고 부르며, 마우스와 키보드 입력을 다루는 Smalltalk-80의 컨트롤러는 자신의 노트에서 Editor라는 네 번째 요소에 해당한다고 적었다. Smalltalk-80 클래스 라이브러리의 MVC는 그가 PARC를 떠난 뒤 Jim Althoff 등이 구현했다.2 이 글에서 말하는 컨트롤러는 Smalltalk-80 쪽의 뜻, 즉 사용자 입력을 받아 모델 호출로 바꾸는 역할이다.
1988년 Glenn E. Krasner와 Stephen T. Pope는 JOOP에서 Smalltalk-80의 MVC를 설명했다. 뷰는 모델에 데이터를 요청해 표시하고, 컨트롤러는 사용자 입력을 받아 모델에 메시지를 보낸다. 모델은 뷰와 컨트롤러를 모르며, 뷰와 컨트롤러는 모델의 의존 객체(dependents)로 등록되어 모델이 changed를 받으면 update 메시지를 받는다.1
연혁
연도 | 사건 | 출처 |
|---|---|---|
1978 | Reenskaug가 Xerox PARC LRG의 방문 연구원으로 지내며(1978년 여름부터 1979년 여름까지) MVC를 구상했다. | |
1979 | 5월 12일자 노트 "Thing-Model-View-Editor". Thing, Model, View, Editor 네 용어를 예제로 설명한다. | |
1979 | 12월 10일자 노트 "Models-Views-Controllers". 모델, 뷰, 컨트롤러라는 이름이 정해졌다. 뷰의 정보를 수정하게 해 주는 에디터는 보조 요소로 남았다. | |
1988 | Krasner와 Pope가 JOOP 8/9월호에 Smalltalk-80 MVC 설명서를 발표했다. | |
1999 | Seshadri가 JSP Model 2 구조를 MVC의 서버 쪽 구현으로 설명했다. | |
2005 | Ruby on Rails 1.0이 공개되었다. Rails 가이드는 Action Controller를 MVC 패턴의 C로 설명한다. | |
2006 | Fowler가 "GUI Architectures"에서 MVC와 그 변형을 정리했다. | |
2009 | Microsoft가 자사 행사 MIX에서 ASP.NET MVC 1.0을 공개했다. |
- 연도
1978
- 사건
Reenskaug가 Xerox PARC LRG의 방문 연구원으로 지내며(1978년 여름부터 1979년 여름까지) MVC를 구상했다.
- 연도
1979
- 사건
5월 12일자 노트 "Thing-Model-View-Editor". Thing, Model, View, Editor 네 용어를 예제로 설명한다.
- 출처
- 연도
1979
- 사건
12월 10일자 노트 "Models-Views-Controllers". 모델, 뷰, 컨트롤러라는 이름이 정해졌다. 뷰의 정보를 수정하게 해 주는 에디터는 보조 요소로 남았다.
- 출처
- 연도
1988
- 사건
Krasner와 Pope가 JOOP 8/9월호에 Smalltalk-80 MVC 설명서를 발표했다.
- 출처
- 연도
1999
- 사건
Seshadri가 JSP Model 2 구조를 MVC의 서버 쪽 구현으로 설명했다.
- 연도
2005
- 사건
Ruby on Rails 1.0이 공개되었다. Rails 가이드는 Action Controller를 MVC 패턴의 C로 설명한다.
- 연도
2006
- 사건
Fowler가 "GUI Architectures"에서 MVC와 그 변형을 정리했다.
- 연도
2009
- 사건
Microsoft가 자사 행사 MIX에서 ASP.NET MVC 1.0을 공개했다.
웹 프레임워크의 MVC
서버 쪽 웹 프레임워크도 MVC라는 이름을 쓰지만 요청 하나가 흐르는 방식은 다르다. Govind Seshadri는 1999년 글에서 서블릿이 컨트롤러로서 요청을 처리하고 JSP 페이지가 화면을 만드는 JSP Model 2 구조를 MVC 패턴의 서버 쪽 구현으로 볼 수 있다고 했다.5 ASP.NET Core 문서는 요청이 컨트롤러로 라우팅되고 컨트롤러가 사용자에게 보여 줄 뷰를 고르며, 뷰와 컨트롤러는 모델에 의존하지만 모델은 둘 어느 쪽에도 의존하지 않는다고 설명한다.6
모델이 뷰를 모른다는 점은 고전 MVC와 같지만 변경 알림은 없다. 웹 요청에서 뷰는 응답 하나를 만들고 끝나므로 모델을 구독하지 않고, 컨트롤러가 요청마다 뷰를 골라 데이터를 넘긴다. 그래서 "뷰가 모델을 관찰한다"는 고전 MVC의 설명은 웹 MVC에 그대로 들어맞지 않는다.
왜 사용하는가?
모델은 화면 없이 시험할 수 있다. 범위 넓힌 예제라면 TodoList의 Add("")를 직접 불러 title is empty 결과를 확인하면 되고, 콘솔도 컨트롤러도 필요 없다.
한 모델에 뷰를 여러 개 붙여도 모두 같은 상태를 보여 준다. Krasner와 Pope는 컨트롤러가 일으킨 모델 변경이 그 컨트롤러와 짝을 이룬 뷰만이 아니라 모델의 모든 뷰에 반영되어야 한다고 썼다.1 예제에서 SummaryView를 추가할 때 TodoList와 TodoController는 고치지 않았다. 입력 방식도 모델과 따로 바꿀 수 있어서, 예제는 키보드 대신 문자열 배열을 컨트롤러에 넣는다.
MVC는 세 역할의 경계를 정할 뿐 모든 코드의 자리를 정해 주지는 않는다. 값이 음수면 빨간색으로 표시하는 규칙처럼 화면에만 필요한 논리와 상태는 도메인 모델에도 위젯에도 잘 맞지 않는다. Fowler는 이 문제를 다루면서 VisualWorks가 화면 논리와 상태를 둘 곳으로 Application Model을 도입했다고 설명한다.7 MVP 패턴의 프레젠터와 MVVM 패턴의 뷰모델도 이런 화면 논리를 뷰에서 떼어 내는 방식이다.
작은 프로그램에서는 클래스와 연결 코드만 늘어난다. 출력도 하나, 입력도 하나라면 루프 하나가 더 읽기 쉽다. 또 이 글의 예제처럼 변경 알림을 동기 호출로 구현하면 모델을 한 번 바꿀 때마다 모든 뷰가 다시 그리므로, 연달아 여러 번 바꾸면 같은 화면을 그 횟수만큼 다시 그린다.
예제
카운터 모델, 뷰, 컨트롤러를 하나씩 만들고 입력 줄 세 개를 컨트롤러에 넣는다.
using System;
var counter = new Counter();
var view = new CounterView(counter);
var controller = new CounterController(counter);
foreach (var line in new[] { "+", "+", "-" })
{
Console.WriteLine("> " + line);
controller.Handle(line);
}
// 모델: 값과 규칙을 가지고, 값이 바뀌면 알린다. 누가 표시하는지는 모른다.
sealed class Counter
{
public int Value { get; private set; }
public event Action? Changed;
public void Increment() { Value++; Changed?.Invoke(); }
public void Decrement() { Value--; Changed?.Invoke(); }
}
// 뷰: 모델의 변경 알림을 구독하고, 알림을 받으면 모델에서 값을 읽어 출력한다.
sealed class CounterView
{
private readonly Counter counter;
public CounterView(Counter counter)
{
this.counter = counter;
counter.Changed += Render;
}
private void Render() => Console.WriteLine("value: " + counter.Value);
}
// 컨트롤러: 입력 줄을 모델 호출로 바꾼다. 화면에 직접 쓰지 않는다.
// 이 예제는 +와 -만 보낸다. 잘못된 입력은 범위 넓힌 예제에서 다룬다.
sealed class CounterController(Counter counter)
{
public void Handle(string line)
{
if (line == "+") counter.Increment();
if (line == "-") counter.Decrement();
}
}> +
value: 1
> +
value: 2
> -
value: 1컨트롤러는 아무것도 출력하지 않고, value: 줄은 모델의 Changed 알림을 받은 뷰가 출력한다. view 변수는 이후에 쓰이지 않지만 생성자에서 이미 구독을 마쳤다.
범위 넓힌 예제
할 일 목록에 뷰 두 개를 붙이고 잘못된 입력을 섞는다. 입력 형식 오류(모르는 명령, 숫자가 아닌 번호)는 컨트롤러가 걸러 낸다. 빈 제목, 중복 제목, 없는 번호, 이미 끝난 항목 같은 규칙 위반은 모델이 예외를 던지는 대신 결과 타입의 반환값으로 돌려준다. 두 종류의 실패 모두 모델 상태가 아니므로 모델 알림을 거치지 않고 컨트롤러가 ErrorView에 직접 넘긴다.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
var list = new TodoList();
var listView = new ListView(list);
var summaryView = new SummaryView(list);
var controller = new TodoController(list, new ErrorView());
string[] script =
[
"add buy milk", "add", "add buy milk", "done 7",
"done x", "done 1", "done 1", "remove 1",
];
foreach (var line in script)
{
Console.WriteLine("> " + line);
controller.Handle(line);
}
readonly record struct Result(bool Ok, string Error)
{
public static Result Success() => new(true, "");
public static Result Fail(string error) => new(false, error);
}
sealed record TodoItem(int Id, string Title, bool Done);
// 모델: 할 일 목록과 규칙(빈 제목 금지, 중복 금지, 완료는 한 번)을 가진다.
// 상태가 실제로 바뀐 경우에만 Changed를 알린다.
sealed class TodoList
{
private readonly List<TodoItem> items = [];
private int nextId = 1;
public IReadOnlyList<TodoItem> Items => items;
public event Action? Changed;
public Result Add(string title)
{
if (title.Length == 0) return Result.Fail("title is empty");
if (items.Any(i => i.Title == title)) return Result.Fail($"'{title}' already exists");
items.Add(new TodoItem(nextId++, title, false));
Changed?.Invoke();
return Result.Success();
}
public Result Complete(int id)
{
var index = items.FindIndex(i => i.Id == id);
if (index < 0) return Result.Fail($"no item {id}");
if (items[index].Done) return Result.Fail($"item {id} is already done");
items[index] = items[index] with { Done = true };
Changed?.Invoke();
return Result.Success();
}
}
// 뷰 1: 목록 전체를 다시 그린다.
sealed class ListView
{
private readonly TodoList list;
public ListView(TodoList list)
{
this.list = list;
list.Changed += Render;
}
private void Render()
{
foreach (var item in list.Items)
Console.WriteLine($" [{(item.Done ? "x" : " ")}] {item.Id} {item.Title}");
}
}
// 뷰 2: 같은 모델을 개수로 요약한다. ListView의 존재는 모른다.
sealed class SummaryView
{
private readonly TodoList list;
public SummaryView(TodoList list)
{
this.list = list;
list.Changed += Render;
}
private void Render()
{
var done = list.Items.Count(i => i.Done);
Console.WriteLine($" open {list.Items.Count - done}, done {done}");
}
}
// 실패 결과(형식 오류와 규칙 위반)를 보여 주는 뷰. 모델 상태가 아니므로 컨트롤러가 직접 호출한다.
sealed class ErrorView
{
public void Show(string message) => Console.WriteLine(" error: " + message);
}
// 컨트롤러: 입력 줄을 해석해 모델을 호출한다.
// 형식 오류(모르는 명령, 숫자가 아닌 번호)는 여기서, 규칙 위반은 모델이 Result로 알린다.
sealed class TodoController(TodoList list, ErrorView errors)
{
public void Handle(string line)
{
var space = line.IndexOf(' ');
var command = space < 0 ? line : line[..space];
var argument = space < 0 ? "" : line[(space + 1)..].Trim();
var result = command switch
{
"add" => list.Add(argument),
"done" => int.TryParse(argument, NumberStyles.None, CultureInfo.InvariantCulture, out var id)
? list.Complete(id)
: Result.Fail($"'{argument}' is not an item number"),
_ => Result.Fail($"unknown command '{command}'"),
};
if (!result.Ok) errors.Show(result.Error);
}
}> add buy milk
[ ] 1 buy milk
open 1, done 0
> add
error: title is empty
> add buy milk
error: 'buy milk' already exists
> done 7
error: no item 7
> done x
error: 'x' is not an item number
> done 1
[x] 1 buy milk
open 0, done 1
> done 1
error: item 1 is already done
> remove 1
error: unknown command 'remove'실패한 입력 뒤에 목록이 다시 출력되지 않는 것은 모델이 상태를 그대로 두어 Changed도 발생하지 않았기 때문이다. 다만 이 예제에는 한계가 있다. Changed는 무엇이 바뀌었는지 알려 주지 않으므로 뷰는 매번 목록 전체를 다시 읽고 그린다. 알림은 등록 순서대로 동기 호출되므로, 한 뷰에서 예외가 나면 뒤의 뷰는 호출되지 않고 그 예외는 모델 상태가 이미 바뀐 뒤에 Complete()를 거쳐 컨트롤러까지 올라온다. 뷰는 생성자에서 구독한 뒤 해제하지 않으므로, 모델보다 먼저 사라져야 하는 뷰라면 옵서버 패턴에서 다루는 구독 해제가 필요하다.
TypeScript 예제
같은 구조를 TypeScript로 옮겼다. C# 이벤트 대신 모델이 함수 배열을 보관하고 onChanged()로 등록을 받는다. 결과는 ok 필드로 성공과 실패를 구별하는 유니언 타입이다.
type Result = { ok: true } | { ok: false; error: string };
const success: Result = { ok: true };
const fail = (error: string): Result => ({ ok: false, error });
type TodoItem = { readonly id: number; readonly title: string; readonly done: boolean };
// 모델: 목록과 규칙을 가지고, 상태가 바뀐 경우에만 등록된 함수를 호출한다.
class TodoList {
#items: TodoItem[] = [];
#nextId = 1;
#listeners: (() => void)[] = [];
get items(): readonly TodoItem[] { return this.#items; }
onChanged(listener: () => void): void { this.#listeners.push(listener); }
add(title: string): Result {
if (title.length === 0) return fail("title is empty");
if (this.#items.some((i) => i.title === title)) return fail(`'${title}' already exists`);
this.#items.push({ id: this.#nextId++, title, done: false });
this.#notify();
return success;
}
complete(id: number): Result {
const index = this.#items.findIndex((i) => i.id === id);
if (index < 0) return fail(`no item ${id}`);
if (this.#items[index].done) return fail(`item ${id} is already done`);
this.#items[index] = { ...this.#items[index], done: true };
this.#notify();
return success;
}
#notify(): void {
for (const listener of this.#listeners) listener();
}
}
// 뷰 1: 목록 전체를 다시 그린다.
class ListView {
constructor(private readonly list: TodoList) {
list.onChanged(() => this.render());
}
private render(): void {
for (const item of this.list.items) {
console.log(` [${item.done ? "x" : " "}] ${item.id} ${item.title}`);
}
}
}
// 뷰 2: 같은 모델을 개수로 요약한다. ListView의 존재는 모른다.
class SummaryView {
constructor(private readonly list: TodoList) {
list.onChanged(() => this.render());
}
private render(): void {
const done = this.list.items.filter((i) => i.done).length;
console.log(` open ${this.list.items.length - done}, done ${done}`);
}
}
// 실패 결과(형식 오류와 규칙 위반)를 보여 주는 뷰. 모델 상태가 아니므로 컨트롤러가 직접 호출한다.
class ErrorView {
show(message: string): void { console.log(" error: " + message); }
}
// 컨트롤러: 입력 줄을 해석해 모델을 호출한다.
// 형식 오류는 여기서, 규칙 위반은 모델이 Result로 알린다.
class TodoController {
constructor(private readonly list: TodoList, private readonly errors: ErrorView) {}
handle(line: string): void {
const space = line.indexOf(" ");
const command = space < 0 ? line : line.slice(0, space);
const argument = space < 0 ? "" : line.slice(space + 1).trim();
const result = this.dispatch(command, argument);
if (!result.ok) this.errors.show(result.error);
}
private dispatch(command: string, argument: string): Result {
switch (command) {
case "add":
return this.list.add(argument);
case "done":
return /^[0-9]+$/.test(argument)
? this.list.complete(Number(argument))
: fail(`'${argument}' is not an item number`);
default:
return fail(`unknown command '${command}'`);
}
}
}
const list = new TodoList();
new ListView(list);
new SummaryView(list);
const controller = new TodoController(list, new ErrorView());
const script = [
"add buy milk", "add", "add buy milk", "done 7",
"done x", "done 1", "done 1", "remove 1",
];
for (const line of script) {
console.log("> " + line);
controller.handle(line);
}> add buy milk
[ ] 1 buy milk
open 1, done 0
> add
error: title is empty
> add buy milk
error: 'buy milk' already exists
> done 7
error: no item 7
> done x
error: 'x' is not an item number
> done 1
[x] 1 buy milk
open 0, done 1
> done 1
error: item 1 is already done
> remove 1
error: unknown command 'remove'출력은 C# 범위 넓힌 예제와 같다. 등록 해제 함수가 없으므로 이 모델에 등록한 뷰는 모델이 살아 있는 동안 계속 알림을 받는다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Glenn E. Krasner, Stephen T. Pope. "A Cookbook for Using the Model-View-Controller User Interface Paradigm in Smalltalk-80". Journal of Object-Oriented Programming, 1988년 8/9월호, pp. 26-49. PDF. ↩
- Trygve Reenskaug. MVC XEROX PARC 1978-79. 저자 페이지의 Internet Archive 보관본(2025년 2월 27일). ↩
- Trygve Reenskaug. "THING-MODEL-VIEW-EDITOR: an Example from a planningsystem". LRG 노트, 1979년 5월 12일. PDF 보관본. ↩
- Trygve Reenskaug. "MODELS - VIEWS - CONTROLLERS". 노트, 1979년 12월 10일, 전 2쪽. PDF 보관본. ↩
- Govind Seshadri. Understanding JavaServer Pages Model 2 architecture (1999년 12월 29일). JavaWorld 기사. ↩
- Microsoft Learn. Overview of ASP.NET Core MVC. MVC pattern 절. ↩
- Martin Fowler. GUI Architectures (2006년 7월 18일). Model View Controller 절, VisualWorks Application Model 절. ↩