2025. 01

무지성 Context API 업보, Zustand로 청산

싸늘하다. 사이트에 리렌더링이 날아와 박힌다.

0. Context API 그만해

이 블로그는 제 기술스택 실습실이자 샌드박스입니다. 단순히 구현하고 싶은 기능이 있으면 구현하고 있는 중입니다. 그런데 다크 모드 및 라이트 모드 기능을 넣고, 랜덤 배경 사진 기능을 넣으니 내비게이션의 폰트 색깔을 동적으로 조정하고 싶어졌습니다. 즉 특정 DOM의 평균 명도를 알아내야 하는 상황이 됐습니다.

Typescript
export const BackgroundProvider = ({ children }: { children: ReactNode }) => {

	...//로직

return (
    <BackgroundContext.Provider
      value={{ src: imgSrc, brightness, DARK_TEXT_PREFERED, backgroundColor }}
    >
      <canvas
        id="background_image"
        width={500}
        height={400}
        className={HIDE}
        ref={canvasRef}
      />
      {children}
    </BackgroundContext.Provider>
  )
}

여기에 구현되어 있는 DARK_TEXT_PREFERED 는 당시 임시로 구현되어 있던 것으로, 의도했던 기능은 특정 DOM의 refObject 값을 전달하면 그 DOM에 어두운 색의 텍스트가 적절한지를 boolean으로 알려주는 것입니다. 당시에는 imgSrc의 평균 픽셀 명도값을 사용하고 있었습니다. 이 기능을 잘 구현하려면 레이아웃 컴포넌트에 또 Provider를 씌워야 했고, Context를 호출해야 하는데 이 구조대로라면 Provider Hell과 더불어 향후 무슨 일이 벌어질지 모르겠다는 생각이 들었습니다.

어떻게 구현해야 하는지는 감이 왔는데, Context만으로 관리하던 상태들을 다 뜯어야 한다는 걸 깨달았습니다. 왜냐하면 당시에 인터페이스를 너무 생각 없이(…) 설계했기도 하고, Context만으로 전역 상태를 계속 관리하다가는 앞으로 복잡한 기능을 추가할 때 불필요한 렌더링이 너무 많아질 것 같다는 생각이 들었기 때문입니다. 이 기회에 상태 관리 도구를 추가하면서, 전역 상태들을 쪼개서 관리해야겠다는 생각이 들었습니다.

1. 상태 관리 도구 선정: Zustand

제가 최근에 사용했던 상태 관리 도구는 JotaiZustand입니다. 같은 개발자가 개발한 것으로 둘의 가장 큰 차이는, 제가 느끼기에는 상태를 atomic하게 다루느냐와 아니냐였습니다. 복잡한 어플리케이션을 개발해본 경험이 없지만 Jotai의 경우 작은 상태들과 거기에서 조합된 상태들을 다루는 데 용이한 편이고(상태 자체만 남기는 편), Zustand는 상태와 상태를 다루는 로직이 좀더 결합된 편이라고 생각합니다.

Zustand를 선택한 이유는 현재 블로그의 전역 상태들이 비동기나 폼 처리(즉 상태끼리의 결합)보다는 특정 기능 하나 또는 특정 인터페이스의 조합에 모두 묶여 있기 때문입니다. 앞으로도 그럴 것이라는 보장은 없지만 Jotai를 사용해 보았을 때 상태가 많아지고, 흩어졌을 때 코드를 정리하기가 좀 난감하다는 생각을 했었기 때문에 이번에는 Zustand를 사용해 보면서 문제점이 있다면 체감해 볼 요량입니다.

2. Provider 해체하기

2—1. 디렉토리 옮기기

우선 이번에 도입한 랜덤 배경 사진 기능을 위해 내비게이션으로 뭉뚱그려 전달하던 컨텍스트 인터페이스를 쪼개, 상태에 직접적으로 연관되어 있는 컴포넌트로 모두 넣었습니다. 기존에는 아래처럼 providers를 한 디렉토리에서 모두 관리하고 있었는데(일종의 store), 이것을

아래처럼 연관된 컴포넌트의 디렉토리에서 관리하는 것으로 변경했습니다. (이 블로그의 디렉토리 구조는 점진적으로 FSD로 옮겨가려고 합니다)

2—2. 리팩토링과 기능 구현

Typescript
export const BackgroundProvider = ({ children }: { children: ReactNode }) => {

	...//로직

return (
    <BackgroundContext.Provider
      value={{ src: imgSrc, brightness, DARK_TEXT_PREFERED, backgroundColor }}
    >
      <canvas
        id="background_image"
        width={500}
        height={400}
        className={HIDE}
        ref={canvasRef}
      />
      {children}
    </BackgroundContext.Provider>
  )
}

Provider 내부에 있던 로직은 그대로 캔버스 컴포넌트와 함께 옮기고, 해당 캔버스 컴포넌트에서 필요한 기능을 구현하면서 아래와 같은 원칙을 세웠습니다.

  • 캔버스와 관련된 로직은 캔버스 컴포넌트와 함께 두기

  • 상태 조작과 관련된 로직은 따로 빼서 상태와 함께 두기

이게 무슨 말인가 하면, 캔버스 컴포넌트에는 캔버스 조작 및 이를 통한 결과값(raw data)만 남기고, 나머지 계산은 slice에서 처리하는 것입니다. 예를 들어 DARK_TEXT_PREFERED는 imgSrc의 계산된 명도값(brightness, raw data)을 받아 186 초과라면 true를 반환하는데, 이렇게 raw data에서 파생된 상태들을 react에 의존할 필요 없이 연산할 때 slice에서 처리하겠다는 뜻입니다.

구현한 기능은 다음과 같습니다.

  1. 특정 DOM의 (key, RefObject) 쌍를 등록해서

  2. 중복 체크하고

  3. 있으면 해당 DOM 영역 범위의 PREFERS_BLACK_TEXT 값을 모두 제공함

이때 DOM을 등록할 때의 key로 찾거나, DOM 으로 key를 찾을 수 있습니다. (코드)

3. 마무리

이렇게 상태 관리 구조를 먼저 개편하고 기능을 구현하니 앓던 이가 빠진 기분이었고요... 나머지 필요한 Context들도 Zustand로 마이그레이션하려고 하는데, 이전에 상태 관리에 대한 생각을 글로 쓴 적이 있었는데 일단 이걸 기준으로 한 번 더 생각해 보려고 합니다.