기본사용법

VanillaFront 튜토리얼 — 5. MVP 패턴

VanillaFront 2026. 9. 11. 09:21

https://vaillafront.com 참조

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() { /* 메인 레이아웃 */ }
}

흐름 요약:

  1. 사용자가 메뉴 클릭 → 라우터가 CustList 뷰 표시
  2. mounted()  loadData()  CustService.list(this, ...) 호출
  3. 서비스가 서버 응답을 받아 callbackList 호출
  4. callbackList가 그리드에 setData()로 데이터 세팅
  5. 사용자가 행 클릭 → onRowClick → 폼에 데이터 세팅

모든 데이터 흐름이 명시적이라는 점을 다시 짚어보세요. 어디에서 무엇이 일어나는지 코드로 따라갈 수 있습니다.


요약 — MVP 5대 규칙

  1. View + Presenter는 Va.View 하나로 통합한다. 어차피 1:1.
  2. Model 역할은 Va.Service가 담당한다. View와 N:N 관계.
  3. 바인딩은 없다 — 데이터는 콜백으로 명시적 전달.
  4. 서버 응답은 표준 스키마({result, data, error})를 따른다.
  5. View 파일이 커지면 importFile·점 표기로 분리한다.