fe.resolver.ts
fe.resolver.ts

Powered by Notion & Next.js

Navigate

  • 개인정보처리방침

Connect

  • GitHub

© 2026 Hanul Lee. All rights reserved.

Powered by Notion & Next.js

목록으로
Development2025년 7월 26일

Jotai를 Jotai처럼 쓰지 않는 팀 컨벤션에 대한 고찰

개발 철학과 팀 컨벤션 사이의 저울질

#React

개요

옛날 Global State Managment 를 다루는 글을 썼을때, 나는 Redux보다 Recoil을 선호한다고 했었다.

📝

"전역 상태 관리 뭐 쓰세요?"에 떳떳해지기 위해

내가 Redux보다 Recoil을 선호하는 이유

지금 회사에서는 Jotai를 쓰고있다.

Recoil과 같은 Atom 기반 라이브러리라서 가볍게 생각했는데, 내 생각과는 많이 다르게 사용하고 있어서 약간 당황했던 기억이 있다.

그리고 얼마 전 합류한 막내가 Jotai 팀 컨벤션을 따르지 않아서 코드리뷰를 해줬었다.

Loading image...
Notion Image

근데 사실 Jotai의 철학상 읽기는 useAtomValue, 쓰기는 useSetAtom 이게 자연스러운 흐름이긴 하다.

그래서 입사 초기의 그 찝찝함을 제대로 해소해보고 싶은 생각이 들어 내 나름대로의 생각을 정리해보려 한다.

닥터팔레트 전역 상태 컨벤션

닥터팔레트 웹 서비스에서 Jotai를 쓸 떄 지켜야하는 컨벤션은 이렇게 생겼다.

markdown
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 변수 참조가 아이디인 셈이다.

typescript
// Jotai의 "정석적" 사용법
const countAtom = atom(0);
const nameAtom = atom('');
const isLoadingAtom = atom(false);

// 파생 atom
const greetingAtom = atom((get) => `Hello, ${get(nameAtom)}!`);

하지만 현재 닥터팔레트 프로젝트는 이렇게 사용하지 않는다.

config/store.ts

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 단일 진입점인 use-medi-talk-store.ts
export const useMediTalkStore = () => {
  return {
    store,
    actions: jotaiCreateActions<typeof mediTalkStoreActions>(mediTalkStoreActions),
    selector: mediTalkStoreSelector,
    initialValue,
  };
};

실제 사용하는 컴포넌트에서는 이 훅만 import 해야한다.

Store, Actions, Selector의 3요소 분리 / 단일 진입점

다른 전역 상태 관리 툴을 찾아보고, 다른 곳에서는 어떻게 쓰는지 조사하고 물어보면서 한가지 알게 된 점은, 결국 Jotai의 프리미티브가 자유롭기 때문에 이런 구조화가 가능했다는 거다.

Redux처럼 쓰려고 Jotai를 선택한 게 아니라, Jotai의 자유도로 팀에 맞는 구조를 직접 설계한 것이다.

그렇다면 왜 자유로운 Bottom-up 대신, 이런 구조화된 Top-down을 선택했을까?

왜 이 패턴인가 — 내 나름의 답

몇 달간 이 컨벤션으로 개발을 해오다보니, 이 패턴에 대한 장점은 이미 내가 몸소 느끼고 있었다.

1. "이 상태 어디있어?" 에 대한 답

Jotai의 ‘아무 데나 atom 선언’ 철학은 자유롭지만, 사람이 많거나, 프로젝트가 오래되면 그 자유가 혼란이 될 수 밖에 없다.

typescript
// 개발자 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());

plain text
// 이 도메인의 상태가 궁금하면?
// → stores/{도메인}/config/store.ts를 보면 된다.

2. "이 상태 누가 바꿨어?" 에 대한 답

이건 Redux가 탄생한 배경과 정확히 같은 문제다. (자세한 내용은 개요에 있는 이전글 링크를 참고!)

Jotai 기본 패턴에서는 어디서든 useSetAtom으로 상태를 직접 바꿀 수 있다.

typescript
// 컴포넌트 A에서
setStore(prev=> ({...prev,chatRoomId:'123' }));

// 컴포넌트 B에서
setStore(prev=> ({...prev,chatRoomId:'456',messageList: [] }));

chatRoomId를 바꿀 때 messageList를 초기화해야 하는 건 B만 알고 있다. 변경 로직이 컴포넌트에 분산되어 있다.

write-only atom으로 Action을 만들면 이 로직이 한 곳에 모인다.

typescript
const onChangeChatRoomId = atom(null, (_, set, chatRoomId: string) => {
  set(store, (prev) => ({
    ...prev,
    chatRoomId,
    endCursor: '',
    messageList: [],
    imageList: [],
  }));
});

컴포넌트는 actions.onChangeChatRoomId('123')만 호출하면 된다. 부수효과를 신경 쓸 필요가 없다.

3. action 사용시 보일러플레이트

액션 사용할때마다 항상 useSetAtom 으로 감싸야 하는 게 귀찮다. (팀원 코드리뷰에서도 내가 언급했던 내용이다.)

어차피 훅을 통해서만 사용하는 구조라면 1, 2번의 장점을 유지하면서 보일러플레이트도 줄일 수 있는데, useSetAtom을 자체적으로 말아버리면 좋지 않을까.

typescript
// 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을 일일이 감쌀 필요 없이, 바로바로 메서드 호출처럼 사용할 수 있다.

이건 코드리뷰에 제안했던 대로 고치면 훨씬 보기가 쉬워져서 해당 도메인의 코드를 가져와봤다.

typescript
  // 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 를 보다가 이상한 점을 발견했다.

typescript
// 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()이 항상 같은 횟수만큼 실행되니, 훅 호출 순서가 우연히 유지되는 것이다.

그리고 이것보다 더 실질적인 문제가 있었다. 메모이제이션이 되지 않는다는 점.

이런 상황을 가정해보자.

typescript
// SomeComponent.tsx

const { actions } = useMediTalkStore();

useEffect(() => {
  actions.initStore();
}, [actions]);

매 렌더마다 Object.fromEntries + map 이 돌아간다.

→ 매 렌더마다 새로운 actions 객체가 생성될 것이다.

→ useEffect 의존성 배열에 넣으면 객체 참조가 바뀌어 무한 루프가 돈다.

실제로 이렇게 사용하는 대부분 useEffect 의존성 배열에 actions를 넣지 않고 ESLint 경고를 무시하거나, eslint-disable 주석을 다는 식으로 우회하고 있었다.

그래서 jotaiCreateActions 를 대체할 useJotaiCreateActions 을 만들었다.

typescript
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 는 생명주기 동안 바뀌지 않으므로, 마운트시 한번만 실행되는 셈이다.

이렇게 해두면 위와 같은 상황에서도 안전하다.

diff
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 등의 의존성 배열에 넣어도 문제 없다.

typescript
const { actions } = useMediTalkStore();

useEffect(() => {
  actions.initStore();
}, [actions]);  // ← actions가 stable이므로 마운트 시 한 번만 실행

끝맺음

이 글을 쓰기 위해 자료를 찾아볼 때 즈음, 이 컨벤션을 처음 설계한 시니어 리더에게 물어봤다.

"정답은 없다. 이 컨벤션은 Jotai를 처음 도입할때 협업에 도움이 되길 바라는 초기 컨벤션이었으며, 여기서 util을 더 추가하고, 불편한 부분은 수정하면서 각자의 프로젝트에 맞게 변형해서 더욱 효율적이고 편하게 활용해보면 된다."

라는 답변을 들었다.

useSelectAtom 은 Jotai 공식문서에도 나와 있는 유틸이다.

여기서 멈추지 않고, 리더는 Jotai 도입 당시 jotaiCreateActions 라는 액션용 래핑 유틸을 만들고, 그것을 좀 더 진화시켜오면서 팀 컨벤션을 지켜내고 있었다. 물론 버그가 있었지만 내가 잘 고쳤으니 뭐…

컨벤션은 만드는 것보다 유지보수하는 것이 더 어렵다. 그리고 유지보수를 잘 하려면, 왜 이렇게 만들었는지를 이해하는 능력이 필요하다.

입사 초기에 ‘Jotai를 왜 이렇게 쓰는걸까?’ 라고 느꼈던 찝찝함은, 결국 Jotai의 철학을 한번 더 공부해보고, 팀이 왜 이 패턴을 선택했는지를 이해해보면서 해소되었다. 그리고 그 이해를 바탕으로, 잠재적 버그를 고칠 수 있었다.

도구의 철학을 모르면 의도적 일탈과 무지에 의한 오용을 구분할 수 없다.

그 구분이 되어야, 비로소 컨벤션을 개선할 자격이 생긴다고 생각한다.

  • 개요
  • 닥터팔레트 전역 상태 컨벤션
  • Store, Actions, Selector의 3요소 분리 / 단일 진입점
  • 왜 이 패턴인가 — 내 나름의 답
  • 1. "이 상태 어디있어?" 에 대한 답
  • 2. "이 상태 누가 바꿨어?" 에 대한 답
  • 3. action 사용시 보일러플레이트
  • 🐛 컨벤션의 버그를 발견..
  • 끝맺음