Context는 상태 관리 툴이 아니다

2023. 9. 24. 16:03카테고리 없음

다음의 글을 번역했습니다. 학습을 위해 번역하여 많은 부분을 생략하였습니다. 

https://blog.isquaredsoftware.com/2021/01/context-redux-differences/

Context와 Redux 이해하기

어떤 도구든 올바르게 사용하기 위해, 다음을 이해하는 것이 중요하다.

  • 도구의 목적
  • 도구가 해결하고자 하는 문제
  • 언제 그리고 왜 이 도구가 생겨났는지

또한 현재 자신의 애플리케이션에서 해결하려는 문제가 무엇인지 이해하고, 이 문제를 해결하기 위한 도구를 선택하는 것 또한 중요하다.

컨텍스트와 리덕스에 대한 대부분의 혼란은 이러한 도구가 실제로 어떤 기능을 하는지, 어떤 문제를 해결하는 지에 대한 이해 부족에서 비롯된다. 따라서 실제로 언제 사용해야 하는지 알기 위해서는 먼저 컨텍스트와 리덕스가 무엇을 하고 어떤 문제를 해결하는지를 명확하게 정의해야 한다.

Context란?

먼저 리액트 문서에서 컨텍스트에 대한 실제 설명을 살펴보자.

 

컨텍스트는 모든 레벨에서 수동으로 props를 전달할 필요 없이 컴포넌트 트리를 통해 데이터를 전달할 수 있는 방법을 제공한다.
일반적인 리액트 애플리케이션에서 데이터는 props를 통해 전달된다. 하지만 특정 유형의 props의 경우에는 이 방식이 번거로울 수 있다. 컨텍스트는 트리의 모든 레벨에 props를 명시적으로 전달하지 않고도 컴포넌트 간에 이러한 값을 공유할 수 있는 방법을 제공한다.

 

값의 managing에 대해서는 언급하지 않고 오직 값의 passingsharing에 대해서만 언급하고 있다는 점에 주목하자.

컨텍스트는 리액트 16.3에서 처음 출시되었다. 이는 리액트 초기 버전부터 사용 가능했지만 설계 결함이 있었던 레거시 컨텍스트를 대체했다. 레거시 컨텍스트의 주요 문제점은 컴포넌트가 shouldComponentUpdate를 통해 렌더링을 건너뛰면 컨텍스트를 통해 전달된 값에 대한 업데이트가 blocked 될 수 있다는 것이었다. 많은 컴포넌트가 성능 최적화를 위해 shouldComponentUpdate에 의존했기 때문에 레거시 컨텍스트는 일반 데이터를 전달하는 데 쓸모가 없었다. createContext는 이 문제를 해결하기 위해 설계되어 중간에 있는 컴포넌트가 렌더링을 건너뛰더라도 값에 대한 모든 업데이트가 하위 컴포넌트에서도 작동하도록 한다.

Context 사용하기

앱에서 컨텍스트를 사용하는 것은 몇 단계를 요구한다:

  • 먼저, const MyContext = React.createContext()를 호출하여 context 객체 인스턴스를 생성한다.
  • 부모 컴포넌트에서, <MyContext.Provider value={someValue}>를 렌더한다. 단일 데이터를 컨텍스트에 넣을 수 있다. 이 값은 문자열, 숫자, 객체, 배열, 클래스 인스턴스, 이벤트 이미터 등 무엇이든 될 수 있다.
  • provider에 중첩된 어떤 컴포넌트에서든, const theContextValue = useContext(MyContext)를 호출할 수 있다.

부모 컴포넌트가 다시 렌더링하고 컨텍스트 프로바이더에 대한 새 참조를 값으로 전달할 때마다 해당 컨텍스트에서 값을 읽는 모든 컴포넌트는 강제로 다시 렌더링된다.

대부분의 경우 컨텍스트의 value는 리액트 컴포넌트의 state로 부터 오게 된다.

function ParentComponent() {
  const [counter, setCounter] = useState(0);

  // value와 setter를 모두 포함하는 객체를 만든다.
  const contextValue = {counter, setCounter};

  return (
    <MyContext.Provider value={contextValue}>
      <SomeChildComponent />
    </MyContext.Provider>
  )
}

자식 컴포넌트는 useContext를 호출하고 값을 읽을 수 있다.

function NestedChildComponent() {
  const { counter, setCounter } = useContext(MyContext);

  // 카운터 값과 setter로 무언가를 수행한다.
}

Context의 목적 및 사용 사례

이를 바탕으로 컨텍스트가 실제로는 아무것도 manage하지 않는다는 것을 알 수 있다. 대신 파이프 또는 웜홀과 비슷하다. <MyContext.Provider> 를 사용하여 파이프의 맨 위에 무언가를 넣으면, 그 무언가(그것이 무엇이든)는 파이프를 통해 다른 컴포넌트가 useContext(MyProvider) 로 요청하는 다른 쪽 끝에서 튀어나올때 까지 내려간다.

따라서 컨텍스트를 사용하는 주된 목적은 prop-drilling을 피하는 것이다. 이 값을 필요한 컴포넌트 트리의 모든 레벨을 통해 명시적으로 프로퍼티로 전달하는 대신, <MyContext.Provider> 에 중첩된 어떠한 컴포넌트 간에 useContext(MyContext) 를 통해 필요에 따라 값을 가져올 수 있다. 이렇게 하면 props 전달 로직을 모두 작성할 필요가 없으므로 코드가 간소화 된다.

Redux란?

비교를 위해 리덕스 문서에 있는 “Redux Essential” 튜토리얼의 설명을 살펴보자

 

리덕스는 “action”이라는 이벤트를 사용하여 애플리케이션 상태를 관리하고 업데이트하기 위한 패턴이자 라이브러리이다. 전체 애플리케이션에서 사용해야 하는 상태를 위한 중앙 집중식 저장소 역할을 하며, 예측 가능한 방식으로만 상태를 업데이트할 수 있도록 규칙을 지정한다.
리덕스는 애플리케이션의 여러 부분에 걸쳐 필요한 상태인 ‘전역’상태를 관리할 수 있도록 도와준다.리덕스에서 제공하는 패턴과 도구를 사용하면 애플리케이션의 상태가 언제, 어디서, 왜, 어떻게 업데이트되는지, 그리고 이러한 변경이 발생할 때 애플리케이션 로직이 어떻게 작동하는지 쉽게 파악할 수 있다.

 

이 설명에 유의하자.

  • 구체적으로 상태를 관리한다고 말하고 있다.
  • 리덕스의 목적은 시간이 지남에 따라 상태가 어떻게 변하는지를 이해하는 데 도움을 주는 것이라고 말하고 있다.

역사적으로 리덕스는 원래 리액트가 나온 지 1년 후인 2014년에 페이스북에서 처음 제안한 패턴인 “Flux 아키텍처”의 구현으로 만들어졌다. 그 발표 이후, 커뮤니티는 Flux 개념에 대한 다양한 접근 방식으로 수십 개의 라이브러리를 만들었다. 2015년에 나온 리덕스는 최고의 디자인, 리액트와 잘 작동하는 디자인으로 ‘Flux 전쟁’에서 빠르게 승리했다.

설계적으로 리덕스는 함수형 프로그래밍 원칙을 사용하여 코드를 최대한 예측 가능한 “reducer”함수로 작성하고, “어떤 이벤트가 발생했는지”에 대한 아이디어와 “해당 이벤트가 발생했을 때 상태가 어떻게 업데이트되는지”를 결정하는 로직을 분리하는 것을 강조한다. 또한 리덕스는 미들웨어를 사용하여 side effects 처리를 포함한 추가적인 리덕스 스토어의 기능을 확장했다.

리덕스는 또한 리덕스 Devtools를 가지고 있는데, 이는 시간 경과에 따른 앱의 action및 state의 변경 내역을 확인할 수 있다.

Redux와 React

리덕스 자체는 UI에 구애받지 않으므로 모든 UI레이어(리액트, 뷰, 앵귤러, 바닐라 자바스크립트)와 함께 사용하거나 아예 UI 없이도 사용할 수 있다.

리덕스는 일반적으로 리액트와 함께 사용된다. React-Redux라이브러리는 공식 UI 바인딩 레이어로, 리액트 컴포넌트가 리덕스 state에서 값을 읽고 액션을 디스패치하여 리덕스 store와 상호 작용할 수 있게 해준다. 따라서 대부분의 사람들이 리덕스를 언급할 때 실제로는 리덕스 store와 React-Redux라이브러리를 함께 사용하는 것을 의미한다.

React-Redux를 사용하면 애플리케이션의 모든 리액트 컴포넌트가 리덕스 store와 통신할 수 있다. 이는 React-Redux가 내부적으로 컨텍스트를 사용하기 때문에 가능하다. 하지만 React-Redux는 현재 상태 값이 아니라 컨텍스트를 통해 Redux store 인스턴스만 전달하는 점에 유의해야 한다. 이것은 실제로 위에서 언급한 것처럼 의존성 주입에 컨텍스트를 사용한 예시이다. 우리는 리덕스로 연결된 리액트 컴포넌트가 리덕스 store와 통신해야 한다는 것을 알고 있지만, 컴포넌트를 정의할 때 어떤 리덕스 store인지 알지도 못하고 신경쓰지도 않는다. 실제 리덕스 store는 런타임에 React-Redux <Provider> 컴포넌트를 사용하여 트리에 주입된다.

이 때문에 React-Redux는 내부적으로 컨텍스트를 사용하기 때문에 props-drilling을 피하는 데에도 사용할 수 있다. 새 값을 <MyContext.Provider> 에 직접 명시적으로 넣는 대신 해당 데이터를 리덕스 store에 넣은 다음 어디서나 액세스 할 수 있다.

리덕스의 목적 및 사용 사례

리덕스를 사용하는 주된 이유는 리덕스 문서의 설명에 잘 나와 있다

 

리덕스에서 제공하는 패턴과 도구를 사용하면 애플리케이션의 상태가 언제, 어디서, 왜, 어떻게 업데이트 되는지, 그리고 이러한 변경이 발생할 때 애플리케이션 로직이 어떻게 작동하는지 쉽게 파악할 수 있다.

 

리덕스를 사용하는 다른 이유도 있다. prop-drilling 방지는 다른 이유 중 하나이다. 많은 사람들이 초기에 prop-drilling을 피하기 위해 리덕스를 선택했는데, 이는 리액트의 레거시 컨텍스트가 제대로 작동하지 않았고 React-Redux는 제대로 작동했기 때문이다.

리덕스를 사용하는 다른 유효한 이유는 다음과 같다.

  • 상태 관리 로직을 UI 레이어와 완전히 분리해서 작성하고 싶을 때
  • 서로 다른 UI 레이어 간에 상태 관리 로직을 공유하려는 경우 (예: AngularJS에서 React로 마이그레이션 중인 애플리케이션)
  • Redux 미들웨어의 강력한 기능을 사용하여 액션이 디스패치될 때 추가 로직을 작성할 수 있다.
  • Redux 상태의 일부를 유지할 수 있다.
  • 개발자가 재현할 수 있는 버그 리포트를 가능하게 할 때
  • 개발 중 로직 및 UI의 디버깅 속도 향상

Dan Abramov는 “You Might Not Need Redux”를 작성하면서 이런 사용 사례를 여러 가지 나열했다.

컨텍스트가 상태 관리가 아닌 이유

“상태”는 애플리케이션의 동작을 설명하는 모든 데이터다. 원한다면 “서버 상태”, “지역 상태”와 같은 카테고리로 나눌 수도 있지만, 핵심은 저장, 읽기, 업데이트, 사용 중인 데이터가 있다는 것이다.

이를 바탕으로 상태 관리는 다음과 같은 방법을 갖는 것을 의미한다고 말할 수 있다

  • 초기 값 저장
  • 현재 값 읽기
  • 값 업데이트

리액트의 useState와 useReducer 훅 역시도 상태 관리의 좋은 예다. 이 두 훅을 사용하면,

  • 훅을 호출하여 초기 값을 저장할 수 있다.
  • 훅을 호출하여 현재 값을 읽을 수 있다.
  • 제공된 setState 또는 dispatch 함수를 호출하여 값을 업데이트 할 수 있다.
  • 컴포넌트가 다시 렌더링되었기 때문에 값이 업데이트되었음을 알 수 있다.

유사하게, 리덕스와 MobX도 분명히 상태 관리이다.

  • 리덕스는 루트 리듀서를 호출하여 초기 값을 저장하고, store.getState()로 현재 값을 읽을 수 있으며, store.dispatch(action)으로 값을 업데이트 하고, store.subscribe(listener)를 통해 리스너에게 스토어가 업데이트되었음을 알린다.
  • MobX는 스토어 클래스에 필드 값을 할당하여 초기 값을 저장하고, 스토어의 필드에 액세스하여 현재 값을 읽고, 해당 필드에 할당하여 값을 업데이트하고, autorun()및 computed()를 통해 변경이 발생했음을 알린다.

또한 fetch한 데이터를 기반으로 초기 값을 저장하고, 훅을 통해 현재 값을 반환하고, “server mutation”을 통해 변경 사항을 알린다는 점에서 React-Query, SWR, Apollo, Urql과 같은 서버 캐싱 도구는 “상태 관리”의 정의에 부합한다고 할 수 있다.

컨텍스트는 이러한 기준을 충족하지 않는다. 따라서 컨텍스트는 상태관리 도구가 아니다.

앞서 설명했듯이 컨텍스트는 그 자체로 아무것도 저장하지 않는다. <MyContext.Provider>를 렌더링하는 부모 컴포넌트는 컨텍스트에 어떤 값을 전달할지 결정할 책임이 있으며, 그 값은 일반적으로 리액트 컴포넌트 상태에 기반한다. 실제 "상태 관리"는 useState/useReducer 훅에서 이루어진다.

컨텍스트는 (이미 어딘가에 존재하는) 상태가 다른 컴포넌트와 공유되는 방식이다.
컨텍스트는 상태 관리와는 거의 관련이 없다.
컨텍스트는 추상화된 상태라기보다는 숨겨진 props에 가깝다고 생각한다.

 

컨텍스트와 useReducer

컨텍스트와 리덕스에 대한 논의에서 한 가지 문제점은 사람들이 실제로는 명시적으로 “내 상태를 관리하기 위해 useReducer를 사용하고, 그 값을 전달하기 위해 컨텍스트를 사용하고 있다”라고 말하지 않고 “나는 컨텍스트를 상요하고 있다”라고 말한다는 점이다. 이 것이 혼란의 일반적인 원인이며, 컨텍스트가 상태를 관리한다는 생각을 하게끔 만들기 때문에 안타까운 일이다.

이제 컨텍스트와 useReducer 조합에 대해 구체적으로 이야기해보자. 컨텍스트 + useReducer는 Redux (React-Redux)와 매우 비슷해보인다.

  • 저장된 값
  • 리듀서 함수
  • 액션 디스패치
  • 해당 값을 전달하고 중첩된 컴포넌트에서 읽는 방법

그러나 컨텍스트 + useReducer의 기능과 리덕스의 기능 및 동작에는 여전히 매우 중요한 차이점이 많다.

  • 컨텍스트+useReducer는 현재 상태 값을 전달하는데 컨텍스트에 의존한다. 하지만 리덕스는 리덕스의 store를 컨텍스트를 이용해 전달시킨다.
  • 즉, useReducer가 새로운 state값을 생성할 때 해당 컨텍스트에 구독된 모든 컴포넌트는 데이터의 일부만 신경 쓰더라도, 강제로 다시 렌더링해야 한다. 이로 인해 상태 값의 크기, 해당 데이터에 구독된 컴포넌트 수, 렌더링 빈도에 따라 성능 문제가 발생할 수 있다. 리덕스를 사용하면 컴포넌트가 스토어 상태의 특정 부분을 구독하고 해당 값이 변경될 때만 다시 렌더링할 수 있다.

그 외에도 몇 가지 중요한 차이점이 더 있다.

  • 컨텍스트+useReducer는 리액트 기능이므로, React 외부에서 사용할 수 없다. 리덕스 store는 어떤 UI와도 독립적이므로 리액트와 별도로 사용할 수 있다.
  • 리액트 개발자 도구를 사용하면 현재 컨텍스트 값은 볼 수 있지만, 시간 경과에 따른 과거 값이나 변경 사항은 볼 수 없다. 리덕스 개발자 도구는 디스패치된 모든 액션, 각 액션의 내용, 각 액션이 처리된 후의 상태, 시간에 따른 각 상태간의 차이를 볼 수 있게 해준다.
  • useReducer에는 미들웨어가 없다.

권장 사항

그렇다면 컨텍스트, 컨텍스트 + useReducer, 리덕스 중 어떤 것을 사용할지 어떻게 결정할까?

해결하려는 문제에 가장 적합한 도구가 무엇인지 결정해야 한다.

  • prop-drilling을 피하는 것만 필요하다면 컨텍스트를 사용하라
  • 리액트 컴포넌트 상태가 적당히 복잡하거나 외부 라이브러리를 사용하고 싶지 않다면 컨텍스트 + useReducer를 사용하라
  • 시간이 지남에 따라 상태의 변화를 더 잘 추적하고 싶거나, 상태가 변경될 때 특정 컴포넌트만 다시 렌더링하도록 해야 하거나, sie-effects를 관리하기 위해 더 강력한 기능이 필요한 경우에는 리덕스를 사용하라

개인적인 의견으론 애플리케이션에서 상태 관련 컨텍스트가 2~3개를 넘어가면 리덕스를 다시 발명하는 것이므로 그냥 리덕스로 전환해야 한다는 것이다.

또 다른 일반적인 우려는 리덕스를 사용하면 boilerplate가 너무 많다는 것이다. 최신 리덕스는 이전에 봤던 것보다 훨씬 더 쉽게 사용할 수 있기 때문에 이러한 불만은 매우 오래된 것이다. 공식 RTK 패키지는 이러한 boilerplate에 대한 우려를 없애주며, React-Redux hooks API는 리액트 컴포넌트에서 리덕스 사용을 간소화한다.

또한 이 옵션들이 상호 배타적인 옵션이 아니라는 점을 지적하는 것이 중요하다. 리덕스, 컨텍스트, useReducer를 동시에 사용할 수 있다. 특히 전역 상태는 리덕스에, 로컬 상태는 리액트 컴포넌트에 배치하고, 각 상태를 리덕스에 둘지 컴포넌트 상태에 둘지 신중하게 결정할 것을 권장한다.