상태 관리를 어떻게 하면 좋을지는 프로젝트를 시작할 때, 혹은 상태 관리가 포함된 코드를 리뷰할 때 자주 나오는 주제이다.
라이브러리를 잘 사용하는 것도 중요하지만 결국 상태가 무엇이고 어떻게 상태관리하면 좋을지 이해한다면 능동적으로 라이브러리를 활용하거나 직접 구현하여 상태관리할 수 있을 것이다.
상태를 다루는 일반적인 방법을 살펴본 후 이를 옵저버 패턴을 이용하여 개선해 보자.
또 옵저버 패턴을 이용하여 관찰자 클래스 방식과 createStore 방식을 바닐라 JS로 구현해 보고 비교해 보자.
프론트엔드에서 상태란
이 글에서 다룰 상태는 값의 변함에 따라 화면에 반영돼야 하는 값으로 정의한다.
그렇다면 프론트엔드에서 상태의 예로는 무엇이 있을까?
이러한 탭바에서 상태는 내가 무슨 탭을 보는지(현재는 Issues)이고 탭을 선택하면 해당 탭 아래에 하이라이트가 되도록 해야 한다.
Open 된 PR의 개수는 몇 개 인지도 예로 들 수 있다.
다른 탭 클릭으로 인해 상태가 바뀌면 그곳 밑에 주황색 border표시가 필요할 것이다.
또 Open 된 PR이 늘거나 줄면 그에 따라 2가 아닌 다른 값으로 화면을 바꾸어 보여줘야 할 것이다.
그러면 상태의 변경과 화면의 변경을 개발자는 어떤 흐름으로 제어해줘야 할지 간단한 그림을 통해 살펴보자
즉 코드에서는 이벤트 발생에 따라 상태의 변경을 체크하고 변경된 상태를 DOM 조작을 통해 화면에 잘 반영해 주면 된다.
위에 깃헙 탭에서 다른 탭을 클릭하여 탭의 하이라이트가 바뀌는 것을 코드로 나타내주면 대충 이런 모양이다.
let currentTab = 'issues';
$tabPullRequest.addEventListener('click', ({ target }) => {
document.getElementById(currentTab).style.borderBottom = 'none';
currentTab = target.innerText;
document.getElementById(currentTab).style.borderBottom = '1px solid orange';
});
하지만 요구사항과 함께 상태에 따라 변경해야 할 지점이 점점 많아진다..
상태는 계속 많아질 것이고 상태에 따라 변해야 하는 화면이나 이벤트들도 점점 많아질 것이다.
예를 들어 위에서는 현재 탭이 무엇이냐에 따라 탭 아래의 리스트를 변경해 주거나 데이터를 받아오거나 권한을 체크하거나 등등의 이벤트등이 더 늘어날 수 있다.
하지만 그렇게 될 경우 데이터와 화면을 둘 다 바꿔주어야 하기 때문에 데이터의 책임을 가진 곳과 화면을 그려주는 데에 책임을 가진 곳을 분리해서 개발하기 쉽지가 않다.
따라서 데이터의 책임을 가진 영역과 화면을 그리는 데에 책임을 가진 영역을 분리하고 유지보수를 쉽게 하기 위해이 한 가지 패턴을 알아야 한다.
옵저버 패턴
옵저버 패턴을 사용하기 이전
값이 바뀌면 화면을 직접 바꿔줘야 해서 데이터와 화면에 대한 책임을 분리하기 어려워.
기능이 추가될 때 바꾸어야 하는 코드가 많아져.
또 수정할 때도 흩어져있는 코드들을 수정해줘야 해서 불편해😰
옵저버 패턴을 사용한 이후
값이 바뀔 때 화면에 바뀌는 요소를 미리 구독해 주면 값만 바뀌면 화면을 알아서 바뀌네?🤗
우리는 이 옵저버 패턴을 이해하기 쉬운 예제를 알고 있다.
바로 유튜브의 알림 설정🔔 시스템이다.
필자는 피식대학이라는 채널을 좋아한다. 피식대학을 예로 들어보자!
수많은 피식대학의 구독자들이 새로운 영상의 업로드를 알기 위해 유튜브에 알림 설정을 해둔다.
그러면 유튜브는 피식대학의 영상들이 올라왔을 때 사용자들에게 알림을 전송한다.
이걸 상태로 바꾸어 이해해 보자.
수많은 컴포넌트가 상태의 변경을 알기 위해 알림 설정 같은 "행위"를 구독한다.
그러면 발행자는 "상태가 변경됨을 감지하여" 컴포넌트에 상태 변경에 따른 해야 할 작업을 실행한다.
이를 설명하는 그림들이 많은데 코드에서 행위를 저장한다는 개념을 이해하기 쉽게 도식을 그려봤다.
코드에서의 절차는 다음과 같이 진행된다.
- 구독자들은 발행자에게 이벤트들을 등록시킨다.
- 발행자는 이들을 이벤트 저장소에서 관리한다.
- 발행자는 특정 상태가 바뀌면 일어나야 하는 이벤트들을 이벤트 저장소에서 실행한다.
이제 이런 방식으로 코드를 만들어보자.
핵심은 어떻게 구독할 때 등록되는 함수들을 저장하고 상태가 바뀔 때 어떻게 실행할지이다.
위에서 봤던 오픈된 PR 개수 컴포넌트를 옵저버 패턴을 이용한 두 가지 방식으로 구현해 보자
첫 번째 방식은 관찰자 클래스를 만들어 이를 상속한 Store를 만드는 것이다.
관찰자 클래스를 만들고 상속하여 데이터를 다루는 곳인 Store를 만든다.
View는 Store를 받아 화면을 그리는 데에 집중할 수 있도록 할 것이다.
한 곳에서 처리해도 되는 코드들을 Store와 View로 나누는 이유는 데이터를 다루는 곳과 화면을 다루는 곳을 분리하기 위함이다.
데이터에 대한 책임과 화면에 대한 책임을 분리하면 한 파일에서 코드가 너무 길어지지 않게 만들기 좋다.
또 더 중요한 것은 디자인이 바뀔 때 데이터를 건드리는 실수를 방지하거나 데이터가 바뀔 때 디자인을 바꾸는 실수를 방지할 수 있기 때문이다.
관찰자 class
(이해를 돕기 위해 프로퍼티, 메서드 명은 한글로)
class 관찰자{
constructor(){
this.발행시_실행될_함수배열 = [];
}
구독(발행시_실행될_함수){
this.발행시_실행될_함수배열.push(발행시_실행될_함수);
}
구독취소(취소할_함수){
this.발행시_실행될_힘수배열.filter((함수)=>함수!==취소할_함수));
}
발행(발행함수에_필요한_데이터){
this.발행시_실행될_함수배열.forEach(함수=>함수(발행함수에_필요한_데이터))
}
}
PRCountStore
(바뀔 상태와 상태를 바꿀 액션들을 담는다.)
class PRCountStore extends 관찰자{
#count;
constructor(){
super();
updatePRCount();
}
async getPRCount(){
const count = await request('/pr/count');
return count;
}
async updatePRCount(){
const count = await this.getPRCount();
this.발행(count);
}
PRCountView
(화면과 관련되는 속성들을 담고 생성할 때 렌더함수를 구독한다.)
View에서만 DOM API(document.getElementById 등)을 다루면 화면 내부를 바꾸고 싶을 때는 View를 찾아가면 된다.
class PRCountView {
#store
constructor({store,selector}){
this.#store = store;
this.selector = selector;
this.#store.subscribe(this.render.bind(this));
this.render();
}
render(){
document.getElementById(selector).innerHTML = this.#store.count;
}
}
View들을 호출하여 화면을 관리하는 진입점
new PRCountView({store: new PRCountStore(), selector: '.pr-repo-tab-count'})
만약 다른 컴포넌트에서 PRCountStore을 변경해도 PrCountView는 렌더링 될 것이다.
꼭 Observable Class의 상속을 통해서만 구현해야 하면 View는 다른 class를 상속받지 못한다.
상속 말고 그저 변수로 사용할 수 있는 Store를 만들 수도 있다.
두 번째는 createStore 함수를 만들어 상태를 함수의 클로저를 이용하여 저장해 보자.
export const createStore = (createState) => {
let state;
const observers = new Set();
const getState = () => state;
const shallowCopy = (state, nextState) => Object.assign({}, state, nextState);
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial;
if (nextState !== state) {
const previousState = state;
state = replace ? nextState : shallowCopy(state, nextState);
observers.forEach((listener) => listener(state, previousState));
}
};
const subscribeWithSelector = (
listener,
selector = getState,
equalityFn = Object.is,
) => {
let currentSlice = selector(state);
const listenerToAdd = () => {
const nextSlice = selector(state);
if (!equalityFn(currentSlice, nextSlice)) {
const previousSlice = currentSlice;
listener((currentSlice = nextSlice), previousSlice);
}
};
observers.add(listenerToAdd);
return () => observers.delete(listenerToAdd);
};
const subscribe = (observer, selector) => {
if (selector) {
return subscribeWithSelector(observer, selector);
}
observers.add(observer);
return () => observers.delete(observer);
};
state = createState(setState, getState, { setState, subscribe });
return { setState, subscribe, getState, ...state };
};
이 함수를 이용하면 구독이 되는 원리를 이해하면 좋을 것 같다.
이해를 위해 사용법 먼저 살펴본 후 어떻게 subscribe를 이용해 구독되는지 살펴보자.
function prCountView(store, selector) {
const render = () => {
document.getElementById(selector).innerHTML = store.count;
};
store.subscribe(render);
store.updatePRCount();
}
const prCountStore = createStore((setState) => ({
count: 0,
updatePRCount: async () => {
const count = await request('/pr/count');
return setState({ count });
},
}));
prCountView(prCountStore, '.pr-repo-tab-count');
prCountView
함수에 매개변수로 store
를 전달해 주고 view의 render
메서드를 구독시켜 주면 store
안에 observers 자료구조에 render메서드가 저장돼 상태가 바뀔 때마다 렌더링을 실행시켜 준다.
그러면 이제 createStore
가 어떻게 동작하는지 살펴보자.
createStore
의 핵심은 함수 내의 함수를 밖에서 사용하게 하는 것이다.
이 동작을 이해하기 위해 다음과 같은 예시를 살펴보자
function setData(callback) {
const data = [];
const put = (item) => data.push(item);
callback(put);
return data;
}
const arr = setData((put) => {
put('개발나무심기');
});
console.log(arr); // ['개발나무심기']
setData
를 호출할 때 함수를 인자로 넘기면서 setData
내부의 put
을 사용하는 것을 확인할 수 있다.
또 그렇게 만들어진 arr
라는 배열을 활용할 수도 있다.
createStore
도 위와 같은 원리로 상태를 안에 있는 변수 state
에 저장하고 밖에서 사용하고 set 하거나 구독할 수 있도록 한다.
그러면 구독(subscribe) 되는 원리도 살펴보자
const subscribeWithSelector = (
listener,
selector = getState,
equalityFn = Object.is,
) => {
let currentSlice = selector(state);
const listenerToAdd = () => {
const nextSlice = selector(state);
if (!equalityFn(currentSlice, nextSlice)) {
const previousSlice = currentSlice;
listener((currentSlice = nextSlice), previousSlice);
}
};
observers.add(listenerToAdd);
return () => observers.delete(listenerToAdd);
};
const subscribe = (observer, selector) => {
if (selector) {
return subscribeWithSelector(observer, selector);
}
observers.add(observer);
return () => observers.delete(observer);
};
subscribe
함수의 역할은 observers
에 받은 함수를 등록하는 것이다.
selector도 받을 수 있는 함수인 이유는 store가 커질 때 특정 상태만 바뀔 때 일어나야 할 일들을 실행시키기 위해서이다.
store.subscribe(
render,
state => state.count
);
이런 식으로 쓸 수도 있다.
setState
함수도 살펴보자.
const shallowCopy = (state, nextState) => Object.assign({}, state, nextState);
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial;
if (nextState !== state) {
const previousState = state;
state = replace ? nextState : shallowCopy(state, nextState);
observers.forEach((listener) => listener(state, previousState));
}
};
setState
는 바뀔 상태 변수나 상태를 바꾸는 함수를 받는다.
그리고 이전 state
와 바뀔 state
를 얕은 비교하고 상태를 바꾸고 구독된 함수들을 실행시킨다.
관찰자 클래스 방식과 createStore 방식을 비교해 보자
관찰자 | createStore | |
---|---|---|
클래스 문법에 의존하는가? | O | X |
생성자를 사용할 수 있는가? | O | X |
상태를 저장하는 방식 | 객체의 프로퍼티 | 클로저 이용 |
마지막으로 요약해 보자
상태는 값의 변함에 따라 화면에 반영돼야 하는 값이다.
옵저버 패턴을 사용하면 데이터를 다루는 영역과 화면을 다루는 영역을 분리하기 쉬워져 유지보수에 용이하다.
관찰자 클래스를 만들어 상속하거나 createStore 함수를 만들어 옵저버 패턴의 구현체를 쉽게 다룰 수 있다.
참고 자료
댓글