Context 실습: 리액트 라우터와 활용
Context 의 적당한 사용 사례는, 리액트 라우터와 함께 사용 할 때 입니다. 프로젝트에서 리액트 라우터를 사용하게 되면, 여러 페이지 사이에서 특정 값을 props 로 사용하려면, 꽤나 번거롭습니다. 물론 Context 없이도 하려면 할 수 는 있지만, 그렇게 하면 App.js 에서부터 다 내려줘야 하니, 번거롭기 짝이 없습니다.
이번 튜토리얼에서는, 리액트 라우터와 Context 를 함께 사용해보도록 하겠습니다.
우리가 만들 프로젝트 미리 보기
우리가 만들 프로젝트는 리액트 라우터를 사용하여 구성한 SPA 에서 Context 를 활용하여 유저 상태를 관리합니다.
계정 정보는 다음과 같습니다:
id: react
pw: good
id: context
pw: fun
프로젝트 준비
여러분들이 빠르게 구현 할 수 있도록 주요 컴포넌트들은 이미 준비해놓았습니다.
위 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;
비로그인 상태로 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 페이지로 리디렉트 되나요?
로그인 기능 구현하기
이번엔 로그인 기능을 구현해보겠습니다. 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);
이제 로그인 할 때 잘못된 정보를 입력하면 에러가 날 것이고, 제대로 입력하면 홈으로 이동 될 것입니다.
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;
헤더에서 이렇게 로그인 결과가 나타났습니다! 로그아웃도 잘 작동하는지 확인해보세요.
이제 비밀페이지도 들어가질거고, 우측에 있는 계정명을 누르면 /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;
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 };
이제 한번 새로고침을 해보세요. 잘 되나요?
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 페이지도 완료가 되었습니다!
다 진행하셨으면 코드가 이전과 동일하게 작동하는지 확인해보세요.
정리
이 실습을 마치고나면 Context 가 조금은 더 편해졌을 것 입니다. 리액트에서 props 를 특정 컴포넌트한테 전달하기 위해서 너무 많은 컴포넌트를 거쳐야 되는 구조라면, 혹은 여러 곳에서 공통적으로 사용하는 값이 있다면, Context 사용을 고려해보세요. 조만간 있을 React v16.7 에서 도입되는 Hooks 기능중에, useContext 를 사용하면 함수형 컴포넌트에서 HOC 외의 또 다른 방식으로도 Context 값을 활용 할 수 있게 될 것입니다. 이에 대해서는 조만간 자료를 공유해드리겠습니다.