깐딴한 상태관리 Store 만들어보기

차차·2024년 1월 31일
3
post-thumbnail

Store 가 왜 필요했을까?

여기서 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 에서 특정 상태값이 업데이트가 되면, 그 상태값을 구독중인 컴포넌트들에게 알려주어야 한다. 알림을 받은 컴포넌트들은 새로운 상태값을 포함하여 다시 렌더링 해야한다.

흠, 뭔가 유튜브 같은데 ?

각각의 상태값은 채널이 되고, 컴포넌트는 채널을 구독해서 알림을 받는 구독자인 것이다. 알림을 받으면 새로운 영상을 보고.. 그건 리렌더링과 같다.

1. Store 의 구조

setData 가 호출된다면 해당 data 를 구독하고 있는 컴포넌트들에게 알려야 한다.
state 들의 값을 보관할 객체와, 컴포넌트들에게 전송해야하는 알림을 보관할 친구가 필요하다.

따라서 아래와 같은 구조를 가진다.

data, channel 은 고유한 state 이름을 키로 가지는 객체이다.

data

  • state 의 값을 보관하는 객체
  • [key]: 상태값

channel

  • state 가 변경될 시, 실행되어야 하는 알림함수들을 보관하는 객체
  • [key] : [알림함수1, 알림함수2, 알림함수3]
  • 여기서 알림함수들은 컴포넌트에서 상태값을 subscribe 하면서 전달해준 함수들이다.
    저 이렇게 알림받을래요! 라고 한 것이다.
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);
    	}
  	}
}

2. Store 의 사용

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 가 바뀔 때 마다 렌더링

디벨롭시키기

1. Store 의 Key

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 이라는 타입을 적용시켰다.

symbol 이란?

  • ES6 에서 추가된 7번째 데이터 타입 (primitive type)
  • 객체의 키값으로 symbol 타입이 허용되며, 타입스크립트의 인덱스 시그니쳐에서도 사용될 수 있다.
  • 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

2. setData 개선하기

보통의 컴포넌트들이라면, 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

  • updateQueue 에 남아있는 상태 키들에 대해, 컴포넌트들에게 notify 해준다.
  • 모든 상태 업데이트를 다 고지한 후에는 큐를 비워준다.

그렇다면 언제 notify 하는 것일까 ?

setData 내부에 이러한 코드가 있다.
Promise.resolve().then(() => this.notifyFromUpdateQueue())

실행 순서는 아래와 같다.

  1. 마이크로테스크 큐에 작업이 추가된다.

    • 여기서는 () ⇒ this.notifyFromUpdateQueue
  2. 현재 실행 중인 콜 스택이 비워지면 이벤트 루프는 마이크로태스크 큐의 작업을 콜 스택에 추가한다.

    • 따라서 여러번의 setData 친구들이 다 끝나면 업데이트 함수가 콜 스택에 추가된다.
  3. 콜 스택에 추가된 업데이트 함수들이 실행된다.

실행될 때 마다 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
마지막 결과만 리렌더링된다

이 외에도, 몇 가지 메소드와 예외처리를 더해서 디벨롭 시켰다.

  • get과 set을 배열로 한번에 반환하는 useData
  • default 값으로 복구시키는 resetData
  • setData, getData 할 때 마다 받은 key 가 올바른 키인지 검사하는 validateKey

각 데이터의 타입이 현재는 any 로 되어있다. 타입을 좀 더 개선할 수 있는 방법도 생각해봐야 겠다. 아니면 개선 안해도 되는 이유라던지..

Store.ts 코드


0개의 댓글