MVC Pattern (vanilla JavaScript)

imnotmoon·2021년 9월 25일
25

요즘도 바닐라로 웹을 짜나?

확실히 요즘은 대부분 프레임워크(혹은 라이브러리) 3대장 React, Vue, Angular로 웹을 짠다.

그치만 나도 웹을 배우는 입장에서 React를 잠시 사용해본 경험이 있지만, 프론트엔드를 업으로 삼기 위해 바닐라부터 다시 공부하는 중이다.

바닐라 자바스크립트는 어떤 라이브러리나 프레임워크든 근간이 되기 때문에 이런 것들을 배우기 전에 무조건 잘하는 것이 좋다고 생각했다.

그리고 요즘은 성능상의 이유로 오히려 프레임워크나 라이브러리를 걷어내는 경우도 있다. 대표적으로 React를 걷어내고 있는 Netflix가 있다.

Vanilla JavaScript로 웹 개발하기

기존에 내가 경험해본 Vanilla JavaScript 웹 개발 방식은 크게 두가지정도가 전부였던것 같다.

  1. <script> 태그로 감싸 html 문서 안에 포함하는 경우와
  2. 별도의 스크립트 파일을 생성해 <script src="..."> 를 통해 html 문서에 링크하는 경우

가 그것이다.

후자의 경우 파일의 개수가 많아질 경우 async 혹은 defer 기능을 통해 다운로드 순서와 실행 순서를 관리할 수도 있고, Webpack과 같은 번들링 툴을 이용해 모듈화된 파일들을 하나로 묶어 관리하기도 한다.

하지만 이런 경우에도 나는 객체지향적으로 웹을 짜본 경험이 없었다.

Vaillna JavaScript OOP

객체지향적인 방식으로, 컴포넌트 구조로 웹을 짜야하는 미션을 받았다. 당연히 한번도 해본적이 없었기 때문에 레퍼런스가 될만한 블로그들을 참고해가며 공부했고, 참고한 글은 아래에 링크로 남겨두겠다. (특히 황준일님의 블로그가 굉장히 도움이 많이됐다.)

MVC Design Pattern

가장 기본적인 디자인패턴 중 하나인 MVC를 적용해보기로 했다.

Model - View - Controller로 구성되어 있고, 프로젝트에 따라, 어떻게 정의하는지에 따라 각자의 역할이 조금씩 달라진다.

Controller가 특히 그런데, Model과 View를 연결하는 인터페이스 정도로만 쓰일 수도 있고(이 경우 ViewModel이라고도 불린다) 여기에 더해져 비즈니스 로직 정의와 수행을 담당할 수도 있다. 어떤 프로젝트인지에 따라, 정의하기에 따라 역할이 천차만별이기 때문에 내 코드를 리뷰해주신 개발자분께서는 '중간친구'라는 표현을 쓰는 것을 추천하시기도 했다.

아래의 경우는 내가 정의해본 역할이고, 절대 정답은 아니다.

  • View : 화면의 구성(렌더링)을 담당한다.
  • Model : 상태(state)를 다룬다.
    • 상태는 화면의 렌더링에 영향을 주는 변수이다.
    • 서버로부터 받은 데이터를 가공하는 역할을 수행할 수도 있다.
  • Controller : View와 Model 사이의 인터페이스 역할, 비즈니스 로직과 이벤트를 처리하는 역할을 한다.

View와 Model의 결합도를 낮추기 위해 Controller라는 완충객체를 두었다.

이렇게 완충지대를 두지 않으면서 결합도를 낮추는 방법은 없거나 굉장히 어렵다.

이를 의사코드로 표현하면 아래와 같다(es6+)

class Component {
	_target;
	_model;
	_view;

	constructor(target, model, view) {
		this._target = () => document.querySelector(target);
		this._model = model;
		this._view = view;
	}
}

class Model {
	_state;
	
	constructor(initialState) {
		this._state = initialState;
	}

}

class View {
	_target;
	
	constructor(target) {
		this._target = () => document.querySelector(target);
	}
}

// 생성 시
const component = new Component('#app', new Model({}), new View('#app'));

ComponentController 역할을 하는 클래스이고, model과 view 인스턴스를 필드로 갖고 있다.

생성 시점에 model과 view 객체를 받아 컴포넌트에 바인딩한다.

각 변수의 역할은 아래와 같다.

Component

  • _target : 컴포넌트가 들어갈 slot이 될 dom의 selector이다.
    • ex. #app 이 target으로 주어진 경우 이 컴포넌트는 #appinnerHTML 로 붙게 된다.
    • dom 객체를 직접 주지 않고 셀렉터를 넘긴 이유는 #app 을 포함하는 상위 컴포넌트에서 재렌더링이 일어날 경우 #app dom이 유실될 수 있기 때문이다.
    • .innerHTML = '' 을 통해 #app dom이 사라진 경우, 바로 다시 생기더라도 이전의 dom와 새로 생긴 dom은 엄연히 다른 객체이다. 따라서 slot의 역할을 할 수 없다.
  • _model : Model 클래스의 객체를 담는다.
  • _view: View 클래스의 객체를 담는다.

Model

  • state를 다루기 때문에 _state 프로퍼티를 선언했고, Object 타입의 객체가 담기게 된다.

View

  • 화면의 렌더링과 관련된 역할을 맡는다.
  • target 을 인자로 받은 이유는 '어디에 가서 붙을지'를 View 객체에서 알아야 렌더링이 가능하기 때문이다.
  • target 을 인자로 받은 후 target 에 해당하는 dom을 잡는 메소드를 생성한다.
    • document.querySelector(target) 처럼 바로 잡지 않은 이유는 해당 dom이 상위 컴포넌트의 재렌더링 과정에서 날아갈 가능성이 있기 때문이다. 한번 날아갔던 dom은 재사용이 불가능하기 때문에 필요할때마다 계속 document.querySelector... 를 호출해 다시 dom을 잡아줘야 한다.
    • () => document.querySelector(target) 처럼 메소드화시킨다면, 평가 시점에 dom을 잡기 때문에 element가 날아갔다가 다시 생기는 여부와 관계없이 '평가 시점에 element가 존재하기만 한다면' 해당 메소드를 계속 재사용이 가능하다.

지금은 단순히 생성자와 필드만 존재하고, 실제로 사용할 수 있게 이를 발전시켜보도록 한다.

Model

모델은 렌더링에 영향을 주는 변수를 관리하고, 서버로부터 받은 데이터를 가공하는 역할을 맡기로 했다.

이에 맞게 구체적으로 코드를 구성하면 아래와 같다.

export default class Model {
	_state;
	
	constructor(state) {
		this._state = state;
	}

	setState = (newState) => {
		this._state = { ...this._state, ...newState };
	}

	getState = (key) => this._state[key];

	get = () => this._state;
}
  • setState() 는 인자로 새로운 state를 받아 기존의 state에 덮어씌우거나 새롭게 정의한다.
  • getState() 는 Controller에서 모델에 정의된 상태를 받아서 사용할 수 있도록 인자로 key 를 받아 key 에 해당하는 값을 리턴한다.
  • get() 은 state를 전부 가져오는 역할을 담당하고, 그냥 내 편의를 위해 구현했다.

모델에서 서버로부터 받은 데이터를 가공하도록 할 수도 있지만, 이 글에서 나는 그 역할까지는 부여하지 않았다. (아마 부여하는쪽이 더 자연스러울 것이다)

View

뷰는 모델에서 가져온 상태값을 토대로 화면을 구성하고, dom으로 매핑한다.

화면을 구성할 때는 createElement 메소드를 이용해 하나하나 태그를 작성하고 붙일 수도 있지만, 템플릿 리터럴을 이용할 수도 있다.

두 방법의 장단점은 각각 아래와 같다.

  • .createElement
    • Pros : 생성한 element가 각각 변수로 존재하도록 할 수 있기 때문에 조금 더 자유로운 dom 조작이 가능하다.
    • Cons : 코드가 길어진다.
      • 코드가 길어진다는 것은 가독성이 떨어지고 버그가 발생할 가능성이 높아진다는 것과 같다.
  • Template literal
    • Pros : 코드가 간결해지고, 직관적이다.
    • Cons : dom을 생성한 시점에서 조작이 불가능하다.
      • 근데 dom을 생성한 시점에서 조작한다고 해봐야 간단한 스타일링과 이벤트 부착 정도가 다일텐데.. 이건 따로 함수로 분리하는게 좋긴 하다.

조금 더 구체화해본 코드는 아래와 같다.

export default View {
	_target;

	constructor(target) {
		this._target = () => document.querySelector(target);
	}

	template = (state) => {
		return `<div>${state.value}</div>`
	}

	render = (state) => {
		this._target().innerHTML = this.template(state);
	}
}
  • template() 메소드는 state를 인자로 받아 이를 통해 템플릿을 구성한다. 지금은 템플릿 리터럴 방식으로 템플릿을 구성하지만, .createElement 메소드를 이용한 방법으로도 구성이 가능하다. (물론 그러면 render() 에서 appendChild 를 이용하는 방식으로 바꿔야 할 것이다)
  • render() 메소드는 target의 내부를 새로 만든 템플릿으로 채운다.

state에 무엇이 들어있는지 확실하게 안다면, 구조 분해 할당(Destructuring Assignment)을 이용하는것이 더 좋다.

Controller

이 글에서 Component 라는 이름으로 Controller가 존재한다. (여기서는 같다고 봐도 무방하다)

Controller는 Model과 View의 결합도를 낮추고, 비즈니스 로직을 처리하는 역할을 부여했다.

이벤트 핸들링도 Controller에서 담당한다.

export default class Component {
	_target;
	_model;
	_view;

	constructor(target, model, view) {
		this._target = target;
		this._model = model;
		this._view = view;

		this.init();
		this.render();
		this.addEvents();
		this.initChildren();
	}

	setState = (newState) => {
		this._model.setState(newState);
		this.render(this._model.get());
	}

	render = (state) => {
		this._view.render(state);
	}
	
	// component 렌더링 직전 처리할 비즈니스 로직
	init = () => {}
	
	// 이벤트 핸들러 부착
	addEvents = () => {}
	
	// 자식 컴포넌트 생성
	initChildren = () => {};
}
		
  • constructor() : 생성 시점에서 target, model, view를 초기화한 후 순서대로 init(), render(), addEvents() 메소드를 호출한다.
    • 이들의 호출 순서도 중요한데, addEvents() 의 경우 dom이 먼저 존재하고 dom에 이벤트를 부착하기 때문에 render() 가 선행되어야 한다.
    • 물론 이벤트 위임을 이용한다면 조금 더 자유로운 타이밍에 호출하는 것도 가능하다.
  • setState() : React의 그것을 모방했다. state에 수정이 가해지면 렌더링이 다시 일어난다.
    • 불필요한 렌더링을 조금이라도 줄이기 위해서는 이전 state와 변경하려는 state를 비교해 변경사항이 있을때만 render() 를 호출하도록 하는것도 좋다.
      • React의 shouldComponentUpdate() 를 직접 구현하는 것과 같다.
  • render() : view에 접근해 화면을 렌더링한다. state 기반으로 view가 렌더링되기 때문에 인자로 state를 같이 넘겨준다.
    • render()setState() 내부에 포함시켰는데, 이렇게 하면 직접 뷰를 수정하지 않더라도 state를 새롭게 설정하는 동작만으로 화면을 다시 그리는 것이 가능해진다.
  • init() : 이제는 사라져버린 React의 componentWillMount() 와 비슷하다.
    • 나는 만들어놓고 사용하지 않았지만, 초기값을 설정하는 등의 작업을 할때 사용한다.
  • addEvents() : 해당 컴포넌트에 관련된 이벤트 핸들러를 부착한다.
  • initChildren() : 대부분의 경우 하위 컴포넌트가 존재할 것이고, 이들을 new Component() 를 통해 생성 후 컴포넌트 트리를 구성한다.
  • 경우에 따라 mount() 등의 메소드를 구현해도 좋다.

예시

/* App.js */
import Component from './Component';
import Model from './Model';
import View from './AppView';

// children
import Header from './Header';
import Container from './Container';

// App Component
export default class App extends Component {
	constructor(target) {
		super(target, new Model({ text : 'this is header' }), new View(target));
	}

	onClickHeader = ({ target }) => { 
		const header = target.closest('.header');
		if(!header) return;
		console.log('Hello World!'); 
	}

	addEvents = () => {
		// 이벤트 위임
		this._target().addEventListener('click', onClickHeader);
	}

	initChildren = () => {
		this.$header = new Header('.header');
		this.$container = new Container('.container');
	}
}
/* AppView.js */
import View from './View';

export default class AppView extends View {
	constructor(target) {
		super(target);
	}
	
	// override template()
	template = (state) => {
		const { text } = state;
		return `
			<div class="header">${text}</div>
			<div class="container">this is container</div>
		`
	}
}

참고

3개의 댓글

comment-user-thumbnail
2021년 9월 28일

MVC구조에서 controller에 대한 이해가 부족했는데 글을 보고 생각을 확장할 수 있었어요. 감사합니다!

답글 달기
comment-user-thumbnail
2021년 12월 19일

좋은 글 너무 감사합니다. 😀 개인 프로젝트에 적용하고 글로 정리하고 싶은데 출처 넘기고 사용해도 될까요??

1개의 답글