소프트웨어 아키텍처 패턴은 UI, 상태, 비즈니스 로직을 분리하여 유지보수성과 확장성을 높이는 것이 공통 목표입니다. 하지만 각 패턴은 관계 구조, 데이터 흐름, 역할 분담 방식이 다르기 때문에 프로젝트 특성에 맞는 선택이 중요합니다.
가장 고전적이고 널리 사용되는 패턴
사용자 입력 → Controller → Model ↔ View
View에서 이벤트 발생)CommentModel에 새로운 댓글 데이터 저장을 요청합니다. (DB에 저장)Controller에 반환합니다.Model에서 다시 가져와 View에 전달하며, 화면을 새로 렌더링하도록 지시합니다.<h3>MVC</h3>
<input id="mvc-input" placeholder="할 일 입력" />
<ul id="mvc-list"></ul>
<script>
/** Model: 데이터 + 비즈니스 로직 */
const Model = {
todos: [],
add(text) { this.todos.push({ text, done: false }); },
toggle(i) { this.todos[i].done = !this.todos[i].done; }
};
/** View: 화면 그리기만 담당 */
const View = {
el: {
input: document.getElementById('mvc-input'),
list: document.getElementById('mvc-list')
},
render(todos) {
this.el.list.innerHTML = todos.map((t, i) => `
<li>
<label>
<input type="checkbox" data-i="${i}" ${t.done ? 'checked' : ''}/>
${t.text}
</label>
</li>
`).join('');
}
};
/** Controller: 사용자 입력 수신 → Model 변경 → View 갱신 */
const Controller = {
init() {
View.el.input.addEventListener('keyup', (e) => {
if (e.key === 'Enter' && e.target.value.trim()) {
Model.add(e.target.value.trim());
e.target.value = '';
View.render(Model.todos);
}
});
View.el.list.addEventListener('change', (e) => {
const i = Number(e.target.dataset.i);
Model.toggle(i);
View.render(Model.todos);
});
View.render(Model.todos);
}
};
Controller.init();
</script>
MVC의 단점을 보완해 View를 더 얇게 만든 구조
사용자 입력 → View → Presenter → Model
└───── 데이터 갱신 ─────┘
View: 숫자 버튼(0-9), 연산자 버튼(+, -, *), 디스플레이 화면으로 구성됩니다. View는 "사용자가 '5' 버튼을 눌렀다" 또는 "'+' 버튼을 눌렀다"는 사실을 Presenter에 전달할 뿐, '5' 다음에 '+'를 누르면 어떻게 해야 하는지는 전혀 모릅니다.
Presenter: View로부터 이벤트를 받아 모든 계산 로직을 처리합니다. "현재 입력 값은 '5'이고, 다음 연산자는 '+'이다"와 같은 상태를 내부적으로 관리합니다. 계산이 끝나면 View에게 "결과 화면에 '12'를 표시해"라고 직접 지시합니다.
Model: (선택적으로) 계산 기록을 저장하는 역할을 할 수 있습니다.
<h3>MVP</h3>
<input id="mvp-input" placeholder="할 일 입력" />
<ul id="mvp-list"></ul>
<script>
/** Model */
const mvpModel = {
todos: [],
add(text) { this.todos.push({ text, done: false }); },
toggle(i) { this.todos[i].done = !this.todos[i].done; }
};
/** View: 이벤트만 위임 + Presenter가 시키는 대로 그리기 */
function createMvpView() {
const input = document.getElementById('mvp-input');
const list = document.getElementById('mvp-list');
let presenter = null; // 의존성 역전: 실제 로직은 Presenter
return {
bind(p) { presenter = p; },
onInit() {
input.addEventListener('keyup', (e) => {
if (e.key === 'Enter' && e.target.value.trim()) {
presenter.handleAdd(e.target.value.trim());
e.target.value = '';
}
});
list.addEventListener('change', (e) => {
presenter.handleToggle(Number(e.target.dataset.i));
});
},
render(todos) {
list.innerHTML = todos.map((t, i) => `
<li>
<label>
<input type="checkbox" data-i="${i}" ${t.done ? 'checked' : ''}/>
${t.text}
</label>
</li>
`).join('');
}
};
}
/** Presenter: View 이벤트 처리, Model 조작, View 업데이트 */
function createPresenter(view, model) {
return {
start() { view.render(model.todos); },
handleAdd(text) {
model.add(text);
view.render(model.todos);
},
handleToggle(i) {
model.toggle(i);
view.render(model.todos);
}
};
}
const mvpView = createMvpView();
const presenter = createPresenter(mvpView, mvpModel);
mvpView.bind(presenter);
mvpView.onInit();
presenter.start();
</script>
양방향 바인딩을 통해 View 코드를 최소화한 구조
사용자 입력 ↔ View ↔ ViewModel ↔ Model
Observable로 관리View: 이메일, 비밀번호, 비밀번호 확인을 위한 <input> 필드와 '가입하기' <button>으로 구성됩니다. 각 입력 필드는 ViewModel의 상태와 양방향으로 바인딩되어 있습니다.
ViewModel: email, password, passwordConfirm 같은 상태 값을 가집니다. 또한, "비밀번호가 8자 이상인가?", "두 비밀번호가 일치하는가?" 와 같은 유효성 검사 로직과, 모든 조건이 충족되었을 때만 true가 되는 isFormValid 같은 **계산된 상태(Computed State)**를 가집니다.
Model: (필요하다면) 최종적으로 유효한 데이터를 서버로 전송하는 로직을 담당합니다.
<h3>MVVM</h3>
<input id="mvvm-input" placeholder="할 일 입력" />
<ul id="mvvm-list"></ul>
<script>
/** ViewModel: 상태를 들고 있고, 변경되면 자동 렌더 트리거 */
const vmState = { todos: [] };
/** 아주 단순한 반응성: Proxy로 set을 가로채 렌더 호출 */
const vm = new Proxy(vmState, {
set(target, key, value) {
target[key] = value;
render();
return true;
}
});
function render() {
const list = document.getElementById('mvvm-list');
list.innerHTML = vm.todos.map((t, i) => `
<li>
<label>
<input type="checkbox" data-i="${i}" ${t.done ? 'checked' : ''}/>
${t.text}
</label>
</li>
`).join('');
}
document.getElementById('mvvm-input').addEventListener('keyup', (e) => {
if (e.key === 'Enter' && e.target.value.trim()) {
// 배열 자체를 바꿔야 Proxy set 트랩을 태움 (불변 업데이트)
vm.todos = [...vm.todos, { text: e.target.value.trim(), done: false }];
e.target.value = '';
}
});
document.getElementById('mvvm-list').addEventListener('change', (e) => {
const i = Number(e.target.dataset.i);
vm.todos = vm.todos.map((t, idx) => idx === i ? { ...t, done: !t.done } : t);
});
render();
</script>
React 생태계에서 확립된 예측 가능한 상태 관리 패턴
Action → Dispatcher → Store → View
↑ ↓
└─────── 사용자 이벤트 ────────┘
{ type: 'ADD_TO_CART', payload: { productId: 'abc', quantity: 1 } } 객체를 생성하여 Dispatcher에 보냅니다.Store에 전달합니다.Store의 변경을 감지하고, 스스로 화면을 다시 렌더링하여 "상품 갯수: 3개"와 같이 업데이트된 내용을 표시합니다.<h3>Flux</h3>
<input id="flux-input" placeholder="할 일 입력" />
<ul id="flux-list"></ul>
<script>
/** Store: 상태 + 구독 + dispatch */
const store = {
state: { todos: [] },
listeners: [],
subscribe(fn) { this.listeners.push(fn); },
dispatch(action) {
this.state = reducer(this.state, action);
this.listeners.forEach(fn => fn(this.state));
}
};
/** Reducer: 순수 함수로 상태 전이 정의 */
function reducer(state, action) {
switch (action.type) {
case 'ADD':
return { todos: [...state.todos, { text: action.text, done: false }] };
case 'TOGGLE':
return {
todos: state.todos.map((t, i) =>
i === action.index ? { ...t, done: !t.done } : t
)
};
default:
return state;
}
}
/** View: 상태를 그리기만, 변경은 항상 dispatch로 */
function renderFlux(nextState = store.state) {
const list = document.getElementById('flux-list');
list.innerHTML = nextState.todos.map((t, i) => `
<li>
<label>
<input type="checkbox" data-i="${i}" ${t.done ? 'checked' : ''}/>
${t.text}
</label>
</li>
`).join('');
}
// 이벤트 → 항상 action dispatch
document.getElementById('flux-input').addEventListener('keyup', (e) => {
if (e.key === 'Enter' && e.target.value.trim()) {
store.dispatch({ type: 'ADD', text: e.target.value.trim() });
e.target.value = '';
}
});
document.getElementById('flux-list').addEventListener('change', (e) => {
store.dispatch({ type: 'TOGGLE', index: Number(e.target.dataset.i) });
});
// 구독하여 자동 렌더
store.subscribe(renderFlux);
renderFlux();
</script>
| 구분 | MVC | MVP | MVVM | Flux |
|---|---|---|---|---|
| 중간 계층 이름 | Controller | Presenter | ViewModel | Store (Dispatcher + Reducer 포함) |
| 기본 패턴 | 전통 서버 사이드 아키텍처 | 구형 모바일/데스크톱 UI | 현대적 반응형 프론트엔드 | 대규모 React SPA 상태 관리 |
| View가 하는 일 | 사용자 입력을 Controller에 전달하고, Controller 지시에 따라 직접 렌더링 또는 Model 변화를 관찰해 반영 | 이벤트를 Presenter에게 위임. Presenter가 주는 데이터만 표시 | ViewModel의 상태를 선언적으로 구독. 상태 변경 시 자동 UI 반영 | Store 상태를 읽고 렌더. 모든 상태 변경은 Action dispatch 후 자동 렌더 |
| 중간 계층의 역할 | Model과 View를 모두 알고 직접 제어 (데이터 변경·화면 갱신) | View와 Model 사이의 모든 흐름을 전담, View 업데이트까지 지시 | UI 상태·비즈니스 로직을 상태 중심으로 관리, Model과 동기화 | Action을 받아 순수 함수(reducer) 로 상태를 갱신하고, 변경을 View에 단방향으로 전파 |
| 상태 보관 | 거의 없음 (요청마다 생성) | Presenter 내부 로컬 상태(화면 단위) | ViewModel이 장기적 UI 상태를 보유 | Store가 앱의 단일 진실(Single Source of Truth) |
| 데이터 흐름 | View ↔ Controller ↔ Model (양방향 가능) | View → Presenter ↔ Model → Presenter → View (Presenter가 전과정 지휘) | View ↔ ViewModel ↔ Model (양방향 자동 바인딩) | View → Action → Store → View (단방향 고정) |
| View 업데이트 방식 | render(template, data) 등 명령형 직접 호출 | Presenter가 View.render()를 직접 호출 | 속성 변경만으로 자동 반응 | Store 구독으로 자동 리렌더 (예: useSelector, subscribe) |
| 의존성 | Controller가 View와 Model 모두에 강하게 의존 | Presenter가 View 인터페이스와 Model에 모두 의존 | Model은 알지만 View와는 느슨하게 연결 | View는 Store를 구독만 하고, Action과 Store는 View에 의존하지 않음 |
| 주 사용처 | 서버 렌더링 웹앱, 전통적 웹 서비스 (Rails, Spring MVC, Django) | 구형 안드로이드·데스크톱 UI, View 테스트가 중요한 앱 | Vue, Angular, SwiftUI, Jetpack Compose 등 현대적 반응형 UI | React + Redux/Zustand/Recoil 등 대규모 SPA·공유 상태 관리 |
MVC → Rails, Django, Spring MVC
MVP (구형) → MVVM (현대적)
소규모: MVVM (Vue/Angular 내장)
대규모: Flux/Redux (React + 상태관리)
핵심은 프로젝트의 복잡도, 팀 크기, 유지보수 기간을 고려해 적절한 패턴을 선택하는 것입니다. 작은 프로젝트에 Flux를 적용하거나, 대규모 앱에 단순한 MVC만 사용하는 것은 비효율적일 수 있습니다.