Context?

React 공식 블로그에서 정의한 Context는 다음과 같다.

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

쉽게 말해서, 데이터를 일일이 전달할 필요없이 한번에 특정 컴포넌트에 전달하는 방법이다. React의 단방향 데이터 흐름상, 하위 컴포넌트가 많아질수록 일일이 값을 전달하기가 매우 번거롭다. Context API는 그런 불편함을 해소하고자 만들어졌다.

Context는 React 16.3에서 새로 추가 된 API이다. 이전 버전에도 있는데? 맞다. 이전 버전의 Context API는 Legacy Context 페이지에서 확인할 수 있다. 이전 버전의 Context API는 곧 지원이 중단될 예정이니 사용중이라면 참고하자.

언제 Context를 사용할까? 로그인 유저, 테마, 언어 등 글로벌한 데이터를 관리할 때 사용한다. 그럼 Context를 어떻게 사용하는지 예제를 통해 알아보자.

Context 사용 예제

흔히 사용하는 웹 페이지의 구성을 예제로 한다. 예제에선 App, Layout, NavBar 세 가지 컴포넌트를 사용했다. 컴포넌트 트리로 표현하면 다음과 같다.

컴포넌트 트리
<App>
  <Layout>
    <NavBar>

최상위 컴포넌트인 App로그인 유저의 데이터를 갖고 있다. 이 로그인 유저의 데이터를 NavBar 컴포넌트에 전달하는 것이 우리의 최종 목적이다.

// App.js
import React from 'react';
import Layout from './layout';

const CurrentUserContext = React.createContext(null);

class App extends React.Component {
  state = { currentUser: null };

  render() {
    return (
      <CurrentUserContext.Provider currentUser={this.state.currentUser}>
        <Layout>
      </CurrentUserContext.Provider>
    );
  }
}

Context를 사용하기 위해서 가장 먼저 해야할 일은 React.createContext(defaultValue)로 Context 객체를 만드는 것이다. 로그인 유저의 기본값은 null이므로 다음과 같이 Context를 생성했다.

const CurrentUserContext = React.createContext(null);

Context를 만들면 ProviderConsumer를 사용할 수 있다. 이 두 가지의 역할은 변수를 전달하는 공급자와 전달받은 변수를 사용하는 소비자이다.

예제를 보면 Layout 컴포넌트를 CurrentUserContext.Provider가 감싸고 있다.

<CurrentUserContext.Provider currentUser={this.state.currentUser}>
  <Layout>
</CurrentUserContext.Provider>

Layout을 포함한 모든 하위 컴포넌트들은 이제부터 공급자로부터 전달받은 currentUser 변수에 접근할 수 있다.

이제 currentUser를 ‘공급’받았으니 ‘소비’할 차례다. 하위 컴포넌트에서 currentUser 변수를 어떻게 접근하는지 다음의 예제를 보자.

// Layout.js
// NavBar 컴포넌트를 별도의 js파일에서 import했다고 가정한다.
class Layout extends React.Component {
  render() {
    return (
      <div>
        <NavBar />
        <h1>Hello, World</h1>
      </div>
    );
  }
}

// NavBar.js
// CurrentUserContext 객체를 별도의 js파일에서 import했다고 가정한다.
class NavBar extends Component {
  render() {
    return (
      <CurrentUserContext.Consumer>
        {currentUser => (
          <a href="/">Home</a>
          <a href="/about">About</a>
          <a href=`/profile/${currentUser.id}`>
        )}
      </CurrentUserContext.Consumer>
    );
  }
}

NavBar 컴포넌트에서 Consumer를 어떻게 사용하고 있는지 주목하자.

<CurrentUserContext.Consumer>
  {currentUser => (
    <a href="/">Home</a>
    <a href="/about">About</a>
    <a href=`/profile/${currentUser.id}`>
  )}
</CurrentUserContext.Consumer>

a 링크들을 CurrentUserContext.Consumer가 감싸고 있고, 그 안에 함수를 통해서 구현하고 있다. 함수의 인자를 통해서 currentUser를 전달받았다. 일단 우리의 목표는 달성했다.

이 함수는 현재 Context의 value(여기선 currentUser)을 인자로 받고 React 노드를 반환하는 함수이다. 이 value는 Context의 가장 가까운 공급자의 값과 같다. 예제에선 하나의 공급자가 있지만 여러 개의 공급자가 존재할 수도 있다. Consumer는 컴포넌트 트리의 최상위에서부터 가장 가까운 공급자의 value를 가져온다.

위의 예제에선 함수를 통해 값을 전달받았지만, 클래스에 Context를 주입하는 방법도 가능하다.

// NavBar.js
// CurrentUserContext 객체를 별도의 js파일에서 import했다고 가정한다.
class NavBar extends Component {
  render() {
    const { currentUser } = this.context;
  
    return (
      <React.Fragment>
        <a href="/">Home</a>
        <a href="/about">About</a>
        <a href=`/profile/${currentUser.id}`>
      </React.Fragment>
    );
  }
}

NavBar.contextType = CurrentUserContext;

이 방법의 장점은 React 컴포넌트의 모든 lifecycle에서 사용이 가능하다는 점이다.

다음은 예제에서 생략된 CurrentUserContext의 js파일이다.

// currentUserContext.js
import React from 'react';

const CurrentUserContext = React.createContext(null);

export default CurrentUserContext;

Redux vs Context API

Context API를 보면 떠오르는 라이브러리가 있다. 바로 Redux다. React와 함께 대표적으로 사용되는 React-redux 라이브러리의 출시일은 2015년 7월 12일이다. React의 출시일이 2015년 3월인걸 생각해보면 둘은 굉장히 오랫동안 함께 쓰여왔다는 걸 알 수 있다.

Redux는 어떻게 특정 컴포넌트에게 데이터를 전달했을까? 정답은 Context API다. 최신이 아닌, 옛날 버전으로.

이제 최신 Context API가 나온 시점에서, Redux를 사용할지 Context API를 사용할지 고민이 될 것이다. Context API와 Redux 각각의 장점을 보면서 살펴보기로 하자.

Redux의 장점

1. pure한 connect

Redux의 connect를 사용하면 자동으로 컴포넌트를 “pure”하게 만든다. 그 말인 즉슨, props가 변경될 때만 다시 렌더링을 한다. 즉, Redux의 state의 일부가 변경될 때만 렌더링이 된다. 따라서 불필요한 렌더링이 알아서 방지되고, 앱 실행 속도를 향상시킬 수 있다. 수동 구현 방법: React.PureComponent를 extends 하거나 shouldComponentUpdate를 이용한다.

2. 쉬운 디버깅

Redux DevTool Extension을 이용하면 모든 action에 대한 로그를 볼 수 있다. action이 실행될 때마다 실행 전/후의 state를 살펴볼 수 있다.

3. middleware를 이용한 커스터마이징

Redux는 다음과 같은 멋진 문구와 함께 middleware를 지원하고 있다.

a function that runs every time an action is dispatched.

  • _FETCH로 시작되는 action이 실행될 때마다 API 요청을 하고싶을 때
  • 분석 소프트웨어에 이벤트를 기록하고 싶을 때
  • 특정 시간에 특정 action 실행을 방지하고 싶을 때
  • JWT 토큰을 가로채서 로컬 저장소에 자동으로 저장하게 만들고 싶을 때

middleware를 이용하면 모두 가능하다. middleware 커스터마이징에 대한 좋은 글이 있다. middle를 작성하는 법

Context API의 장점

1. 유연하다

- Consumer를 HOC로 만들기

CurrentUserContext를 매번 필요할 때마다 추가하는 것이 번거로울 수 있다.

만약 value를 props로 받고 싶다면, 다음과 같이 구현할 수 있다.

function withCurrentUser(Component) {
  return function ConnectedComponent(props) {
    return (
      <CurrentUserContext.Consumer>
        {currentUser => <Component {...props} currentUser={currentUser}/>}
      </CurrentUserContext.Consumer>
    );
  }
}

그러면 NavBar 컴포넌트를 withCurrentUser를 이용하여 다음과 같이 바꿀 수 있다.

// NavBar.js
class NavBar extends Component {
  render() {
    const { currentUser } = this.props;
  
    return (
      <React.Fragment>
        <a href="/">Home</a>
        <a href="/about">About</a>
        <a href=`/profile/${currentUser.id}`>
      </React.Fragment>
    );
  }
}

export default withCurrentUser(NavBar);

그럼 context가 Redux의 connect처럼 작동한다. 단, 자동 pure화 기능은 빠졌다.

- Provider에서 state 관리하기

context의 Provider는 단순히 값을 전달하는 연결고리일 뿐이다. 데이터를 유지하는 기능은 없다. 대신에 wrapper를 만들어서 데이터를 관리하게 만들 수 있다.

위의 예제에서는 App 컴포넌트가 데이터를 관리하고 있다. Redux의 store와 비슷한 컨셉으로 context를 통해 state를 전달하는 컴포넌트를 구현할 수 있다:

class UserStore extends React.Component {
  state = {
    currentUser: {
      avatar:
        "https://www.gravatar.com/avatar/5c3dd2d257ff0e14dbd2583485dbd44b",
      name: "Harim",
      email: "harim123@gmail.com",
    }
  };

  render() {
    return (
      <CurrentUserContext.Provider value={this.state.currentUser}>
        {this.props.children}
      </CurrentUserContext.Provider>
    );
  }
}

// ... 중도 생략 ...

ReactDOM.render(
  <UserStore>
    <App />
  </UserStore>,
  document.querySelector("#root")
);

이제 유저 데이터는 유일한 관심사가 유저 컴포넌트인 UserStore에서 관리한다. 이를 통해 App을 stateless하게 만들 수 있다. 깔끔해진 코드는 덤이다.

- Context를 통해서 Action 전달하기

Provider에서 전달하는 값엔 어떤 데이터든지 전달이 가능하다. 즉, 함수도 전달이 가능하다. value 뿐만 아니라 ‘Action’도 전달이 가능하다는 말이다.

Context API vs Redux, 그래서 승자는?

지금까지 두 가지를 모두 살펴보았다. 결과적으로 말하면, 기호와 상황에 맞게 사용하면 된다.

현재 앱의 규모가 얼마나 큰지, 또는 얼마나 커질 것인에 따라 다르다. 얼마나 많은 사람이 사용할 것인지 (개인 프로젝트인지 아니면 협업 프로젝트인지), 당신 혹은 당신의 팀이 기능적인 개념에 대해서 얼만큼 경험이 있는지 (ex: immutability 또는 pure function 등 Redux가 의존하는 개념) 생각해보자.

개인적으로는 Context API가 React에 내장되있고, 유연한 구현이 가능하다는 점에서 Context API를 더 자주 쓰게 될거 같다.

참고 자료

React Official Context Document, https://reactjs.org/docs/context.html

Context api vs Redux, https://daveceddia.com/context-api-vs-redux/