이전 포스트에 이어서, 다양한 상태 관리 라이브러리들에 유연한 구조를 갖게 코드를 작성하는 방법을 고민해봤다.
준비중인 개인 프로젝트는 여러 컴포넌트에서 사용되는 상태들 중 일부를 멀리 떨어진 다른 컴포넌트에서도 사용해야 하기에,
또한 유연성을 증대시키기 위해 Atomic방식의 상태관리 라이브러리를 사용하는 쪽으로 마음이 굳어졌다.
그럼에도, 새로운 개념의 클라이언트 상태 관리 라이브러리가 2~3년마다 나오는 것 같았기에 쉽게 이들을 교체할 수 있는 구조를 고심하고 제작하고자 한다.
따라서, 이번 실습의 목표는 다음과 같다.
직접 다양한 상태관리 라이브러리의 변경에 유연한 커스텀 훅과 컴포넌트를 만들어보고, 테스트 코드까지 작성해보자!
목표에 집중하기 위해 최대한 간단하게 기본 환경을 구성했다.
main.tsx, App.tsx, Comp1.tsx, Comp2.tsx, Comp3.tsx가 존재한다.
function App() {
return (
<>
<Comp1 />
<Comp2 />
<Comp3 />
</>
);
}Comp1, Comp2, Comp3에서 쓰이는 authorization과 actHistories 는 공유 상태로 설정했다.
또한, Comp2와 Comp3이 멀리 떨어진 컴포넌트라 가정하고, 한쪽에서 쓰기, 다른 쪽에서 읽기를 수행하도록 했다.
사용되는 상태관리 라이브러리는 다음과 같다.
후술 하겠지만, Mobx의 특성으로 인해, Comp1,2,3을 다음과 같이 분리했다.
export const CoreComp1 = () => {
const { authorization, toggleAuthorization } = useAuthorization();
return <>
...
</>;
}
const Comp1 = () => {
return (
<>
<CoreComp1 />
</>
);
};
위 코드에서, CoreComp1은 상태를 사용해 UI를 반환하는 핵심 컴포넌트가 되고
Comp1은 사용되는 상태관리 라이브러리에 따라 CoreComp1을 사용하는 중간 컴포넌트가 들어올 수 있도록 했다.
각 라이브러리에서 공용으로 사용하도록 Vanilla로 제작해두었다.
// src/sharingStates.ts
export interface IActHistory {
id: number;
name: string;
}
export interface ISharingStates {
authorization: boolean;
actHistories: IActHistory[];
}
const sharingStates: ISharingStates = {
authorization: false,
actHistories: [],
};
export default sharingStates;
각 상태관리 라이브러리 별 커스텀 훅을 제작하고,
이를 상태에 따른 공통 커스텀 훅에서 사용되도록 했다.
// src/commonCustomHooks/useActHistories.ts
const useActHistories = () => {
return // 여기에 상태 관리 라이브러리에 맞춘 커스텀 훅이 실행됨.
};
export default useActHistories;
// src/commonCustomHooks/useAuthorization.ts
const useAuthorization = () => {
return // 여기에 상태 관리 라이브러리에 맞춘 커스텀 훅이 실행됨.
};
export default useAuthorization;
공통 커스텀 훅 내부에서는 상태 관리 라이브러리 관련 코드를 고려하지 않고 교체할 수 있도록, 내부 로직을 없앴다.
사실, 로직이 존재하는 각 라이브러리 별 커스텀 훅 내부에서 공유 상태를 불러오지 않고, 인자로 받아오는 방법을 사용한다면
관심사 분리도 되고 테스트코드 작성도 편리했을 것이다.
그러나, 각 라이브러리별 상태 관리 방법이 다르기에 커스텀 훅 내부 코드는 달라 재사용이 어려우므로교체 용이성에 초점을 두어 위와 같은 구조를 사용했다.
redux는 store에 reducer를 등록하고, 보통 App을 감싸는 Provider를 통해 이를 내려보내준다.
useSelector와 useDispatch를 통해 상태를 사용하고 action을 dispatch한다.
reducer는 slice를 통해 손쉽게 정의 가능하고, 이 때 initialState를 정의한다.
redux toolkit을 사용하면 mutable하게 상태를 처리할 수 있다.
따라서 sharingStates.ts에서 가져와 상태를 정의했다.
// src/reduxThings/reducers/ActHistoriesReducer.ts
const initialState = sharingStates.actHistories;
const ActHistorySlice = createSlice({
name: 'ActHistory',
initialState,
reducers: {
add: (state, action: PayloadAction<{ historyName: string }>) => {
state.push({ id: state.length, name: action.payload.historyName });
},
mutate: (state, action: PayloadAction<{ id: number; newName: string }>) => {
const targetHistory = state.find(
(history) => history.id === action.payload.id
);
if (targetHistory) targetHistory.name = action.payload.newName;
},
},
});
export const { add, mutate } = ActHistorySlice.actions;
export default ActHistorySlice.reducer;
// src/reduxThings/reducers/AuthorizationReducer.ts
const AuthorizatoinSlice = createSlice({
name: 'Authorization',
initialState: sharingStates.authorization,
reducers: {
toggle: (state) => {
return !state;
},
},
});
export const { toggle } = AuthorizatoinSlice.actions;
export default AuthorizatoinSlice.reducer;
테스트 코드에서 재사용하기 위해 Provider를 따로 제작해주었고,
useSelector에서 상태 타입을 부여하기 위해 다음과 같이 store를 정의했다.
// src/reduxThings/ReduxProvider.tsx
const ReduxProvider = ({ children }: { children: ReactNode }) => {
return <Provider store={store}>{children}</Provider>;
};
export default ReduxProvider;
// src/reduxThings/reduxStore.ts
const store = configureStore({
reducer: {
ActHistory: ActHistoryReducer,
Authorization: AuthorizationReducer,
},
});
export default store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch: UseDispatch<AppDispatch> = useDispatch;
export const useAppSelector: UseSelector<RootState> = useSelector;
실제 로직은 전부 reducer내부에 존재하기 때문에,
각 커스텀 훅 내부 코드는 간단했다.
// src/reduxThings/useReduxActHistories.ts
import { add, mutate } from './reducers/ActHistoriesReducer';
const useReduxActHistories = () => {
const actHistories = useSelector((state: RootState) => state.ActHistory);
const mydispatch = useAppDispatch();
const addActHistories = (historyName: string = '기본') =>
mydispatch(add({ historyName }));
const mutateActHistoryName = (id: number, newName: string) => {
mydispatch(mutate({ id, newName }));
};
return { actHistories, addActHistories, mutateActHistoryName };
};
export default useReduxActHistories;
// src/reduxThings/useReduxAuthorization.ts
import { toggle } from './reducers/AuthorizationReducer';
const useReduxAuthorization = () => {
const authorization = useAppSelector((state) => state.Authorization);
const dispatch = useAppDispatch();
const toggleAuthorization = () => {
dispatch(toggle());
};
return { authorization, toggleAuthorization };
};
export default useReduxAuthorization;
testing-library/react와 testing-library/jest-dom, jest를 사용했다.
renderHook을 사용해 리덕스 베이스 훅의 기능만 테스트했고, ReduxProvider를 wrapper로 사용했다.
// src/reduxThings/useReduxActHistories.test.ts
describe('useReduxActHistories 테스트', () => {
beforeEach(() => {
sharingStates.actHistories = [];
});
test('addActHistories로 actHistories는 1개가 되어야 함 ', () => {
const { result } = renderHook(() => {
return useReduxActHistories();
},{ wrapper: ReduxProvider }
);
act(() => {
result.current.addActHistories('하나');
});
expect(result.current.actHistories.length).toBe(1);
expect(
result.current.actHistories[result.current.actHistories.length - 1]
).toEqual(expect.objectContaining({ id: 0, name: '하나' }));
});
});
useReduxAuthorization에 대한 테스트도 유사하니, 생략하겠다.
커스텀 훅이나 테스트 코드 작성이 간편해서 좋았으나,
보일러 플레이트가 좀 많았다.

파일 개수 만이 아니라, 각 파일 내 코드도 꽤 많았다.
또한, 최상단에 Provider를 놓아야 하는 점이 불편했다.
// src main.tsx
...
<ReduxProvider>
<App />
</ReduxProvider>
...
물론, zustand를 사용하면 Provider를 안써도 되긴 하나, 태생적인 한계가 있었다.
Top-Down방식이기에, 여러 reducer에 사용되는 states들을 조합하기 어려웠다.
mobx는 observable을 observer나 useObserver로 감싼 컴포넌트 내부에서 사용하는 방식이다.
따라서, 커스텀 훅이 쓰이는 컴포넌트를 wrapping하는 ObservingComp를 제작해야했다.
또한, 다른 상태관리 라이브러리들과 일관된 사용성을 만들기 위해
컴포넌트 인자로 observable을 전달받는 방식이 아닌,
커스텀 훅 내부에서 observable을 사용하는 방식을 선택했다.
이를 위해선, React의 Context를 사용해야 했으므로, Provider와 함께 다음과 같이 제작했다.
// src/mobxThings/ObservingContextProvider.tsx
import { useObserver } from 'mobx-react-lite';
const ObservableSharingStatesContext = createContext(observableSharingStates);
const ObservableActHistoriesContext = createContext(observableActHistories);
export { ObservableSharingStatesContext, ObservableActHistoriesContext };
function ObservingContextProvider<T>({context, value, children}: {
context: Context<T>;
value: T;
children: () => ReactNode;
}) {
return (
<context.Provider value={value}>{useObserver(children)}</context.Provider>
);
}
export default ObservingContextProvider;
// src/mobxThings/ObservingComp1.tsx
const ObservingComp1 = () => {
// context 사용을 최대한 쪼갰음. 불필요한 렌더링을 최대한 줄이기 위해서.
return (
<ObservingContextProvider
context={ObservableSharingStatesContext}
value={observableSharingStates}
>
{CoreComp1}
</ObservingContextProvider>
);
};
export default ObservingComp1;
// src/mobxThings/ObservingComp2.tsx
const ObservingComp2 = () => {
return (
<ObservingContextProvider
context={ObservableActHistoriesContext}
value={observableActHistories}
>
{CoreComp2}
</ObservingContextProvider>
);
};
export default ObservingComp2;
// ObservingComp3은 ObservingComp2와 같으므로, 생략한다.
지금보니, 그냥 Observable이름에 따라 Provider Wrapper 이름으로 지을걸 그랬다.
이들을 각 Comp에 적용하면 다음과 같은 코드가 된다.
const Comp1 = () => {
return (
<>
<ObservingComp1 />
</>
);
};
구독 가능한 상태에 대해서는 변화에 감지당하는 기준으로 쪼개 observable로 정의했다.
export const observableSharingStates = observable(sharingStates);
observableSharingStates.actHistories = observable(
observableSharingStates.actHistories
);
export const observableActHistories = observableSharingStates.actHistories;
Redux와는 다르게, 커스텀 훅 내부에 로직이 정의되므로,
커스텀 훅이 반환하는 메서드를 커스텀 훅 함수 외부에 따로 정의했다.
// src/mobxThings/useMobxActHistory.tsx
const _addActHistories = (actHistories: IActHistory[]) => {
return function (newName: string = '기본') {
const newHistory: IActHistory = {
id: actHistories.length,
name: newName,
};
actHistories.push(newHistory);
};
};
const _mutateActHistoryName = (actHistories: IActHistory[]) =>
function (targetId: number, newName: string) {
const targetHistory = actHistories.find(
(history: IActHistory) => history.id === targetId
);
if (targetHistory) targetHistory.name = newName;
};
const useMobxActHistory = () => {
const actHistories = useContext(ObservableActHistoriesContext);
const addActHistories = _addActHistories(actHistories);
const mutateActHistoryName = _mutateActHistoryName(actHistories);
return { actHistories, addActHistories, mutateActHistoryName };
};
export default useMobxActHistory;
// src/mobxThings/useMobxAuthorization.tsx
const _toggleAuthorization = (observableSharingStates: ISharingStates) => {
observableSharingStates.authorization =
!observableSharingStates.authorization;
};
const useMobxAuthorization = () => {
const observableSharingStates: ISharingStates = useContext(
ObservableSharingStatesContext
);
const toggleAuthorization = _toggleAuthorization(observableSharingStates);
return {
authorization: observableSharingStates.authorization,
toggleAuthorization,
};
};
export default useMobxAuthorization;
사용한 라이브러리는 이전과 동일하다.
기존에 존재하는 Context를 사용하지만, 최대한 테스트 코드는 독립적으로 작동시키고 싶어
다음과 같이 mocking용 observable을 제작했다.
이상했던 점은, act내부에서 훅을 사용했는데
rerender 함수를 실행해야 result가 변경되었다는 점이다.
// src/mobxThings/useMobxAuthorization.test.tsx
const initValue = false;
const initStates = observable({ actHistories: [], authorization: initValue });
const MockProvider = ({ children }: { children: ReactNode }) => (
<ObservableSharingStatesContext.Provider value={initStates}>
{children}
</ObservableSharingStatesContext.Provider>
);
describe('useAuthorization 테스트', () => {
test('toggleAuthorization이 되나?', () => {
const { result, rerender } = renderHook(() => useMobxAuthorization(), {
wrapper: MockProvider,
});
act(() => {
result.current.toggleAuthorization();
});
rerender();
expect(result.current.authorization).toBe(!initValue);
});
});
useMobxActHistory의 테스트 코드도 위와 비슷하니 생략한다.
기본적으로 mutable하게 사용하는 점이 괜찮았으나, observable을 사용하기 위해 useObserver를 붙여야 하는 점은 불편했다.
이에 의해 깔끔한 코드를 만들기 위해 ObservingComp들을 제작해야 했다.
또한, Context를 통해 observable을 전달한다는 점도 불편했다.
observable이 변하면 Context하위의 컴포넌트들도 전부 리렌더링 될 것이다.
Context의 단점이 명확한 상황에서 이에 의존하는 방식이 좋지 않았다.
이에 각 파일 내부 내용이 많지는 않았지만, 여러 파일을 만들어야 했다.

코드 상으로는 보일러 플레이트가 제일 작다.
적절한 atom만 생성하면 되기 때문이다.
여러 atom을 합쳐 derived atom을 제작하면, atom의 변화가 derived atom에 반영되는 것도 좋았다.
이들을 다음과 같이 정의했따.
// src/jotaiThings/atoms.ts
const authorizationAtom = atom(false);
const actHistoriesAtom = atom<IActHistory[]>([]);
const sharingStatesAtom = atom<ISharingStates>((get) => {
const authorization = get(authorizationAtom);
const actHistories = get(actHistoriesAtom);
return { authorization, actHistories };
});
export { authorizationAtom, actHistoriesAtom, sharingStatesAtom };
그러나, 후술하겠지만, 렌더링 최적화를 위해 write-only atom을 제작하면서 살짝 복잡해진다.
코드 상 derived atom인 shringStatesAtom은 read-only atom이다.
Atomic방식은 useState를 사용하는 것과 매우 유사했기 때문에, 기본 사용법은 러닝커브가 거의 없었다.
또한 Jotai는 RCC에서는 Provider가 필요 없기에 코드가 깔끔했다.
// src/jotaiThings/useJotaiAuthorization.ts
const useJotaiAuthorization = () => {
const [authorization, setAuthorization] = useAtom(authorizationAtom);
const toggleAuthorization = () => {
setAuthorization(!authorization);
};
return { authorization, toggleAuthorization };
};
export default useJotaiAuthorization;
역시 커스텀 훅 내부에 로직이 존재하기에, 커스텀 훅 외부로 분리하는게 맞겠지만,
코드가 간단하여 이대로 두었다.
useSetAtom을 사용하면, 굳이 write-only atom을 제작하지 않고도 불필요한 리렌더링을 방지할 수 있다고 한다..!
사용방법도 생각보다 간단하니, 밑의 방법보다 useSetAtom을 애용하자!
const efficientSetFunc = useSetAtom(someAtom);
만약 write-only atom을 사용해야겠다면 아래와 같이 처리할수도 있을 것이다..
튜토리얼에서, write-only atom을 사용하면 필요한 곳에서만 렌더링을 유발할 수 있다하여 시도해봤다.
커스텀 훅 내부에 임시로 최적화를 위한 atom들을 다음과 같이 정의했다.
// src/jotaiThings/useAuthorization.ts
const historyValueAtom = atom((get) => get(actHistoriesAtom));
type updateArg = IActHistory[] | ((past: IActHistory[]) => IActHistory[]);
const setAtom = atom(null, (get, set, update: updateArg) => {
const realUpdate =
typeof update === 'function' ? update(get(actHistoriesAtom)) : update;
set(actHistoriesAtom, realUpdate);
});
벌써부터 코드가 좀 복잡한데,
historyBalueAtom은 read-only atom이고, setAtom은 write-only atom이다.
atom을 정의할 때, read와 write함수를 넎어 정의할 수 있는데, 첫 인자는 read함수, 두 번째 인자는 write함수이다.
get을 통해 atom의 value를 가져오고,
set으로 atom의 value를 set하며,
update를 통해 useAtom으로부터의 setState함수에 들어오는 인자에 접근할 수 있다.
기본 atom 사용으로 얻는 setState처럼, 값과 함수를 모두 받을 수 있도록 정의하기 위해 위와 같이 write함수를 정의했다.
이를 적용한 커스텀 훅 관련 코드는 다음과 같다.
// src/jotaiThings/useJotaiActHistories.ts
// * 타입스크립트를 사용했기에, 가시성 위해 정의 순서를 아래와 같이 할 수 있었음.
const useJotaiActHistories = () => {
const getActHistories = useActHistoriesValue;
const addActHistories = useAddActHistory();
const mutateActHistoryName = useMutateActHistoryName();
return {
getActHistories,
addActHistories,
mutateActHistoryName,
};
};
export default useJotaiActHistories;
const useActHistoriesValue = () => {
const [actHistories] = useAtom(historyValueAtom);
return actHistories;
};
const useAddActHistory = () => {
const [, setatom] = useAtom(setAtom);
return (newName: string = '기본') => {
setatom((past) => [...past, { id: past.length, name: newName }]);
};
};
const useMutateActHistoryName = () => {
const [, setatom] = useAtom(setAtom);
return (id: number, newName: string) => {
setatom((prev) => {
const newHistories = [...prev];
const target = newHistories.find((history) => history.id === id);
if (target) target.name = newName;
return newHistories;
});
};
};
커스텀 훅에서 state와 이에 대한 기능 함수들을 같이 제공하려 했기에 복잡해졌다.
write-only atom을 제외한 다른 atom들은 useAtom에 사용하면, 변화가 있을 때 마다 리렌더링을 유발한다.
따라서 커스텀 훅 내부에서
const [actHistories] = useAtom(historyValueAtom); 을 정의하고,
값인 actHistories를 반환할 수 없었다.
따라서 렌더링 최적화를 위해서는 커스텀 훅에서 state자체를 반환하는게 아니라,
get함수의 형태로 반환해야 했다.
const getActHistories = useActHistoriesValue;
또한, 이렇게 get함수의 내용을 정의한 형태를 지키기 위해
다른 기능 함수들의 정의가 고차함수 형태를 띄게 되었다.
이렇게 write-only atom을 사용한다면, get함수를 사용하지 않는 곳에서는 리렌더링이 일어나지 않게 된다.
독립적인 테스트를 위해 Provider와 Store를 정의해 wrapper로 사용할 수 있었다.
코드는 다음과 같다.
// src/jotaiThings/useJotaiAuthorization.test.ts
describe('useJotaiAuthorization 테스트: ', () => {
const initValue = false;
beforeEach(() => {
sharingStates.authorization = initValue;
});
const mockStore = createStore();
mockStore.sub(authorizationAtom, () => {});
const MockJotaiProvider = ({ children }: { children: ReactNode }) => (
<Provider store={mockStore}>{children}</Provider>
);
test('토글로 true되어야 함.', () => {
const { result } = renderHook(() => useJotaiAuthorization(), {
wrapper: MockJotaiProvider,
});
act(() => {
result.current.toggleAuthorization();
});
expect(result.current.authorization).toBe(!initValue);
});
});
useJotaiActHistories테스트에 대해서도 위와 비슷하게 작성했다.
(리렌더링 여부를 체크할 순 있었지만 생략했다.)
다른 상태관리 라이브러리들보다 간단하게 사용 가능했고,
useState의 사용감을 그대로 느낄 수 있었으며,
bottom-up방식으로 derived Atom을 만들수도 있어서 매우 좋았다.
read-only, write-only방식으로 derived Atom을 만들어 atom의 변화에 반응할수도,
util에서 selectAtom을 사용해 atom의 일부 필드의 변화에만 반응시킬 수도 있어 유연함에서 강점을 보였다.
다만, 렌더링 최적화를 위해 기존의 atom과 이를 기반으로하는 write-only atom을 만들어야 한다는 점에서 관리의 어려움을 증대시켰고,
위 내용을 수정하자면, useSetAtom과 useAtomValue를 통해 간단하게 분리해서 처리할 수 있다.
커스텀 훅에서 readable atom을 같이 전달 하기 위해서는 get함수로 반환해야 한다는 점이 불편한 사용감을 전달했다.
개인적으로는, atom의 합성이 강력하여 상태관리 라이브러리를 담는 커스텀 훅의 기능을 넘볼 수 있어 책임 분리에 어려움도 있었다.
튜토리얼에서는 createAtoms 패턴을 소개하는데, 팩토리 패턴의 변형 느낌이다.
const createAtoms = (initValue)={
const baseAtom = atom(initValue);
const valueAtom = atom((get)=>get(baseAtom));
const setAtom = atom(null, (get, set, update)=>{set(update)})
return [valueAtom, setAtom];
};
이를 변형하여, read-only atom없이 최적화를 위한 write-only atom만 필요하다면 다음과 같이 제작하는 좋아 보인다.
const createEfficientAtoms=<T>(initValue)=>{
const baseAtom = atom<T>(initValue);
type updateArg = T | ((past: T) => T);
const setAtom = atom(null,
(get, set, update: updateArg)=> {
const realUpdate = typeof update === 'function' ?
update(get(actHistoriesAtom)) : update;
set(actHistoriesAtom, realUpdate);
}
);
return [baseAtom, setAtom];
}
baseAtom을 반환하는 이유는, atom합성에 유연하게 사용할 수 있기 위함이다.
세가지 상태관리 라이브러리들을 커스텀 훅 안에 적용할 수 있었다.
다만, 각 상태관리 라이브러리마다 사용방법이 천차만별이라
모든 상황을 대비하려면 최대한 컴포넌트를 분리하는 것이 중요해보였다.
최적화처럼 특정 상태관리 라이브러리의 활용을 극대화 하기 위해서는
get함수를 사용해야 하는 등 불편한 DX를 유발하기도 했다.
그래도 일반적으로는 커스텀 훅과 적절한 추상화를 통해
유연하게 이들 간 교체 가능함을 확인했고,
OOP를 프론트엔드에 적용하는 경험을 얻었다.