Caro-kann middleware를 통해 고민해보는 프록시와 데코레이터
이 포스트는 어떤 개념을 설명하거나 결론을 내리지 않는다. 그저 내가 만든 전역 상태 관리 라이브러리 Caro-Kann의 middleware에 프록시 패턴과 데코레이터 패턴을 적용해보다가 떠오른 생각과 고민을 가능한 두서 있게 남길 뿐이다.
아래는 패턴을 적용하기 전의 persist 미들웨어이다. persist 미들웨어는 클라이언트가 변경한 상태를 브라우저의 로컬 스토리지 등에 지속적으로 백업해야 하기에, Store 객체의 setStore 메서드의 기능을 오버라이드 해야한다. 하지만 상태를 백업하는 행위가 리턴 값에 영향을 미치는 것은 아니기 때문에 setStore 메서드는 타입적으로 이전과 이후가 동일하다.
export const persist: Middleware["persist"] = <T,>(initState: T | MiddlewareStore<T>, options: PersistConfig<T>) => {
// 1. Store 객체를 구하고
const Store = isMiddlewareStore(initState) ? initState.store : createStore(initState);
// 2. persist option을 파싱하고
const optionObj = parseOptions(options);
// 3. 브라우저의 스토리지에서 저장된 값을 가져와
const initialState = getStorage({ ...optionObj, initState: persistProxy.getInitState() }).state;
// 4. 저장된 값으로 Store 객체의 값을 초기화 한다
Store.setStore(initialState);
// 5. 그리고 setStore가 스토리지에 값을 저장하도록 한다.
const setStore = (nextState: SetStateAction<T>) => {
Store.setStore(nextState);
if (optionObj.storageType) setStorage({ ...optionObj, value: Store.getStore() });
};
return {
store: {...Store, setStore},
[storeTypeTag]: "persist"
}
};
자바스크립트가 제공하는 Proxy 클래스를 사용해 setStore 메서드 오버라이드를 별도의 핸들러로 분리시켰다. persistProxy는 Store와 타입적으로 ─ 혹은 덕타이핑 적으로 ─ 동일하다. 덕분에 persist 미들웨어의 타입 선언을 변경할 필요 없이 프록시 객체가 전체 코드에 잘 녹아들었다.
export const persist: Middleware["persist"] = <T,>(initState: T | MiddlewareStore<T>, options: PersistConfig<T>) => {
const Store = isMiddlewareStore(initState) ? initState.store : createStore(initState);
const optionObj = parseOptions(options);
const persistProxy = new Proxy(Store, persistProxyHandler(optionObj))
const initialState = getStorage({ ...optionObj, initState: persistProxy.getInitState() }).state;
Reflect.apply(persistProxy.setStore, persistProxy, [initialState]);
return {
store: persistProxy,
[storeTypeTag]: "persist"
}
};
const persistProxyHandler = <T>(optionObj: ReturnType<typeof parseOptions>): ProxyHandler<Store<T>> => ({
get: (target, prop) => {
if (prop === "setStore") {
return function(nextState: SetStateAction<T>)) {
target.setStore(nextState);
if (optionObj.storageType) setStorage({ ...optionObj, value: target.getStore() });
};
}
return Reflect.get(target, prop);
},
})
그런데 reducer 미들웨어에 프록시 패턴을 적용하던 도중 문제가 발생했다. 우선 기존의 reducer 미들웨어는 아래와 같이 생겼다. 잘 보면 persist와 달리 setStore의 파라미터가 달라져서 오버라이드 이전과 이후의 타입이 불일치한다.
export const reducer: Middleware["reducer"] = <T, A extends object>(reducer: (state: T, action: A) => T, initState: T | MiddlewareStore<T>) => {
const Store = isMiddlewareStore(initState) ? initState.store : createStore(initState);
const setStore = (action: A) => {
// @ts-ignore
Store.setStore(prev => reducer(prev, action), action.type);
};
return {
store: { ...Store, setStore },
[storeTypeTag]: "reducer"
}
}
그런 이유로 reducer 미들웨어에 대해 프록시 패턴을 적용하고 나면 "'ProxyHandler<Store<T, A>>' 형식의 인수는 'ProxyHandler<Store<T, SetStateAction<T>>>' 형식의 매개 변수에 할당될 수 없습니다."라는 타입 에러가 발생한다. 이는 프록시 패턴을 적용함에 있어 실제 객체와 프록시 객체 간의 호환성을 보장해야 하기 때문이다.
export const reducer: Middleware["reducer"] = <T, A extends object>(reducer: (state: T, action: A) => T, initState: T | MiddlewareStore<T>) => {
const Store = isMiddlewareStore(initState) ? initState.store : createStore(initState);
const reducerProxy = new Proxy(Store, reducerProxyHandler(reducer));
return {
store: reducerProxy,
[storeTypeTag]: "reducer"
}
}
const reducerProxyHandler = <T, A extends object>(reducer: (state: T, action: A) => T): ProxyHandler<Store<T, A>> => ({
get: (target, prop) => {
if (prop === "setStore") {
const setStore = (action: A) => {
Store.setStore(prev => reducer(prev, action));
};
return setStore;
}
return Reflect.get(target, prop);
},
})
그렇다면 프록시 패턴 대신 데코레이터 패턴을 적용했어야 옳은 걸까? 고민해봤으나 아직까지는 마땅한 대답을 구하지 못했다. 프록시 패턴과 마찬가지로 데코레이터 패턴 역시 리스코프 치환 원칙을 만족해야 하니까. 생각해보면 어떤 미들웨어는 리스코프 치환 원칙을 만족하고, 어떤 미들웨어는 그걸 만족하지 않는데 프록시와 데코레이터 패턴을 적용하려 했던 게 잘못이었을지 모르겠다. 아니면 그 이전에 서로 다른 방식으로 Store 객체를 튜닝하는데 이것들을 미들웨어라는 이름으로 뭉뚱그려놓았던 게 잘못일 수도 있고. 어쩌면 아직 다루지 않은 패턴이 이 문제를 해결해줄 지도 모르겠다.
첫 줄에 밝힌 것과 같이 이 포스트에는 어떠한 결론도 없다. 생각과 고민을 가능한 두서 있게 남기겠다 했지만, 그렇게 두서가 있는 것 같지 않다. 나중에 좀 더 공부하고 좀 더 고민한 뒤에 명쾌한 결론이 나온다면 이 포스트를 수정하겠다는 것만 밝히고 이만 줄여야겠다.
블로그의 정보
Ayden's journal
Beard Weard Ayden