흔히, 디자인 패턴이라 하면 객체지향 언어의 원조인 자바에서 많이 활용된다. 디자인 패턴의 종류는 매우 많으며 쓰임새 또한 다양하다. 하지만 자바라는 언어 자체가 백엔드 언어이다 보니, 자바스크립트로 디자인 패턴을 공부하여도 프론트엔드 코드에 어떻게 적용시킬지 늘 고민이다.
이번 포스팅에서는 프론트엔드에서도 유용한 디자인 패턴 중 하나인 옵저버 패턴에 대해 알아본다. 또한, 옵저버 패턴을 적용하여 상태 관리 로직을 구현해보겠다.
1️⃣ 옵저버 패턴
옵저버 패턴은 크게 주체(Subject)와 옵저버(Observer) 사이의 관계를 설계하는 패턴이다. 먼저, 각각 무엇을 뜻하는지 알아보자.
주체(Subject)
- 옵저버들을 관리하는 객체이다. 옵저버는 원하는 주체와 결합할 수 있으며 주체는 결합된 옵저버들을 관리한다.
- 주체는 옵저버를 추가하거나 제거할 수 있다.
- 주체는 상태를 가지고 있으며, 상태가 변경되면 관리 중인 모든 옵저버에게 상태변화를 알린다.
옵저버(Observer)
- 옵저버는 관찰자라는 영어 직역처럼, 주체의 상태를 관찰한다.
- 주체의 상태가 변경되면, 옵저버는 변경된 상태를 전달받아 적절한 업데이트 수행한다.
옵저버 패턴에서 중요한 점은, 주체와 옵저버의 느슨한 결합 관계이다. 왜 느슨한 결합일까 ?
이유는 아래와 같다.
- 주체와 옵저버 는 서로의 구체적인 구현을 알 필요가 없다. (독립성)
- 옵저버 를 추가하거나 제거해도 주체의 코드를 변경할 필요가 없다. (유연성)
- 주체는 상태 관리와 옵저버 의 목록만 관리하면 된다. 각 옵저버는 상태 변경에 대한 업데이트만 하면 된다. (단일 책임 원칙 준수)
- 주체는 상태가 변경되면 반드시 관리 중인 옵저버에게 상태 변경을 알려야 된다.
필자는 항상 느끼지만 개발 공부는 이론보다는 직접 구현해보는 게 훨씬 와닿는다고 생각한다. 옵저버 패턴을 구현해보자.
2️⃣ 옵저버 패턴 추상화
먼저, 추상화를 해보자. 주체와 관찰자 객체의 멤버를 추상화 해보면 다음과 같다.
주체 (Subject)
- Property
- state : 주체의 상태
- observers : 관리 중인 옵저버들의 모음
- Method
- getState : 주체의 현재 상태값을 리턴
- setState : 주체의 현재 상태를 새로운 상태로 변경
- addObserver : 옵저버 추가
- removeObserver : 옵저버 제거
- notify : 상태가 변경되면 옵저버들에게 변경된 상태 알림
관찰자 (Observer)
- Method
- update : 상태가 변경되면 내부적으로 업데이트
이제 직접 구현해보자.
// core.js
export class Subject {
constructor(initialstate = null) {
this.state = initialstate;
this.observers = [];
}
getState() {
return this.state;
}
setState(newState) {
this.state = newState;
this.notify(this.state);
}
addObserver(observer) {
this.observers.push(observer);
}
removeObserver(observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
notify() {
for (const observer of this.observers) {
observer.update(this.state);
}
}
}
export class Observer {
update(newState){}
}
update 메서드는 옵저버마다 다르기에 추상 클래스에서는 구현하지 않았다.
추상화한 클래스를 이용하여 구체화 해보자. 실생활의 예시로 생각해보면 주체는 유튜브 채널이 될 수 있고, 옵저버는 채널의 구독자가 될 수 있다. 유튜브 채널에서 영상을 새로 업로드하면 구독자들에게 알림을 보내는 시스템을 그대로 모방한 것이다.
// main.js
import { Subject,Observer } from "./core.js";
class YouTubeChannel extends Subject {
constructor(initialstate){
super(initialstate)
}
}
class Subscriber extends Observer {
constructor(name){
this.name = name;
}
update(newState){
console.log(`${this.name}님, ${newState}가 업로드 되었습니다 !! `)
}
}
const myYouTube = new YouTubeChannel();
const subscriber1 = new Subscriber('subscriber1');
const subscriber2 = new Subscriber('subscriber2');
myYouTube.addObserver(subscriber1)
myYouTube.addObserver(subscriber2)
myYouTube.setState('New video')
주체인 myYouTube의 상태만 변경해주면 옵저버들인 subscriber 에게 상태 변화를 알려주어 update 메서드를 실행한다.
3️⃣ 클라이언트 상태 관리에 적용
이제 옵저버 패턴을 활용하여 아주 간단한 todo 앱을 만들어 클라이언트 단에서 어떻게 상태 관리를 할 수 있는지 알아보자. 먼저 기본적인 HTML 구조를 만들자.
// index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script type="module" src="main.js"></script>
</head>
<body>
<h1>TODO 리스트<span class="count">(count)</span></h1>
<div>
<input type="text" id="new-todo" placeholder="새로운 할 일">
<ul class="todo-list">
</ul>
<button id="add-todo">추가</button>
</div>
</body>
</html>
옵저버 패턴을 클라이언트 상황에 맞게 구체화 해보자.
// main.js
import { Subject, Observer } from './core.js';
class TodoModel extends Subject {
constructor(initialstate) {
super(initialstate);
}
addTodo(todoValue) {
if (!todoValue.trim()) return;
const newTodo = {
id: this.state.length + 1,
value: todoValue,
};
const newTodoList = [...this.state, newTodo];
this.setState(newTodoList);
}
}
class TodoList extends Observer {
update(newState) {
const $ul = document.querySelector('.todo-list');
$ul.innerHTML = `
${newState.map((todo) => `<li>${todo.value}</li>`).join('')}
`;
}
}
class TodoCount extends Observer {
update(newState) {
const $count = document.querySelector('.count');
$count.innerHTML = `
(${newState.length})
`;
}
}
const todoModel = new TodoModel([
{ id: 1, value: '밥 먹기' },
{ id: 2, value: '샤워 하기' },
]);
const todoList = new TodoList();
const todoCount = new TodoCount();
todoList.update(todoModel.getState());
todoCount.update(todoModel.getState());
코드를 정리해보자.
주체 (Subject) : todoModel
옵저버 (Observer) : todoList, todoCount
todoModel 에 초기 상태값을 할당하고, 각 옵저버들의 update 메서드를 직접 호출하여 초기 상태값을 기반으로 화면에 렌더링 하였다.
그런데, 직접 옵저버를 조작하는 것은 옵저버 패턴과 맞지 않다. 주체의 추상 클래스에 intialize 메서드를 추가하여 초기 상태값으로 관찰자에게 알림을 보내도록 구현해보자.
// core.js
export class Subject {
/*
생략
*/
notify() {
for (const observer of this.observers) {
observer.update(this.state);
}
}
initialize() {
this.notify();
}
}
export class Observer {
// 생략
}
// main.js
import { Subject, Observer } from './core.js';
class TodoModel extends Subject {
// 생략
}
class TodoList extends Observer {
// 생략
}
class TodoCount extends Observer {
// 생략
}
const todoModel = new TodoModel([
{ id: 1, value: '밥 먹기' },
{ id: 2, value: '샤워 하기' },
]);
const todoList = new TodoList();
const todoCount = new TodoCount();
todoModel.addObserver(todoList)
todoModel.addObserver(todoCount)
todoModel.initialize() // 초기화
이제 이벤트 핸들러를 등록하여 주체의 상태의 변화를 주자.
input 태그에서 입력을 받고 추가 버튼을 클릭하면 상태가 변경되게 구현하자.
// main.js
import { Subject, Observer } from './core.js';
/*
생략
*/
const todoList = new TodoList();
const todoCount = new TodoCount();
todoModel.addObserver(todoList);
todoModel.addObserver(todoCount);
function getElement(selector) {
return document.querySelector(selector);
}
function appendTodo() {
const $input = getElement('#new-todo');
const inputValue = $input.value;
todoModel.addTodo(inputValue);
$input.value = '';
}
function addClickHandler(element) {
element.addEventListener('click', appendTodo);
}
todoModel.initialize();
addClickHandler(getElement('#add-todo'));
상태만 변경해주니 옵저버인 list와 count가 변경된 상태에 맞게 업데이트 하는 것을 확인할 수 있다.
4️⃣ 모듈화
현재 main.js의 하나의 파일 안에 다양한 기능이 들어있고, 코드도 복잡해졌기에 모듈화를 해보자.
폴더 구조는 아래와 같다.
.
├─ src
│ ├─ components
│ │ ├─ todoCount.js
│ │ └─ todoList.js
│ ├─ core
│ │ ├─ observer.js
│ │ └─ subject.js
│ ├─ model
│ │ └─ todoModel.js
│ └─ utils
│ ├─ domUtil.js
│ └─ eventHandler.js
├─ index.html
├─ main.js
└─ package.json
모듈화를 하면서 로직이 조금 수정된 부분만 살펴보겠다.
// main.js
import { TodoCount } from './src/components/todoCount.js';
import { TodoList } from './src/components/todoList.js';
import { TodoModel } from './src/model/todoModel.js';
import { getElement } from './src/utils/domUtil.js';
import { addClickHandler, appendTodo } from './src/utils/eventHandler.js';
const todoModel = new TodoModel([
{ id: 1, value: '밥 먹기' },
{ id: 2, value: '샤워 하기' },
]);
const todoList = new TodoList();
const todoCount = new TodoCount();
todoModel.addObserver(todoList);
todoModel.addObserver(todoCount);
todoModel.initialize();
addClickHandler(getElement('#add-todo'), appendTodo(todoModel));
// src/utils/eventHandler.js
import { getElement } from './domUtil.js';
export function appendTodo(todoModel) {
return () => {
const $input = getElement('#new-todo');
const inputValue = $input.value;
todoModel.addTodo(inputValue);
$input.value = '';
};
}
export function addClickHandler(element, handler) {
element.addEventListener('click', handler);
}
모듈화로 인해 appendTodo, addClickHandler 함수의 매개변수를 추가했다.
5️⃣ 추가 기능 구현
이번에는 삭제 기능을 추가로 구현해보자. 현재 로직에 맞게 옵저버 패턴을 이용해서 구현하자.
먼저 TodoList 템플릿에 삭제 버튼을 넣어준다.
// src/components/todoList.js
import { Observer } from '../core/observer.js';
export class TodoList extends Observer {
update(newState) {
const $ul = document.querySelector('.todo-list');
$ul.innerHTML = `
${newState
.map(
(todo) =>
`<li data-id=${todo.id}>
${todo.value}
<button>삭제</button>
</li>`
)
.join('')}
`;
}
}
TodoModel 에 deleteTodo 메서드를 추가한다.
// src/model/todoModel.js
import { Subject } from '../core/subject.js';
export class TodoModel extends Subject {
constructor(initialstate) {
super(initialstate);
}
addTodo(todoValue) {
// 생략
}
deleteTodo(id) {
const deletingIndex = this.state.findIndex((item) => item.id == id);
if (deletingIndex == -1) return;
this.state.splice(deletingIndex, 1);
this.setState(this.state);
}
}
이벤트 핸들러인 removeTodo 함수를 구현한다.
// src/utils/eventHandler.js
import { getElement } from './domUtil.js';
export function appendTodo(todoModel) {
// 생략
}
export function removeTodo(todoModel) {
return (e) => {
if (e.target.tagName == 'BUTTON') {
const $li = e.target.closest('li');
const deletingId = $li.dataset.id;
todoModel.deleteTodo(deletingId);
}
};
}
export function addClickHandler(element, handler) {
element.addEventListener('click', handler);
}
모든 삭제 버튼에 핸들러를 등록하지 않고, 이벤트 위임을 사용할 수 있게 구현하였다.
main에 핸들러를 추가한다.
// main.js
import { TodoCount } from './src/components/todoCount.js';
import { TodoList } from './src/components/todoList.js';
import { TodoModel } from './src/model/todoModel.js';
import { getElement } from './src/utils/domUtil.js';
import { addClickHandler, appendTodo, removeTodo } from './src/utils/eventHandler.js';
const todoModel = new TodoModel([
{ id: 1, value: '밥 먹기' },
{ id: 2, value: '샤워 하기' },
]);
const todoList = new TodoList();
const todoCount = new TodoCount();
todoModel.addObserver(todoList);
todoModel.addObserver(todoCount);
todoModel.initialize();
addClickHandler(getElement('#add-todo'), appendTodo(todoModel));
addClickHandler(getElement('.todo-list'), removeTodo(todoModel));
잘 작동하는 것을 확인할 수 있다.
6️⃣ 관심사 분리
그런데, 자세히 생각해보면 옵저버 패턴에서 옵저버는 상태 변화에만 관심을 가져하는데 TodoList, TodoCount 는 현재 옵저버임에도 상태 변화뿐만 아니라 컴포넌트의 UI와 강하게 결합되어 있다. update 메서드 내에서 DOM 조작을 하고 있기 때문이다. 따라서, 옵저버의 역할만 할 수 있게 View 클래스를 도입하여 역할을 나누자.
프로젝트 구조를 다음과 같이 재구성 하였다.
.
├─ src
│ ├─ core
│ │ ├─ observer.js
│ │ └─ subject.js
│ ├─ model
│ │ └─ todoModel.js
│ ├─ utils
│ │ ├─ domUtil.js
│ │ └─ eventHandler.js
│ ├─ views
│ │ ├─ todoCountView.js
│ │ └─ todoListView.js
│ └─ observers
│ ├─ todoCount.js
│ └─ todoList.js
├─ index.html
├─ main.js
└─ package.json
observers 폴더로 옵저버를 따로 분류하였다.
// src/observers/todoList.js
import { Observer } from '../core/observer.js';
export class TodoListObserver extends Observer {
constructor(view) {
super();
this.view = view;
}
update(newState) {
this.view.render(newState);
}
}
// src/observers/todoCount.js
import { Observer } from '../core/observer.js';
export class TodoCountObserver extends Observer {
constructor(view) {
super();
this.view = view;
}
update(newState) {
this.view.render(newState);
}
}
두 옵저버의 역할은 똑같다. 생성자 함수로 state와 관련된 view를 받는다. 그리고 상태가 변경되면 update 메서드를 통해 view를 렌더링 해준다.
view 는 다음과 같이 구현했다.
// src/views/todoListView.js
export class TodoListView {
constructor() {
this.rootElement = document.querySelector('.todo-list');
}
render(state) {
this.rootElement.innerHTML = `
${state
.map(
(todo) =>
`<li data-id=${todo.id}>
${todo.value}
<button>삭제</button>
</li>`
)
.join('')}
`;
}
}
// src/views/todoCountView.js
export class TodoCountView {
constructor() {
this.rootElement = document.querySelector('.count');
}
render(state) {
this.rootElement.innerHTML = `<span>(${state.length})</span>`;
}
}
rootElement는 뷰의 최상위 요소이다. state가 변경되면 뷰는 정해진 형식으로 렌더링 한다.
// main.js
import { TodoCountObserver } from './src/observers/todoCount.js';
import { TodoListObserver } from './src/observers/todoList.js';
import { TodoModel } from './src/model/todoModel.js';
import { getElement } from './src/utils/domUtil.js';
import { addClickHandler, appendTodo, removeTodo } from './src/utils/eventHandler.js';
import { TodoCountView } from './src/views/todoCountView.js';
import { TodoListView } from './src/views/todoListView.js';
const todoModel = new TodoModel([
{ id: 1, value: '밥 먹기' },
{ id: 2, value: '샤워 하기' },
]);
const todoCountView = new TodoCountView()
const todoListView = new TodoListView()
const todoListObserver = new TodoListObserver(todoListView);
const todoCountObserver = new TodoCountObserver(todoCountView);
todoModel.addObserver(todoListObserver);
todoModel.addObserver(todoCountObserver);
todoModel.initialize();
addClickHandler(getElement('#add-todo'), appendTodo(todoModel));
addClickHandler(getElement('.todo-list'), removeTodo(todoModel));
브라우저를 실행하면 이전과 마찬가지로 정상적으로 작동되는 것을 확인할 수 있다.
현재 코드에서 아쉬운 점은 main 에서 이벤트 핸들러를 등록하는 것보다, view 클래스에서 이벤트 등록 메서드를 추가하는 것이 더욱 바람직해 보인다. 사실, 리팩토링은 원래 끝이 없고 계속 진행하면 포스팅의 주제와 맞지 않기에 이번 포스팅은 여기까지만 다루겠다.
옵저버 패턴은 위와 같이 클라이언트에서 매우 유용한 디자인 패턴이다. 실제로, 리액트의 대표적인 상태 관리 라이브러리인 Redux는 옵저버 패턴을 기반으로 구현되어 있다. 옵저버 패턴을 사용함으로써 주체에 옵저버를 등록하기만 하면 주체의 상태가 변경될 때 옵저버는 내부적으로 알아서 업데이트를 수행하고, 코드의 유지보수성과 확장성을 높일 수 있다. 이러한 구조 덕분에, 복잡한 애플리케이션에서도 상태 변화에 따른 UI 업데이트나 로직 처리가 간결해지고, 변화에 유연하게 대응할 수 있다.
'JavaScript' 카테고리의 다른 글
[JavaScript] Promise 정적 메소드 분석 (1) | 2024.09.15 |
---|---|
[JavaScript] Promise와 비동기(Asnycronous) 처리 (0) | 2024.09.14 |
[JavaScript] drag (드래그) 관련 이벤트 전격 분석 (0) | 2024.09.10 |
[JavaScript] Element.closest 메소드 분석 (0) | 2024.08.31 |
[JavaScript] 이벤트 버블링(Bubbling), 이벤트 위임(Delegation) (2) | 2024.08.31 |