Context 실습: 리액트 라우터와 활용

Context 의 적당한 사용 사례는, 리액트 라우터와 함께 사용 할 때 입니다. 프로젝트에서 리액트 라우터를 사용하게 되면, 여러 페이지 사이에서 특정 값을 props 로 사용하려면, 꽤나 번거롭습니다. 물론 Context 없이도 하려면 할 수 는 있지만, 그렇게 하면 App.js 에서부터 다 내려줘야 하니, 번거롭기 짝이 없습니다.

이번 튜토리얼에서는, 리액트 라우터와 Context 를 함께 사용해보도록 하겠습니다.

우리가 만들 프로젝트 미리 보기

Edit context-with-router

우리가 만들 프로젝트는 리액트 라우터를 사용하여 구성한 SPA 에서 Context 를 활용하여 유저 상태를 관리합니다.

계정 정보는 다음과 같습니다:

id: react
pw: good

id: context
pw: fun

프로젝트 준비

여러분들이 빠르게 구현 할 수 있도록 주요 컴포넌트들은 이미 준비해놓았습니다.

Edit context-with-router

위 CodeSandbox 에서 진행하시거나, 컴퓨터상에서 직접 작업하시고 싶으면 다음 명령어를 통해 git 에 올린 프로젝트를 받은 후 template 브랜치를 checkout 해주세요.

$ git clone https://github.com/vlpt-playground/context-with-router
$ cd context-with-router
$ git checkout template

프로젝트 살펴보기 및 요구사항

현재 프로젝트에는 다음 라우트들이 준비되어있습니다:

<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/secrets" component={Secrets} />
<Route path="/me" component={Me} />
<Route path="/login" component={Login} />

우리가 완성시킬 프로젝트의 요구사항은 다음과 같습니다:

  • secrets 페이지와 me 페이지는 로그인상태에서만 들어갈 수 있고 로그인이 안되어있으면 로그인 페이지로 이동한다.
  • me 페이지에서는 현재 로그인중인 유저명이 나타난다.
  • 헤더에서도 로그인중인 유저명이 나타난다.
  • 로그아웃을 하면 값이 모두 초기화된다.
  • localStorage 를 사용하여 새로고침이되도 상태가 유지되도록 한다.

로그인이 완료된 상태의 화면은 다음과 같이 보여지게 됩니다:

Context 만들기

유저 상태를 관리할 UserContext 를 만들어보겠습니다. 이 파일은 src/contexts 디렉토리를 생성하여 그 안에 작성해주겠습니다. 다음 코드에 달린 주석들을 하나하나 읽어보면서 코드를 작성해보세요!

src/contexts/UserContext.js

import React, { Component, createContext } from 'react';

// Context 를 만듭니다.
const UserContext = createContext();

// UserContext 안에 있는 Consumer 를 UserConsumer 로 네이밍을 해줍니다.
// Provider 는 우리가 하단 코드에서 이를 사용하여 UserProvider 컴포넌트를 만들게 되니 유지합니다.
const { Provider, Consumer: UserConsumer } = UserContext;

// 사용자 계정정보를 가짜로 담아뒀습니다.
const users = [
  {
    id: 1,
    username: 'react',
    password: 'good',
  },
  {
    id: 2,
    username: 'context',
    password: 'fun',
  },
];

class UserProvider extends Component {
  // UserProvider 에서 다룰 상태입니다.
  state = {
    logged: false, // 현재 로그인중
    username: null, // 로그인중인 사용자명
  };

  // UserProvider 에서 다룰 액션 함수들입니다.
  actions = {
    // 로그인 함수
    login: form => {
      const { username, password } = form; // form 안엔 username 과 password 가 들어있습니다.
      const user = users.find(u => u.username === username); // username 으로 계정정보를 찾습니다.

      // 유저가 존재하지 않거나 비밀번호가 틀리면
      if (!user || user.password !== password) {
        return false; // 실패처리
      }

      // 성공시 state 를 업데이트 합니다.
      this.setState({
        logged: true,
        username,
      });

      return true; // 성공을 알립니다.
    },
    logout: () => {
      // 로그아웃 시 상태를 초기상태로 돌립니다.
      this.setState({
        logged: false,
        username: null,
      });
    },
  };

  render() {
    // 편의상 value 를 만들어서 JSX 를 작성하기 전에 우리가 Context 에서 사용 할 값들을 미리 넣어줍니다.
    const value = {
      state: this.state,
      actions: this.actions,
    };

    // Provider 에게 value 를 전달해줍니다.
    return <Provider value={value}>{this.props.children}</Provider>;
  }
}

// 우리가 만든 UserConsumer 과 UserProvider 를 내보내줍니다.
export { UserConsumer, UserProvider };

이제 프로젝트에 UserContext 를 적용하기 위해서 App.js 를 UserProvider 로 감싸줍시다!

src/App.js

import React, { Component } from 'react';
import { Route } from 'react-router-dom';

import './App.css';
import Header from './components/Header';
import Home from './pages/Home';
import About from './pages/About';
import Secrets from './pages/Secrets';
import Me from './pages/Me';
import Login from './pages/Login';
import { UserProvider } from './contexts/UserContext';

class App extends Component {
  render() {
    return (
      <UserProvider>
        <div className="App">
          <Header />
          <div>
            <Route exact path="/" component={Home} />
            <Route path="/about" component={About} />
            <Route path="/secrets" component={Secrets} />
            <Route path="/me" component={Me} />
            <Route path="/login" component={Login} />
          </div>
        </div>
      </UserProvider>
    );
  }
}

export default App;

Edit context-with-router

비로그인 상태로 Secrets 페이지 방문시 리디렉트

이제 UserProvider 를 사용하여 프로젝트에 UserContext 를 적용해주었으니, 우리가 value 에 넣어줬었던 값을 UserConsumer 를 통해 사용해볼 차례입니다. 가장 쉬운 작업부터 먼저 해보겠습니다. 만약에 비로그인 상태로 Secrets 페이지에 방문하면, /login 으로 이동시키는 기능을 구현해보겠습니다.

이 과정에선, JSX 상에서 리디렉트를 할 수 있게 해주는 Redirect 컴포넌트가 사용됩니다.

src/pages/Secrets.js

import React from 'react';
import { Redirect } from 'react-router-dom';
import { UserConsumer } from '../contexts/UserContext';
const Secrets = () => {
  return (
    <div>
      <UserConsumer>
        {({ state }) => {
          // state.logged 를 확인하고 이 값이 false 면 Redirect 컴포넌트로 리디렉트
          if (!state.logged) return <Redirect to="/login" />;
          return null; // true 일땐 그냥 null 반환
        }}
      </UserConsumer>
      <h1>비밀</h1>
      <p>이 페이지는 비밀스러운 페이지여서, 로그인 안하면 튕겨 나가요!</p>
    </div>
  );
};

export default Secrets;

다 구현하셨으면, secrets 페이지에 들어가보세요. 자동으로 login 페이지로 리디렉트 되나요?

Edit context-with-router

로그인 기능 구현하기

이번엔 로그인 기능을 구현해보겠습니다. LoginForm 에서 Context 에 있는 액션들에 접근하기 위하여, UserConsumer 를 LoginForm 에서 렌더링 해도 되긴 합니다만, 그렇게 하면 handleSubmit 메소드에서 접근을 할 수 없습니다. 따라서 LoginForm 에서 UserConsumer 를 사용하려면 다음과 같은 형식으로, JSX 단에서 handleSubmit 함수를 구현해주어야 합니다.

<form onSubmit={(e) => {
  e.preventDefault();
  // ...
}}>

우리는 JSX 에서 함수 선언하는것 말고, 사전에 선언된 handleSubmit 를 사용하고 싶으니, UserConsumer 를 사용하는곳은 LoginForm 이 아니라, LoginForm 을 사용하는 Login 페이지 컴포넌트여야합니다.

그럼, 한번 Login 페이지에서 UserConsumer 를 사용하여 LoginForm 에세 login 액션함수를 전달해주겠습니다.

src/pages/Login.js

import React from 'react';
import LoginForm from '../components/LoginForm';
import { UserConsumer } from '../contexts/UserContext';

const Login = () => {
  return (
    <div>
      <h1>로그인</h1>
      <p>전혀 로그인이랑은 관계없어 보이지만.. 로그인입니다.</p>
      <UserConsumer>
        {({ actions }) => <LoginForm onLogin={actions.login} />}
      </UserConsumer>
    </div>
  );
};

export default Login;

간단하죠?

이제 LoginForm 에서 onLogin 값의 결과에 따라 에러를 보여주거나, 홈으로 이동을 시키겠습니다. 메소드 로직상에서 라우팅을 하려면 history 객체에 접근해야 하므로, withRouter HOC 를 사용합니다.

이 컴포넌트에서는 onLogin 에 현재 username 과 password 값을 전달하여 로그인 성공여부를 확인한 뒤, state.error 값을 true 로 전환시켜 에러메시지를 띄웁니다.

src/components/LoginForm.js

import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';

class LoginForm extends Component {
  state = {
    username: '',
    password: '',
    error: false, // 에러 값 확인
  };
  handleChange = e => {
    this.setState({
      [e.target.name]: e.target.value,
    });
  };

  handleSubmit = e => {
    e.preventDefault();
    // 결과 확인
    const result = this.props.onLogin({
      username: this.state.username,
      password: this.state.password,
    });

    // 로그인 실패
    if (!result) {
      this.setState({
        error: true,
      });
      return;
    }
    // 홈으로 이동
    this.props.history.push('/');
  };

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <input
          name="username"
          value={this.state.username}
          onChange={this.handleChange}
          placeholder="아이디"
        />
        <input
          type="password"
          name="password"
          value={this.state.password}
          onChange={this.handleChange}
          placeholder="비밀번호"
        />
        <button type="submit">로그인</button>
        {this.state.error && <div>로그인 에러!</div>}
      </form>
    );
  }
}

// history 를 사용하기 위해 withRouter 사용
export default withRouter(LoginForm);

Edit context-with-router

이제 로그인 할 때 잘못된 정보를 입력하면 에러가 날 것이고, 제대로 입력하면 홈으로 이동 될 것입니다.

Header에서 로그아웃 구현하기

이제 Header 컴포넌트 에서 로그아웃 하는 기능을 구현하겠습니다. 추가적으로 로그인시에는 현재 로그인중인 사용자의 username 과 /me 로 이동하는 링크도 보여주겠습니다.

src/components/Header.js

import React from 'react';
import { Link } from 'react-router-dom';
import { UserConsumer } from '../contexts/UserContext';
import './Header.css';

const Header = () => {
  return (
    <div className="Header">
      <div>
        <Link to="/"></Link>
        <Link to="/about">소개</Link>
        <Link to="/secrets">비밀</Link>
      </div>
      <div>
        <UserConsumer>
          {({ state, actions }) => {
            // 로그인중임
            if (state.logged) {
              // Fragment 사용: <>...</>
              return (
                <>
                  <span>
                    안녕하세요
                    <b>
                      <Link to="/me">{state.username}</Link>
                    </b>
                    !
                  </span>
                  <span className="logout" onClick={actions.logout}>
                    로그아웃
                  </span>
                </>
              );
            }
            // 로그인중 아님
            return <Link to="/login">로그인</Link>;
          }}
        </UserConsumer>
      </div>
    </div>
  );
};

export default Header;

Edit context-with-router

헤더에서 이렇게 로그인 결과가 나타났습니다! 로그아웃도 잘 작동하는지 확인해보세요.

이제 비밀페이지도 들어가질거고, 우측에 있는 계정명을 누르면 /me 페이지도 들어가질 것입니다. 하지만, 아직 Me 페이지는 구현하지 않았으니 "로그인한 계정: 어쩌고" 라고만 나오겠죠.

이것도 구현해봅시다!

Me 페이지 구현하기

Me 페이지에서는 만약 로그인이 되어있지 않다면 로그인페이지로 리디렉트하고, 로그인중이라면 state.username 을 보여줍니다.

src/pages/Me.js

import React from 'react';
import { Redirect } from 'react-router-dom';
import { UserConsumer } from '../contexts/UserContext';

const Me = () => {
  return (
    <UserConsumer>
      {({ state }) => {
        // 비로그인시 리디렉트
        if (!state.logged) return <Redirect to="/login" />;
        // 로그인시 현재 username 보여주기
        return (
          <div>
            <h1>내 정보</h1>
            <p>로그인한 계정: {state.username}</p>
            <p>
              로그인을 하면 어떤 계정인지 보일거고, 안하면 로그인 페이지로
              이동돼요.
            </p>
          </div>
        );
      }}
    </UserConsumer>
  );
};

export default Me;

Edit context-with-router

Me 페이지가 잘 나타나나요?

localStorage 에 값 저장시켜서 상태 유지하기

로그인이 성공하면 로그인 상태를 localStorage 에 저장시켜서 새로고침이 되도 상태가 날아가지 않게 하는 작업을 해보겠습니다. localStorage 는 브라우저에서 지원하는 객체로서, 사용자의 브라우저에 원하는 값을 문자열 형태로 저장 할 수 있게 해줍니다.

Context 에서 login 과 logout 함수쪽에서 localStorage 에 값을 넣고, 지우게 하는 로직을 추가하고, constructor 에서 localStorage 를 읽어오는 작업을 하시면 됩니다.

import React, { Component, createContext } from 'react';

// Context 를 만듭니다.
const UserContext = createContext();

// UserContext 안에 있는 Consumer 를 UserConsumer 로 네이밍을 해줍니다.
// Provider 는 우리가 하단 코드에서 이를 사용하여 UserProvider 컴포넌트를 만들게 되니 유지합니다.
const { Provider, Consumer: UserConsumer } = UserContext;

// 사용자 계정정보를 가짜로 담아뒀습니다.
const users = [
  {
    id: 1,
    username: 'react',
    password: 'good',
  },
  {
    id: 2,
    username: 'context',
    password: 'fun',
  },
];

class UserProvider extends Component {
  // UserProvider 에서 다룰 상태입니다.
  state = {
    logged: false, // 현재 로그인중
    username: null, // 로그인중인 사용자명
  };

  constructor(props) {
    super(props);
    // 중요! 만약 로컬스토리지가 없는 환경 (예: 서버사이드 렌더링) 이라면 진행하지 않음.
    if (typeof localStorage === 'undefined') return;
    // user 를 로컬스토리지에서 읽어오고
    const user = localStorage.getItem('user');
    if (user) {
      // 존재하면
      try {
        // 파싱을 시도합니다. 근데 이게 실패 할 수도 있으므로 try 로 감쌌습니다.
        const parsed = JSON.parse(user);
        // 파싱 문제 없으면 state 에 반영.
        this.state = {
          logged: true,
          username: parsed.username,
        };
      } catch (e) {
        console.log(e);
      }
    }
  }

  // UserProvider 에서 다룰 액션 함수들입니다.
  actions = {
    // 로그인 함수
    login: form => {
      const { username, password } = form; // form 안엔 username 과 password 가 들어있습니다.
      const user = users.find(u => u.username === username); // username 으로 계정정보를 찾습니다.

      // 유저가 존재하지 않거나 비밀번호가 틀리면
      if (!user || user.password !== password) {
        return false; // 실패처리
      }

      // 성공시 state 를 업데이트 합니다.
      this.setState({
        logged: true,
        username,
      });

      // 유저 정보를 로컬스토리지에 기록합니다.
      localStorage.setItem('user', JSON.stringify(user));

      return true; // 성공을 알립니다.
    },
    logout: () => {
      // 로그아웃 시 상태를 초기상태로 돌립니다.
      this.setState({
        logged: false,
        username: null,
      });

      // 로컬스토리지에서 기존 값을 날립니다.
      localStorage.removeItem('user');
    },
  };

  render() {
    // 편의상 value 를 만들어서 JSX 를 작성하기 전에 우리가 Context 에서 사용 할 값들을 미리 넣어줍니다.
    const value = {
      state: this.state,
      actions: this.actions,
    };

    // Provider 에게 value 를 전달해줍니다.
    return <Provider value={value}>{this.props.children}</Provider>;
  }
}

// 우리가 만든 UserConsumer 과 UserProvider 를 내보내줍니다.
export { UserConsumer, UserProvider };

이제 한번 새로고침을 해보세요. 잘 되나요?

Edit context-with-router

recompose의 fromRenderProps 로 구현해보기

이번 실습을 통하여 프로젝트를 구현을 하면서, Consumer 컴포넌트와 Render Props 패턴을 여러번 사용해보면서 이미 이 방식에 많이 익숙해지셨을 것 입니다. 이 방식이 충분~히 편하다고 생각하신다면 이 섹션은 굳이 진행하지 않으셔도 됩니다.

이번 섹션에서는, recompose 에 있는 fromRenderProps HOC 를 사용해서 똑같은 기능을 구현해보는 방법을 알아보겠습니다.

주요 차이점은, Render Props 형태의 코드를 쓰지 않고 HOC 를 사용해서 해결한다는 점이고, 함수를 사용해서 값을 받아오는 대신에 props 로 값들을 받아오기 때문에 컴포넌트의 JSX 코드를 읽을 때 훨씬 간결하다는 점 입니다.

현재 프로젝트엔 이미 recompose 가 설치되어있기 때문에 설치는 생략합니다.

우리가 이전 예제에서 진행했던대로, Secrets, Login, Header, Me 순서로 구현해보겠습니다.

Secrets 코드 수정하기

src/pages/Secrets.js

import React from 'react';
import { UserConsumer } from '../contexts/UserContext';
import { Redirect } from 'react-router-dom';
import { fromRenderProps } from 'recompose';

const Secrets = ({ logged }) => {
  return (
    <div>
      {!logged && <Redirect to="/login" />}
      <h1>비밀</h1>
      <p>이 페이지는 비밀스러운 페이지여서, 로그인 안하면 튕겨 나가요!</p>
    </div>
  );
};

export default fromRenderProps(UserConsumer, ({ state }) => ({
  logged: state.logged,
}))(Secrets);

Login 코드 수정하기

아까전에는 handleSubmit 을 사용하기 위해서 UserConsumer 를 Login 컴포넌트에서 사용을 해주었는데요, 이제 HOC 를 사용하여 어짜피 props 로 받아올 수 있게 되니 이제 굳이 Login 쪽에서 UserConsumer를 사용 할 필요 없습니다. 그 대신에, LoginForm 에서 바로 구현하시면 됩니다.

src/pages/Login.js

import React from 'react';
import LoginForm from '../components/LoginForm';

const Login = () => {
  return (
    <div>
      <h1>로그인</h1>
      <p>전혀 로그인이랑은 관계없어 보이지만.. 로그인입니다.</p>
      <LoginForm />
    </div>
  );
};

export default Login;

src/components/LoginForm.js

기존에 LoginForm 에서 이미 withRouter HOC 를 사용하고 있었으니, HOC 를 깔끔하게 합쳐서 사용 할 수 있게 해주는 compose 함수를 불러와서 사용하겠습니다.

import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { compose, fromRenderProps } from 'recompose';
import { UserConsumer } from '../contexts/UserContext';

class LoginForm extends Component {
  state = {
    username: '',
    password: '',
    error: false,
  };
  handleChange = e => {
    this.setState({
      [e.target.name]: e.target.value,
    });
  };
  handleSubmit = e => {
    e.preventDefault();
    const result = this.props.onLogin({
      username: this.state.username,
      password: this.state.password,
    });

    // 로그인 실패
    if (!result) {
      this.setState({
        error: true,
      });
      return;
    }
    // 홈으로 이동
    this.props.history.push('/');
  };
  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <input
          name="username"
          value={this.state.username}
          onChange={this.handleChange}
          placeholder="아이디"
        />
        <input
          type="password"
          name="password"
          value={this.state.password}
          onChange={this.handleChange}
          placeholder="비밀번호"
        />
        <button type="submit">로그인</button>
        {this.state.error && <div>로그인 에러!</div>}
      </form>
    );
  }
}

const enhance = compose(
  withRouter,
  fromRenderProps(UserConsumer, ({ state, actions }) => ({
    onLogin: actions.login,
  })),
);
export default enhance(LoginForm);

확실히 온전히 props 를 사용해서 Context 에 있는 값을 사용 할 수 있으니 편하지 않나요?

Header 코드 수정하기

src/components/Header.js

import React from 'react';
import { Link } from 'react-router-dom';
import './Header.css';
import { UserConsumer } from '../contexts/UserContext';
import { fromRenderProps } from 'recompose';

const Header = ({ username, logged, onLogout }) => {
  return (
    <div className="Header">
      <div>
        <Link to="/"></Link>
        <Link to="/about">소개</Link>
        <Link to="/secrets">비밀</Link>
      </div>
      <div>
        {logged ? (
          <>
            <span>
              안녕하세요
              <b>
                <Link to="/me">{username}</Link>
              </b>
              !
            </span>
            <span className="logout" onClick={onLogout}>
              로그아웃
            </span>
          </>
        ) : (
          <Link to="/login">로그인</Link>
        )}
      </div>
    </div>
  );
};

export default fromRenderProps(UserConsumer, ({ state, actions }) => ({
  username: state.username,
  logged: state.logged,
  onLogout: actions.logout,
}))(Header);

헤더쪽도 작업이 마무리되었습니다.

Me 페이지 수정하기

마지막으로, Me 페이지도 작업해보겠습니다.

src/pages/Me.js

import React from 'react';
import { UserConsumer } from '../contexts/UserContext';
import { fromRenderProps } from 'recompose';
import { Redirect } from 'react-router-dom';

const Me = ({ logged, username }) => {
  if (!logged) {
    return <Redirect to="/login" />;
  }
  return (
    <div>
      <h1>내 정보</h1>
      <p>로그인한 계정: {username}</p>
      <p>
        로그인을 하면 어떤 계정인지 보일거고, 안하면 로그인 페이지로 이동돼요.
      </p>
    </div>
  );
};

export default fromRenderProps(UserConsumer, ({ state }) => ({
  logged: state.logged,
  username: state.username,
}))(Me);

Me 페이지도 완료가 되었습니다!

다 진행하셨으면 코드가 이전과 동일하게 작동하는지 확인해보세요.

Edit context-with-router

정리

이 실습을 마치고나면 Context 가 조금은 더 편해졌을 것 입니다. 리액트에서 props 를 특정 컴포넌트한테 전달하기 위해서 너무 많은 컴포넌트를 거쳐야 되는 구조라면, 혹은 여러 곳에서 공통적으로 사용하는 값이 있다면, Context 사용을 고려해보세요. 조만간 있을 React v16.7 에서 도입되는 Hooks 기능중에, useContext 를 사용하면 함수형 컴포넌트에서 HOC 외의 또 다른 방식으로도 Context 값을 활용 할 수 있게 될 것입니다. 이에 대해서는 조만간 자료를 공유해드리겠습니다.

results matching ""

    No results matching ""