모델뷰컨트롤러(MVC) 개요
가장 오래된 MV*패턴의 고전으로 가장 많은 연구가 되어있는 패턴이기도 합니다. 그만큼 구현 방법이 다양하여 현 시점에서 어떤 게 MVC의 정답이라고 말할 수는 없지만 일명 제왕적 컨트롤러 중심의 MVC가 대세가 되었습니다. 이번 포스팅에서는 MVC를 자세히 살펴보고 실질적인 구현을 같이 살펴봅니다. 모든 아키텍쳐 글에서 단골로 등장하는 Todo앱을 작성하는 것으로 샘플 코드를 제작해보죠(언급한대로 고전 MVC에서는 모델의 옵져버로 뷰를 등록하고 컨트롤러가 모델을 업데이트하는 형태도 존재했지만 현 시점에는 거의 없어 컨트롤러 중심의 MVC로 진행합니다)
본 시리즈에서는 “나만의 MVC프레임웍 만들기” 같은 게 목표가 아닙니다. MVC프레임웍의 기본적인 사고 방식을 이해하는데 초점을 맞추고 있습니다.
컨트롤러 제작 – 태스크(Task)를 파악하기
MVC에서 중심이 되는 컨트롤러는 수행해야 하는 태스크 단위로 편성됩니다. 따라서 하나의 앱을 기능적인 태스크로 분석하는 것이야말로 개별 컨트롤러를 파악하는 지름길인 셈입니다. 예제로 사용될 Todo앱의 기능을 파악해 볼 텐데 우선 고유명사를 두 개만 정의하고 진행해보죠.
- 목록 – 향후 Todo앱에서 ‘목록’이라는 단어는 ‘할일’의 그룹을 의미합니다. 예를 들면 개인, 회사, 취미 같은 할일을 묶어둘 그룹입니다.
- 할일 – 할일이야말로 Todo앱에서 수행해야만 하는 개별적인 아이템입니다. 하나의 목록에 소속되도록 하죠.
(이렇듯 프로젝트에서 쓰이는 고유명사를 사전에 정의한 ‘딕셔너리’는 개발시에 참여자들과의 커뮤니케이션이 큰 도움이 됩니다)
‘목록’과 ‘할일’이라는 엔티티를 기초로 해서 태스크를 정리해 보겠습니다.
- 목록 리스트
- 목록 상세
- 목록 생성
- 목록 수정
- 목록 삭제
- 할일 생성
- 할일 수정
- 할일 삭제
‘할일’을 완료로 표시하거나 미완으로 되돌리는 것도 수정이라 할 수 있으므로 별도의 태스크로 분리하지 않았습니다. 위와 같은 태스크는 기본적인 CRUD로 쉽게 도출될 수 있지만 UI상에서 보다 복잡한 기능을 지원해야 하는 경우는 거기에 맞춰 더욱 늘어나게 됩니다. 우선 태스크를 도출했으므로 그만큼의 컨트롤러를 제작하면 됩니다. 기본 형태만 잡아볼까요.
코드 1-1
const Controller = class{}; const controllers = new Map([ ["list_list", class extends Controller{}], ["list_view", class extends Controller{}], ["list_new", class extends Controller{}], ["list_edit", class extends Controller{}], ["list_remove", class extends Controller{}], ["todo_new", class extends Controller{}], ["todo_edit", class extends Controller{}], ["todo_remove", class extends Controller{}] ]);
아직 베이스가 되는 Controller도 정의하지 않았고 그걸 상속한 개별 컨트롤러의 구체적인 코드도 없습니다만 대충 위와 같은 코드가 되는 것입니다.
위에서 보기 좋게 정리한 controllers맵은 이후 라우터의 테이블로 변하게 됩니다. 우선은 시각적으로 확인하기 쉽게 만들었습니다.
라우터(Router)
MVC패턴에서 라우터라는 존재가 등장하기 마련입니다. 위에서 태스크별로 생성한 컨트롤러는 각각의 태스크를 커버하지만 그들 사이의 연결과 컨트롤러 초기화를 해줄 일종의 구동 엔진이 필요합니다. 결국 라우터란 중앙 집중적인 엔진을 통해 각 태스크를 수행하는데 필요한 컨트롤러를 활성화시키는 일반적인 구현체라고 봐도 무방합니다(퍼사드같은..) 아주 심플한 라우터를 작성해보겠습니다.
코드 1-2
const Router = class{ route(path){ new controllers[path](); } }; const router = new Router(); router.route('list_list');
위의 라우터는 코드 1-1에서 작성한 controllers 맵에 의존하고 있습니다. 매우 간단하지만 이로서 자유롭게 태스크에 맞는 컨트롤러를 활성화시킬 수 있게 되었습니다.
list_list 태스크 해소
목록 리스트를 보여주는 list_list 컨트롤러를 실제로 작성해보겠습니다. 컨트롤러는 관련된 모델과 뷰를 전부 초기화하고 실행할 책임을 갖습니다. 따라서 다음의 코드처럼 컨트롤러가 필요한 구성요소를 초기화하고 사용합니다.
코드 1-3
const list_list = class extends Controller{ constructor(){ super(); const model = new List_list_model(); const view = new List_list_view(); view.render(model); //뷰에게 모델전달 } };
컨트롤러나 라우터가 뷰에게 모델을 전달하는데, 이는 많은 MVC패턴에서 자주 채용되는 형태입니다. 사실 MVC의 원래 사상에서는 뷰는 모델에 대한 의존성이 없고 오히려 모델이 뷰를 아는 형태이므로 아래와 같은 코드가 바람직할지도 모릅니다.
코드 1-3-1
const list_list = class extends Controller{ constructor(){ super(); const view = new List_list_view(); //모델에게 뷰전달 const model = new List_list_model(view); model.update(); } };
1-3의 형태의 구현이 대부분이므로 1-3연장선 하에서 관련된 모델과 뷰를 작성해보죠.
List_list_model 작성
모델은 목록 리스트에 표시될 내용을 정확히 담고 있어야 합니다. 하지만 이 모델이 꼭 실제 엔티티의 저장소일 필요는 없습니다. 오히려 실체를 별도로 두고 그것을 가공하여 필요한 데이터를 만드는 역할로 이해하는 편이 좋습니다. 그렇다면 이 시점에서 실제 목록을 저장하는 역할은 List클래스에 맡기고 List_list_model은 목록리스트에 표시되기 적당한 형태로 List인스턴스를 출력하는 기능을 수행하는 모델이라 생각하는 편이 더 유연할 수 있습니다(DAO처럼)
목록 리스트에서는 생성된 목록의 배열을 주는 셈이므로 간단히 다음과 같이 정리해볼 수 있습니다.
코드 1-4
//실제 목록엔티티의 클래스 const List = class{ constructor(id, title){ this.id = id; this.todo = []; this.title = title; //생성 시 저장소에 넣는다. List.instances.add(this); } }; //실질적으로 목록인스턴스를 저장하는 저장소 List.instances = new Set(); //컨트롤러에 필요한 데이터를 공급하는 모델 const List_list_model = class{ get data(){ //인스턴스의 배열을 준다. return [...List.instances]; } };
- 실제 생성된 목록을 저장하는 저장소는 List의 instances속성에 할당된 Set입니다.
- 하지만 List_list_model은 실제 저장소와 상관없는,
- 컨트롤러가 소비해야 모델에 대한 정보를 추려내는 중간 필터링 역할을 수행합니다.
위의 코드에서 get data()는 List.instances를 배열로 가공하여 반환하는 역할을 수행하고 있습니다. 이 data속성을 바로 뷰에서 소비하게 됩니다.
List_list_view작성
이제 공급 받은 모델로부터 실제 화면을 만들어내는 뷰를 제작할 차례입니다.
코드 1-5
const List_list_view = class extends View{ render(model){ //배열로 List인스턴스가 들어옴 return `<ul>${model.reduce((str, v)=>str += `<li>${v.title}</li>`, "")}</ul>`; } };
위의 뷰는 받아온 모델을 기반으로 간단히
- ..
형태의 리스트를 만들어내고 있습니다. 이렇게 문자열을 출력하는 것으로는 제대로 화면에 나올 수 없겠죠. 이제 컨트롤러와 라우터에서 공통적인 관심사를 분리하고 완전한 애플리케이션이 되도록 전반적인 수정을 해야 합니다. 우선 컨트롤러부터 개선해보죠.
코드 1-6
const list_list = class extends Controller{ constructor(){ super(); const model = new List_list_model(); const view = new List_list_view(); this.result = view.render(model.data); } };
위에서 바뀐 점은 딱 하나로 뷰의 결과를 this.result에 넣어주는 것입니다. 이로서 뷰가 그려진 결과는 컨트롤러의 result속성에 저장되게 되었습니다. 라우터에서는 이를 활용하면 될 것입니다. 결국 컨트롤러가 처리해주는 부분은 해당 태스크의 본문 뿐이므로 그 바탕이 되는 화면의 초기화와 갱신 등은 전부 라우터에서 공통로직으로 처리하면 될 일입니다. 라우터를 개선해보겠습니다.
코드 1-7
const Router = class{ constructor(stage){ //그려질 뷰의 루트를 정한다. this.stage = stage; } route(path){ const {result} = new controllers[path](); //루트를 갱신한다. this.stage.innerHTML = result; } }; //생성 시 루트엘리먼트 전달 const router = new Router(document.getElementById('stage')); router.route('list_list');
리스트의 목록 클릭 시 처리
위의 코드까지 진행해도 화면에 그리는 건 문제없습니다. 하지만 각 목록을 클릭했을 때 아무런 상호작용을 할 수 없게 됩니다. 뷰는 컨트롤러를 직접 알고 상호작용에 대한 일체를 컨트롤러에게 위임합니다. 우선 뷰가 컨트롤러를 알게 하려면 컨트롤러가 뷰를 생성할 때부터 넘겨주는 방법을 쓸 수 있습니다.
코드 1-8
const list_list = class extends Controller{ constructor(){ super(); const model = new List_list_model(); //생성 시 컨트롤러를 넘겨준다 const view = new List_list_view(this); this.result = view.render(model.data); } };
뷰는 생성 시 컨트롤러를 받았으므로 render시 컨트롤러에게 상호작용에 대한 뒷 처리를 떠 넘길 수 있습니다. 하지만 이렇게 하려면 뷰가 이전처럼 문자열을 생성하는 게 아니라 직접 DOM객체를 생성해야 합니다. 이를 포함하여 뷰를 개선해보죠.
코드 1-9
//엘리먼트 생성기 const newEl = name=>document.createElement(name); const List_list_view = class extends View{ constructor(controller){ super(); //컨트롤러의 참조 this.controller = controller; } render(model){ return model.reduce((ul, v)=>{ const li = newEl('li'); li.innerHTML = v.title; //컨트롤러에게 떠넘긴다. li.addEventListener('click', _=>this.controller.click(v.id)); ul.appendChild(li); return ul; }, newEl('ul')); } };
뷰가 엘리먼트를 반환하므로 라우터의 코드도 변화합니다.
코드 1-10
const Router = class{ constructor(stage){ this.stage = stage; } route(path){ const {result} = new controllers[path](); this.stage.innerHTML = '';//초기화 this.stage.appendChild(result); //DOM을 추가 } };
하지만 보다 중요한 변화는 뷰의 click을 처리해 줄 컨트롤러의 새로운 click()메소드입니다.
코드 1-11
const list_list = class extends Controller{ constructor(){ super(); const model = new List_list_model(); const view = new List_list_view(this); this.result = view.render(model.data); } click(id){ //새로운 화면으로 라우팅한다. router.route("list_view/" + id); } };
이제 컨트롤러의 click()이 어떤 식으로 라우터와 대화하는지 알 수 있습니다. 또한 라우터는 ‘/’로 이어지는 추가적인 스펙이 필요함을 알 수 있습니다. 라우터를 고쳐보죠.
코드 1-12
const Router = class{ constructor(stage){ this.stage = stage; } route(path){ path = path.split('/'); // 슬래쉬로 타겟과 데이터를 나눈다. //뒷 부분을 전달함 const {result} = new controllers[path[0]](path[1]); this.stage.innerHTML = ''; this.stage.appendChild(result); } };
지금까지 작성한 모든 코드를 모아볼까요?
- 보다 프레임웍스럽게 라우터 안에 라우팅 테이블을 구성하도록 변경하고,
- 컨트롤러도 스코프 참조 대신 명시적으로 생성 시 라우터를 받도록
수정했습니다.
코드 1-13
//모델 const List = class{ constructor(id, title){ this.id = id; this.todo = []; this.title = title; List.instances.add(this); } }; List.instances = new Set(); const Model = class{} const List_list_model = class extends Model{ get data(){return [...List.instances];} }; //뷰 const newEl = name=>document.createElement(name); const View = class{}; const List_list_view = class extends View{ constructor(controller){ super(); this.controller = controller; } render(model){ return model.reduce((ul, v)=>{ const li = newEl('li'); li.innerHTML = v.title; li.addEventListener('click', _=>this.controller.click(v.id)); ul.appendChild(li); return ul; }, newEl('ul')); } }; //컨트롤러 const Controller = class{}; const List_list = class extends Controller{ constructor(router, data){ super(); this.router = router; const view = new List_list_view(this); const model = new List_list_model(); this.result = view.render(model.data); } click(id){this.router.route("list_view/" + id);} }; //라우터 const Router = class{ constructor(stage){ this.stage = stage; this.router = new Map(); } add(target, controller){ this.router.set(target, data=>new controller(this, data).result); } route(path){ const [target, data] = path.split('/'); this.stage.innerHTML = '<h2>To-do</h2>'; this.stage.appendChild(this.router.get(target)(data)); } }; //라우터 생성 const router = new Router(document.getElementById('stage')); //라우팅 테이블 설정 router.add('list_list', List_list); //라우팅 시작! router.route('list_list');
실제 구동 샘플은 아래와 같습니다.
https://hikamaeng.github.io/mvc/1-13.html
물론 현재로서는 List를 생성한 적이 없어 아무런 데이터가 없습니다. 따라서 화면에는 To-do라는 글씨만 덩그랗게 있을 뿐입니다.
리스트를 추가하기 위해 신속하게 기존 List_list_view에 새로운 목록을 추가할 수 있게 기능을 확장해보죠.
새로운 목록의 추가
기존 뷰는 목록리스트만 ul, li로 출력하는 형태로 되어있습니다. 여기에 보다 목록추가라는 기능을 넣어보죠.
코드 1-14
const newEl = name=>document.createElement(name); const View = class{}; const List_list_view = class extends View{ constructor(controller){ this.controller = controller; } render(model){ //전체를 감쌀 section생성 const section = newEl('section'); //기존 ul도 section에 넣고 section.appendChild(model.reduce((ul, v)=>{ const li = newEl('li'); li.innerHTML = v.title; li.addEventListener('click', _=>this.controller.click(v.id)); ul.appendChild(li); return ul; }, newEl('ul'))); //section 하단에 새 목록 버튼을 생성한다. const btn = newEl('button'); btn.innerHTML = "+새 목록"; btn.addEventListener('click', _=>this.controller.newList()); section.appendChild(btn); return section; } };
이에 맞춰 컨트롤러에도 새로운 newList메소드가 추가되어야합니다.
코드 1-15
const Controller = class{}; const List_list = class extends Controller{ constructor(router, data){ super(); this.router = router; const view = new List_list_view(this); const model = new List_list_model(); this.result = view.render(model.data); } click(id){this.router.route("list_view/" + id);} newList(){this.router.route("list_new");} };
리펙토링을 반복하기
이쯤 되니 click과 newList가 특별한 기능없이 라우트를 호출하는 기능을 반복하고 있음을 알 수 있습니다. 이제 비워뒀던 Controller클래스로 공통 기능을 옮겨 route라는 메소드로 구현하고 생성자도 만들어보죠.
코드 1-16
const Controller = class{ constructor(router, data){ this.router = router; this.data = data || ''; } route(path){this.router.route(path);} }; const List_list = class extends Controller{ constructor(...arg){ super(...arg); //부모에게 위임 const view = new List_list_view(this); const model = new List_list_model(); this.result = view.render(model.data); } };
이제 별로 특별하지 않았던 두 개의 메소드는 Controller공용으로 옮겨 갔으므로 이에 따라 뷰의 코드도 변경됩니다. 뷰도 컨트롤러를 받는 부분을 부모측으로 위임합니다.
코드 1-17
const newEl = name=>document.createElement(name); const View = class{ constructor(controller){ this.controller = controller; } }; const List_list_view = class extends View{ constructor(...arg){ super(...arg); } render(model){ const section = newEl('section'); section.appendChild(model.reduce((ul, v)=>{ const li = newEl('li'); li.innerHTML = v.title; li.addEventListener( 'click', //click에서 route로 변경 _=>this.controller.route('list_view/' + v.id) ); ul.appendChild(li); return ul; }, newEl('ul'))); const btn = newEl('button'); btn.innerHTML = "+새 목록"; btn.addEventListener( 'click', //newList에서 route로 변경 _=>this.controller.route('list_new') ); section.appendChild(btn); return section; } };
위의 뷰 코드를 보면 addEventListener를 거는 부분에서 기존의 개별 컨트롤러 메소드를 호출하는 코드에서 route메소드를 호출하도록 변경되어있습니다. 언틋 보면 전체 코드가 줄어들고 route라는 만능키로 해결하는 듯하여 편해진 듯하지만 여기에는 양날의 검이 도사리고 있습니다.
- 각 행위마다 귀찮게 컨트롤러의 대응 메소드를 만들지 않아서 좋다!
- 하지만 기존에는 라우팅정보를 컨트롤러만 알고 있었는데 이제는 뷰가 라우팅정보(‘list_new’, ‘list_view’ 등)을 알고 있어 라우팅정보 변경시 뷰를 같이 수정해야 한다.
장점보다는 단점이 더욱 심각한 문제를 일으키게 되므로 다시 롤백해야 합니다. 애당초 MVC를 도입한 이유가 수정에 대한 여파를 격리하기 위해서인데 이래서야 모든 코드를 다 수정해야 하는 상태가 되어버립니다. 따라서 공통 기능이 존재하되 이는 컨트롤러 내부에서 사용하고 뷰에게는 컨트롤러의 개별 메소드를 노출하는 편이 라우터와 뷰가 바인딩되는 것을 막을 수 있습니다.
코드 1-18
//컨트롤러 const Controller = class{ constructor(router, data){ this.router = router; this.data = data || ''; } route(path){this.router.route(path);} }; const List_list = class extends Controller{ constructor(...arg){ super(...arg); const view = new List_list_view(this); const model = new List_list_model(); this.result = view.render(model.data); } //내부에서 공통함수 이용 click(id){this.route("list_view/" + id);} newList(){this.route("list_new");} }; //뷰 const newEl = name=>document.createElement(name); const View = class{ constructor(controller){ this.controller = controller; } }; const List_list_view = class extends View{ constructor(...arg){super(...arg);} render(model){ const section = newEl('section'); section.appendChild(model.reduce((ul, v)=>{ const li = newEl('li'); li.innerHTML = v.title; li.addEventListener('click', _=>this.controller.click(v.id)); ul.appendChild(li); return ul; }, newEl('ul'))); const btn = newEl('button'); btn.innerHTML= "+새 목록"; btn.addEventListener('click', _=>this.controller.newList()); section.appendChild(btn); return section; } };
다시 뷰 측의 코드에서는 컨트롤러만 알면 되도록 변경되었습니다.
list_new 작성
위에서 ‘list_new’를 라우트할 준비가 되었으므로 실제 이를 담당할 List_new 컨트롤러와 List_new_view 뷰, List_new_model 모델을 만들어야 합니다. 이제 코드가 제법 반복되어 익숙할 때가 되었으니 공통적인 부모 클래스나 도우미 함수를 배제하고 필요한 부분만 작성해가보록 하죠.
코드 1-19
//모델 const List_new_model = class extends Model{ add(title){ //실제 목록을 생성한다. new List(Date.now() + '' + Math.random(), title); } }; //뷰 const List_new_view = class extends View{ constructor(...arg){super(...arg);} render(model){ const section = newEl('section'); section.innerHTML = `<h3>새 목록추가</h3> <input type="text" placeholder="목록명을 입력하세요"/> <button>추가하기</button>`; section.querySelector('button').addEventListener( 'click', e=>this.controller.add(e.target.previousElementSibling.value) ); return section; } }; //컨트롤러 const List_new = class extends Controller{ constructor(...arg){ super(...arg); const view = new List_new_view(this); this.result = view.render(); } add(title){ if(title){ const model = new List_new_model(); model.add(title); //리스트로 복귀 this.route('list_list'); }else{ //타이틀 값이 제대로 안들어올 때 } } }; //라우팅테이블 등록 router.add('list_new', List_new);
위 코드에서 뷰는 컨트롤러의 add를 부르게 되고 컨트롤러는 이를 처리할 모델을 호출하여 실제로 목록을 등록합니다. 이러한 흐름만 중요한 게 아니라 MVC에서는 각각의 디테일이 매우 중요합니다.
우선 뷰를 뚫어져라 보면 컨트롤러에게 보낼 입력 값을 뷰에서부터 스스로 만들어내는 책임을 지고 있습니다. 코드에서는
e=>this.controller.add(e.target.previousElementSibling.value)
이 부분입니다. 하지만 값을 보낼 뿐이지, 이를 검증하지는 않습니다. 검증은 컨트롤러의 몫이기 때문입니다.
컨트롤러의 add를 보면 title이 빈 값인지 체크하는 간단한 밸리데이션을 포함하고 있습니다. 밸리데이션을 통과한 경우 모델을 통해 실질적인 데이터를 갱신하고 목록리스트 화면으로 라우팅하는 흐름을 진행합니다.
하지만 만약 밸리데이션을 통과하지 못했다면 어떻게 해야 할까요? MVC에서 일반적으로 헬을 만들어내는 부분은 바로 여기입니다. 코드의 else부분을 어떻게 채우냐가 문제의 핵심입니다.
여태까지의 흐름은 뷰가 컨트롤러를 호출하는 흐름입니다. 하지만 지금은 컨트롤러가 다시 뷰와 대화해야하는 상황입니다. 여기에는 두 가지 대처 방법이 일반적으로 쓰이는데 각기 장단점이 있습니다.
컨트롤러가 뷰에게 지시하기
우선 최초 컨트롤러는 뷰를 초기화한 적이 있기 때문에 이 시점에 뷰를 속성으로 잡아두면 필요할 때 뷰의 메소드를 호출할 수 있게 됩니다. 만약 밸리데이션 에러에 대응하는 뷰의 메소드가 제공되는 식으로 리펙토링한다면 다음과 같은 것입니다.
코드 1-20
//뷰 const List_new_view = class extends View{ constructor(...arg){super(...arg);} render(model){ const section = newEl('section'); section.innerHTML = `<h3>새 목록추가</h3> <input type="text" placeholder="목록명을 입력하세요"/> <button>추가하기</button> <div style="color:red"></div>`; //에러출력위치 section.querySelector('button').addEventListener( 'click', e=>this.controller.add(e.target.previousElementSibling.value) ); //미리 뷰의 참조를 잡아둔다 this.section = section; return section; } //밸리데이션 에러의 뷰측 처리 invalidTitle(){ this.section.querySelector('input').value = ''; this.section.querySelector('div').innerHTML = '목록명 좀 제대로...'; } }; //컨트롤러 const List_new = class extends Controller{ constructor(...arg){ super(...arg); const view = new List_new_view(this); //미리 뷰의 참조를 잡아둠 this.view = view; this.result = view.render(); } add(title){ if(title){ const model = new List_new_model(); model.add(title); this.route('list_list'); }else{ //뷰측 메소드 호출 this.view.invalidTitle(); } } };
이 방식에서 뷰는 엘리먼트를 미리 참조로 잡아둬야 하고, 컨트롤러는 미리 뷰의 참조를 잡아두어야 나중에 사용할 수 있게 됩니다.
하지만 컨트롤러는 뷰의 로직을 전혀 관여하지 않아 깔끔하게 뷰의 로직이 캡슐화 됩니다.
이 구조는 장점만 있을 것 같지만 실제로는 단점이 오히려 큰 편입니다.
- 현재 뷰의 상태를 재현하기 힘들게 됩니다. 최초 렌더링으로 그려진 것이 아니라 그 이후 다양한 뷰 간섭으로 만들어진 화면이라 정확히 동일한 화면을 재현하기 어렵습니다.
- 뷰의 여러 메소드가 뷰를 깨트리기 쉽게 됩니다. 결국 개별 뷰의 메소드는 비연속적으로 뷰의 상태를 변화시키기 때문에 경우의 수가 폭증하여 뷰의 에러를 일으키게 됩니다.
이 단점은 처음에는 느끼지 못하지만 뷰가 컨트롤러와 복잡한 상호작용을 할수록 기하급수적으로 헬을 만들어냅니다.
따라서 또 다른 방법인 render에서 단일 로직으로 해결하기를 추천합니다.
render에서 모든 것을 해결하는 방식에서는 뷰가 그려지는 로직이 단일하기 때문에 경우의 수를 만들어내지 않고 간단히 특정 상태를 정확히 재현할 수 있습니다. 이미 변화가능성이 전부 데이터로 들어온다는 것을 기반으로 뷰를 만든다면 밸리데이션도 포함되어 그려질 것입니다.
코드 1-21
const List_new_view = class extends View{ constructor(...arg){super(...arg);} render(model){ const section = newEl('section'); // model.valiError속성을 처음부터 반영하여 렌더 section.innerHTML = `<h3>새 목록추가</h3> <input type="text" placeholder="목록명을 입력하세요"/> <button>추가하기</button> <div style="color:red">${model.valiError || ''}</div>`; section.querySelector('button').addEventListener( 'click', e=>this.controller.add(e.target.previousElementSibling.value) ); return section; } };
위에서 render메소드는 처음부터 밸리데이션 에러를 의미하는 model의 model.valiError속성을 반영하여 그림을 그리고 있습니다
(${model.valiError || ”} 부분) 덕분에 render에서 section을 참조로 잡지 않아도 괜찮습니다.
컨트롤러는 다음과 같이 특별한 뷰의 메소드를 호출하지 않고 뷰 렌더링을 수행할 수 있습니다.
코드 1-22
const List_new = class extends Controller{ constructor(...arg){ super(...arg); const view = new List_new_view(this); //들어온 데이터를 전달함. const model = this.data ? JSON.parse(this.data) : {}; this.result = view.render(model); } add(title){ if(title){ const model = new List_new_model(); model.add(title); this.route('list_list'); }else{ //그저 라우팅하면 알아서 된다. /이하가 data로 수신됨 this.route('list_new/{"valiError":"목록명 좀 제대로..."}'); } };
위의 흐름에서는 컨트롤러가 특별히 뷰에 메소드를 호출하지 않고 그저 기존과 동일한 라우팅만 처리하고 있습니다. 필요한 데이터는 전부 라우팅 정보로 보내줬으므로 뷰는 언제나 동일한 데이터 구조 하에서 단일한 렌더링 로직을 유지하게 됩니다.
여기까지의 코드를 한 번에 모아보겠습니다. 특히 list_list와 list_new를 만들면서 불일치하는 인터페이스나 라우터의 데이터를 전달하는 방법을 일관성있게 고치도록 하죠.
코드 1-23
//모델 const List = class{ constructor(id, title){ this.id = id; this.todo = []; this.title = title; List.instances.add(this); } }; List.instances = new Set(); const Model = class{} const List_list_model = class extends Model{ //메소드 스타일로 통일 getData(){return [...List.instances];} }; const List_new_model = class extends Model{ add(title){new List(Date.now() + '' + Math.random(), title);} }; //뷰 const View = class{ static newEl(name){return document.createElement(name);} constructor(controller){this.controller = controller;} }; const List_list_view = class extends View{ constructor(...arg){super(...arg);} render(model){ const section = View.newEl('section'); //목록리스트도 h3로 표시추가 section.innerHTML = '<h3>목록리스트</h3>'; section.appendChild(model.reduce((ul, v)=>{ const li = View.newEl('li'); li.innerHTML = v.title; li.addEventListener('click', _=>this.controller.click(v.id)); ul.appendChild(li); return ul; }, View.newEl('ul'))); const btn = View.newEl('button'); btn.innerHTML = "+새 목록"; btn.addEventListener('click', _=>this.controller.newList()); section.appendChild(btn); return section; } }; const List_new_view = class extends View{ constructor(...arg){super(...arg);} render(model){ const section = View.newEl('section'); section.innerHTML = `<h3>새 목록추가</h3> <input type="text" placeholder="목록명을 입력하세요"/> <button>추가하기</button> <div style="color:red">${model.valiError || ''}</div>`; section.querySelector('button').addEventListener( 'click', e=>this.controller.add(e.target.previousElementSibling.value) ); return section; } }; //컨트롤러 const Controller = class{ constructor(router, data){ this.router = router; this.data = data; } //라우트 방식을 일관성 있게 경로와 데이터로 나눔 route(path, data){this.router.route(path, data);} //result속성에 대해 보다 강력하게 전개함 get result(){ //result를 읽을 때 설정한 적이 없으면 예외가 발생함 if(!this._result) throw "result undefined"; return this._result; } set result(v){this._result = v;} }; const List_list = class extends Controller{ constructor(...arg){ super(...arg); const view = new List_list_view(this); const model = new List_list_model(); this.result = view.render(model.getData()); } //라우트 방식을 일관성 있게 경로와 데이터로 나눔 click(id){this.route("list_view",{id});} newList(){this.route("list_new");} }; const List_new = class extends Controller{ constructor(...arg){ super(...arg); const view = new List_new_view(this); this.result = view.render(this.data || {}); } add(title){ if(title){ const model = new List_new_model(); model.add(title); this.route('list_list'); }else{ //라우트 방식을 일관성 있게 경로와 데이터로 나눔 this.route('list_new", {valiError:"목록명 좀 제대로..."}); } } }; //라우터 const Router = class{ constructor(stage){ this.stage = stage; this.router = new Map(); } add(target, controller){ this.router.set(target, data=>new controller(this, data).result); } //경로와 별도로 데이터를 받아들임 route(path, data){ this.stage.innerHTML = '<h2>To-do</h2>'; this.stage.appendChild(this.router.get(path)(data)); } }; const router = new Router(document.getElementById('stage')); router.add('list_list', List_list); router.add('list_new', List_new); router.route('list_list');
실제 구동 샘플은 아래와 같습니다.
https://hikamaeng.github.io/mvc/1-23.html
실행해보면 목록리스트와 목록생성 및 밸리데이션 처리가 잘 작동하는 것을 볼 수 있습니다.
이제 이를 좀 구조화된 폴더로 다음과 같이 정리해볼 수 있을 것입니다.
위 구조에서
- app.js에는 라우터를 초기화하고 최초 라우팅을 설정하는 코드가 들어가게 되고,
- core.js에는 MVC추상클래스 및 라우터클래스의 정의가 들어가며
- 각 폴더별로 목적에 따라 MVC구상클래스를 관리하는 형태가 되어
확장하기도 편리하고 수정 시에도 정확히 원하는 곳을 찾아 수정할 수 있게 됩니다. 이 후 컨트롤러, 뷰 모델은 차근차근 해당 폴더에 추가되어가겠죠.
결론
지금까지 두 개의 태스크를 추가하면서 MVC의 여러 면을 살펴봤습니다. MVC는 가장 오래된 패턴이자 연구가 많이 되어있는 패턴으로 프레임웍마다 변형은 끝이 없을 정도로 다양합니다. 하지만 근본적으로 컨트롤러가 중심이 되어 하나의 태스크를 처리해가는 방식으로 격리를 취합니다. 이 방식은 사실 각 태스크가 어느 정도 격리할 수 있는 구조이면서 동시에 각 태스크가 독립적인 목적을 처리하고 있는 경우에 효과적입니다.
하지만 태스크의 기능이 다른 태스크에서 재활용되거나 상호 참조를 통해 작동하는 태스크가 많아질수록 굉장히 정교한 코딩을 요구하게 됩니다.
특히 상태 변화를 중계하는 절차와 복잡한 입출력에 대응하는 커뮤니케이션 방법을 죄다 개발자 역량에 맡기는 면이 강합니다.
가장 많이 쓰이기도 하지만 반대로 개발자의 역량에 따라 잘 운영되기도 하고 금새 헬로 빠지기도 하는 패턴인 셈입니다(먼들 그렇지 않겠습니까만..)
위의 샘플에서도 나왔지만 개별 MVC요소에서 중복되는 내용은 추상층으로 옮기거나 참조해야 하는 제 3의 객체를 계속 만들어내게 됩니다.
결국 복잡성을 잘 관리해서 각 MVC의 중복을 얼마나 줄이고 코드를 건강하게 유지할 수 있는가의 문제입니다만 현실적으로는 이게 굉장히 힘듭니다.
MVC의 특성 상 새로운 태스크를 추가하기 쉽고 실제 개발도 태스크로 나눠주면 동시에 개발을 해갈 수도 있을 것입니다.
오히려 그렇기 때문에 함부로 추상층의 기능을 추가하거나 도우미 객체를 만들면 의외로 중복이 제거되는 게 아니라 새로운 코드만 양산하게 되고 일관성이 더욱 떨어지게 됩니다. 이걸 결정할 수 있는 시점이란 게 어느 정도 유형별 태스크가 다 만들어져서 공통 요소를 인식할 수 있는 때인데 그렇다고 판단하고 추상화 작업을 하면 새로운 유형이 와락 발생하는 경우가 비일비재 합니다.
개인적으로 느끼는 MVC패턴의 단점은
- 개발자 역량에 따라 코드의 건강함이 확연하게 차이가 난다는 점과
- 그럼에도 불구하고 쓰레기같은 코드를 마구 양산해가며 태스크를 완수해갈 수 있다는 점입니다.
(응? 2번은 장점인가?)
업계에서는 2번의 매력을 간과할 수 없고 역사와 전통이 오래되다보니 사수급의 개발자들이 이 패턴에 익숙한 경우가 많아 높은 확률로 애플리케이션 개발에 채택됩니다.
연습문제
- 초급 – 처음 제시되었던 8개의 태스크를 전부 구현하세요.
- 초급 – 라우터에 경로를 등록하는 시점에 관련된 뷰와 모델 클래스도 전부 등록하게 하여 컨트롤러가 직접 뷰와 모델을 생성하지 않고 라우터에서 주입해주세요.
- 중급 – 기저 모델인 List의 경우 CRUD가 일어날 때 직렬화하여 localstorage에 저장 및 복원도 할 수 있게 하세요.
- 중급 – 위의 저장소 연결을 서버와 하세요.
- 중급 – json으로부터 라우팅 테이블을 초기화할 수 있게 변경하세요.
- 번외 – 이상의 내용을 안드로이드 네이티브로 구현하세요.
P.S 연습문제를 다 푸셔도 제가 해드릴 수 있는 건 별로 없습니다만 댓글로 질문하시면 성실히 답해드립니다 ^^;;
recent comment