본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

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의 요청 흐름은 다음과 같다.

  1. 클라이언트가 HTTP 요청을 보낸다.
  2. 라우터(router)가 URL과 메서드를 보고 해당 컨트롤러 액션으로 요청을 전달한다.
  3. 컨트롤러가 요청 파라미터를 검증·변환해 모델의 동작을 호출한다.
  4. 모델이 비즈니스 규칙을 적용하고 상태를 변경하거나 조회한다.
  5. 컨트롤러가 결과를 뷰에 넘긴다.
  6. 뷰가 HTML(또는 JSON)로 렌더링해 응답한다.

데스크톱 GUI 계열의 고전적 MVC에서는 뷰가 모델의 변경을 관찰자 패턴(Observer pattern) 으로 구독한다. 모델이 바뀌면 알림이 가고 뷰가 스스로 갱신된다. 반면 서버 사이드 웹에서는 요청-응답이 단절되어 있으므로, 컨트롤러가 매 요청마다 모델 데이터를 뷰에 전달하는 방식이 일반적이다. 이 차이 때문에 웹 MVC는 엄밀히 "Model 2"라고 불리기도 한다.

의존 방향의 원칙은 다음과 같다.

  • 모델은 뷰와 컨트롤러에 의존하지 않는다.
  • 뷰는 모델을 읽을 수 있지만 모델을 직접 변경하지 않는다.
  • 컨트롤러는 모델과 뷰를 모두 알지만, 비즈니스 로직은 갖지 않는다.

4. 코드 예제

외부 라이브러리 없이 TypeScript로 작은 할 일 목록(Todo)을 MVC로 구현한 예제다. 모델은 옵저버로 변경을 알리고, 뷰는 콘솔에 출력한다.

typescript
// 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 같은 파생 패턴과 프레임워크별 용어 차이를 구분해서 이해해야 한다.