바닐라 자바스크립트로 Observer Pattern(옵저버 패턴) 흉내내보기
워낙 좋은 기술이 많은 요즘은 프론트엔드를 공부할 때 바닐라 자바스크립트를 조금 맛본 뒤 바로 리액트, 뷰와 같은 라이브러리나 프레임워크로 넘어가는 것 같습니다. 저 또한 크게 다르지 않았고, 바닐라 자바스크립트로 '상태'라는 걸 굳이 만들어보지 않았던 것으로 기억합니다. 다만, 리액트로 넘어가기 전, 부스트캠프에서 '옵저버 패턴'을 소개한 적이 있는데, 리액트를 무작정 사용하기 전에 어떻게 변화하는 값에 맞춰 화면에 렌더링 되는지를 직접 만들어보며 이해하는 것이 맹점이었습니다.
그때는 리액트도, 리덕스도 경험해보기 전이라 어떤 장점이 있는지도, 어떻게 하는 느낌인지도 파악을 못 해 눈물을 흘리며 넘어갔던 기억이 납니다. 그리고 이래저래 맛보기를 해본 지금, 바닐라 자바스크립트로 todo list를 연습해보면서 드디어 기회가 왔다, 하는 느낌이 들어 여러 자료를 참고하면서 제 입맛대로 한번 구현해보기로 했습니다.
옵저버 패턴이란?
우선 옵저버 패턴이 정확히 무엇일까요? 위키피디아의 정의를 참고해보겠습니다.
옵저버 패턴(observer pattern)은 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다. 주로 분산 이벤트 핸들링 시스템을 구현하는 데 사용된다. 발행/구독 모델로 알려져 있기도 하다.
처음 접했을 때도 그렇고, 지금 봐도 단번에 '아하!'하고 이해되는 말은 아닌 거 같습니다. 특정 언어에 국한된 개념도 아니기 때문에 저렇게 범용적으로 표현이 되는데, 제가 이해한 대로 단순하게 얘기해보자면,
어떤 객체(뷰)가 변화할 때 이와 연관된 여러 객체에게 직접 통보하지 않고 중간관리자를 두어 변화의 감지와 통보를 대신하게 만드는 디자인 패턴.
가장 간단하게 투두리스트로 예를 들어보겠습니다.
할일이 목록으로 보이고, 하단에 할 일의 총 개수를 출력해주는 화면이 있다고 생각해봅시다. 이때, 할 일이 하나 추가되면 총 개수도 그에 맞게 변화해야 합니다. 그럼 단순하게 생각하면, 할 일이 추가되는 이벤트가 발생하고, 이게 성공하면 total count 옆의 숫자를 +1 된 값으로 바꾸면 됩니다. 삭제되면 -1된 값으로 바꾸게 되겠고요. 지금은 단순하게 두 가지 뷰끼리만 소통하고 있지만, 만약 이 할 일 목록이 변화할 때마다 함께 유기적으로 변해야 하는 ui 요소가 5개, 10개가 된다면 어떨까요? 그럼 추가할 때마다 그 모든 뷰를 렌더링 하는 함수를 하나하나 불러와서 실행시켜주어야 하는데, 이때부터 무언가 격하게 잘못되었다는 느낌이 들게 됩니다.
무엇이 편해지는가?
이 때 옵저버 패턴이 등장합니다. 먼저 할 일 목록의 변화를 대신 감지하고 통보하는 중간관리자(publisher/발행하는 객체)를 만듭니다. 그리고 할 일 목록과 관련이 있는 모든 뷰는 '옵저버' 또는 '리스너'라고 부릅니다. 변화를 관찰하고 이에 맞게 대응하는 객체이기에 이런 명칭이 붙습니다.
이 중간관리자가 할일 목록의 변화를 감지하면 옵저버들의 렌더링 함수를 대신 실행시킵니다. 이러면 할 일 목록 뷰에서 직접 다른 뷰로 이벤트를 하나하나 보낼 필요 없이, 중간관리자에게만 자신의 변화를 알리면 됩니다. (물론 옵저버에게 최초 한 번은 어떤 변화/이벤트를 감지해야 하고, 이때 어떤 함수를 실행해야 할지 알리는 작업은 필요합니다. 이걸 보통 subscribe 한다고들 많이 표현합니다) 그림으로 간단하게 변화를 표현해본다면 이렇습니다.
기존 형태
옵저버 패턴
이렇게 중간관리자가 대신 처리하게 하면 뷰끼리의 의존성이 없어지기 때문에 복잡한 구성에서도 각각의 뷰가 처리해야 되는 부분만 고민하면 됩니다. 즉, 객체끼리의 의존성이 낮아져 결합도가 낮아지기 때문에 유지보수에도, 확장성 면에서도 유리합니다. 리액트에서도 상태의 변화를 감지하기 위해 하나하나 props로 내려주는 대신 상태관리를 통해 변화를 알리고 그 변화를 감지하여 다시 렌더링 되는 것과 비슷한 느낌이라고 생각합니다.
코드로 구현해보자!
이런 개념을 기반으로 내가 흉내를 낸다면 어떻게 해야할지 고민을 하면서 여러 참고 코드를 살펴보았습니다. 예시들마다 코드의 세세한 부분이 많이 다르고, 간단하게만 구현하고 끝난 경우들이 많아 특정 참고 코드가 있진 않습니다. 대신 이전에 사용해본 상태 관리 툴에 기반해서 중간관리자 대신 '상태'와 '리듀서'를 묶어서 관리하는 store라는 것을 만들어 이벤트 감지, 상태 변경, 이벤트 함수 실행 등을 자동화할 수 있도록 구성해보았습니다. context API + useReducer의 느낌과 비슷하지 않나 싶습니다.
그럼 이런 store를 어떻게 만들면 될까요? 저는 createStore(비동기는 createAsyncStore)라는 함수를 만들고, 인자로 상태의 초기값과 상태에 변화를 가하는 이벤트와 로직이 담긴 리듀서를 넘겨주었습니다.
// Store.js
const createStore = (initialState, reducer) => {
let state = initialState;
const events = {};
// 상태 변화 시 실행할 함수 등록
const subscribe = (actionType, eventCallback) => {
if (!events[actionType]) {
events[actionType] = [];
}
events[actionType].push(eventCallback);
};
// 이벤트에 해당하는 함수 모두 실행
const publish = (actionType) => {
if (!events[actionType]) {
return;
}
events[actionType].map((cb) => cb());
};
// 상태에 이벤트와 필요한 데이터를 보내는 함수
const dispatch = (action) => {
// action에는 type(이벤트), payload(데이터)가 있음
state = reducer(state, action);
publish(action.type);
};
const getState = () => state;
return {
getState,
subscribe,
dispatch,
};
};
store는 다음과 같은 것들을 포함합니다.
- state - 말 그대로 상태입니다. 이벤트에 따라 변화하게 되는 우리의 관심사입니다.
- events - 이벤트 이름이 key, 해당 이벤트 발생 시 실행되어야 하는 함수들이 배열로 저장되어 있습니다.
- subscribe - state에 어떤 이벤트가 발생할 때 실행되어야하는 함수를 등록하는 함수입니다. 한 마디로 변화를 감지하면 나에게도 알려달라고 구독하는 것입니다.
- publish - 어떤 이벤트 이름을 넣어주면 events에서 이에 해당하는 함수를 모두 실행시키는 함수입니다. store내부에서만 사용되는 함수입니다.
- dispatch - 리듀서와 비슷하게 action 객체를 받습니다. 이 action 객체는 type에는 이벤트 이름을, payload에는 해당 이벤트 발생 시 상태 변화를 주기 위해 필요한 데이터가 담겨있습니다. 그리고 이 상태 변화 로직은 reducer에서 정의하고 있기 때문에 reducer를 호출하여 변화를 줍니다. 그 후, 변화가 되었다면 publish 함수를 호출하여 등록된 함수를 일괄적으로 실행합니다.
그렇다면 이제 store에 인자로 넘겨줄 초기 상태와 리듀서를 만들어야겠죠? 그 부분은 아래 코드를 통해 확인할 수 있습니다.
// todoStore.js
import Store from '../store/Store.js';
export const ADD_TODO = 'ADD_TODO';
export const GET_TODOS = 'GET_TODOS';
const reducer = (state, action) => {
switch (action.type) {
case ADD_TODO:
return [...state, action.payload]
case GET_TODOS:
return state
default:
return state;
}
};
const initialState = [];
const todoStore = Store.createStore(initialState, reducer);
export default todoStore;
reducer는 상태와 action 객체를 전달 받으면 action 객체 안의 type에 해당하는 이벤트를 찾습니다. 해당 이벤트마다 위에서 보는 것처럼 상태를 변화시킵니다. 초기값인 InitialState는 할 일의 배열이기 때문에 최초에는 빈 배열로 선언해주었습니다.
이렇게 하면 위의 createStore가 필요로 하는 인자가 모두 완성되기 때문에, 반환되는 store 객체를 활용하여 상태 관리와 렌더링을 처리할 수 있게 됩니다.
예를 들어, 어떤 뷰에서 할일이 추가된다면 store에 이렇게 dispatch를 보내면 됩니다.
todoStore.dispatch({type: ADD_TODO, payload: {todo}});
그렇다면 이 값이 변화할 때 새로이 렌더링을 해야 하는 뷰에선 어떻게 작성해야 할까요?
할 일 목록을 출력하는 뷰는 우선 화면출력을 위해 render라는 함수를 가지고 있게 됩니다. 이 함수는 현재 todoStore가 가지고 있는 할일 목록을 기반으로 화면에 렌더링 해주는 함수입니다. 그렇다면 다른 뷰에서 ADD_TODO 이벤트를 dispatch 할 때 이 render함수가 이를 감지하고 실행이 되어야 최신화된 목록이 화면에 출력될 수 있습니다. 이를 위해선 subscribe 함수로 등록을 하면 됩니다.
todoStore.subscribe(ADD_TODO, render);
이렇게만 등록해두면 ADD_TODO 이벤트 발생 시 실행할 함수로 render를 추가하기 때문에 다른 뷰에서 직접 이 함수를 호출할 필요가 없어집니다.
이후에 뷰가 추가되더라도, 상황에 따라 subscribe를 하거나 dispatch만 잘 실행시키면 원하는 결과를 얻을 수 있습니다. 처음에는 직접 호출하면 되는 것을 먼 길을 돌아 어렵게 작성하는 것처럼 보이지만, 한번 잘 작성을 해두면 이후에 다른 상태가 추가되거나 뷰를 여러 개 추가하더라도 확장성 면에서 훨씬 유리해집니다.
마치며
옵저버 패턴은 패턴 내 각각의 요소에 대해서 찾아보는 글마다 조금씩 다른 이름을 사용하는 것 같습니다. 그래서 정확히 어떤 용어를 쓰면 좋을지 난감해 개인적인 표현으로 많이 풀어보았는데, 여전히 쓰면서도 미흡한 부분이 많다는 걸 느끼게 됩니다.
최근에는 모두 프레임워크 등을 사용하여 개발하다 보니, 이런 부분을 직접 구현할 일이 스터디를 하는 때 외에는 크게 있을까 싶기는 합니다. 그래도 개인적으로 바닥부터 직접 구현해보면서 reducer나 상태 관리가 어떤 맥락에서 동작하는지, 또 이걸 간단하면서도 견고하게 구현해둔 리액트, 리덕스 등에 다시 한번 감사함(?)을 느끼게 된 것 같습니다. 이후에도 저는 혹시라도 바닐라 자바스크립트로 상태 관리가 필요한 순간이 있다면 아마 이 코드를 재활용하지 않을까 싶습니다.