개발 철학과 팀 컨벤션 사이의 저울질
옛날 Global State Managment 를 다루는 글을 썼을때, 나는 Redux보다 Recoil을 선호한다고 했었다.
내가 Redux보다 Recoil을 선호하는 이유
지금 회사에서는 Jotai를 쓰고있다.
Recoil과 같은 Atom 기반 라이브러리라서 가볍게 생각했는데, 내 생각과는 많이 다르게 사용하고 있어서 약간 당황했던 기억이 있다.
그리고 얼마 전 합류한 막내가 Jotai 팀 컨벤션을 따르지 않아서 코드리뷰를 해줬었다.

근데 사실 Jotai의 철학상 읽기는 useAtomValue, 쓰기는 useSetAtom 이게 자연스러운 흐름이긴 하다.
그래서 입사 초기의 그 찝찝함을 제대로 해소해보고 싶은 생각이 들어 내 나름대로의 생각을 정리해보려 한다.
닥터팔레트 웹 서비스에서 Jotai를 쓸 떄 지켜야하는 컨벤션은 이렇게 생겼다.
store/{name}-store/
├── config/
│ ├── store.ts ← 하나의 거대한 atom
│ ├── actions.ts ← write-only atom
│ └── selector.ts ← 순수 함수
└── use-{name}-store.ts ← 단일 진입점 훅Store, Actions, Selector 는 config 디렉토리 안에 분리해서 선언, 단일 진입점 훅에서 이걸 말아서 사용한다.
Jotai의 원래 철학인
Primitive and flexible state management for React
Primitive(원시적), Bottom-Up… Jotai를 다른 라이브러리와 비교할 때 가장 많이 나오는 말이다.
비슷한 모델인 역사의 뒤안길로 사라진 Recoil 보다 더 간단하다. 왜냐면 atom에 key 를 넣을 필요마저 없으니까. 단지 JavaScript 변수 참조가 아이디인 셈이다.
// Jotai의 "정석적" 사용법
const countAtom = atom(0);
const nameAtom = atom('');
const isLoadingAtom = atom(false);
// 파생 atom
const greetingAtom = atom((get) => `Hello, ${get(nameAtom)}!`);하지만 현재 닥터팔레트 프로젝트는 이렇게 사용하지 않는다.
config/store.ts
// store.ts
interface MediTalkStoreModel {
encounterId: string;
chatRoomId: string;
patient: PatientFragment | undefined;
unreadChatRoomCount: string;
openedChatRoomCount: string;
messageList: MediTalkStoreModelProps[];
imageList: imageProps[];
chatting: ServiceSupportEnum;
// ...
}
const mediTalkVariables = atom<MediTalkStoreModel>(initialValue);사용하는 여러개의 필드를 독립적인 별도의 atom 으로 두지 않고, 하나의 거대한 atom에 때려 넣는다.
Zustand나 Redux 에 좀 더 가까운 느낌이다.
config/actions.ts
// actions.ts
const onChangeChatRoomId = atom(null, (_, set, chatRoomId: string) => {
set(MediTalkStoreInfo.store, (prev) => ({
...prev,
chatRoomId,
endCursor: '', // chatRoomId가 바뀌면 endCursor도 초기화
messageList: [], // messageList도 초기화
imageList: [], // imageList도 초기화
}));
});
// ...
export const mediTalkStoreActions = {
onChangeChatRoomId,
// ...
}Write-only Atom (첫번째 인자가 null인 atom) 으로 액션을 만든다.
config/selector.ts
// selector.ts
export const mediTalkStoreSelector = {
patient: (store: MediTalkStoreModel) => store.patient,
chatRoomId: (store: MediTalkStoreModel) => store.chatRoomId,
messageList: (store: MediTalkStoreModel) => store.messageList,
// ...
};Jotai의 파생 아톰 derived atom: atom((get) ⇒ …) 형식이 아닌 순수 함수로 정의한다.
use-{name}-store.ts
// 단일 진입점인 use-medi-talk-store.ts
export const useMediTalkStore = () => {
return {
store,
actions: jotaiCreateActions<typeof mediTalkStoreActions>(mediTalkStoreActions),
selector: mediTalkStoreSelector,
initialValue,
};
};실제 사용하는 컴포넌트에서는 이 훅만 import 해야한다.
다른 전역 상태 관리 툴을 찾아보고, 다른 곳에서는 어떻게 쓰는지 조사하고 물어보면서 한가지 알게 된 점은, 결국 Jotai의 프리미티브가 자유롭기 때문에 이런 구조화가 가능했다는 거다.
Redux처럼 쓰려고 Jotai를 선택한 게 아니라, Jotai의 자유도로 팀에 맞는 구조를 직접 설계한 것이다.
그렇다면 왜 자유로운 Bottom-up 대신, 이런 구조화된 Top-down을 선택했을까?
몇 달간 이 컨벤션으로 개발을 해오다보니, 이 패턴에 대한 장점은 이미 내가 몸소 느끼고 있었다.
Jotai의 ‘아무 데나 atom 선언’ 철학은 자유롭지만, 사람이 많거나, 프로젝트가 오래되면 그 자유가 혼란이 될 수 밖에 없다.
// 개발자 A: packages/p_planner/src/atoms/calendar.ts
export const selectedDateAtom = atom(LocalDate.now());
// 개발자 B: packages/drpalette/src/modules/feature/planner/atoms.ts
export const currentDateAtom = atom(LocalDate.now());// 이 도메인의 상태가 궁금하면?
// → stores/{도메인}/config/store.ts를 보면 된다.이건 Redux가 탄생한 배경과 정확히 같은 문제다. (자세한 내용은 개요에 있는 이전글 링크를 참고!)
Jotai 기본 패턴에서는 어디서든 useSetAtom으로 상태를 직접 바꿀 수 있다.
// 컴포넌트 A에서
setStore(prev=> ({...prev,chatRoomId:'123' }));
// 컴포넌트 B에서
setStore(prev=> ({...prev,chatRoomId:'456',messageList: [] }));chatRoomId를 바꿀 때 messageList를 초기화해야 하는 건 B만 알고 있다. 변경 로직이 컴포넌트에 분산되어 있다.
write-only atom으로 Action을 만들면 이 로직이 한 곳에 모인다.
const onChangeChatRoomId = atom(null, (_, set, chatRoomId: string) => {
set(store, (prev) => ({
...prev,
chatRoomId,
endCursor: '',
messageList: [],
imageList: [],
}));
});컴포넌트는 actions.onChangeChatRoomId('123')만 호출하면 된다. 부수효과를 신경 쓸 필요가 없다.
액션 사용할때마다 항상 useSetAtom 으로 감싸야 하는 게 귀찮다. (팀원 코드리뷰에서도 내가 언급했던 내용이다.)
어차피 훅을 통해서만 사용하는 구조라면 1, 2번의 장점을 유지하면서 보일러플레이트도 줄일 수 있는데, useSetAtom을 자체적으로 말아버리면 좋지 않을까.
// jotai-create-actions.ts
import { ExtractAtomArgs, WritableAtom, useSetAtom } from 'jotai';
type WithInitialValue<Value> = {
init: Value;
};
type actionsType<T> = {
[k in keyof T]: WritableAtom<null, ExtractAtomArgs<T>, void> & WithInitialValue<null>;
};
type WrappedActions<T> = {
[K in keyof T]: ReturnType<typeof useSetAtom<null, ExtractAtomArgs<T[K]>, void>>;
};
export const jotaiCreateActions = <T>(actions: actionsType<T>) => {
return {
...(Object.fromEntries(
Object.entries(actions).map(([key, action]) => {
return [
key,
useSetAtom(
action as WritableAtom<null, ExtractAtomArgs<T>, void> & WithInitialValue<null>,
),
];
}),
) as WrappedActions<T>),
};
};이렇게 해두면 사용할때 useSetAtom을 일일이 감쌀 필요 없이, 바로바로 메서드 호출처럼 사용할 수 있다.
이건 코드리뷰에 제안했던 대로 고치면 훨씬 보기가 쉬워져서 해당 도메인의 코드를 가져와봤다.
// before
const addSingleImage = useSetAtom(addSingleImageAtom);
const addImage = useSetAtom(addImageAtom);
const removeImage = useSetAtom(removeImageAtom);
const addAllImagesByEncounterId = useSetAtom(addAllImagesByEncounterIdAtom);
const removeAllImagesByEncounterId = useSetAtom(removeAllImagesByEncounterIdAtom);
// after — 훅에서 이미 바인딩 되어 나온다
const { actions } = useImageStore();
actions.addSingleImage(data);
actions.removeImage(id);이 컨벤션으로 개발하던 어느 날, 단일 진입점 훅에서 사용하는 jotaiCreateActions.ts 를 보다가 이상한 점을 발견했다.
// jotai-create-actions.ts (기존 코드)
export const jotaiCreateActions = <T>(actions: actionsType<T>) => {
return Object.fromEntries(
Object.entries(actions).map(([key, action]) => {
return [
key,
useSetAtom(action), // ← ???
];
}),
) as WrappedActions<T>;
};React의 훅은 조건문이나 반복문 같은 제어문에 있으면 안된다.
React는 훅 호출 순서(call index)로 상태를 추적한다.
반복문 안에서 훅을 호출하면, 반복 횟수가 렌더마다 달라질 경우 호출 순서가 어긋나서 예측 불가능한 동작이 발생할 수 있다.
이 코드가 지금까지 문제없이 동작했던 이유는 actions 객체의 키 개수가 런타임에 변하지 않기 때문이다.
.map()이 항상 같은 횟수만큼 실행되니, 훅 호출 순서가 우연히 유지되는 것이다.
그리고 이것보다 더 실질적인 문제가 있었다. 메모이제이션이 되지 않는다는 점.
이런 상황을 가정해보자.
// SomeComponent.tsx
const { actions } = useMediTalkStore();
useEffect(() => {
actions.initStore();
}, [actions]);매 렌더마다 Object.fromEntries + map 이 돌아간다.
→ 매 렌더마다 새로운 actions 객체가 생성될 것이다.
→ useEffect 의존성 배열에 넣으면 객체 참조가 바뀌어 무한 루프가 돈다.
실제로 이렇게 사용하는 대부분 useEffect 의존성 배열에 actions를 넣지 않고 ESLint 경고를 무시하거나, eslint-disable 주석을 다는 식으로 우회하고 있었다.
그래서 jotaiCreateActions 를 대체할 useJotaiCreateActions 을 만들었다.
export const useJotaiCreateActions = <T extends Record<string, WritableAtom<unknown, any[], any>>>(
actions: T,
): WrappedActions<T> => {
const store = useStore();
return useMemo(() => {
return Object.fromEntries(
Object.entries(actions).map(([key, action]) => {
return [key, (...args: ExtractAtomArgs<typeof action>) => store.set(action, ...args)];
}),
) as WrappedActions<T>;
}, [store, actions]);
};useStore() 로 store 인스턴스를 훅의 최상위에서 한 번만 가져오고, 이후에는 store.set()이라는 일반 메서드를 호출한다.
반복문 안에서 호출해도 Rules of Hooks과 무관하다.
그리고 Object.fromEntries 로 반환된 값을 useMemo로 감싼다.
store와 actions 는 생명주기 동안 바뀌지 않으므로, 마운트시 한번만 실행되는 셈이다.
이렇게 해두면 위와 같은 상황에서도 안전하다.
export const useMediTalkStore = () => {
const { store, initialValue } = MediTalkStoreInfo;
return {
store,
- actions: jotaiCreateActions<typeof MediTalkStoreActions>(MediTalkStoreActions),
+ actions: useJotaiCreateActions<typeof MediTalkStoreActions>(MediTalkStoreActions),
selector: MediTalkStoreSelector,
initialValue,
};
};이렇게만 바꾸면 이제 actions 는 stable한 객체가 되므로, useEffect 등의 의존성 배열에 넣어도 문제 없다.
const { actions } = useMediTalkStore();
useEffect(() => {
actions.initStore();
}, [actions]); // ← actions가 stable이므로 마운트 시 한 번만 실행이 글을 쓰기 위해 자료를 찾아볼 때 즈음, 이 컨벤션을 처음 설계한 시니어 리더에게 물어봤다.
"정답은 없다. 이 컨벤션은 Jotai를 처음 도입할때 협업에 도움이 되길 바라는 초기 컨벤션이었으며, 여기서 util을 더 추가하고, 불편한 부분은 수정하면서 각자의 프로젝트에 맞게 변형해서 더욱 효율적이고 편하게 활용해보면 된다."
라는 답변을 들었다.
useSelectAtom 은 Jotai 공식문서에도 나와 있는 유틸이다.
여기서 멈추지 않고, 리더는 Jotai 도입 당시 jotaiCreateActions 라는 액션용 래핑 유틸을 만들고, 그것을 좀 더 진화시켜오면서 팀 컨벤션을 지켜내고 있었다. 물론 버그가 있었지만 내가 잘 고쳤으니 뭐…
컨벤션은 만드는 것보다 유지보수하는 것이 더 어렵다. 그리고 유지보수를 잘 하려면, 왜 이렇게 만들었는지를 이해하는 능력이 필요하다.
입사 초기에 ‘Jotai를 왜 이렇게 쓰는걸까?’ 라고 느꼈던 찝찝함은, 결국 Jotai의 철학을 한번 더 공부해보고, 팀이 왜 이 패턴을 선택했는지를 이해해보면서 해소되었다. 그리고 그 이해를 바탕으로, 잠재적 버그를 고칠 수 있었다.
도구의 철학을 모르면 의도적 일탈과 무지에 의한 오용을 구분할 수 없다.
그 구분이 되어야, 비로소 컨벤션을 개선할 자격이 생긴다고 생각한다.