Ayden's journal

Caro-Kann - 전역 상태 관리 도구

이 문서는 caro-kann@3.0.3을 기반으로 작성되었습니다.

 

caro-kann은 내부적으로 useSyncExternalStore 훅을 사용해 작성된 전역 상태 관리 도구입니다. 타입스크립트를 지원하며 Next.js와 React.js에서 사용할 수 있습니다. useState와 유사한 문법을 채택하고 있기에 React.js에 익숙한 개발자라면 누구나 쉽게 사용할 수 있습니다.

 

what's new in caro-kann@3.0.0

  • 새로운 버전의 caro-kann은 내부 코드 구조를 개선하고, 트리 셰이킹 등의 방법을 적극적으로 도입하였습니다. 그 결과 2.2.0 버전과 비교하여 번들 크기가 최대 6배 가까이 줄어들었습니다. 이 과정에서 StoreContext가 제거되었으며 useDerivedStore는 useStore의 derived 메서드로 통합되었습니다.
  • Semantics are important. 각 기능과 API는 명확한 목적과 의도를 가지고 설계되었으며, 이를 통해 개발자는 상태 관리와 관련된 코드를 더 직관적이고 의도적으로 작성할 수 있습니다. API 이름과 동작은 가능한 한 직관적으로 변경되었으며, 사용자가 실수로 인해 발생할 수 있는 모호성을 최소화하는 데 중점을 두었습니다. 이러한 의미론적 개선은 단순히 코드의 작동 방식뿐만 아니라, 코드가 읽히고 이해되는 방식을 개선함으로써 장기적인 유지보수성과 협업 가능성을 높이는 데 기여하기를 바라고 있습니다.
  • 이전 버전에서는 create에 포함되어있던 persist 기능이 middleware로 분리되었습니다. middleware로 분리된 persist는 전역 상태와 독립적으로 동작하도록 설계되어, 기존 상태 관리 로직에 영향을 주지 않고도 쉽게 통합할 수 있습니다. 이로써 애플리케이션의 성능과 유지보수성은 물론, 확장성까지도 크게 향상되었습니다.
  • middleware에는 persist와 함께 reducer, zustand, devtools 기능이 포함되어 있어, 상태 관리와 관련된 다양한 기능을 유연하게 적용할 수 있습니다. 각 기능은 애플리케이션의 요구 사항에 맞게 독립적으로 사용하거나 조합하여 활용할 수 있습니다.

 

install and import

npm i caro-kann@latest
import { create } from "caro-kann";
import { persist, zustand, reducer, devtools } from "caro-kann/middleware"

 

create a store

caro-kann에서 store는 전역 상태가 저장되어있는 외부 공간으로 정의합니다. 이러한 store를 만들기 위해서 caro-kann은 create 함수를 사용합니다. 이 함수는 초기값을 받아 이를 내부 store에 저장하고 useStore를 리턴합니다. 꼭 기억해두어야 할 사실은, create 함수의 평가는 반드시 컴포넌트 외부에서 이루어져야 한다는 점입니다. 그렇지 않으면 컴포넌트의 생애주기에 따라 store가 소실될 수 있습니다.

const useStore = create({
  name: "Ayden Blair",
  age: 30,
  isMarried: false,
});

useStore 훅은 리액트의 useState 훅과 동일하게 [ value, setValue ] 로 이루어진 튜플을 리턴합니다. 사용법 역시 useState와 동일합니다.

function Comp() {
  const [value, setValue] = useStore();
  
  return (
    <button onClick={() => setValue(prev => ({...prev, age: prev.age + 1}))}>
      Now age is { value.age }. Next, age will be { value.age + 1 } 
    </button>
  )
}

 

nested objects

아래와 같은 중첩된 객체 상태가 있을 때, Caro-Kann은 여러 방식으로 이를 업데이트 할 수 있습니다. 첫 번째 방법은 스프레드 연산자를 사용해 객체의 각 수준을 복사하는 것입니다. 이를 통해 기존의 값에 새로운 상태 값을 수동으로 병합하게 됩니다.

const useStore = create({
  deep: {
    nested: {
      obj: { count: 0 }
    }
  }
})
 
const [value, setValue] = useStore()

setValue(store => ({
  deep: {
    ...state.deep,
    nested: {
      ...state.deep.nested,
      obj: {
        ...state.deep.nested.obj,
        count: state.deep.nested.obj.count + 1
      }
    }
  }
})

불변성 상태 업데이트를 돕는 Immer 라이브러리를 사용하면 훨씬 더 간단하게 중첩된 객체 상태를 업데이트할 수 있습니다. Caro-Kann의 훌륭한 타입 설계 덕분에 별다른 타입 어노테이션 없이도 produce 함수가 store 객체의 타입을 자동으로 추론합니다.

// With Immer
import { produce } from 'immer';

const [value, setValue] = useStore()

setValue(produce(store => { ++store.deep.nested.obj.count }))

지금까지 알아본 방식은 모두 기존 객체 상태를 통해 새로운 객체 상태를 리턴하고 있습니다. 아래의 '선택자 함수' 항목에서 더 자세히 살펴보겠지만, useStore에 선택자 함수를 사용하면 setValue가 중첩된 프로퍼티를 인식하며, 아주 간단하게 중첩된 객체 상태를 업데이트할 수 있습니다.

const [count, setCount] = useStore(store => store.deep.nested.obj.count)

setCount(prev => prev + 1)

 

selector function

만약 컴포넌트가 객체 형태의 전역 상태를 참조하고 있다면, 컴포넌트에서 사용하지 않는 프로퍼티가 변경되어도 컴포넌트가 리랜더링 됩니다. 이를 방지하기 위해 useStore는 선택자 함수를 통해 객체 형태의 전역 상태로부터 일부 프로퍼티 값만 가져올 수 있습니다. 아래의 예시 코드에서 컴포넌트는 전역 상태의 a 프로퍼티 값이 변경되어도 리랜더링 되지 않습니다. 또한, 선택자 함수를 사용하면 setter 역시 전체 프로퍼티를 대상으로 하지 않고, 선택자 함수로 선택된 일부 프로퍼티 값만을 변경할 수 있습니다.

function Comp() {
  const [age, setAge] = useStore(store => store.age);
  
  return (
    <button onClick={() => setAge(prev => prev + 1)}>
      Now age is { age }. Next, age will be { age + 1 } 
    </button>
  )
}

그런데 컴포넌트에서 age 값만을 사용한다고 해도, 경우에 따라서는 isMarried 값을 변경해야 할 수도 있습니다. 이를 위해 선택자 함수가 제공되었을 때 useStore는 튜플의 세 번째 요소로 setValue를 리턴합니다.

function Comp() {
  const [age, setAge, setValue] = useStore(store => store.age);
  
  return (
    <>
      <button onClick={() => setAge(prev => prev + 1)}>
        Now age is { age }. Next, age will be { age + 1 } 
      </button>
      <button onClick={() => setValue(prev => ({ ...prev, isMarried: true })}>
        Get Married
      </button>
    </>
  )
}

위에서 잠깐 이야기했던 바와 같이 선택자 함수를 사용하면 중첩된 객체 상태 변경도 간단히 처리할 수 있습니다. 이를 위해 선택자 함수를 작성할 때는 몇 가지 규칙을 따라야 합니다. 우선 모든 선택자 함수는 화살표 함수로 작성되어야 합니다. 또, store로부터 중첩된 객체 상태의 값을 선택하는 데 변수를 사용할 수 없습니다. 마지막으로 선택자 함수에는 { ? : & } 다섯 특수 문자를 사용할 수 없습니다. 선택자 함수를 작성하며 이러한 규칙을 따르지 않는다면 런타임 에러를 맞닥트리게 될 것입니다 :)

const useStore = create({
  ["a-to-z"]: 0,
  b: {
    c: 0,
    d: {
      e: 0,
      f: 0
    }
  }
})

// 선택자 함수는 화살표 함수만을 사용해야 합니다
const getAtoZ = (store) => store["a-to-z"]
const [atoZ, setAtoZ] = useStore(getAtoZ) // ok

// 점 표기법과 대괄호 표기법을 섞어도 괜찮습니다
const [e, setE] = useStore(store => store["b"].d.e) // ok

// 대괄호 표기법 내에 변수를 사용할 수 없습니다
const c = "c"
const [c, setC] = useStore(store => store.b[c]) // Error

// { ? : & } 특수문자를 사용할 수 없습니다
const [b, setB] = useStore(store => typeof store.b !== object ? true : false) // Error
const [f, setF] = useStore({ b: { d: { f }}} => f) // Error

 

derived state

자바스크립트의 함수는 1급 객체로써 프로퍼티와 메서드를 가질 수 있습니다. useStore은 튜플을 리턴하는 함수인 동시에 derived라는 메서드를 가진 객체이기도 합니다. 위에서 살펴본 선택자 함수와 유사하게 derived 메서드는 파생 함수를 인자로 받습니다. 이 메서드를 통해 기존의 상태를 기반으로 파생 상태(derived state)를 만들어낼 수 있습니다. 이는 상태의 재사용성과 조합성을 높이는 데 유용하며, 복잡한 상태 관리 로직을 단순화하는 데 도움을 줍니다. 파생 상태는 참조하는 상태가 변경될 때마다 새로 계산됩니다.

function Comp() {
  const [age, setAge] = useStore(store => store.age)

  return (
    <button onClick={() => setAge(prev => prev + 1)}>
      Now age is { age }. Next, age will be { age + 1 } 
    </button>
  )
}

function VotingRightsIndicator() {
  const hasVotingRights = useStore.derived(
    store => store.age >= 18
      ? "You have voting rights."
      : "You do not have voting rights.";
  );

  return <div>{hasVotingRights}</div>;
};

 

Middleware

현재 Caro-Kann은 persist, zustand, reducer, devtools의 네 가지 미들웨어를 지원합니다. 이를 통해 create 함수는 전역 상태 관리, 상태 저장, 상태 변경 로직, 디버깅 기능을 효율적으로 처리할 수 있으며, 애플리케이션의 구조와 요구 사항에 맞춰 유연하게 적용할 수 있습니다.

 

persist

persist 미들웨어를 사용하면 caro-kann이 관리하는 전역 상태를 로컬 스토리지, 세션 스토리지, 쿠키 등에 저장할 수 있게 됩니다. 이러한 기능은 웹 페이지의 테마 설정과 같이 사용자가 페이지를 새로 고침하거나 세션이 종료된 후에도 상태를 유지해야 하는 값들에 적합하며, 특히 사용자 경험을 개선하는 데 중요합니다.

const useStore = create(
  persist(initialState, persistOptions)
)

전역 상태를 저장소에 보관할 때, Caro-Kann은 state와 함께 version을 명시합니다. 이를 사용하면 애플리케이션의 상태 구조가 변경되었을 때도 이전 버전의 데이터를 손쉽게 변환하거나 무시할 수 있습니다. 가령 theme에 배경색 말고도 글꼴 크기에 대한 요구가 추가되어야 한다고 생각해봅시다. Caro-Kann은 이를 처리하기 위해 migrate 객체를 사용합니다.

type Theme = "light" | "dark";

const useStore = create<Theme>(
  persist(
    "light",
    {
      local: "theme",
   // session: "theme",
   // cookie: "theme",
    }
  )
);
Key Value
theme {"state":"light","version":0}

 

migrate 객체가 존재하면 Caro-Kann은 클라이언트가 서비스에 접속할 때 자동으로 버전 차이를 확인합니다. 만약 클라이언트의 상태가 최신 버전이 아닐 경우에는 migrate.strategy 함수를 호출하여 상태를 최신 버전으로 업데이트 합니다. strategy 메서드는 클라이언트에 존재하는 기존 상태와 버전을 인자로 받아, 이를 기반으로 업데이트된 상태를 반환합니다.

type Theme = { color: "light" | "dark", fontSize: number };

const useStore = create<Theme>(
  persist(
    { color: "light", fontSize: 16 },
    {
      local: "theme",
      migrate: {
        version: 1,
        strategy: (prevState, prevVersion) => {
          return { color: prevState, fontSize: 16 };
        },
      },
    }
  )
);
Key Value
theme {"state":{"color":"dark","fontSize":16},"version":1}

 

migrate를 사용해서 0버전을 1버전으로 잘 업데이트 했습니다. 그런데 몇 주 뒤, 선임 개발자가 찾아와 글꼴 상태를 나타내는 이름을 font-size로 바꿔야 한다고 합니다. migrate는 클라이언트가 서비스에 접속할 때만 동작하므로, 아직 서비스에 접속하지 않은 유저 클라이언트는 여전히 0 버전인 상태입니다. 따라서 우리는 0버전과 1버전 모두를 해결해야 합니다. 하지만 걱정할 필요 없습니다. switch 문을 사용하면 각각의 버전에 대해 효과적으로 대응할 수 있습니다.

type Theme = { color: "light" | "dark", ["font-size"]: number };

const strategy = (prevState: any, prevVersion: number) => {
  switch (prevVersion) {
    case 0:
      return { color: prevState, ["font-size"]: 16 };
    case 1:
      return { color: prevState.color, ["font-size"]: prevState.fontSize };
    default:
      return prevState;
  }
}

const useStore = create<Theme>(
  persist(
    { color: "light", ["font-size"]: 16 },
    {
      local: "theme",
      migrate: {
        version: 2,
        strategy,
      },
    }
  )
);
Key Value
theme {"state":{"color":"dark","font-size":16},"version":2}


이전 버전이 여럿이라면 prevState의 타입을 지정하는 것이 사실상 불가능합니다. 때문에 any를 사용하게 되는데, 이로 인해 Caro-Kann은 제대로 상태를 추론하지 못하게 됩니다. 그러니 migrate를 사용해 버전 관리를 하고 있다면 playTartakower에 제네릭 타입을 제공하여 Caro-Kann이 제대로 상태의 타입을 추론할 수 있도록 해주어야 합니다.

 

zustand

Caro-Kann의 useStore 함수는 기본적으로 useState API와 유사하게 [value, setValue] 튜플을 반환합니다. 이는 상태를 읽고 업데이트하는 기본적인 방식으로, 상태 관리가 직관적이고 간단합니다. 하지만 zustand 미들웨어를 사용하면 useStore 함수가 zustand가 제공하는 API와 유사한 방식으로 동작하게 됩니다. 이를 통해 개발자는 동일한 프로젝트에서도 필요에 따라 상태 관리 방식을 유연하게 선택할 수 있습니다.

const useStore = create<TStore>(
  zustand(initialFn: (set, get, api) => initialState)
)

zustand 미들웨어를 사용하는 경우 caro-kann은 타입 추론에 실패하게 됩니다. 따라서 create 함수에 store의 타입을 지정해주어야 합니다.

type TStore = { count: number, increment: () => void, decrement: () => void }

const useStore = create<TStore>(
  zustand((set, get, api) => ({
    count: 0,
    increment: () => set({count: get().count + 1}),
    decrement: () => set(store => ({...store, count: store.count - 1})),
  }))
);

export default function Page() {
  const { count, increment, decrement } = useStore()

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  )
}

 

reducer

reducer 미들웨어는 상태 관리에서 중앙 집중식 상태 변환을 처리하는 방식으로, 상태의 변경을 예측 가능하고 일관성 있게 유지할 수 있게 합니다. 이 패턴은 주로 Redux에서 사용하는 방식으로, 상태를 불변성을 유지하며 업데이트하도록 설계되어 있습니다. reducer 미들웨어는 주로 액션(action)을 통해 상태를 변경하며, 상태 업데이트 로직을 중앙에서 관리하게 됩니다.

const useStore = create(
  reducer(reduceFn, initialState)
)

reducer 미들웨어를 사용하면 useStore는 [value, setValue] 대신 [value, dispatch] 튜플을 반환합니다. dispatch 함수는 action 객체를 인자로 받으며, 이를 통해 reduceFn의 로직을 트리거하여 상태를 변경할 수 있습니다. reduceFn은 각 액션의 타입에 따라 상태를 업데이트하는 역할을 하며, 액션 객체의 type과 payload를 기반으로 상태를 변경하는 로직을 정의합니다.

const useStore = create(
  reducer((store, { type, payload = 1 }: { type: string, payload?: number }) => {
    switch (type) {
      case "INCREMENT":
        return { count: store.count + payload };
      case "DECREMENT":
        return { count: store.count - payload };
      default:
        return store;
    }
  },
  { count: 0 })
);
 
export default function Page() {
  const [count, dispatch] = useStore(store => store.count)
 
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => dispatch({ type: "INCREMENT", payload: 2 })}>Increment</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>Decrement</button>
    </div>
  )
}

 

devtools

devtools 미들웨어를 사용하면 상태 관리가 더욱 직관적이고 효율적으로 이루어집니다. 이 미들웨어는 Redux DevTools 확장 프로그램을 통해 애플리케이션의 상태 변화를 실시간으로 추적할 수 있게 해줍니다. 개발자는 상태가 어떻게 변경되는지 명확히 알 수 있으며, 이를 통해 디버깅과 최적화가 용이해집니다.

const useStore = create(
  devtools(initialState, storeName)
)

예를 들어, devtools 미들웨어를 사용하여 count 상태를 관리하는 예시는 다음과 같이 실시간으로 상태 변화를 관찰하고, 버튼 클릭 시 상태를 증가시키거나 감소시킬 때마다 Redux DevTools에서 변화가 기록되는 모습을 확인할 수 있습니다. 이 방식은 특히 복잡한 상태 관리 및 디버깅을 단순화하고, 개발자의 작업 효율성을 크게 향상시킵니다.

const useStore = create(
  devtools({ count: 0 }, "devtoolsTestStore")
);

export default function Page() {
  const [count, setCount] = useStore(store => store.count)

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  )
}

 

Middleware Composition

Caro-Kann의 여러 미들웨어는 특정 조건에 따라 결합하여 사용할 수 있습니다. 만약 미들웨어가 useStore가 반환하는 튜플 중 setValue의 동작을 변경한다면, 다른 미들웨어 내부에서 사용할 수 없습니다. 또한, 결합할 미들웨어는 initialState 위치에서 호출해야 합니다. 따라서 튜플을 반환하지 않고 initialState 대신 initialFn을 받는 zustand 미들웨어는 다른 미들웨어와 결합하여 사용할 수 없습니다.

reducer 미들웨어는 initialState를 받지만, setValue 대신 dispatcher를 반환합니다. 이로 인해 다른 미들웨어의 initialState 위치에서 호출할 수 없지만, 다른 미들웨어들을 reducer의 initialState 위치에서 호출하여 결합할 수 있습니다. 반면, persist와 devtools 미들웨어는 결합에 제한이 없어 자유롭게 다른 미들웨어와 함께 사용할 수 있습니다.

const useStore = create(
  reducer(
    (store, { type, payload = 1 }: { type: string, payload?: number }) => {
      switch (type) {
        case "INCREMENT":
          return { count: store.count + payload };
        case "DECREMENT":
          return { count: store.count - payload };
        default:
          return store;
      }
    },
    persist(
      devtools(
        { count: 0 },
        "devtoolsTestStore"
      ),
      { local: "count" }
    )
  )
);

export default function Page() {
  const [count, dispatch] = useStore(store => store.count)
 
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => dispatch({ type: "INCREMENT", payload: 2 })}>Increment</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>Decrement</button>
    </div>
  )
}

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기