React 전역 상태 관리 무엇을 사용하면 좋을까 (Context API Vs. Recoil)

시소·2024년 3월 3일
0
post-thumbnail

들어가며 ..
다음은 이 포스팅에서 다루는 내용이다.

  • React에서 전역 상태를 관리하는 방법 소개 및 비교: Context API와 Recoil을 중심으로
  • Context API의 성능 이슈에 관하여
  • Context API로 작성한 코드를 Recoil로 마이그레이션 해 본 과정

1. Context API

소개

  • 리액트에서 전역적으로 상태 관리를 하기 위해 사용되는 도구이다.
  • 컴포넌트 트리 전체에서 props로 데이터를 전달하지 않고도, 여러 컴포넌트 간 상태를 손쉽게 공유할 수 있다.
  • 주로 애플리케이션의 전반적 정보(테마, 언어 설정, 로그인 사용자 정보 등)를 효과적인 관리를 위해 사용한다.

동작 원리

크게 3가지 요소로 구성된다.

  • ① createContext: 컨텍스트를 생성한다.
  • ② Context Provider: Provider로 컴포넌트 트리를 감싸면, 자식 컴포넌트에서 해당 컨텍스트 값을 참조할 수 있다.
  • ③ Context Consumer: Provider로부터 전달 받은 상태를 참조한다.

주의점

일일이 컴포넌트 트리 저 아래에 있는 곳까지 props를 전해 주지 않아도 되므로 보다 쉬운 상태 관리가 가능해지지만,

  • 남용하게 되면 컴포넌트 간의 의존성이 높아져 유지보수가 어려워 질 수 있다.
  • 또한 Context API가 렌더링 시마다 호출되기 때문에 불필요한 렌더링이 과도하게 발생할 수 있다.
  • 따라서 대규모의 상태 관리나 복잡한 로직을 다룰 때는 한계가 있으며 최적화에 대한 고민이 필요하다.

2. Recoil

리코일에 대한 기본적인 내용은 본 블로그의 Recoil을 이용한 React 상태 관리 포스팅에서도 정리되어 있다.

소개

  • 상태 관리에 특화된 라이브러리로, 역시 전역 상태 관리를 용이하게 해준다.
  • 뿐만 아니라 동일한 종류의 상태를 여러 개 생성하는 것과 같은 동적으로 생성된 상태 관리도 지원된다.(atomFamily)
  • Context API의 대안으로도 사용되며, 컴포넌트 트리 어디에 위치하든 간단히 상태를 읽고 쓸 수 있다.
  • hooks 기반의 간단하고 친숙한 API와 자체적인 성능 최적화 기능을 제공한다.

동작 원리

주로 2가지 주요 개념으로 구성된다.

  • ① atom: 상태의 단위로, 어느 컴포넌트에서든 읽고 쓸 수 있는 값이다.
  • ② selector: 파생된 상태(derived state)에 해당하는 개념으로, atom에 의존하여 계산된 값을 반환한다.

주의점

상태 관리 로직을 보다 명확하게 분리하고 효과적으로 관리할 수 있지만,

  • 라이브러리 자체의 학습 곡선이 있어, 도입 전 미리 충분한 학습과 테스트를 선행해야 한다.
  • 또한 상태 관리가 복잡해질수록 처리 방식에 대한 깊은 이해가 없다면 오히려 성능 이슈나 예기치 못한 버그를 초래할 가능성이 있다.
  • React와는 별도로 개발・유지되는 라이브러리임으로 React 버전 업데이트에 따라 호환성 문제 등이 발생할 수 있어 최신 소식이나 업데이트에 맞춰 대응이 필요해질 수도 있다.

3. Context API Vs. Recoil

성능

  • Context API: 컴포넌트 트리의 모든 컴포넌트가 컨텍스트를 구독하므로, 컨텍스트 값이 변경될 때마다 해당 트리의 모든 컴포넌트가 리렌더링 된다. (컴포넌트 트리 깊이에 따른 성능 저하 발생 가능성 O)
  • Recoil: 상태의 읽기 및 쓰기에 대해 기본적으로 제공되는 여러 자체 기능을 통해 성능 최적화를 지원하며 Atom, Selector 등으로 불필요한 리렌더링을 방지한다. (필요한 값만 구독, 내부적인 메모이제이션 수행, 별도의 DevTools 지원 등)

프로젝트 별 적합성

  • Context API: 간단한 상태 관리나 전역적인 데이터 전달이 필요한 소규모의 프로젝트에 적합하다. 리액트 내장 도구로 별도의 설치 과정이 필요하지 않아 추가적인 설정 없이 쉽게 사용이 가능하다.
  • Recoil: 대규모의 애플리케이션에서 좀 더 복잡한 상태 관리가 필요할 때 적합하다. 그외에 비동기 상태나 여러 컴포넌트 간 복잡한 의존성을 다룰 때 효과적이다. 외부 라이브러리임으로 별도의 설치 및 설정 과정이 필요하다.

정리

  • Context API: 리액트의 기본 기능을 활용해 간편하게 전역 상태를 관리하고자 할 때 적합하며, 소규모 애플리케이션에서는 충분하나 규모가 커지면 성능 및 유지보수 함에 있어 한계가 있을 수 있다.
  • Recoil: 상태 읽기/쓰기에 대해 세밀한 제어나 성능 최적화를 위한 도구를 여럿 제공하여, 대규모 프로젝트에서 상태를 관리함에 있어서 성능이나 유지 보수성이 중요한 경우 적합하다.

🔎 어느 도구를 선택하든, 해결하고자 하는 문제에 가장 적합한 도구는 있어도, 항상 모든 경우에 좋은 단 하나의 도구가 정해져 있지는 않을 것이다. 고유의 특성과 한계점을 먼저 따져 보는게 중요하지 않을까.


4. Context API는 사용하면 안되는 것일까?

React 진영에서 널리 사용되고 있는 여러 상태 관리 도구들이 있다.
예를 들면 Redux, MobX, Recoil, Jotai, Zustand, React Query, SWR 등... 상당히 많은 종류의 도구가 있다.

각각의 라이브러리는 특화되어 있는 부분이 서로 다르다. 이 중에서도 마지막 2가지는 비동기 데이터 처리에 특화되어 있는 라이브러리들이라 치고, 나머지는 상태를 중앙 집중식으로 관리하거나 보다 간단하고 단순하게 상태 관리를 할 수 있게 해준다.

물론 모든 종류의 도구를 전부 사용해 본 것은 아니지만, 이들을 소개하는 문서에는 공통점이 있었는데
바로 'React의 컨텍스트 지옥(Context Hell) 문제를 해결하고자 등장하였다' 라는 문구를 심심치 않게 볼 수 있다는 것이었다.
그래서 자연스레 '🤔 과연 Context API를 다른 상태 관리 라이브러리로 대체해야만 할까?'와 같은 물음이 떠오르곤 했다.

그렇다면 일반적으로 대두되는 Context API의 성능 관련 이슈에 적절히 대응하면서도 장점을 최대한 살릴 수 있도록 활용하는 방법에 대해 정리해 보았다.

Context API 가 유용한 상황

  • React 기본 기능을 최대한 활용하고자 할 때: React 내장 기능이므로 별도의 라이브러리를 설치하거나 설정하는 과정이 필요하지 않다. 프로젝트 의존성을 최소화하고 학습 곡선을 줄일 수 있으며, React 생태계 내에서 완벽한 호환성을 보장한다.
  • 소규모의 프로젝트상태 관리가 단순한 경우: 규모나 요구 사항이 단순한 경우라면 간단하고 직관적인 API를 사용해 충분히 해결할 수 있다.

Context API의 성능이 문제가 된다면?

  • 만약 성능 이슈나 불필요한 렌더링이 발생한다면, React에서 제공하는 React.memo, useMemo, useCallback 등의 최적화 기능을 활용해 개선 가능하다.
  • 기존 프로젝트에서 Context API 사용으로 인해 성능이 크게 문제가 되지 않는다면, 굳이 다른 라이브러리로 마이그레이션 해야만 할 필요는 X

Context API 최적화 코드 예시

성능 최적화에서 중요한 것은 '컴포넌트가 렌더링 될 때 마다 Context 값이 재생성되지 않도록 관리'하는 것이다.
이렇게 하면 Context를 사용하는 모든 소비자 컴포넌트가 불필요하게 렌더링 되는 것을 방지할 수 있다.

useMemo 활용하여 value가 변경될 때만 재계산 하는 코드:

// ExamProvider.js
const ExamContext = createContext(null)

const ExamProvider = ({ children }) => {
  const [value, setValue] = useState('init value')
  
  const contextValue = useMemo(() => ({ value, setValue }), [value])
  
  return (
    <ExamContext.Provider value={contextValue}>
      {children}
    </ExamContext.Provider>
  )
}

React.memo 활용하여 props 변화가 존재할 때만 렌더링 하는 코드:

// ExamComponent.js
const ExamComponent = React.memo(({ onClick }) => {
  const { value } = useContext(ExamContext)
  
  console.log('ExamComponent Rendered.')
  
  return (
    <>
      <p>Value: {value}</p>
      <button onClick={onClick}>Click!</button>
    </>
  )
})

useCallback 활용하여 필요 시에만 함수를 재생성 하는 코드:

// App.js
const App = () => {
  const { setValue } = useContext(ExamContext)
  
  const handleClick = useCallback((newValue) => {
    setValue(newValue)
  }, [setValue])
  
  return (
    <ExamProvider>
      <ExamComponent onClick={handleClick} />
    </ExamProvider>
  )
}

5. 예제: Context API -> Recoil 마이그레이션

공통 컴포넌트를 Recoil로 전환하는 방법

이번에 Recoil을 학습해보게 되면서 코드에 익숙해지고자 기존 프로젝트에 useContext 를 사용해 만들어진 Alert 공통 컴포넌트를 Recoil 방식으로 한 번 변경해 보았다.

먼저 아래는 원래 있던 코드이다.

‣ AlertDialogProvider.js

const AlertDialogContext = createContext()

export const useAlertDialog = () => {
  return useContext(AlertDialogContext)
}

export const AlertDialogProvider = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false)
  const [title, setTitle] = useState("")
  const [desc, setDesc] = useState("")
  const [action, setAction] = useState({})
  
  const hasButtonAction = () => {
    return action?.label && typeof action?.handler === 'function'
  }
  
  const openAlert = (title, desc, action) => {
    setTitle(title)
    setDesc(desc)
    setIsOpen(true)
    if (action.lable && typeof action.handler === "function") {
      setAction(action) 
    } else {
      setAction({})
    }
  }
  
  const closeAlert = () => {
    setIsOpen(false)
  }
  
  return (
    <AlertDialogContext.Provider value={{ openAlert, closeAlert }}>
      {children}
      <AlertOverlay
        isOpen={isOpen}
        onClose={closeAlert}
        title={title}
        description={desc}
        action={action}
      />
    </AlertDialogContext.Provider>
  )
}

‣ AlertOverlay.js

// Chakra-UI로 작성한 공통 얼럿 컴포넌트
const AlertOverlay = ({ isOpen, onClose, title, desc, action }) => {
  return (
    <AlertDialog
      isOpen={isOpen}
      onClose={onClose}
    >
      <AlertDialogContent>{title}</AlertDialogContent>
      <AlertDialogHeader>{desc}</AlertDialogHeader>
      <AlertDialogFooter>
        <Button onClick={onClose}>Close</Button>
        {action?.label && typeof action?.handler === 'function' && (
          <Button onClick={action.handler}>{action.label}</Button>
        )}
      </AlertDialogFooter>
    </AlertDialog>
  )
}

‣ main.js

<>
  <AlertDialogProvider>
    {/* ... 그 외 다른 Provider 등 */}
    <App />
  </AlertDialogProvider>
</>

변경한 과정은 다음과 같다.

  • ① 기존에 얼럿과 관련된 상태값인 isOpen, title, desc, action 속성을 모두 Atom 안에 기본값과 함께 정의한다.
  • ② 컴포넌트 어디서나 얼럿을 열고 닫을 수 있도록 관련 함수를 외부에 제공하는 커스텀 훅을 정의한다. (openAlert, closeAlert, hasButtonAction)
  • ③ 화면에 보여질 얼럿 컴포넌트를 정의한다. Atom으로부터 현재 보유하고 있는 상태값을 읽어와 적절하게 렌더링을 수행한다.
  • ④ 마지막으로 App.jsx에 방금 정의한 얼럿 컴포넌트 <AlertOverlay />를 가져다 놓는다.

‣ atoms.js

export const alertDialogState = atom({
  key: 'alertDialogState', 
  default: {
    isOpen: false,
    title: '',
    desc: '',
    action: {}
  }
})

‣ useAlertDialog.js

const useAlertDialog = () => {
  const [alertDialog, setAlertDialog] = useRecoilState(alertDialogState)
 
  const openAlert = (title, desc, action) => {
    setAlertDialog({
      isOpen: true,
      title,
      desc,
      action: action && hasButtonAction() ? action : {}
   })
  }
 
  const closeAlert = () => {
    setAlertDialog((prev) => ({
      ...prev,
      isOpen: false
    }))
  }
  
  const hasButtonAction = () => {
    return (
      alertDialog.action?.label &&
      typeof alertDialog.action?.handler === 'function'
    )
  }
 
  return { openAlert, closeAlert, hasButtonAction }
}

export default useAlertDialog

‣ AlertOverlay.js

const AlertOverlay = () => {
  const alertDialog = useRecoilValue(alertDialogState)
  const { closeAlert, hasButtonAction } = useAlertDialog()
  
  return (
    <AlertDialog
      isOpen={alertDialog.isOpen}
      onClose={closeAlert}
    >
      <AlertDialogContent>
        <AlertDialogHeader>{alertDialog.title}</AlertDialogHeader>
        <AlertDialogBody>{alertDialog.desc}</AlertDialogBody>
        <AlertDialogFooter>
          <Button onClick={closeAlert}>Close</Button>
          {hasButtonAction() && (
            <Button onClick={alertDialog.action.handler}>
              {alertDialog.action.label}
            </Button>
          )}
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

‣ App.js

<>
  {/* .. 기존 내용들 .. */}
  <AlertOverlay />
</>

이후 얼럿을 표시하고자 하는 컴포넌트에서 const { openAlert } = useAlertDialog() 와 같이 불러와 호출하면 어디서나 얼럿을 띄우고 닫을 수 있게 된다.


6. 결론

어느 기술을 도입하든 각 프로젝트에 맞는 최적의 도구를 선택하는 것이 효율성과 유지보수성에 있어서 큰 도움이 될 것이다. 이러한 선택은 프로젝트의 규모, 구조, 개발자의 선호도 등의 요소에 따라 이루어질 수 있다. 선택에 있어 도움이 될 수 있도록 지금까지 Context API와 Recoil을 중심으로 각 방식의 특성과 그들이 사용되기 적절한 프로젝트에 대해 알아보았다.

마지막으로 정리해보자면,

  • Context API도 상황에 따라 그 자체로도 충분히 유용하게 사용될 수 있다. 다만 성능 최적화에 신경 쓰기.
    • (그렇지만 지금까지 실무에서는 Context API를 사용하는 걸 잘 못본 것 같다 🙄)
  • 다른 여러 외부 라이브러리도 고유한 특징이 있으므로 프로젝트 요구 사항이나 각자 팀의 선호도에 따라 선택.
  • 두 방식을 모두 혼용하는 시나리오도 존재할 수 O.
    • 예를 들면 전역적인 설정이나 사용자 인증 정보 같은 정적인 상태는 Context API로 관리,
    • 변화가 잦고 복잡한 비즈니스 로직을 갖는 상태는 Recoil을 통해 관리하는 등
  • (컨텍스트에만 국한된 건 아니지만.. 전역 상태는 정말 필요한 경우에만 사용하자. 로컬 상태로도 해결할 수 있는 문제를 굳이 전역 데이터로 만들지 말자.)

7. 참고 자료

profile
배우고 익힌 것을 나만의 언어로 정리하는 공간 ..🛝

0개의 댓글