0314

개발

MVC 패턴 완전 정복: 왜, 어떻게, 그리고 어디까지 나눠야 하는가

| 서론

우아한테크코스 8기 프리코스에 2주차 미션을 하면서 문득, “코드는 돌아가는데... 뭔가 찜찜하다.” 라는 생각에 사로 잡히게 되었다.

기능은 잘 작동하지만, 코드를 들여다보면 마음 한켠이 불편한 순간이었다. 바로, 코드의 구조가 명확하지 않았고, 파일 및 폴더 구조에 대한 의문점이 계속 남아 있었다.

그래서 오늘은 이러한 문제를 해결하고자 MVC 패턴에 대해서 정리해보려고 한다.

| MVC는 왜 생겼을까? — “스파게티 코드의 시대”

먼저, MVC 패턴은 왜 생기게 되었는지를 정리해보고 싶었다. 그래야 내가 체감했던 점이 더욱 와닿을 수 있을 것이라 생각했다.

프로그래밍의 초창기에는 입력받고, 계산하고, 출력하는 구조로, 프로그램이 단순했다.

하지만 기능이 많아지고, UI가 등장하면서 복잡도가 급격히 늘었다.

예를 들어, “할 일 관리(To-Do List)” 앱을 만든다고 해보자.

할 일을 추가하고, 완료 상태를 바꾸고, 목록을 출력해야 한다.


그런데 이 세 가지를 하나의 파일 안에서 처리하기 시작하면 금방 코드가 엉켜버리게 된다.

  • 화면(UI)과 데이터 로직이 섞여 있고,

  • 기능을 수정하면 다른 부분이 망가지며,

  • 한 함수가 입력/출력/계산을 모두 떠안는다.

이런 코드를 흔히 스파게티 코드(spaghetti code) 라고 부르며,
이 문제를 해결하기 위해 등장한 개념 중 하나가 바로 MVC 패턴(Model-View-Controller) 이다.

? 핵심 포인트: MVC는 “코드를 분리하는 법”이 아니라 “책임을 나누는 사고방식”: 코드의 목적은 단순히 작동이 아니라, 변화에 강한 구조를 만드는 것.

| MVC 각 구성 요소의 역할 — “각자 맡은 일만 하자”

MVC는 세 가지 구성 요소로 이루어지며, 이 셋은 서로 협력하지만, 결코 서로의 역할을 침범하지 않는다.

구성요소

역할

예시

Model

데이터와 비즈니스 로직

할 일 목록, 완료 여부, 추가/삭제 로직

View

사용자에게 보여지는 화면

콘솔 출력, HTML UI

Controller

Model과 View를 연결, 흐름 제어

입력 처리, 버튼 클릭 이벤트

  • Model은 “무엇을 저장하고 어떻게 조작할지”를 책임지고,

  • View는 “그 내용을 어떻게 보여줄지”를 담당하며,

  • Controller는 “사용자의 행동을 받아 Model과 View를 연결”한다.

? 핵심 포인트- Model: 실제 데이터와 규칙.- View: 화면 표현만.- Controller: 전체의 흐름 제어.

| MVC가 없을 때의 코드 — “스파게티 한 그릇”

아래와 같이 스파게티 코드는, 한 파일 안에 모든 것을 넣은 코드이다.

JavaScript
const todos = [];

function run() {
  const input = prompt('할 일을 입력하세요:');
  todos.push({ text: input, done: false });

  console.clear();
  console.log('? 현재 할 일 목록:');
  todos.forEach((todo, idx) => {
    console.log(`${idx + 1}. ${todo.done ? '[완료]' : '[미완료]'} ${todo.text}`);
  });

  const doneIndex = parseInt(prompt('완료할 번호를 입력하세요:'), 10);
  if (!isNaN(doneIndex)) {
    todos[doneIndex - 1].done = true;
  }

  run();
}

run();

겉으로 보기엔 간단하고, 바로 작동하지만, 시간이 지나면서 문제가 드러난다.

  • 입력 로직, 출력 로직, 데이터 로직이 한데 뒤섞여 있다.

  • 새로운 기능을 추가하거나 수정할 때마다, 전체를 건드려야 한다.

  • 테스트하기도 어렵고, 코드 재사용이 거의 불가능하다.

? 핵심 포인트- “잘 돌아가는 코드 ≠ 잘 설계된 코드”- 한 파일에 모든 역할을 몰아넣으면 변경이 두려워진다.

| MVC로 리팩터링 — “역할을 나눠보자”

이제 위 코드를 MVC 구조로 분리해보자. 핵심은 “서로 다른 관심사(Concern)”를 각자 다른 파일로 옮기는 것이다.

1) Model (TodoModel.js)

code
export default class TodoModel {
  constructor() {
    this.todos = [];
  }

  add(text) {
    this.todos.push({ text, done: false });
  }

  toggle(index) {
    if (this.todos[index]) {
      this.todos[index].done = !this.todos[index].done;
    }
  }

  getAll() {
    return this.todos;
  }
}

Model은 데이터의 상태를 관리하고, 조작하는 로직만 가진다.

할 일을 추가하거나, 완료 상태를 바꾸는 등의 동작이 여기에 포함된다.

하지만 이 단계에서는 입력이나 출력은 전혀 신경 쓰지 않는다.

2) View (TodoView.js)

JavaScript
export default class TodoView {
  render(todos) {
    console.clear();
    console.log('? 현재 할 일 목록:');
    todos.forEach((todo, idx) => {
      console.log(`${idx + 1}. ${todo.done ? '[완료]' : '[미완료]'} ${todo.text}`);
    });
  }

  askForInput() {
    return prompt('할 일을 입력하세요:');
  }

  askForToggle() {
    return parseInt(prompt('완료할 번호를 입력하세요:'), 10);
  }
}

View는 오직 사용자에게 보여주는 일만 한다.

이 코드는 HTML UI 대신 콘솔을 이용하지만, 핵심은 같다.

만약 나중에 웹 페이지로 바꿔도 Model이나 Controller는 손대지 않아도 된다.

3) Controller (TodoController.js)

code
import TodoModel from './model/TodoModel.js';
import TodoView from './view/TodoView.js';

export default class TodoController {
  constructor() {
    this.model = new TodoModel();
    this.view = new TodoView();
  }

  async start() {
    while (true) {
      this.view.render(this.model.getAll());
      const input = this.view.askForInput();
      if (!input) break;
      this.model.add(input);

      const toggleIndex = this.view.askForToggle();
      if (!isNaN(toggleIndex)) {
        this.model.toggle(toggleIndex - 1);
      }
    }
  }
}

Controller는 사용자의 행동을 받아 Model과 View를 연결하는 역할을 한다.


즉, “어떤 순서로 어떤 작업을 실행할지”를 결정하는 흐름의 주인과 같다.

하지만 데이터를 직접 수정하거나, 화면을 그리는 일은 하지 않는다.

| 좋은 분리 기준 — “서로 다른 이유로 바뀌는 코드 분리”

MVC의 핵심은 코드의 기능이 아니라 변화의 이유(Change Reason) 에 있다.

바뀌는 이유

수정 위치

UI 디자인 변경

View

데이터 구조 변경

Model

입력/출력 흐름 변경

Controller

예를 들어 “콘솔 UI”를 “웹 UI”로 바꾸더라도, Model은 그대로 둔다.

데이터 구조가 안정적이라면 View만 바꿔도 전혀 문제없이 작동한다.

? 핵심 포인트- View는 “보여주는 방법”- Model은 “보여줄 내용”- Controller는 “보여줄 타이밍”을 담당

| 잘못된 MVC 분리 사례 — “이럴 거면 MVC 안 쓰는 게 낫다”

1) Controller가 모든 걸 하는 경우

code
// Controller가 직접 Model과 View를 조작
this.todos.push({ text, done: false });
console.log('출력:', text);

Controller가 모든 역할을 떠안으면 ‘비대한 컨트롤러(Fat Controller)’가 된다.

이럴 때는 Model과 View의 존재 이유가 사라진다..

2) View가 로직을 포함한 경우

code
// 출력할 때 데이터 수정까지 함
todos[index].done = true;
console.log('완료 처리됨!');

이런 코드는 겉보기엔 편하지만, 화면과 로직이 얽혀 테스트가 거의 불가능하다.

3) 유의할 점

: 그렇기에, 아래와 같은 체크리스트를 고려하면서 MVC 패턴을 적용해보면 좋을 것 같다.

  • Model, View, Controller의 책임이 명확한가?

  • 한 파일이 여러 이유로 바뀔 가능성이 있는가?

  • 로직 변경 시 View를 수정하지 않아도 되는가?

  • 각 부분이 독립적으로 테스트 가능한가?

| 마무리 정리

MVC는 단순히 폴더를 세 개로 나누는 구조가 아니며, 프로그램의 성장과 변화를 견딜 수 있는 사고방식이다.

우리는 코드를 ‘예쁘게 나누기 위해’ MVC를 사용하는 게 아니라, ‘변화를 안전하게 받아들이기 위해’ MVC를 사용해야 한다고 생각한다.