VanillaFront 튜토리얼 — 5. MVP 패턴
VanillaFront 튜토리얼 — 5. MVP 패턴
지금까지 화면(View)과 이동(Router)을 다뤘습니다. 그런데 실제 업무 화면은 데이터를 조회하고, 서버에 전송하고, 결과에 따라 UI를 갱신하는 로직으로 반은 차 있습니다. 이 로직을 View 안에 다 몰아넣으면 파일이 금세 무거워지죠.
이번 편은 VanillaFront가 채택한 MVP 패턴 — 화면과 로직을 어떻게 나누는지에 관한 이야기입니다.
MVP 패턴이란
Model — View — Presenter의 약자입니다.
계층역할
| Model | 데이터·비즈니스 로직 (서버 통신 포함) |
| View | 화면에 보이는 UI |
| Presenter | View와 Model 사이를 중재하는 컨트롤 로직 |
핵심: View가 Model에 직접 붙지 않습니다. 모든 데이터 요청·응답이 Presenter를 거칩니다.
사용자
↓ 클릭·입력
View
↓ 이벤트 전달
Presenter
↓ 데이터 요청 ↑ 데이터 응답
Model (서버·DB) ──────────┘
↓ 응답
Presenter
↓ View 갱신
View
MVC / MVVM과의 차이
세 패턴은 자주 헷갈립니다. 핵심 차이만 짚으면:
패턴Model ↔ View 연결
| MVC | Controller가 있지만, Model과 View가 서로 알고 있을 수도 있음 |
| MVVM | ViewModel이 데이터 바인딩으로 View와 자동 동기화 |
| MVP | Presenter가 100% 중재. View와 Model은 서로 모름 |
VanillaFront가 MVP를 선택한 이유:
- 프로젝트가 커지면 MVC의 Model-Controller-View 관계가 그물망처럼 얽힘
- MVP는 View와 Presenter가 1:1 관계이므로 하나의 묶음으로 관리하기 좋음
- 바인딩이 없어 데이터 흐름이 명시적 — 디버깅 편함
장점
- View와 Model이 서로 몰라 관계가 명확
- Presenter 하나만 바꾸면 View의 동작을 바꿀 수 있음
- 테스트가 쉬움 (Presenter 단독 테스트 가능)
단점
- Presenter가 무거워질 수 있음 → VanillaFront는 파일 분리 지원으로 이 문제를 완화
VanillaFront의 MVP 응용
전형적인 MVP에서 조금 다르게 응용했습니다.
1) View + Presenter를 Va.View 하나에 통합
원래 MVP에서 View와 Presenter는 별도 파일이지만, 어차피 1:1 관계라서 분리 효과가 크지 않습니다. VanillaFront는 Va.View 클래스 안에 View의 UI 선언(config())과 Presenter의 로직(메서드들)을 함께 담습니다.
class CustList extends Va.View {
// Presenter 역할 — 로직·이벤트·서버 호출
mounted() {
this.loadData();
}
loadData() {
CustService.list(this, {}, this.onLoaded);
}
onLoaded(view, ok, res) {
if (ok) view.getRef('grid').setData(res.data.list);
}
onRowClick(grid, key, val, data) {
console.log('선택:', data);
}
// View 역할 — UI 선언
config() {
return {
tagName: 'page',
tags: [{
tagName: 'panel',
tags: [
{ tagName: 'button', text: '조회', onClick: 'onSearch' },
{ tagName: 'grid', ref: 'grid', columns: [...], onRowClick: 'onRowClick' }
]
}]
};
}
}
한 파일에 담겨 있지만 역할은 명확히 분리됩니다. config() 이하는 View, 나머지 메서드는 모두 Presenter.
2) Model 역할은 Service가 담당
Model은 서비스라는 별도 모듈로 뺐습니다.
// service/CustService.js
export default class CustService extends Va.Service {
static list(view, params, callback) {
Va.Http.request(view, '/api/custs', params, {}, callback);
}
static save(view, data, callback) {
Va.Http.request(view, '/api/custs', data, { method: 'POST' }, callback);
}
}
- Va.Service를 상속받고
- 정적 메서드로 서버 통신을 캡슐화
- static list(view, params, callback) 패턴이 표준
3) View ↔ Service는 N:N
- 하나의 View가 여러 Service를 호출할 수 있고
- 하나의 Service를 여러 View가 공유합니다
┌──────────────┐
┌────────→ │ CustService │ ←────────┐
│ └──────────────┘ │
┌───────┴──────┐ ┌────────┴──────┐
│ CustList │ │ CustDetail │
└──────────────┘ └───────────────┘
│ ┌──────────────┐ │
└────────→ │ ProductSvc │ ←────────┘
└──────────────┘
View와 Presenter가 1:1로 딱 붙어 있는 반면, Service는 그물망 형태로 얽힙니다. 이 분리가 비즈니스 로직 재사용성의 열쇠입니다.
4) 바인딩 없음 — 명시적 데이터 흐름
Vue·Angular의 양방향 바인딩 같은 마법은 없습니다. 데이터는 명시적으로 전달됩니다.
// 서버 → 그리드
onLoaded(view, ok, res) {
view.getRef('grid').setData(res.data.list);
}
// 그리드 → 폼
onRowClick(grid, key, val, data) {
this.getRef('form').setData(data);
}
바인딩이 없다는 건 매번 명시적으로 값을 넣어야 한다는 뜻입니다. 얼핏 번거로워 보이지만, 데이터 흐름을 눈으로 따라갈 수 있고 예측 불가능한 리렌더링이 없다는 큰 장점이 있습니다.
데이터 응답 프로토콜
서버가 내려주는 JSON은 일정한 스키마를 따르길 권장합니다.
{
"result": true,
"data": {
"list": [ ... ],
"info": { ... },
"field": "홍길동"
},
"error": {
"message": ""
}
}
- result — 성공(true) / 실패(false)
- data — 성공 시 응답 페이로드. 리스트든 단일 객체든 여기 담음
- error.message — 실패 시 오류 메시지
이 스키마를 프로젝트 내에서 통일하면:
- Service에서 오류 처리 패턴이 반복되지 않음
- 프레임워크 공통 처리로 로딩·에러 UI를 자동화하기 쉬움
- 개발자가 다른 뷰 코드를 봐도 흐름이 예측됨
뷰가 커질 때 — 파일 분리
MVP의 단점인 "Presenter가 커진다"에 대비해 VanillaFront는 View와 Presenter 각각을 파일로 분리할 수 있게 지원합니다.
1) UI 분리 — importFile
config() 안의 UI 트리 일부를 별도 파일로 뺄 수 있습니다.
// view/order/OrderList.js
config() {
return {
tagName: 'page',
tags: [
{ tagName: 'importFile', src: './OrderListHeader.js' },
{ tagName: 'importFile', src: './OrderListGrid.js' }
]
};
}
각 파일은 UI 조각을 반환:
// view/order/OrderListHeader.js
export default {
tagName: 'panel',
tags: [
{ tagName: 'h1', innerHTML: '주문 목록' },
{ tagName: 'searchField', ref: 'search', label: '검색' }
]
};
2) 이벤트·로직 분리 — 점(.) 표기
이벤트 콜백을 별도 파일에 두고, on...에서 점(.) 으로 지정할 수 있습니다.
// view/order/OrderList.js
config() {
return {
tagName: 'button',
text: '저장',
onClick: 'saveHandlers.onSave' // ← 점 표기
};
}
// view/order/saveHandlers.js
export default {
onSave(btn, el, evt) {
// 저장 로직
},
onValidate(view) {
// 검증 로직
}
};
효과: View 파일에는 화면 구조만 남고, 이벤트 로직은 별도 파일로 정리 → 유지보수 편해짐.
표준 프로젝트 구조
VanillaFront 프로젝트의 관행적 폴더 배치:
project/
├── view/ # 화면 (Va.View)
│ ├── cust/
│ │ ├── CustList.js
│ │ └── CustReg.js
│ ├── admin/
│ └── product/
├── service/ # 서비스 (Va.Service — Model)
│ ├── CustService.js
│ └── ProductService.js
├── lib/ # VanillaFront 라이브러리
│ ├── va.js
│ └── va.css
├── assets/ # 정적 자원
│ ├── img/
│ ├── css/
│ │ ├── va.css
│ │ └── user.css
│ └── extra/
├── App.js # 루트 뷰
├── index.js # 부트스트랩
├── index.html # 진입 HTML
└── router.js # 라우터 경로 정의
관행
- 폴더명: 소문자로 시작하는 카멜 (cust, productMng)
- 파일명(뷰·서비스): 대문자로 시작하는 파스칼 (CustList.js, CustService.js)
- view/: 업무 모듈별로 세분화. 파일 수가 많아지면 하위 폴더로 정리
- service/: 프로젝트 규모에 따라 세분화 결정
- assets/: 이미지, 외부 라이브러리, 정적 리소스
실전 예제 — 고객 목록
세 파일이 어떻게 협업하는지 완결된 예제로 봅시다.
1) assets/temp.json — 서버 응답 시뮬레이션
{
"result": true,
"data": {
"list": [
{ "custId": "C001", "custName": "홍길동", "phone": "010-1111-2222" },
{ "custId": "C002", "custName": "김철수", "phone": "010-3333-4444" }
]
},
"error": { "message": "" }
}
2) service/CustService.js — Model
export default class CustService extends Va.Service {
static list(view, params, callback) {
const options = { method: 'GET' };
Va.Http.request(view, './assets/temp.json', params, options,
function(view, responseOk, resObj) {
callback(view, responseOk, resObj);
}
);
}
}
- Va.Service 상속
- static 메서드로 노출
- Va.Http.request(view, url, params, options, callback) 규약
- 필요하면 fetch, axios 사용해도 무방
3) view/cust/CustList.js — View + Presenter
import CustService from '../../service/CustService.js';
export default class CustList extends Va.View {
mounted() {
this.loadData();
}
// Presenter — 데이터 로드
loadData() {
CustService.list(this, {}, this.callbackList);
}
// Presenter — 응답 처리
callbackList(view, ok, res) {
if (ok && res.result) {
view.getRef('grid').setData(res.data.list);
} else {
new Va.Alert({
title: '오류',
message: res.error?.message || '조회 실패'
}).show(view);
}
}
// Presenter — 이벤트 핸들러
onSearch(btn, el, evt) {
this.loadData();
}
onRowClick(grid, key, val, data) {
this.getRef('form').setData(data);
}
// View — UI 선언
config() {
return {
tagName: 'page',
tags: [{
tagName: 'panel',
tags: [
{ tagName: 'h1', innerHTML: '고객 목록' },
{ tagName: 'button', text: '조회', onClick: 'onSearch' },
{
tagName: 'grid',
ref: 'grid',
columns: [
{ key: 'custId', title: '고객코드', width: 100 },
{ key: 'custName', title: '고객명', fillRatio: 1 },
{ key: 'phone', title: '전화', width: 150 }
],
onRowClick: 'onRowClick'
}
]
}]
};
}
}
Va.registerView('/view/cust/CustList', CustList);
4) App.js — 라우터로 등록
import './view/cust/CustList.js';
import './router.js';
export default class App extends Va.View {
onClickMenu(component, evt) {
Va.setRouterUrl('mainArea', component.option.menuName);
}
config() { /* 메인 레이아웃 */ }
}
흐름 요약:
- 사용자가 메뉴 클릭 → 라우터가 CustList 뷰 표시
- mounted() → loadData() → CustService.list(this, ...) 호출
- 서비스가 서버 응답을 받아 callbackList 호출
- callbackList가 그리드에 setData()로 데이터 세팅
- 사용자가 행 클릭 → onRowClick → 폼에 데이터 세팅
모든 데이터 흐름이 명시적이라는 점을 다시 짚어보세요. 어디에서 무엇이 일어나는지 코드로 따라갈 수 있습니다.
요약 — MVP 5대 규칙
- View + Presenter는 Va.View 하나로 통합한다. 어차피 1:1.
- Model 역할은 Va.Service가 담당한다. View와 N:N 관계.
- 바인딩은 없다 — 데이터는 콜백으로 명시적 전달.
- 서버 응답은 표준 스키마({result, data, error})를 따른다.
- View 파일이 커지면 importFile·점 표기로 분리한다.