재직중인 회사에서 개발중인 프로젝트는 상태관리 라이브러리로 MobX를 사용하고 있다. 사내의 많은 프로젝트들이 MobX를 사용하고 있어서 사내 개발자들이 해당 라이브러리에 익숙한 점이 MobX 채택의 주된 이유였다.
Redux, MobX를 둘 다 사용해본 입장으로서, MobX는 Redux에 비해 상태변경에 있어 매우 유연한 라이브러리다. 진짜 편하다..
원하는 상태를 observable한 상태로 만들어두고, observer로 만든 리액트 컴포넌트 내에서 observable state를 사용하기만 컴포넌트가 알아서 해당 state의 변화를 관찰하고, 변화를 감지하면 리렌더가 발생한다.
라이브러리는 보통 Too Much Magic과 Too Much Boilerplate 특징 중 하나를 가지고 있다.
Too much magic 특성을 가진 라이브러리는, 개발자가 세세한 부분을 신경쓰지 않아도 라이브러리가 알아서 다 해결해준다. 그런 만큼 개발자는 세세한 부분에 대한 통제권이 약해져서 파인 튜닝에 제약이 생길 수 있다.
Too much boilerplate 특성을 가진 라이브러리는 개발자가 세세한 부분을 컨트롤 할 수 있는 대신, 라이브러리가 해결해주지 못하는 부분들 또한 신경써야 하는 부분들이 생긴다.
개인적으로 MobX의 observable, observer 기능은 Too much magic이라고 생각한다.
observer 컴포넌트 안에서 특정 observable 상태를 관찰한다는 별도의 명시 없이, 존재하는 모든 observable 상태를 자동으로 관찰하기 때문에, 개발자가 원하든 않든 내부의 observable 상태가 하나라도 바뀌면 리렌더가 트리거된다. 이러한 특징 때문에, 렌더링 최적화를 위해서는 컴포넌트를 매우 잘게 쪼개줘야 한다.
예를 들어, 회원가입 Form을 만들 때 각 항목에 대한 출력과 조작을 위해 각 input을 observer로 감싸고, Store에서 필요한 getter/setter를 불러오는 등의 Boilerplate 작업이 필요하다.
(React Hook Form이라는 옵션이 있지만… MobX 선에서 해결하기+controlled value로 조작하려면 이렇게 해야한다…)
const RegisterNameInput = observer(() => {
const {registerStore: {name, setName}} = useStore();
return <input value={name} onChange={setName}/>
})
// 다른 항목도 대충 이런 식으로...
const RegisterForm = observer(() => {
return <>
<RegisterNameInput/>
<RegisterEmailInput/>
<RegisterPasswordInput/>
<RegisterGenderRadioGroup/>
...
</>
})
정리하면, MobX에 존재하는 observer, observable의 Magic으로 인해 Boilerplate가 늘어났고, Magic과 Boilerplate 둘 중 하나만 했으면 좋겠다는 생각이 들었다.
내가 원하는 상태관리 라이브러리의 요구사항은 크게 두 가지였다.
마블의 ‘앤트맨’을 보면서 양자역학을 접한 경험이 있다. 그 중 ‘양자 얽힘’이라는 현상이 인상 깊었는데, 아무리 멀리 떨어져 있더라도 한 양자의 상태가 변하면, 그 양자에 얽혀있는 또다른 양자의 상태 또한 그에 맞춰 변화된다는게 현상의 대략적인 내용이었다.
어느 컴포넌트에서라도 상태를 변경하면, 그 상태와 얽혀있는 컴포넌트는 모두 그 상태의 변화에 영향을 받는다…라는 컨셉을 양자얽힘과 엮으면 재미있을 것 같았다. 따라서 이 라이브러리에서는 핵심 요소를 다음과 같이 명명했다.
(양자 중첩 개념은 어떤 걸로 치환할 수 있을까..?)
어떤 값을 리액트 컴포넌트가 구독 가능한 값으로 만들기 위해서는 다음과 같은 요소가 필요하다.
type Setter<T> = Dispatch<SetStateAction<T>>;
class QuantumClass<T> {
// useState의 두 번째 return값인 setter를 전달해서 구독하는 방식
private _setters: Set<Setter<T>> = new Set();
private _value: T;
private readonly _initialValue: T;
constructor(initialValue: T) {
if (typeof initialValue === 'function') {
throw TypeError('Functions cannot be quantized.')
}
// 기존 값을 변형하기 않기 위해 별도의 복제본을 할당
this._initialValue = JSON.parse(JSON.stringify(initialValue));
this._value = JSON.parse(JSON.stringify(initialValue));
}
setValue<U extends T>(value: ((prevState: T) => U) | U): void {
const oldValue = this._state
if (typeof value === 'function') {
this.setValue((value as (prevState: T extends Array<infer U> ? Array<U> : T) => U)(this.unwrap()));
} else {
this._value = value;
}
// 이전 값과 신규 값이 차이가 없을 경우 notify 하지 않음
if (deepEqual(oldValue, this._value)) return;
// 구독중인 컴포넌트에 신규 value와 함께 notify
this._setters.forEach((v) => v(this._value));
}
// 값 초기화
reset() {
this.setValue(this._initialValue);
}
get value() {
return this._value;
}
// 신규 구독
subscribe(setter: Setter<T>) {
this._setters.add(setter);
}
// 구독 해제
unsubscribe(setter: Setter<T>) {
this._setters.delete(setter);
}
}
가장 기본적인 Quantum이 완성된 모습이다.
여태까지 만든 코드는 Shallow한 양자화만 가능하다. 즉 원시 타입인 number
, string
, boolean
등은 문제가 없지만, object
의 경우 내부의 속성까지는 양자화되지 않는다.
내부의 속성까지도 양자화 되게 생성자를 발전시켜보자.
export interface Quantum<T = any> {
value: T;
setValue<U extends T>(value: U | ((prevState: T) => U)): void;
unwrap(): T extends Array<infer U> ? Array<U> : T;
reset(): void;
}
class QuantumClass<T> implements Quantum<T> {
private _setters: Set<Setter<T>> = newSet();
private _value: T;
private readonly _initialValue: T;
constructor(initialValue: T) {
// 함수는 각종 충돌 이슈로 Quantum의 값으로 들어가지 않는게 좋을 것이라 판단함
if (typeof initialValue === 'function') {
throwTypeError('Functions cannot be quantized.')
}
this._initialValue =JSON.parse(JSON.stringify(initialValue));
this._value =JSON.parse(JSON.stringify(initialValue));
this.setValue = this.setValue.bind(this);
this.unwrap = this.unwrap.bind(this);
this.quantizeProperties();
}
...
private quantizeProperties() {
if (typeof this._value !== 'string') {
for (const key in this._value) {
// type-check을 일단 회피하기 위해 never로 할당
this._value[key] = new QuantumClass(this._value[key]) as never;
}
}
}
이런 방식으로 재귀적인 양자화를 돌리면 내부의 속성, 내부의 속성의 속성 등까지 모두 양자화가 된다.
하지만 현재 value의 속성들에 강제로 Quantum 객체를 넣어놓은 상태이므로, 외부에서 사용하고자 하면 타입 오류가 일어나는 문제가 있다. 더 나아가, 해당 타입 문제가 해결되더라도 nested object의 경우 a.value.b.value.c.value와 같이 코드가 못생겨진다는 문제가 있다.
const test = new QuantumClass({a: 1, b: {c: 1}})
test.value.a.value // Unresolved variable value
test.value.b.value.c.value // 못생겼다
‘c 속성까지 접근하는 방식을 test.a.b.c.value
의 형태로 간소화 하는 방법은 없을까?’ 라는 생각으로 방법을 조사하다가, Proxy를 사용하면 원하는 목적을 달성할 수 있을 것 같았다.
일단 가장 먼저, 타입스크립트를 속일 필요가 있었다. value가 원시 타입일 때는 그대로 가되, object(Array 포함)일 경우에는 value의 속성 타입과 Quantum 타입을 모두 접근 가능하게 만든다.
export type Quantized<T> =
Quantum<T> & (
T extends Function
? never
: T extends object
? { [key in keyof T]: Quantized<T[key]> }
: Quantum<T>
);
그리고 Proxy가 달린 Quantum을 반환하는 함수를 만든다.
export function quantize<T>(value: T): T extends Function ? never : Quantized<T> {
if (typeof value === 'function') {
throw TypeError('Functions cannot be quantized.')
}
const quantumHandler: ProxyHandler<QuantumClass<T>> = {
get(target: QuantumClass<T>, p: string | symbol, receiver: any): any {
if (target.value && typeof target.value === 'object') {
if (p in target.value) {
const val = target.value[p as keyof typeof target.value];
if (typeof val === 'function') {
return (val as Function).bind(target.value)
}
return val;
}
}
return Reflect.get(target, p, receiver);
},
}
const proxy: Quantum<T> = new Proxy(new QuantumClass(value), quantumHandler);
return proxy as T extends Function ? never : Quantized<T>;
}
이렇게 Proxy를 물려주면, target의 value에 접근하고자 하는 속성명이 존재하는 경우, value의 속성으로 우회해서 접근할 수 있게 해준다.
(함수에 대한 바인딩을 해준 이유는 value가 Array같은 object일 때 map, forEach와 같은 메서드가 정상적으로 동작하게 해주기 위함이다.)
이제 quantize 함수를 활용하여 value의 속성들에 재귀적으로 Proxy가 달린 QuantumClass를 할당한다.
class QuantumClass<T> implements Quantum<T>, SubscribableQuantum<T> {
private _setters: Set<Setter<T>> = new Set();
private _value: T;
private readonly _initialValue: T;
constructor(initialValue: T) {
if (typeof initialValue === 'function') {
throw TypeError('Functions cannot be quantized.')
}
// 속성명이 겹치는 경우 콘솔에 경고문 출력
if (initialValue && typeof initialValue === 'object') {
for (const key in initialValue) {
if (key in this) {
console.warn(`property name collision warning: getter/method '${key}' will be masked by the property of provided value.`)
}
}
}
this._initialValue = JSON.parse(JSON.stringify(initialValue));
this._value = JSON.parse(JSON.stringify(initialValue));
this.setValue = this.setValue.bind(this);
this.quantizeProperties();
}
...
private quantizeProperties() {
if (typeof this._value !== 'string') {
for (const key in this._value) {
// new QuantumClass 대신 quantize를 통해 재귀적으로 할당한다.
this._value[key] = quantize(this._value[key]) as never;
}
}
}
추후 순수한 값들을 필요로 할 때, Quantum으로 감싸진 값들을 순수 값으로 변경해주는 과정 또한 필요할 것이다. 이를 위해 각종 경우의 수를 고려하여 재귀적으로 unwrap 해주는 메서드를 만든다.
unwrap(): T extends Array<infer U> ? Array<U> : T {
if (Array.isArray(this._value)) {
return this._value.map(v => v.unwrap()) as T extends Array<infer U> ? Array<U> : never;
}
if (this._value && typeof this._value === 'object') {
const unwrapped = {};
for (const key in this._value) {
const val = this._value[key];
if (val instanceof QuantumClass) {
Object.assign(unwrapped, {[key]: val.unwrap()})
} else {
Object.assign(unwrapped, {[key]: val})
}
}
return unwrapped as T extends Array<infer U> ? never : T;
}
return this._value as T extends Array<infer U> ? never : T;
}
원시 타입일 경우 값을 그대로, object일 경우 재귀적으로 unwrap을 해주고, Array일 경우 map 함수로 unwrap된 요소들을 가진 배열을 반환한다.
getter에 변화가 있었으니 이에 맞춰 setter(setValue) 또한 변경되어야 한다.
setValue<U extends T>(value: ((prevState: T) => U) | U): void {
const oldValue = this.unwrap();
if (typeof value === 'function') {
this.setValue((value as (prevState: T extends Array<infer U> ? Array<U> : T) => U)(this.unwrap()));
} else {
const json = JSON.parse(JSON.stringify(value));
if (this._value && typeof this._value === 'object') {
const value = this._value
for (const key in json) {
if (key in this._value) {
(this._value[key as keyof typeof value] as Quantized<unknown>).setValue(json[key])
} else {
this._value[key as keyof typeof value] = quantize(json[key]) as never;
}
}
for (const key in this._value) {
if (!(key in json)) {
delete this._value[key as keyof typeof value];
}
}
this._value = (Array.isArray(this._value) ? this._value.filter(v => !!v) : {...this._value}) as T;
} else {
this._value = json;
}
}
const newValue = this.unwrap();
// structural equality check
if (deepEqual(oldValue, newValue)) return;
this._setters.forEach((v) => v(this._value));
}
아예 새로운 Quantum 객체로 덮어씌우지 않고 기존에 있던 Quantum에 재귀적으로 setValue
를 호출하는 이유는, 기존 Quantum에 구독중이던 컴포넌트가 Dangling 상태인 Quantum을 바라보고 있는 경우를 최소화하기 위함이다.
Quantum 객체는 얼추 완료되었다. 다음은 이 Quantum을 구독하는 hooks를 만든다.
가장 먼저, Quantum에 구독하는 hooks인 useEntangler
이다. (양자얽힘의 그 얽힘 맞다)
export const useEntangler = <T>(quantum: Quantum<T>): Quantized<T> => {
const [, setState] = useState<T>(() => quantum.value);
useEffect(() => {
quantum.subscribe(setState);
return () => {
quantum.unsubscribe(setState);
};
}, [quantum]);
return quantum as Quantized<T>;
};
앞서 만들었던 Quantum의 subscribe
, unsubscribe
메서드를 통해, 값이 변할 경우 useState
hooks의 setState
함수를 신규 값과 함께 호출하도록 하는 hooks이다.
다시 보니, subscribe
메서드와 unsubscribe
메서드는 useEntangler
hooks 외에는 굳이 사용할 필요가 없어 보인다. 타입스크립트를 활용하여 해당 메서드는 일반적인 사용때 노출되지 않도록 한다.
export interface Quantum<T = any> {
value: T;
setValue<U extends T>(value: U | ((prevState: T) => U)): void;
unwrap(): T extends Array<infer U> ? Array<U> : T;
reset(): void;
}
export interface SubscribableQuantum<T = any> extends Quantum<T> {
subscribe(setter: Setter<T>): void;
unsubscribe(setter: Setter<T>): void;
}
class QuantumClass<T> implements Quantum<T>, SubscribableQuantum<T> {
...
}
--------------------------------------------------------------
import {Quantized, Quantum, SubscribableQuantum} from './Quantum';
export const useEntangler = <T>(quantum: Quantum<T>): Quantized<T> => {
const [, setState] = useState<T>(() => quantum.value);
useEffect(() => {
const q = quantum as SubscribableQuantum<T>
q.subscribe(setState);
return () => {
q.unsubscribe(setState);
};
}, [quantum]);
return quantum as Quantized<T>;
};
이런 식으로 타입을 설정해주면 개발자가 타입캐스팅을 하지 않는 이상 subscribe
메서드를 접근할 일이 없게 된다.
이 useEntangler
hooks를 활용해서, 컴포넌트 내에서 Quantum state를 만들고 싶은 경우에 사용하는 useQuantum
hooks를 만든다.
export const useQuantum = <T>(value: T) => {
const [quantum] = useState(quantize<T>(value));
return useEntangler(quantum);
}
다음은 Quantum 값을 기반으로 메모이제이션 된 값을 반환하는 useCollider
이다. (입자 가속 추
import {Quantum, SubscribableQuantum} from './Quantum';
export const useCollider = <T>(collider: () => T, deps: Quantum[]) => {
const [state, setState] = useState<T>(collider());
useEffect(() => {
const setter = () => setState(collider());
deps.forEach((v) => (v as SubscribableQuantum<T>).subscribe(setter));
return () => {
deps.forEach((v) => (v as SubscribableQuantum<T>).unsubscribe(setter));
};
}, []);
return state;
};
첫 번째 인자에는 원하는 값을 반환하는 콜백 함수가 들어가고, 두 번째 값으로는 Quantum 배열이 들어가는데, 배열에 들어있는 Quantum의 값이 변경될 경우 콜백 함수가 호출되어 새로운 값을 state에 저장하는 방식이다.
추가적으로, useEntangler
를 사용하지 않고 바로 컴포넌트 props로 quantum을 넘겨서 구독하는 Entangler
컴포넌트를 만들었다.
interface Props<T> {
watch: Quantum<T> | Quantum<T>[];
children: () => JSX.Element;
}
const entanglerComponent = <T,>({ watch, children }: Props<T>) => {
const [, setRenderFlag] = useState({});
useEffect(() => {
const watchlist = Array.isArray(watch) ? watch : [watch];
const rerender = () => {
setRenderFlag({});
}
watchlist.forEach((q) => {
(q as SubscribableQuantum<T>).subscribe(rerender);
});
return () => {
watchlist.forEach((q) => {
(q as SubscribableQuantum<T>).unsubscribe(rerender);
});
};
}, [watch]);
return <>{children()}</>;
};
export const Entangler = memo(entanglerComponent, (before, after) => {
const beforeWatchlist = (Array.isArray(before.watch) ? before.watch : [before.watch]);
const afterWatchlist = (Array.isArray(after.watch) ? after.watch : [after.watch]);
return arraysEqual(beforeWatchlist, afterWatchlist);
});
watch
에 구독을 원하는 Quantum(단일 혹은 Quantum의 배열)을 지정하고, children
에 렌더링할 요소를 반환하는 콜백을 넣으면, 구독한 Quantum 값의 변화가 있을 때마다 렌더링이 일어나게 된다.
원하는 요소들을 모두 만들었으니 테스트해볼 일만 남았다.
원하던 라이브러리의 요구사항을 다시 나열해보고 실제로 원하는 대로 동작하는지 확인해본다.
const test = quantize({a: 1, b: 2, c: 3})
const AWatcher = () => {
// test.value.a가 아닌 test.a로 바로 접근 가능
const a = useEntangler(test.a);
return <div>{a.value}</div>
}
const BWatcher = () => {
const b = useEntangler(test.b);
return <div>{b.value}</div>
}
const ColliderTest = () => {
const {a, b, c} = test;
// a, b, c의 합이 10이 넘을 경우 false -> true로 변경됨 - 단 한 번 리렌더 일어남
const sumGreaterThan10 = useCollider(() => a.value + b.value + c.value > 10, [a, b, c])
return <div>{sumGreaterThan10 ? '10보다 큼' : '10보다 작음'}</div>
}
function App() {
// test 자체에 구독
useEntangler(test);
const {a, b, c} = test;
return (
<div style={{margin: 100, width: 100}}>
<div>
{/* 각 quantum의 값을 변경하는 버튼 */}
<button onClick={() => a.setValue(v => v + 1)}>increment A</button>
<button onClick={() => b.setValue(v => v + 1)}>increment B</button>
<button onClick={() => c.setValue(v => v + 1)}>increment C</button>
</div>
<div>
{/* 별도로 리렌더링 되는지 확인*/}
<AWatcher/>
<BWatcher/>
{/* watch에 전달한 test.c만 바뀌었을 때 리렌더링 되는지 확인 */}
<Entangler watch={c}>{() => <div>{c.value}</div>}</Entangler>
<ColliderTest/>
</div>
</div>
);
}
A의 값을 증가시켰을 때, useEntangler를 통해 test.a
를 구독한 AWatcher
만 리렌더가 일어난다
B의 값을 증가시켰을 때, useEntangler를 통해 test.b
를 구독한 BWatcher
만 리렌더가 일어난다.
C의 값을 증가시키면, test.c
를 구독중인 Entangler
컴포넌트로 감싼 요소가 리렌더된다.
마지막으로, a, b, c의 값이 10을 초과했을 때만 맨 밑의 ColliderTest
안에 있는 sumGreaterThan10
가 true
로 변하면서 단 한 번 리렌더가 된다.
이로써 구독한 Quantum에만 반응하고, Quantum이 object일 경우 내부 값에도 구독이 가능한 점을 확인하였다.
이왕 만든거 한번 배포까지 해서 경험해보는게 좋지 않을까 싶어 찾아봤는데, 그닥 어렵지 않아서 서너번 시행착오를 통해 배포까지 무사히 마쳤다.
다만 배포버전을 만들 때 CommonJS, ESModule과 관련된 이슈가 있었는데 이 부분은 좀 더 공부해봐야겠다.
개인 프로젝트에서 실사용 해보면서 허점이 제법 발견되고 있어서, 개선을 계속 해줘야 할 것 같다. 기술 도입이 비교적 자유로운 사내 전용 백오피스에서 실험삼아 사용해보는 것도 나쁘지 않을 것 같다.