개발
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가 없을 때의 코드 — “스파게티 한 그릇”
아래와 같이 스파게티 코드는, 한 파일 안에 모든 것을 넣은 코드이다.
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)
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)
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)
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가 모든 걸 하는 경우
// Controller가 직접 Model과 View를 조작
this.todos.push({ text, done: false });
console.log('출력:', text);Controller가 모든 역할을 떠안으면 ‘비대한 컨트롤러(Fat Controller)’가 된다.
이럴 때는 Model과 View의 존재 이유가 사라진다..
2) View가 로직을 포함한 경우
// 출력할 때 데이터 수정까지 함
todos[index].done = true;
console.log('완료 처리됨!');이런 코드는 겉보기엔 편하지만, 화면과 로직이 얽혀 테스트가 거의 불가능하다.
3) 유의할 점
: 그렇기에, 아래와 같은 체크리스트를 고려하면서 MVC 패턴을 적용해보면 좋을 것 같다.
Model, View, Controller의 책임이 명확한가?
한 파일이 여러 이유로 바뀔 가능성이 있는가?
로직 변경 시 View를 수정하지 않아도 되는가?
각 부분이 독립적으로 테스트 가능한가?
| 마무리 정리
MVC는 단순히 폴더를 세 개로 나누는 구조가 아니며, 프로그램의 성장과 변화를 견딜 수 있는 사고방식이다.
우리는 코드를 ‘예쁘게 나누기 위해’ MVC를 사용하는 게 아니라, ‘변화를 안전하게 받아들이기 위해’ MVC를 사용해야 한다고 생각한다.