MVC: 모델·뷰·컨트롤러로 관심사를 분리하는 아키텍처 패턴
MVC(Model-View-Controller)는 애플리케이션을 데이터와 비즈니스 규칙(Model), 화면 표현(View), 입력 처리와 흐름 제어(Controller)로 나누는 아키텍처 패턴이다. 관심사 분리(Separation of Concerns)를 통해 변경 영향 범위를 줄이고 테스트를 쉽게 만든다. 이 글에서는 요청 처리 흐름, TypeScript
By 송민성
1. 개념
MVC(Model-View-Controller)는 애플리케이션을 세 가지 역할로 분리하는 아키텍처 패턴(architectural pattern)이다.
| 구성 요소 | 책임 | 알고 있어야 하는 것 |
|---|---|---|
| 모델(Model) | 도메인 데이터, 비즈니스 규칙, 상태 변경 | 화면이나 HTTP를 몰라도 된다 |
| 뷰(View) | 모델의 상태를 사용자에게 보여주는 표현 | 모델의 데이터를 읽는 방법 |
| 컨트롤러(Controller) | 사용자 입력을 해석하고 모델과 뷰를 연결 | 어떤 모델 동작을 호출하고 어떤 뷰를 선택할지 |
MVC는 1970년대 후반 Smalltalk-79 환경에서 Trygve Reenskaug가 정리한 개념에서 출발했다. 이후 GUI 프레임워크와 웹 프레임워크(Rails, Django의 MTV, Spring MVC, ASP.NET MVC 등)로 퍼졌고, 각 환경에 맞게 변형되었다. 그래서 "MVC"라는 이름은 같아도 구현 방식은 프레임워크마다 다르다.
2. 왜 사용하는가
- 관심사 분리(Separation of Concerns): 화면 변경이 비즈니스 규칙에 영향을 주지 않고, 규칙 변경이 템플릿을 건드리지 않게 한다.
- 테스트 용이성: 모델은 HTTP나 DOM 없이 단위 테스트할 수 있다.
- 재사용성: 같은 모델을 HTML 뷰, JSON 응답, CLI 출력 등 여러 뷰에서 사용할 수 있다.
- 협업 효율: 프런트엔드(뷰)와 백엔드(모델·컨트롤러) 담당자가 비교적 독립적으로 작업할 수 있다.
3. 동작 원리
전통적인 서버 사이드 웹 MVC의 요청 흐름은 다음과 같다.
- 클라이언트가 HTTP 요청을 보낸다.
- 라우터(router)가 URL과 메서드를 보고 해당 컨트롤러 액션으로 요청을 전달한다.
- 컨트롤러가 요청 파라미터를 검증·변환해 모델의 동작을 호출한다.
- 모델이 비즈니스 규칙을 적용하고 상태를 변경하거나 조회한다.
- 컨트롤러가 결과를 뷰에 넘긴다.
- 뷰가 HTML(또는 JSON)로 렌더링해 응답한다.
데스크톱 GUI 계열의 고전적 MVC에서는 뷰가 모델의 변경을 관찰자 패턴(Observer pattern) 으로 구독한다. 모델이 바뀌면 알림이 가고 뷰가 스스로 갱신된다. 반면 서버 사이드 웹에서는 요청-응답이 단절되어 있으므로, 컨트롤러가 매 요청마다 모델 데이터를 뷰에 전달하는 방식이 일반적이다. 이 차이 때문에 웹 MVC는 엄밀히 "Model 2"라고 불리기도 한다.
의존 방향의 원칙은 다음과 같다.
- 모델은 뷰와 컨트롤러에 의존하지 않는다.
- 뷰는 모델을 읽을 수 있지만 모델을 직접 변경하지 않는다.
- 컨트롤러는 모델과 뷰를 모두 알지만, 비즈니스 로직은 갖지 않는다.
4. 코드 예제
외부 라이브러리 없이 TypeScript로 작은 할 일 목록(Todo)을 MVC로 구현한 예제다. 모델은 옵저버로 변경을 알리고, 뷰는 콘솔에 출력한다.
// mvc-todo.ts
// 실행: npx tsx mvc-todo.ts
// ---------- Model ----------
interface Todo {
id: number;
title: string;
done: boolean;
}
type Listener = (todos: readonly Todo[]) => void;
class TodoModel {
private todos: Todo[] = [];
private nextId = 1;
private listeners: Listener[] = [];
subscribe(listener: Listener): void {
this.listeners.push(listener);
}
private notify(): void {
const snapshot = this.todos.map((t) => ({ ...t }));
for (const l of this.listeners) l(snapshot);
}
add(title: string): void {
const trimmed = title.trim();
if (trimmed.length === 0) {
throw new Error("제목은 비어 있을 수 없습니다.");
}
this.todos.push({ id: this.nextId++, title: trimmed, done: false });
this.notify();
}
toggle(id: number): void {
const todo = this.todos.find((t) => t.id === id);
if (!todo) throw new Error(`존재하지 않는 id: ${id}`);
todo.done = !todo.done;
this.notify();
}
}
// ---------- View ----------
class TodoConsoleView {
render(todos: readonly Todo[]): void {
console.log("---- 할 일 목록 ----");
if (todos.length === 0) {
console.log("(비어 있음)");
return;
}
for (const t of todos) {
console.log(`[${t.done ? "x" : " "}] ${t.id}. ${t.title}`);
}
}
showError(message: string): void {
console.log(`오류: ${message}`);
}
}
// ---------- Controller ----------
class TodoController {
constructor(
private readonly model: TodoModel,
private readonly view: TodoConsoleView,
) {
// 모델 변경 -> 뷰 갱신 연결
this.model.subscribe((todos) => this.view.render(todos));
}
handleAdd(rawTitle: string): void {
try {
this.model.add(rawTitle);
} catch (e) {
this.view.showError((e as Error).message);
}
}
handleToggle(rawId: string): void {
const id = Number(rawId);
if (!Number.isInteger(id)) {
this.view.showError("id는 정수여야 합니다.");
return;
}
try {
this.model.toggle(id);
} catch (e) {
this.view.showError((e as Error).message);
}
}
}
// ---------- 조립 및 실행 ----------
const controller = new TodoController(new TodoModel(), new TodoConsoleView());
controller.handleAdd("MVC 공부하기");
controller.handleAdd(" ");
controller.handleAdd("테스트 작성하기");
controller.handleToggle("1");
controller.handleToggle("abc");
controller.handleToggle("99");이 예제에서 컨트롤러는 입력 문자열을 숫자로 바꾸는 등 입력 해석만 담당하고, 빈 제목 거부 같은 규칙은 모델이 책임진다. 뷰는 모델의 스냅샷만 받으므로 모델 내부 배열을 직접 수정할 수 없다.
5. 시간 복잡도 또는 성능 특성
MVC 자체는 구조적 패턴이라 고유한 시간복잡도가 없다. 성능은 각 계층의 구현에 좌우된다.
- 위 예제에서
toggle은Array.find를 쓰므로 todo 개수 n에 대해 O(n)이다.Map<number, Todo>로 저장하면 평균 O(1)로 줄일 수 있다. - 위 예제의
notify는 매번 전체 목록을 복사하므로 O(n)이다. 변경 빈도가 높고 데이터가 크면 부분 갱신 또는 불변 자료구조를 고려한다. - 계층 분리로 호출 단계가 늘어나는 비용은 일반적으로 네트워크나 DB I/O에 비해 무시할 만하다. 병목은 대개 쿼리(N+1 문제 등)와 렌더링에서 생긴다.
6. 실무 사용 사례
- 서버 사이드 렌더링 프레임워크: Spring MVC, Ruby on Rails, ASP.NET Core MVC는 컨트롤러가 요청을 받아 모델을 다루고 템플릿 뷰를 렌더링한다.
- REST API 서버: 뷰가 HTML 템플릿 대신 JSON 직렬화 계층(DTO, serializer)이 된다. NestJS의 컨트롤러-서비스 구조가 이 형태에 가깝다.
- 프런트엔드 프레임워크: 초기 Backbone.js, AngularJS 1.x가 MVC 계열이었다. 현재 React 등은 컴포넌트 기반 단방향 데이터 흐름을 쓰며 MVC와는 구조가 다르다.
- 파생 패턴: MVP(Model-View-Presenter), MVVM(Model-View-ViewModel)은 뷰와 로직의 결합 방식을 바꾼 변형이다. 안드로이드, WPF 등에서 흔히 쓰인다.
7. 주의할 점
- 비대한 컨트롤러(Fat Controller): 검증, 계산, DB 접근을 컨트롤러에 몰아넣으면 테스트와 재사용이 어려워진다. 비즈니스 로직은 모델(또는 서비스 계층)로 옮긴다.
- 빈약한 모델(Anemic Domain Model): 모델이 getter/setter만 가진 데이터 덩어리가 되고 규칙이 서비스와 컨트롤러에 흩어지는 현상이다.
- 뷰 안의 로직: 템플릿에 복잡한 조건문, 쿼리 호출을 넣으면 뷰가 모델 역할을 침범한다.
- "모델"의 의미 혼동: 프레임워크에서 말하는 모델은 ORM 엔티티만 뜻하기도 하고 도메인 모델 전체를 뜻하기도 한다. 팀 내에서 범위를 합의해 두는 것이 좋다.
- 프레임워크별 차이: Django는 MTV(Model-Template-View)로 부르며, 여기서 View가 MVC의 컨트롤러 역할에 가깝다. 용어만 보고 같은 구조라고 단정하지 않는다.
- 규모에 맞는 적용: 아주 작은 스크립트에 계층을 모두 나누면 오히려 복잡도만 늘어난다.
8. 핵심 정리
- MVC는 애플리케이션을 모델(데이터·규칙), 뷰(표현), 컨트롤러(입력·흐름)로 나누는 아키텍처 패턴이다.
- 모델은 뷰와 컨트롤러를 모르는 독립적인 계층이어야 한다.
- 컨트롤러는 얇게 유지하고, 비즈니스 로직은 모델 쪽에 둔다.
- 고전 GUI MVC는 옵저버로 뷰를 갱신하고, 서버 사이드 웹 MVC는 요청마다 컨트롤러가 뷰에 데이터를 전달한다.
- MVC 자체에는 시간복잡도가 없으며, 성능은 각 계층의 구현(쿼리, 렌더링)에 달려 있다.
- MVP, MVVM 같은 파생 패턴과 프레임워크별 용어 차이를 구분해서 이해해야 한다.