들어가며
프론트엔드 개발의 주요 과제는 화면과 화면에 표시되기까지의 데이터를 관리하는 것입니다.
데이터를 관리하는 것이 복잡해짐에 따라, 여러 방식이 제시되었습니다.
이 글에서 프론트엔드에서 데이터를 관리하기 위한 주요 디자인 패턴을 하나씩 살펴보겠습니다.
설계의 기본 요소
프론트엔드 개발의 디자인 패턴에는 기본적으로 화면(UI)를 담당하는 요소와 데이터를 담당하는 요소가 있습니다.
화면을 다루는 요소는 View, Presentation Layer로 부르며, 사용자가 화면에서 보고 상호작용하는 모든 것을 책임집니다.
데이터를 다루는 요소는 Model, Business Layer로 부르며, 데이터를 관리하고 비즈니스 로직을 처리합니다. 클라이언트에서는 상태와 상태 관리 로직이 모델에 해당되고, 서버에서는 DB 스키마, ORM 객체 등이 모델에 해당됩니다.
이제 View와 Model이 각자의 역할에만 집중할 수 있게끔 이 둘의 상호작용을 중재하는 요소(Mediator)가 필요합니다.
디자인 패턴은 보통 이 중재 방식을 어떻게 구현할지에 따라 달라집니다.
MVC 패턴
MVC 패턴은 Model, View, Controller로 구성됩니다.
간단한 TodoList를 MVC 패턴으로 구현해보겠습니다.
View는 화면에 보여줄 html 요소를 생성하고, 화면에 보여주는 역할을 합니다.
Model은 Todo 데이터를 관리하는 역할을 합니다.
Controller는 View와 Model을 연결하는 역할을 합니다. 이벤트를 처리하고, 데이터를 수정하고, View를 업데이트하는 역할을 합니다.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="root"></div>
<script>
class TodoView {
constructor() {
this.container = document.querySelector("#root");
this.input = document.createElement("input");
this.todoList = document.createElement("ul");
this.addButton = document.createElement("button");
this.addButton.textContent = "할 일 추가";
this.container.appendChild(this.input);
this.container.appendChild(this.addButton);
this.container.appendChild(this.todoList);
}
/**
* todo 데이터를 todoList 요소에 렌더링하는 메서드
* @param {string[]} todos
*/
renderTodo(todos) {
this.todoList.innerHTML = "";
todos.forEach((todo, index) => {
const listItem = document.createElement("li");
listItem.textContent = todo;
const deleteButton = document.createElement("button");
deleteButton.textContent = "삭제";
deleteButton.dataset.index = index;
listItem.appendChild(deleteButton);
this.todoList.appendChild(listItem);
});
}
}
class TodoModel {
constructor() {
this.todos = [];
}
addTodo(todo) {
this.todos.push(todo);
}
deleteTodo(index) {
this.todos.splice(index, 1);
}
getTodos() {
return this.todos;
}
}
class TodoController {
constructor(todoView, todoModel) {
this.view = todoView;
this.model = todoModel;
this.view.addButton.addEventListener(
"click",
this.handleAddTodo.bind(this)
);
this.view.todoList.addEventListener(
"click",
this.handleDeleteTodo.bind(this)
);
this.updateView();
}
handleAddTodo() {
const todoText = this.view.input.value.trim();
if (!todoText) return;
this.model.addTodo(todoText);
this.updateView();
this.view.input.value = "";
}
handleDeleteTodo(event) {
if (event.target.tagName !== "BUTTON") return;
const index = parseInt(event.target.dataset.index, 10);
this.model.deleteTodo(index);
this.updateView();
}
updateView() {
const todos = this.model.getTodos();
this.view.renderTodo(todos);
}
}
const app = new TodoController(new TodoView(), new TodoModel());
</script>
</body>
</html>
Controller에서 이벤트 리스너인 handleAddTodo와 handleDeleteTodo는 입력받은 데이터를 모델에 전달하고, View를 업데이트하고 있습니다.
View를 업데이트하는 코드가 두 번 등장하는 것을 볼 수 있습니다. 기능이 추가된다면, 사용자 상호작용에 따라 상태를 변경하고, 그에 맞는 View를 매번 찾아 업데이트하는 코드를 반복적으로 작성해야합니다.
데이터를 변경하면, View가 자동으로 변경되게 만들 수 없을까요? 이를 위해 반응성을 추가할 수 있습니다.
Observer 패턴
모델이 변경되면 변경 사실을 외부로 알리는 코드를 작성해보겠습니다.
TodoModel은 데이터가 변경될 때 알림을 보냅니다. addTodo, deleteTodo 메서드가 호출될 때 notify 메서드를 호출하여 listeners에 등록된 함수들을 호출합니다.
TodoController는 TodoModel의 구독 함수에 알림 발생 시 동작(View를 업데이트하는 콜백)을 전달합니다.
이로써 TodoController는 사용자 상호작용 코드에 View를 업데이트하는 코드를 넣지 않고, 구독하는 모델이 변경되면 자동으로 View를 업데이트할 수 있게 됩니다.
// Observer 패턴을 적용한 TodoModel과 TodoController
class TodoModel {
constructor() {
this.todos = [];
this.listeners = [];
}
subscribe(listener) {
this.listeners.push(listener);
}
notify() {
this.listeners.forEach(listener => listener(this.todos));
}
addTodo(todo) {
this.todos.push(todo);
this.notify();
}
removeTodo(index) {
this.todos.splice(index, 1);
this.notify();
}
getTodos() {
return this.todos;
}
}
class TodoController {
constructor(todoView, todoModel) {
this.view = todoView;
this.model = todoModel;
this.model.subscribe(todos => this.view.renderTodo(todos));
this.view.addButton.addEventListener(
"click",
this.handleAddTodo.bind(this)
);
this.view.todoList.addEventListener(
"click",
this.handleDeleteTodo.bind(this)
);
this.view.renderTodo(this.model.getTodos());
}
handleAddTodo() {
const todoText = this.view.input.value.trim();
if (!todoText) return;
this.model.addTodo(todoText);
this.view.input.value = "";
}
handleDeleteTodo(event) {
if (event.target.tagName !== "BUTTON") return;
const index = parseInt(event.target.dataset.index, 10);
this.model.removeTodo(index);
}
}
이러한 패턴을 Observer 패턴 또는 발행-구독 패턴 이라고 합니다. 상태가 변경되면 자동으로 알림이 모두에게 전달되어서, 상태와 변경되어야하는 View를 연결하지 않아도 됩니다. 확장성이 높아지겠죠?
MVVM 패턴
MVVM 패턴은 Model, View, ViewModel의 세 가지 컴포넌트로 구성됩니다. MVC에서는 컨트롤러가 뷰의 업데이트를 직접 처리하지만, MVVM에서는 뷰가 뷰모델의 변경에 반응해서 스스로 업데이트합니다. 이 과정이 데이터 바인딩입니다.
데이터 바인딩은 데이터 흐름을 제어하는 방식에 따라 단방향, 양방향으로 구분됩니다.
단방향 바인딩은 뷰모델에서 뷰로만 데이터가 흐릅니다. 뷰모델의 상태가 변경되면 뷰가 스스로 업데이트됩니다. 하지만 뷰에서 발생한 변경이 뷰모델로 전달되지 않습니다. 그래서 이벤트 핸들러를 명시적으로 작성해야 합니다.
반면 양방향 바인딩은 뷰 <-> 뷰모델 양쪽으로 데이터가 흐릅니다. 이벤트 핸들러 로직을 내부적으로 숨기고, 개발자를 대신해 처리해줄 수 있습니다. 따라서 개발 속도가 빨라질 수 있지만, 데이터 흐름을 추적하기 어려워질 수 있습니다.
코드로 구현해 봅시다.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
<input type="text" data-bind="newTodo" />
<button data-click="addTodo">추가</button>
<ul data-list="todos"></ul>
</div>
<script>
class ViewModel {
constructor() {
this.observers = [];
this.newTodo = "";
this.todos = [];
}
subscribe(callback) {
this.observers.push(callback);
}
notify(property, value) {
this.observers.forEach(cb => cb(property, value));
}
set(property, value) {
this[property] = value;
this.notify(property, value);
}
get(property) {
return this[property];
}
addTodo() {
const text = this.newTodo.trim();
if (!text) return;
this.todos.push(text);
this.notify("todos", this.todos);
this.set("newTodo", "");
}
removeTodo(index) {
this.todos.splice(index, 1);
this.notify("todos", this.todos);
}
}
function bindViewModel(viewModel, root) {
// input 값 양방향 바인딩
const boundInputs = root.querySelectorAll("[data-bind]");
boundInputs.forEach(inputEl => {
const property = inputEl.getAttribute("data-bind");
// 뷰모델의 값으로 input 뷰의 값을 초기화
inputEl.value = viewModel.get(property) ?? "";
// 뷰 -> 뷰모델
inputEl.addEventListener("input", e => {
viewModel.set(property, e.target.value);
});
// 뷰모델 -> 뷰
viewModel.subscribe((changedProp, newValue) => {
if (changedProp === property && inputEl.value !== newValue) {
inputEl.value = newValue;
}
});
});
// 이벤트 바인딩
const clickableEls = root.querySelectorAll("[data-click]");
clickableEls.forEach(el => {
const methodName = el.getAttribute("data-click");
el.addEventListener("click", () => {
const fn = viewModel[methodName];
if (typeof fn === "function") fn.call(viewModel);
});
});
// 리스트 렌더링
const listEls = root.querySelectorAll("[data-list]");
listEls.forEach(el => {
const property = el.getAttribute("data-list");
const renderList = items => {
el.innerHTML = "";
items.forEach((item, index) => {
const li = document.createElement("li");
li.textContent = item;
const removeButton = document.createElement("button");
removeButton.textContent = "삭제";
removeButton.addEventListener("click", () => {
viewModel.removeTodo(index);
});
li.appendChild(removeButton);
el.appendChild(li);
});
};
renderList(viewModel.get(property) || []);
viewModel.subscribe((changedProp, newValue) => {
if (changedProp === property) {
renderList(newValue);
}
});
});
}
const vm = new ViewModel();
const rootElement = document.getElementById("app");
bindViewModel(vm, rootElement);
</script>
</body>
</html>
MVVM 패턴에선 HTML 자체가 선언적인 뷰 역할을 하기 때문에, 앞에서 본 다른 패턴과 달리 View 클래스를 선언할 필요가 없습니다.
뷰모델 클래스는 앞에서 본 모델 클래스와 달리, 뷰에서 사용되는 데이터를 관리하는 책임도 가지는 것을 볼 수 있습니다.
bindViewModel()이 실행되면 data-* 속성을 스캔해 뷰모델에 연결합니다.
Flux 패턴 (React)
MVVM 패턴의 양방향 바인딩은 편리하지만, 애플리케이션이 복잡해질수록 데이터 흐름을 추적하기 어려워집니다. 여러 컴포넌트가 동일한 상태를 공유하고 수정할 때, 어디서 상태가 변경되었는지 파악하기 힘들어지는 것이죠.
Flux 패턴은 이 문제를 해결하기 위해 단방향 데이터 흐름을 강제합니다. 데이터는 항상 Action → Dispatcher → Store → View 순서로만 흐르며, View에서 상태를 직접 수정할 수 없습니다.
Flux 패턴은 Action, Dispatcher, Store, View의 네 가지 요소로 구성됩니다.
Action은 애플리케이션에서 발생하는 모든 변경사항을 나타내는 객체입니다. “할 일을 추가한다”, “할 일을 삭제한다”처럼 무엇을 할지(type)와 필요한 데이터(payload)를 담고 있습니다.
Dispatcher는 모든 Action을 받아서 등록된 Store들에게 전달하는 중앙 허브 역할을 합니다.
Store는 애플리케이션의 상태와 비즈니스 로직을 관리합니다. MVVM의 ViewModel과 유사하지만, Dispatcher를 통해서만 상태를 변경할 수 있다는 점이 다릅니다.
View는 Store의 상태를 화면에 표시하고, 사용자 상호작용이 발생하면 Action을 발행합니다.
데이터 흐름을 정리하면 다음과 같습니다:
- 사용자가 View에서 버튼을 클릭
- View는 Action Creator를 호출하여 Action 생성
- Action이 Dispatcher로 전달
- Dispatcher가 등록된 모든 Store에게 Action 전달
- Store가 Action을 처리하여 상태 변경
- Store가 View에게 변경 알림
- View가 새로운 상태로 다시 렌더링
이렇게 단방향으로만 데이터가 흐르기 때문에, 상태 변경을 추적하고 디버깅하기가 훨씬 쉬워집니다.
코드로 구현해 봅시다.
// ============ Dispatcher ============
// 모든 액션을 중앙에서 관리하는 디스패처
class Dispatcher {
constructor() {
this.callbacks = [];
}
register(callback) {
this.callbacks.push(callback);
}
dispatch(action) {
this.callbacks.forEach(callback => callback(action));
}
}
const dispatcher = new Dispatcher();
// ============ Actions ============
const ActionTypes = {
ADD_TODO: "ADD_TODO",
REMOVE_TODO: "REMOVE_TODO",
};
const TodoActions = {
addTodo(text) {
dispatcher.dispatch({
type: ActionTypes.ADD_TODO,
payload: { text },
});
},
removeTodo(index) {
dispatcher.dispatch({
type: ActionTypes.REMOVE_TODO,
payload: { index },
});
},
};
// ============ Store ============
// 애플리케이션의 상태와 비즈니스 로직을 관리
class TodoStore {
constructor() {
this.todos = [];
this.listeners = [];
// Dispatcher에 콜백 등록
dispatcher.register(this.handleAction.bind(this));
}
handleAction(action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
this.todos.push(action.payload.text);
this.emitChange();
break;
case ActionTypes.REMOVE_TODO:
this.todos.splice(action.payload.index, 1);
this.emitChange();
break;
}
}
getTodos() {
return this.todos;
}
// View가 Store의 변경사항을 구독할 수 있게 함
addChangeListener(callback) {
this.listeners.push(callback);
}
emitChange() {
this.listeners.forEach(listener => listener());
}
}
const todoStore = new TodoStore();
// ============ View ============
class TodoView {
constructor() {
this.app = document.getElementById("app");
this.todoList = document.createElement("ul");
this.input = document.createElement("input");
this.addButton = document.createElement("button");
this.addButton.textContent = "Add";
this.app.appendChild(this.input);
this.app.appendChild(this.addButton);
this.app.appendChild(this.todoList);
// 이벤트 리스너 등록 - Action Creator를 호출
this.addButton.addEventListener("click", () => {
const todoText = this.input.value.trim();
if (todoText) {
TodoActions.addTodo(todoText); // Action 발행
this.input.value = "";
}
});
this.todoList.addEventListener("click", event => {
if (event.target.tagName === "BUTTON") {
const index = parseInt(event.target.dataset.index, 10);
TodoActions.removeTodo(index); // Action 발행
}
});
// Store의 변경사항 구독
todoStore.addChangeListener(() => {
this.render();
});
// 초기 렌더링
this.render();
}
render() {
const todos = todoStore.getTodos();
this.todoList.innerHTML = "";
todos.forEach((todo, index) => {
const listItem = document.createElement("li");
listItem.textContent = todo;
const deleteButton = document.createElement("button");
deleteButton.textContent = "Delete";
deleteButton.dataset.index = index;
listItem.appendChild(deleteButton);
this.todoList.appendChild(listItem);
});
}
}
// 앱 초기화
const app = new TodoView();
결론
지금까지 프론트엔드 개발에서 사용되는 주요 디자인 패턴의 진화 과정을 살펴보았습니다.
MVC 패턴은 View, Model, Controller를 분리하여 관심사를 명확히 했지만, Controller에서 View 업데이트를 수동으로 관리해야 했습니다.
Observer 패턴을 도입하면서 Model의 변경사항이 자동으로 View에 반영되게 되었고, 반복적인 업데이트 코드를 줄일 수 있었습니다.
MVVM 패턴은 데이터 바인딩을 통해 View와 ViewModel을 선언적으로 연결했습니다. 특히 양방향 바인딩은 개발 생산성을 높였지만, 복잡한 애플리케이션에서는 데이터 흐름을 추적하기 어려운 문제가 있었습니다.
Flux 패턴은 단방향 데이터 흐름을 강제하여 이 문제를 해결했습니다. Action → Dispatcher → Store → View의 명확한 흐름은 상태 관리를 예측 가능하게 만들고, 대규모 애플리케이션의 복잡도를 관리할 수 있게 해주었습니다.
현대 프론트엔드 프레임워크들은 이러한 패턴들의 아이디어를 받아들여 발전했습니다. React는 Flux 패턴을 기반으로 한 단방향 데이터 흐름을, Vue는 MVVM의 데이터 바인딩을, Angular는 MVC/MVVM의 개념을 각자의 방식으로 구현하고 있습니다.
어떤 패턴이 가장 좋다고 말할 수는 없습니다. 프로젝트의 규모, 팀의 선호도, 요구사항에 따라 적절한 패턴을 선택하는 것이 중요합니다. 다만 각 패턴이 어떤 문제를 해결하기 위해 등장했는지 이해한다면, 더 나은 설계 결정을 내릴 수 있을 것입니다.
참고 자료
- 다시 깊게 익히는 인사이드 리액트 - 윤재원(단테)