리액트 Context는 상태 관리 도구가 아니다

jh22j9·2021년 12월 17일
14

리액트의 Context API를 제대로 알고 사용했는지 돌아보기 위해 Why React Context is Not a "State Management" Tool 을 읽고 정리한 글

Context vs Redux는 현재의 Context API가 릴리즈된 이래로 리액트 커뮤니티 내에서 가장 널리 논의되어온 주제 중 하나이다. 안타깝게도 대부분의 논의는 둘의 목적과 쓰임에 대한 혼동에서 비롯한다.

이 글을 통해 Context와 Redux가 정확히 무엇인지, 어떤 식으로 쓰이는지, 어떻게 다른지, 언제 써야하는지를 명확히 정리하고자 한다.

Context와 Redux 이해하기


어떤 도구이든 정확하게 사용하려면 반드시 이해해야 할 것:

  • 도구의 용도가 무엇인지
  • 도구가 어떤 문제를 해결하고자 하는지
  • 언제, 어떠한 이유로 만들어졌는지

또한 현재 해결하고자 하는 문제가 무엇인지 정확히 파악한 뒤에 직면한 문제를 가장 잘 풀어낼 도구를 선택하면 된다. 이 때 그 도구를 선택한 이유는 누군가 써보라고 해서, 유명해서가 아니라 현재 주어진 상황에서 최선으로 작동하기 때문이어야 한다.

Context vs. Redux를 둘러싼 대부분의 오해는 이 도구들의 기능과 목적에 대한 이해 부족에서 비롯한다. 그러므로 언제 사용할지 판단하기 위해 우선 이 둘이 무엇을 하고, 무엇을 해결하는지부터 명확히 정의해보자.

React Context는 무엇인가?

현재 리액트 문서에 있는 Context에 대한 설명부터 살펴보자.

context를 이용하면 단계마다 일일이 props를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있습니다.
일반적인 React 애플리케이션에서 데이터는 위에서 아래로 (즉, 부모로부터 자식에게) props를 통해 전달되지만, 애플리케이션 안의 여러 컴포넌트들에 전해줘야 하는 props의 경우 (예를 들면 선호 로케일, UI 테마) 이 과정이 번거로울 수 있습니다. context를 이용하면, 트리 단계마다 명시적으로 props를 넘겨주지 않아도 많은 컴포넌트가 이러한 값을 공유하도록 할 수 있습니다.

관리에 대한 내용이 전혀 없으며, 값의 '전달', '공유'만을 언급한다.

현재의 React Context API(React.createContext())는 React 16.3 버전에서 처음 릴리즈 되었다. 이는 이전 버전의 리액트에서도 작동하였으나 큰 결함이 있던 기존의 Context API를 대체하는 것이었다. 기존 Context API의 주 문제점은 shouhlComponentUpdate()를 통해 해당 컴포넌트의 렌더링이 생략될 경우, Context로 전달받은 값의 업데이트를 보장할 수 없다는 것이었다. 다수의 컴포넌트가 성능 최적화를 shouldComponentUpdate에 기대고 있었으므로 기존의 Context는 데이터 전달 기능을 제대로 하지 못했다. 이 문제를 해결하기 위해 createContext()가 만들어졌고, 중간에서 렌더링이 생략되더라도 하위 컴포넌트에서 모든 업데이트가 가능해졌다.

Context 사용하기

React Context를 사용하려면 다음 단계를 거친다:

  • const MyContext = React.createContext()로 컨텍스트 객체 생성
  • 상위 컴포넌트에서 <MyContext.Provider value={someValue}>를 렌더링한다. 데이터 한 덩어리를 컨텍스트에 넣어주는 것으로, string, a number, an object, an array, a class instance, an event emitter 등 모든 형태가 될 수 있다.
  • provider 내부에 있는 모든 하위 컴포넌트에서 const theContextValue = useContext(MyContext)같은 형태로 이 데이터를 호출할 수 있다.

상위 컴포넌트가 리렌더링 되면 context provider에 새로운 레퍼런스가 value로 전달되고, context가 읽을 수 있는 모든 컴포넌트가 강제로 리렌더 된다.

보통 context value는 컴포넌트의 State로부터 온다 :

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

  // Create an object containing both the value and the setter
  const contextValue = {counter, setCounter};

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

이제 하위 컴포넌트에서 useContext를 호출하여 이 값을 읽을 수 있다 :

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

  // do something with the counter value and setter
}

Context의 목적 및 쓰임

코드를 보면 Context가 그 어떤 것도 '관리'하지는 않음을 볼 수 있다. 컨텍스트는 파이프나 웜홀에 가깝다. <MyContext.Provider>로 파이프의 최상단에 무언가를 넣으면, 그것이 useContext(MyContext)를 외친 쪽으로 튀어나오는 것이다.

이런 개념을 의존성 주입(Dependenty Injection)이라고 한다. 하위 컴포넌트에서 특정 값을 필요로 할 때, 직접 생성 또는 마련하지 않고 런타임 시에 상위 컴포넌트의 어딘가로부터 값을 내려받는 것이다.

Redux는 무엇일까?

비교를 위해 Redux 공식 문서의 정의를 살펴본다.

Redux is a pattern and library for managing and updating application state, using events called "actions". It serves as a centralized store for state that needs to be used across your entire application, with rules ensuring that the state can only be updated in a predictable fashion.
Redux helps you manage "global" state - state that is needed across many parts of your application.
The patterns and tools provided by Redux make it easier to understand when, where, why, and how the state in your application is being updated, and how your application logic will behave when those changes occur.

위 설명은

  • 명확히 상태 관리에 대해 말하고 있다.
  • 리덕스의 목적이 State 업데이트 과정의 이해를 돕는 것이라고 말하고 있다.

리덕스는 처음에 Flux Architecture 적용의 일환으로 만들어졌다. 이 패턴은 페이스북이 2014년 첫 제안하였으며 리액트의 근간이 되기도 한 아키텍처로, 이후 리액트 커뮤니티는 십여 개의 서로 다른 접근 방식을 가진 Flux 패턴 기반 라이브러리를 만들어냈다. 그리고 2015년, 최선의 디자인으로 당시의 여러 이슈에 대한 해결책이 되었으며 리액트와의 궁합도 훌륭한 리덕스가 출시되자마자 리덕스는 Flux 전쟁의 승자가 된다.

리덕스는 함수형 프로그래밍 원리를 사용하여 최대한 예측 가능한 코드로 리듀서 함수를 작성하도록 하며, '이벤트 정의' 로직과 '이벤트가 일어났을 때의 상태 변화 로직'을 분리하도록 한다. 리덕스 스토어의 기능을 확장하는 미들웨어를 사용하여 사이드 이펙트를 처리하기도 한다.

또한, Redux Devtools을 제공하므로 이를 통해 액션 히스토리와 스테이트 변화 과정을 앱에서 볼 수도 있다.

Redux와 React

리덕스는 모든 UI layer(React, Vue, Angular, vanilla JS 등)와 함께 사용할 수 있다.

그러나 대부분의 경우 리액트와 함께 쓰인다. React-Redux 라이브러리는 공식 UI 바인딩 레이어로써 리액트 컴포넌트가 리덕스의 상태 값을 읽고 액션을 디스패치하는 방식을 통해 리덕스 스토어와 상호작용 할 수 있게 해준다. 그러므로 보통 '리덕스'를 사용한다고 말하면, Redux store + React-Redux library를 함께 쓰는 것'을 뜻할 때가 많다.

React-Redux 라이브러리 덕분에 모든 리액트 컴포넌트는 리덕스 스토어와 소통할 수 있다. 이는 React-Redux 가 내부적으로 Context를 사용하기 때문에 가능한 것이다. 그러나 중요한 것은, React-Redux가 컨텍스트를 통해 전달하는 것은 현재의 상태 값(state value)이 아니라 리덕스 스토어 인스턴스(Redux store instance)라는 것이다. 이것이 바로 컨텍스트를 의존성 주입으로 사용하는 것의 예시이다. 우리는 리덕스와 연결된 리액트 컴포넌트들이 스토어의 문을 두드릴 것을 알지만, 컴포넌트를 정의할 때는 어떤 리덕스 스토어와 연결할지 신경쓰지 않고 알지도 못한다. 리덕스 스토어는 React-Redux의 <Provider>를 통해 런타임 시에 트리에 주입될 뿐이다.

React-Redux가 내부적으로 Context를 사용한다는 이유 때문에 prop-drilling을 피하기 위한 목적으로 React-Redux를 사용하기도 한다. <MyContext.Provider>안에 공공연하게 새로운 값을 넣어주는 대신, 리덕스 스토어에 넣고 어디에서든 접근하여 쓰는 것이다.

(React-)Redux의 목적 및 쓰임

리덕스를 사용하는 주된 이유 중 하나가 공식 문서에 설명되어 있다:

The patterns and tools provided by Redux make it easier to understand when, where, why, and how the state in your application is being updated, and how your application logic will behave when those changes occur.

prop-drilling을 피하기 위함도 그 외 다른 이유들 중 하나이다. 많은 이들이 이 이유로 초기에 리덕스를 선택했다. 리액트의 기존 Context가 제대로 작동하지 않았었기 때문이다.

Context가 상태 관리 툴이 아닌 이유


상태(State)란 어플리케이션의 작동에 관여하는 모든 데이터를 말한다. 상태는 서버 상태(server state), 커뮤니케이션 상태(communication state), 로케이션 상태(location state)로 분류할 수 있다. 중요한 것은 데이터가 필요 시 저장되고(stored), 읽히고(read), 업데이트되고(updated), 사용되어야(used) 한다는 점이다.

그런 의미에서 상태 관리란:

  • 초기 값 저장
  • 현재 값 읽기
  • 값 업데이트를 의미한다고 할 수 있다.
  • 일반적으로 현재 값이 변경되면 알리는 역할도 포함한다.

리액트의 useStateuseReducer 훅은 상태 관리의 좋은 예시가 된다. 이 두가지 훅을 통해

  • 훅을 호출하면서 초기 값 저장
  • 훅을 호출하면서 현재 값 읽기
  • setStatedispatch 함수를 호출하여 값 업데이트
  • 컴포넌트가 리렌더되면서 값이 업데이트 된다.

이 맥락에서 Redux는 완전한 상태 관리 툴이다:

  • 리덕스 스토어는 root reducer를 호출하면서 초기 값을 저장하고, store.getState()로 현재 값을 읽고, store.dispatch(action)으로 값을 업데이트 할수 있게 하며 store.subscribe(listener)를 통해 스토어의 업데이트를 알린다.

리액트 Context는 이 조건에 부합하지 않으므로 상태 관리 툴이 아니다. Context는 그 자체로는 아무것도 '저장'하지 않는다. 상위 컴포넌트가 <MyContext.Provider>를 렌더하는 상위 컴포넌트는 Context에 어떤 값을 넣어줄지 결정하는 것에만 관여한다. 그리고 이 값은 보통 리액트 컴포넌트의 상태에 기반한다. 실질적인 '상태 관리'는 useState/useReducer 훅으로 이루어지는 것이다.

컨텍스트는 이미 어딘가에 존재하는 상태를 다른 컴포넌트와 공유하는 방법일 뿐이다.

Context와 Redux의 비교


Context와 React-Redux의 기능을 정리해보자:

  • Context

    • 어떤 것도 저장하거나 '관리'하지 않는다.
    • 리액트 컴포넌트에서만 작동한다.
    • 타입에 관계 없는 단일 값을 전달한다(원시, 객체, 클래스 등).
    • 단일 값을 읽을 수 있도록 한다.
    • prop-drilling을 피하기 위해 사용할 수 있다.
    • React DevTools에서 Provider, Consumer가 있는 컴포넌트의 현재 값을 볼 수 있다. 그러나 상태 업데이트 히스토리는 볼 수 없다.
    • Context 가 전달하는 값이 업데이트 되면 Consumer 컴포넌트도 자동으로 업데이트 되며, 이를 스킵할 방법이 없다.
    • 사이드 이펙트를 처리하는 메커니즘이 전무하며, 오로지 컴포넌트 렌더링에만 관여한다.
  • React+Redux

    • 단일 값을 저장하고 관리한다. (원칙적으로 객체이다.)
    • 리액트 컴포넌트가 아닌 다른 UI와 함께 사용할 수 있다.
    • 단일 값을 읽을 수 있도록 한다.
    • prop-drilling을 피하기 위해 사용할 수 있다.
    • 액션을 디스패칭 하고 리듀서를 작동시킴으로써 값을 업데이트 한다.
    • DevTool에서 디스패치된 모든 액션과 스테이트 변화를 시간 순으로 볼 수 있다.
    • 사이드 이펙트를 발생시키기 위한 미들웨어를 사용할 수 있다.
    • 컴포넌트가 스토어 업데이트를 구독하고, 스토어 상태의 특정한 일부를 추출하고 해당 값이 바뀌었을 때만 컴포넌트가 리렌더되도록 할 수 있다.

보다시피 이 둘은 기능적으로 매우 다른 툴이다. 하나의 공통점이 있다면 prop-drilling을 피하게 해준다는 것 뿐이다.

Context와 useReducer


Context vs Redux 논쟁에서의 유일한 문제점은, 사람들이 "useReducer로 상태 관리를 하고, Context로 값을 전달해준다."고 말하는 대신 "나 Context 사용했어."라고 말한다는 것에 있다. 이것이 이 혼란의 공통적인 원인이며, 이 때문에 Context로 상태를 관리한다는 잘못된 이야기가 지속된다고 본다.

그렇다면, 이제 Context + useReducer의 조합을 살펴보자. 이 조합은 Redux + React-Redux와 끔직히도 닮아있다. 두 조합 모두에는 아래의 공통점이 있다.

  • 저장(보관)되는 값
  • 리듀서 함수
  • 액션 디스패칭
  • 중첩 컴포넌트 내에서 값을 전달하고 읽는 기능

반면 큰 차이점도 있는데,

  • Context + useReducer는 리액트의 기능이므로 리액트에서만 사용할 수 있다. Redux 스토어는 모든 UI에 독립적이므로 별개로 사용 가능하다.
  • 리액트 DevTools로 현재 컨텍스트 값을 볼 수는 있지만, 시간 순으로 지난 기록을 볼 수는 없다.
    리덕스 DevTools로는 디스패치된 모든 액션, 모든 액션의 컨텐츠, 액션 실행 후의 상태, 시간에 따른 상태의 차이를 모두 볼 수 있다.
  • useReducer에는 미들웨어가 없다. useReduceruseEffect를 함께 사용하여 사이드 이펙트를 낼 수 있고, 미들웨어와 비슷한 대안을 사용하려는 시도도 있어왔으나 기능과 효과면에서 리덕스 미들웨어에 비해서는 한계를 지닌다.

스코프 문제와 불필요한 리렌더를 막기 위해 여러 개의 컨텍스트를 두고 같은 맥락을 공유하는 스테이트끼리 묶어 분리하는 방식을 추천하는 글도 여럿 볼 수 있다. 가능한 방법이긴 하나, 이는 React-Redux를 더 수준 낮게 재구현하는 방식과 다름없다.

얼핏 보기에는 Context + useReducer가 Redux + React-Redux와 거의 같아보일지 몰라도, 절대 동일하지 않고 기능적으로 리덕스를 대체할 수는 없는 것이다.

유즈 케이스 요약


각각의 사용 예시를 다시 정리하면 이와 같다:

  • Context
    • prop-drilling 없이 중첩 컴포넌트에 값을 전달해준다.
  • useReducer
    • reducer 함수를 사용하여 보통 수준의 복잡도를 지닌 리액트 컴포넌트의 상태를 관리한다.
  • Context + useReducer
    • reducer 함수를 사용하여 보통 수준의 복잡도를 지닌 리액트 컴포넌트의 상태를 관리하고, prop-drilling 없이 중첩 컴포넌트에 값을 전달한다.
  • Redux
    • reducer 함수를 사용하여 중간 또는 높은 수준의 복잡도를 지닌 리액트 컴포넌트 상태를 관리한다.
    • 언제, 왜, 어떻게 상태가 변해왔는지 시간 순으로 추적한다.
    • UI 레이어와 상태 관리 로직을 완벽히 분리한다.
    • 서로 다른 UI 레이어 간 상태 관리 로직을 공유한다.
    • 강력한 Redux 미들웨어 기능으로 액션 디스패치 시 로직을 추가한다.
    • Redux 상태에 항시적으로 접근된 상태이다.
    • 개발자가 버그 리포트를 재현할 수 있다.
    • 개발 시 로직과 UI를 빠르게 디버깅 할 수 있다.
  • Redux + React-Redux
    • 리액트 컴포넌트와 리덕스 스토어를 연결하여 모든 리덕스의 유즈 케이스를 구현할 수 있다.

결론


그래서 Context, Contexr + useReducer, Redux + React-Redux 중에 무엇을 사용할 것인가?

지금 해결할 문제가 무엇인지에 달렸다.

  • 단순히 prop-drilling을 피하고 싶다면 Context를 사용하라.
  • 컴포넌트 복잡도가 보통 수준이거나 외부 라이브러리를 사용하고 싶지 않다면 Context + useReducer 조합을 사용하라.
  • 상태 변화 추적이 필요할 때, 상태 변화 시 특정 컴포넌트만 리렌더하고 싶을 때, 사이드 이펙트를 강력하게 컨트롤하고 싶을 때는 Redux + React-Redux를 사용하라.

🔗 Why React Context is Not a "State Management" Tool

0개의 댓글