여기서 Store 는 상태값(= state)들을 관리해주는 친구이다.
state 가 변경될 시 state 를 보여주고 있는 컴포넌트는 다시 렌더링되어야 한다. 새로운 state 를 보여줘야 하기 때문이다.
A 컴포넌트와 B 컴포넌트가 있다고 해보자. 그리고 두 컴포넌트에서 item 이라는 상태값을 공유한다고 가정할 때,
무조건 A, B의 부모 컴포넌트가 item 을 쥐고 item 과 setItem 을 내려줘야 한다.
// App
new ComponentA({
// ...
props: {
item: this.state.item,
setItem: (newItem) => this.setState({ item: newItem })
}
})
new ComponentB({
// ...
props: {
item: this.state.item,
setItem: (newItem) => this.setState({ item: newItem })
}
})
이렇게 될 때 부모인 App 컴포넌트는 item 이라는 상태값을 화면에 보여주지 않아도 가지고 있어야 한다. 즉, 알 필요가 없는 데이터를 쥐고 있다.
또한, 자식 컴포넌트에게 넘겨준 setState 가 호출될 때 마다 리렌더링이 일어난다. 여기서 자식의 자식 컴포넌트까지 내려간다면? 또는 자식 컴포넌트에게 나눠준 setState 가 다양하다면? 추적하기 어려운 많은 리렌더링이 발생할 것이다.
따라서 이러한 경우를 위해 컴포넌트랑 분리되어 상태값을 관리해주는 친구가 필요하다. 그게 바로 Store 를 만들게 된 이유이다.
Store 친구를 구현하기 위해서 여러 레퍼런스를 찾아봤을 때, 다수의 사례가 Observer 패턴으로 구현했음을 알 수 있었다.
✅ Observer 패턴이란?
하나의 객체의 상태가 변경되면, 그 객체에 의존하는 다수의 객체에게 알림이 전달되고, 의존하고 있는 각각의 객체가 업데이트 되는 방식이다.
Store 에서 특정 상태값이 업데이트가 되면, 그 상태값을 구독중인 컴포넌트들에게 알려주어야 한다. 알림을 받은 컴포넌트들은 새로운 상태값을 포함하여 다시 렌더링 해야한다.
흠, 뭔가 유튜브 같은데 ?
각각의 상태값은 채널이 되고, 컴포넌트는 채널을 구독해서 알림을 받는 구독자인 것이다. 알림을 받으면 새로운 영상을 보고.. 그건 리렌더링과 같다.
setData 가 호출된다면 해당 data 를 구독하고 있는 컴포넌트들에게 알려야 한다.
state 들의 값을 보관할 객체와, 컴포넌트들에게 전송해야하는 알림을 보관할 친구가 필요하다.
따라서 아래와 같은 구조를 가진다.
data, channel 은 고유한 state 이름을 키로 가지는 객체이다.
[key]: 상태값
[key] : [알림함수1, 알림함수2, 알림함수3]
class Store {
data;
channel;
constructor() {
this.data = {};
this.channel = {};
}
subscribe(keyList, func) {
// 컴포넌트에서 상태값 구독을 위해 호출
// func 에는 알림받으면 작동시킬 함수를 전달해줘야 한다.
}
#notify(key) {
// 상태값이 변경되면,
// 구독중인 컴포넌트들의 알림 함수들을 호출한다.
}
addData({key, default}){
// 상태값 최초 등록
this.data[key] = {
value: default,
default
}
this.channel[key] = [];
return key;
}
getData(key) {
// 해당되는 상태값을 반환
return this.data[key]
}
setData(key) {
// 상태값을 변경하는 함수를 반환
// 1. 상태값 변경 2. 컴포넌트들에게 알림
return (newData) => {
this.data[key] = newData;
this.#notify(key);
}
}
}
store 는 아래과 같이 생성해준다.
const store = new Store();
const someStateKey = store.addData({
key : 'someStateKey',
default: 'initialValue'
});
// 반환된 someStateKey를 통해 get,set,subscribe 가 이루어진다.
store 를 사용하는 컴포넌트에서는, setData 를 호출하여 업데이트 함수를 받고 getData 를 호출하여 원하는 상태값을 받는다.
const setSomeState = store.setData(someStateKey);
const someState = store.getData(someStateKey);
// 업데이트 함수 실행
setSomeState('newValue');
컴포넌트 내부에서 알림함수를 아래와 같이 등록하면, set 이 일어날 때 마다 리렌더링을 발생시킬 수 있다.
store.subscribe(someStateKey, () => this.render());
// someState 가 바뀔 때 마다 렌더링
Store 는 상태값의 이름 역할을 하는 key 에게 상당히 의존적이다. data 객체와 channel 객체가 모두 key 중심으로 이루어져 있으며, setData 와 getData 가 모두 key 를 받아서 작동된다.
초기에는 key 값의 타입을 string 으로 설정했다.
interface Data {
[key: string]: {
default: any;
value: any;
};
}
interface Channel {
[key: string]: VoidFunction[];
}
‘고유한 문자열’ 로 약속한 것이다. 하지만 이런 경우 몇 가지 문제점이 발생했다.
⚠️ 휴먼 에러 발생 가능성
addData 를 호출하여 최초 상태를 등록할 때, 원하는 키값을 넣어서 등록한다.
해당 state 를 사용하려면 key 값으로 ‘data-key’
를 오타없이 전달해 주어야 한다. 이 부분에서 사용자는 이 키값을 완벽하게 기억해야 한다는 번거로움이 있다.
이를 위해서 addData 가 아예 키 값을 반환하고, 다음 사용할 때는 반환된 키 값을 사용하게 했다.
const dataKey = store.addData({
key: 'data-key',
default: ''
})
// 다른 파일
import {dataKey} from '...'
store.getState(dataKey);
⚠️ 중복된 키 값
addData 가 key 를 반환하게 하고 이를 가져다가 쓰는 방법으로 일일이 키값을 써야 하는 문제를 해결하긴 했지만, 아직 문제가 남아있었다.
문자열 타입이기 때문에 중복된 키 값으로 등록을 해주면 분명히 의도치 않은 결과가 발생할 것이다. 즉, 키가 고유하다는 것을 보장할 수 없다.
const dataKey1 = store.addData({
key: 'data-key',
default: ''
})
const dataKey2 = store.addData({
key: 'data-key',
default: ''
})
console.log(dataKey1 === dataKey2) // true;
또한, 반환된 키 값을 import 해서 쓰지 않고 문자열로 직접 입력(store.getData(’data-key’)
) 하면 그만이다 ^_^.. 그렇게 되면 굳이 addData 가 키 값을 반환할 이유가 없다.
이러한 문제들을 해결하기 위해 symbol 이라는 타입을 적용시켰다.
이제 store 의 data 와 channel 은 symbol 타입을 키값으로 갖는다.
interface Data {
[key: symbol]: {
default: any;
value: any;
};
}
interface Channel {
[key: symbol]: VoidFunction[];
}
addData 는 받은 문자열로 Symbol key 를 생성하여 반환한다.
addData<T>({
key,
default: defaultValue,
}: {
key: string;
default: T;
}) {
const uniqueKey = Symbol(key);
this.data[uniqueKey] = {
default: defaultValue,
value: defaultValue,
};
this.channel[uniqueKey] = [];
return uniqueKey;
}
이제 addData 를 호출하여 생성되는 키는 입력한 문자열과 무관하게, 무조건 고유의 값이다.
해당 state 를 사용하려면 반환된 키를 사용해야 함도 분명해졌다.
const dataKey1 = store.addData({
key: 'data-key',
default: ''
})
const dataKey2 = store.addData({
key: 'data-key',
default: ''
})
console.log(dataKey1 === dataKey2) // true;
console.log(typeof dataKey1) // symbol
보통의 컴포넌트들이라면, subscribe 에 렌더링 함수를 전달했을 것이다.
따라서 setData 가 호출될 때 마다 리렌더링이 발생한다.
const setCount = store.setData(countKey);
// 버튼 클릭 시
setCount(prev => prev+1); // notify!
setCount(prev => prev+1); // notify!
setCount(prev => prev+1); // notify!
버튼 클릭 한번에 count++ 가 3번 일어나게 작성했다면, 당연히 3번 리렌더링이 발생한다.
리액트에서는 이러한 경우를 위해, 여러번 업데이트가 일어나도 이를 일괄 처리하는 batching
이 이루어진다. 간단히 말하면, state 변경을 한 곳에 모아놨다가 최종 결과만 한 번 렌더링해준다.
유사하게 동작시키기 위해, 비동기적으로 notify 되도록 했다.
class Store {
data: Data;
channel: Channel;
updateQueue: Set<keyof Data> = new Set();
// ...
async notifyFromUpdateQueue() {
for (const key of this.updateQueue) {
this.#notify(key);
}
this.updateQueue.clear();
}
setData<T = any>(key: keyof Data) {
return (value: T | ((x: T) => void)) => {
// ...
this.updateQueue.add(key);
Promise.resolve().then(() => this.notifyFromUpdateQueue());
};
}
}
export default Store;
updateQueue
notifyFromUpdateQueue
그렇다면 언제 notify 하는 것일까 ?
setData 내부에 이러한 코드가 있다.
Promise.resolve().then(() => this.notifyFromUpdateQueue())
실행 순서는 아래와 같다.
마이크로테스크 큐에 작업이 추가된다.
() ⇒ this.notifyFromUpdateQueue
현재 실행 중인 콜 스택이 비워지면 이벤트 루프는 마이크로태스크 큐의 작업을 콜 스택에 추가한다.
콜 스택에 추가된 업데이트 함수들이 실행된다.
실행될 때 마다 store 내부의 updateQueue 와, 스크립트가 실행되는 마이크로태스크큐는 아래와 같이 변한다.
setCount(prev => prev+1);
// updateQueue : [countKey]
// taskQueue : [notifyFromUpdateQueue()]
setCount(prev => prev+1);
// updateQueue : [countKey]
// taskQueue : [notifyFromUpdateQueue(), notifyFromUpdateQueue()]
setCount(prev => prev+1);
// updateQueue : [countKey]
// taskQueue : [notifyFromUpdateQueue(), notifyFromUpdateQueue(), ...]
모든 setCount 호출이 끝나면, 태스크큐에 쌓아놨던 notifyFromUpdateQueue 를 실행한다.
notifyFromUpdateQueue(); // updateQueue: [countKey]
notifyFromUpdateQueue(); // updateQueue: []
notifyFromUpdateQueue(); // updateQueue: []
setData 가 호출될 때 마다 쌓여서 여러번 실행되긴 하지만, 첫 실행 시 updateQueue 를 비워주기 때문에 이후 실행에서는 아무 일을 하지 않는다.
아래와 같은 코드를 써서 실험해봤다!
setUserInfo((prev) => ({
...prev,
age: prev.age + 1,
}));
setUserInfo((prev) => ({
...prev,
age: prev.age - 1,
}));
setUserInfo((prev) => ({
...prev,
age: prev.age + 1,
}));
// render 함수 내부
const { name, age } = store.getData(userInfo);
console.log('render age :', age);
before
3번의 리렌더링이 그대로 발생한다
(첫번째 로그는 최초렌더링)
after
마지막 결과만 리렌더링된다
이 외에도, 몇 가지 메소드와 예외처리를 더해서 디벨롭 시켰다.
각 데이터의 타입이 현재는 any 로 되어있다. 타입을 좀 더 개선할 수 있는 방법도 생각해봐야 겠다. 아니면 개선 안해도 되는 이유라던지..