01.18 Code Splitting 0


React.js

01.18. 리액트 코드 스플리팅

싱글 페이지 애플리케이션단점은 페이지 로딩 속도지연될 수 있다는 것이다. 로딩 속도가 지연되는 이유는 자바스크립트 번들 파일에 모든 애플리케이션로직을 불러오므로 규모가 커지면서 용량도 커지기 때문이다. 하지만 이 문제는 코드 스플리팅(code splitting)을 하면 해결할 수 있다.

코드 스플리팅(code splitting)은 말 그대로 코드를 분할한다는 의미이다. webpack에서 프로젝트를 번들링할 때 파일 하나라 아니라 여러 개로 분리시켜서 결과물을 만들 수 있다. 또 페이지를 로딩할 때 한꺼번에 불러오는 것이 아니라 필요한 시점에 불러올 수도 있다.

01.18.1. 코드 스플리팅의 기본

webpack4 이전의 버전에서는 vendor 를 직접 설정해야 했지만, webpack4 부터는 자동으로 생성해주기 때문에 별도의 설정은 하지 않아도 된다.

01.18.1.1. 비동기적 코드 불러오기: 청크생성

페이지에서 필요한 코드들만 불러오려면, 청크(chunk)를 생성해야한다. 청크를 생성하면 페이지를 로딩할 때 필요한 파일만 불러올 수 있고, 아직 불러오지 않은 청크 파일들은 나중에 필요할 때 비동기적으로 불러와 사용하라 수 있다.
// src/components/SplitMe.js
import React from 'react';

const SplitMe = () => {
return (
<h3>청크</h3>
);
};

export default SplitMe;
청크를 생성할 컴포넌트 자체는 특별히 하는 것이 없다. 다만 이 컴포넌트를 불러오는 것이 평상시와는 조금 다르다. 비동기적으로 파일을 불러오려면 import 를 코드 최상단에 적는 것이 아니라, 특정 함수 내부에서 작성한다. LifeCycle 메서드 안에 넣을 수도 있고, 별도의 이벤트를 설정하여 불러오도록 설정할 수도 있다.

SplitMe 를 비동기적으로 불러올 AsyncSplitMe 컴포넌트를 만들어 버튼을 눌렀을 때, SplitMe 컴포넌트를 불러와 state 에 담고 이를 렌더링한다.
// src/components/AsyncSplitMe.js
import React, {Component} from 'react';

class AsyncSplitMe extends Component {
state = {
SplitMe: null
}

loadSplitMe = () => {
// 비동기적으로 코드를 불러온다. 함수는 Promise 를 결과로 반환한다.
// import() 는 모듈의 전체 네임스페이스를 불러오므로, default 를 직접 지정해야한다.
import('./SplitMe').then(({ default: SplitMe }) => {
this.setState({
SplitMe
});
});
}

render() {
const { SplitMe } = this.state;
// SplitMe 가 있으면 이를 렌더링하고, 없으면 버튼을 렌더링한다.
// 버튼을 누르면 SplitMe 를 불러온다.
return SplitMe ? <SplitMe /> : <button onClick={this.loadSplitMe}>SpdlitMe 로딩</button>
}
}

export default AsyncSplitMe;

// src/App.js
import React from 'react';
import {Route} from 'react-router-dom';

import {Home, About, Posts} from 'pages';
import Menu from 'components/Menu';

import AsyncSplitMe from 'components/AsyncSplitMe';

const App = () => {
return (
<div>
<Menu />
<AsyncSplitMe />
(...)
</div>
);
}

export default App;
크롬 개발자 도구의 Network 탭을 열어 SplitMe 비동기적 로딩 버튼을 누르면 네트워크에는 2.chunk.js 파일을 불러온 기록이 남는다.
이렇게 코드 위에서 import __ from __ 이 아닌 import() 함수로 컴포넌트를 불러오면 webpack 은 청크를 생성하여 저장한다. 

01.18.1.2. 라우트에 코드 스플리팅

SplitMe, AsyncSplitMe 는 더 이상 사용하지 않기 때문에 삭제하고, App 에서 import 했던 코드도 삭제한다.

- asyncComponent 함수 생성
비동기적으로 불러올 코드가 많으면 청크를 생성할 때마다 파일에 비슷한 코드들을 반복하여 작성해야한다. 
조금 더 편하게 구현할 수 있도록 따로 함수화하여 재사용한다.
// lib/asyncComponent.js
import React from 'react';

export default function asyncComponent (getComponent) {
return class AsyncComponent extends React.Component {
static Component = null;
state = { Component: AsyncComponent.Component };

constructor(props) {
super(props);
if (AsyncComponent.Component) return ;
getComponent().then(({default: Component}) => {
AsyncComponent.Component = Component;
this.setState({Component});
});
}

render() {
const { Component } = this.state
if (Component) {
return <Component {...this.props} />
}
return null;
};
}
};
이 함수는 컴포넌트를 import 하는 함수를 호출하는 함수를 파라미터로 받는다.
asyncComponent( () => import('./Home') );
그리고 파라미터로 받은 함수는 constructor 에서 실행하여 컴포넌트를 불러온다. 해당 컴포넌트를 실제로 렌더링할 때 파일을 불러오도록 설정한 것이다. 컴포넌트가 로딩되면 불러온 컴포넌트를 state 에 집어넣고, 또 static 값으로도 설정한다.

컴포넌트가 언마운트되었다가 나중에 다시 마운트될 때는 컴포넌트를 다시 새로 불러오지 않고, static 값으로 남아있는 이전에 불러온 컴포넌트 정보를 재사용한다.

01.18.1.3. 라우트 코드 스필리팅용 인덱스 생성

// src/pages/index.async.js
import asyncComponent from '../lib/asyncComponent';

export const Home = asyncComponent( () => import('./Home') );
export const About = asyncComponent( () => import('./About') );
export const Post = asyncComponent( () => import('./Post') );
export const Posts = asyncComponent( () => import('./Posts') );

// src/App.js
import React from 'react';
import {Route} from 'react-router-dom';

import {Home, About, Posts} from 'pages/index.async.js';
import Menu from 'components/Menu';

const App = () => {
return (
<div>
<Menu />
<Route exact path="/" component={Home} />
<Route path="/about/:name?" component={About} />
<Route path="/posts" component={Posts} />
</div>
);
};

export default App;
크롬 개발자 도구의 Network 탭을 연 상태에서 메뉴의 링크를 클릭해서 이동해보면 비동기 로딩이 처릭 될 것이다.( Posts 컴포넌트는 index.async.js 파일로 치환하지 않았기 때문에 현재는 비동기 로딩이 되지 않는다. 나중에 설정할 것이기 때문에 무시해도 된다.)

코드 스플리팅은 프로젝트 규모가 클수록 효과가 있다. 소규모 프로젝트라면 코드 스플리팅을 해 보았자 3kb 미만으로 큰 효과는 없다.
소규모 프로젝트라면 코드 스플리팅은 생략해도 된다. 나중에 파일 크기가 좀 커졌다고 느낄 때 코드 스플리팅을 구현해도 무방하다.



01.17 React Router 0


React.js

01.17. React Router

01.17.1. SPA

SPASingle Page Application, 말 그대로 페이지가 하나인 애플리케이션이라는 의미이다.
전통적인 페이지는 여러 페이지로 구성되어 있다.

유저가 요청할 때 마다 페이지를 새로고침하며, 페이지를 로딩할 때마다 서버에서 리소스를 전달받아 해석한 후 렌더링한다. HTML 파일 또는 템플릿 엔진 등을 사용해서 애플리케이션 뷰를 어떻게 보일지도 서버에서 담당한다.

웹에서 제공하는 정보가 점점 많아지면서 속도 문제가 발생했는데, 이를 해소하려고 캐싱과 압축을 해서 서비스를 제공한다. 그러나 이 방법은 사용자와 상호작용(interaction)이 많은 모던 웹 애플리케이션에선느 충분하지 않을 수 이다. 서버에서 렌더링을 담당한다는 것은 그만큼 서버 사줭늘 렌더링하는데 사용한다는 의미로, 불필요한 트래픽이 낭비가 되기 때문이다.

리액트 같은 라이브러리 또는 프레임워크를 사용해서 뷰 렌더링을 유저의 웹 브라우저가 담당하도록 하고, 애플리케이션을 우선 웹 브라우저에 로드시킨 후 필요한 데이터만 전달받아 보여 줄 것이다.

싱글 페이지 애플리케이션은 서버에서 제공하는 페이지가 하나이지만, 로딩을 한 번 하고 나면 웹 브라우저에서 나머지 페이지들을 정의한다. 페이지에 들어온 후 다른 페이지로 이동할 때는 서버에 새로운 페이지를 요청하는 것이 아니라, 새 페이지에서 필요한 데이터만 받아 와 그에 따라 웹 브라우저가 다른 종류의 뷰를 만들어 주는 것이다.

SPA 단점은 앱 규모가 커지면 자바스크립트 파일 크기도 너무 커진다는 것이다. 페이지를 로딩할 때, 유저가 실제로 방문하지 않을 수도 있는 페이지와 관련된 컴포넌트 코드도 함께 불러오기 때문이다. 하지만 코드 스플리팅(code splitting)을 사용하면 라우트별로 파일을 나누어 트래픽과 로딩 속도를 개선할 수 있다.

01.17.2. React Router


리액트 라우터를 사용한 클라이언트 라우팅















리액트 라우터를 사용하면 페이지 주소를 변경했을 때 주소에 따라 다른 컴포넌트를 렌더링해 주고, url 정보(파라미터, 쿼리 등)를 컴포넌트의 props 로 전달해서 컴포넌트 단에서 url 상태에 따라 다른 작업을 하도록 설정할 수 있다.
$ create-react-app react-route-sample
$ cd react-route-sample
$ yarn add react-router-dom
프로젝트 초기화를 위해 아래 파일을 삭제한다.
  • src/App.css
  • src/App.test.js
  • src/logo.svg
디렉터리를 생성한다.
  • src/components: 컴포넌트들이 위치하는 디렉터리
  • src/pages: 각 라우트들이 위치하는 디렉터리

01.17.2.1. NODE_PATH 설정

컴포넌트나 모듈을 import 할 때 보통 상대 경로로 불러왔다. 하지만 디렉터리 구조가 복잡하면 상대 경로 주소도 복잡해지기 때문에 헷갈릴 수 있다. 이런 문제는 프로젝트의 루트 경로를 지정하여 파일을 절대 경로로 불러오면 쉽게 해결할 수 있다.
// package.json
  "scripts": {
"start": "NODE_PATH=src react-scripts start",
"build": "NODE_PATH=src react-scripts build",
(...)
},
※ Windows 운영체제에서는 yarn 으로 cross-env를 설치해야 정상적으로 작동한다.
$ yarn add cross-env

// package.json
"scripts": {
"start": "cross-env NODE_PATH=src react-scripts start",
"build": "cross-env NODE_PATH=src react-scripts build",
(...)
},
이렇게 설정하면 파일을 절대 경로로 불러올 수 있다.

01.17.2.2. 컴포넌트 생성

// src/App.js
import React, { Component } from 'react';

class App extends Component {
render() {
return (
<div>
리액트 라우터
</div>
);
}
}

export default App;
App 컴포넌트에서는 웹 브라우저의 주소에 따라 어떤 컴포넌트를 보여줄 지 정의한다.
Root 컴포넌트를 만들고 BrowserRouter를 적용한다. BrowserRouter는 HTML5의 history API를 사용하여 새로고침하지 않고도 페이지 주소를 교체할 수 있게한다.
// src/Root.js
import React from 'react';
import {BrowserRouter} from 'react-router-dom';
import App from './App';

const Root = () => {
return (
<BrowserRouter>
<App />
</BrowserRouter>
);
};

export default Root;
index.js 에서 App 이 아닌 Root를 렌더링하도록 수정한다.
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import 'index.css';
import Root from 'Root';
import * as serviceWorker from 'serviceWorker';

ReactDOM.render(<Root />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();
서버를 실행시켜 페이지가 렌더링되는지 확인한다.
$ yarn start
브라우저에서 작성한 텍스트가 확인된다면 적용된 것이다.
페이지를 열었을 때, 기본적으로 보여 줄 Home 라우트를 만든다.
// src/pages/Home.js
import React from 'react';

const Home = () => {
return(
<div>
<h2></h2>
</div>
);
};

export default Home;
같은 형식으로 About 컴포넌트를 만든다. 이 컴포넌트는 /about 주소로 들어왔을 때 보이는 라우트이다.
// src/pages/About.js
import React from 'react';

const About = () => {
return(
<div>
<h2>소개</h2>
<p>
리액트 라우트 소개 페이지.
</p>
</div>
);
};

export default About;
생성한 페이지 컴포넌트들을 불러와 파일 하나로 내보낼 수 있도록 인덱스 파일을 만든다.
// src/pages/index.js
/* 다음 코드는 컴포넌트를 불러온 후 동일한 이름으로 내보낸다. */
export { default as Home } from './Home';
export { default as About } from './About';
만든 페이지에 주소를 설정한다. 페이지 주소를 설정할 때는 Route 컴포넌트를 사용한다.
// src/App.js
import React from 'react';
import {Route} from 'react-router-dom';

import {Home, About} from 'pages';

const App = () => {
return (
<div>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
</div>
);
}

export default App;
Route 컴포넌트에서 경로는 path 값으로 설정하고, 보여 줄 컴포넌트는 component 값으로 설정한다.
첫 번재 라우트 Home 은 주소가 / 와 일치할 대 보여주도록 설정했다.
exact 값은 주소가 여기에서 설정한 path 와 정확히 일치할 때만 보이도록 설정하는 것이다.
exact 값을 제거하면 /about 경로로 들어와도 / 경로의 내부이기 때문에 일치하는 것으로 간주하여 컴포넌트가 보인다.

01.17.2.3. 라우트 파라미터와 쿼리 읽기

라우트의 경로에 특정 값을 넣는 방법은 두 가지이다.
하나는 params 를 사용하는 것이고, 나머지 하나는 Query String 을 사용하는 것이다.

params
// src/App.js
import React from 'react';
import {Route} from 'react-router-dom';

import {Home, About} from 'pages';

const App = () => {
return (
<div>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/about/:name" component={About} />
</div>
);
}

export default App;
URL 의  params 를 지정할 때는 :key 형식으로 설정한다.
// src/pages/About.js
import React from 'react';

const About = ({match}) => {
return(
<div>
<h2>소개</h2>
<p>
{match.params.name} 리액트 라우트 소개 페이지.
</p>
</div>
);
};

export default About;
params 객체는 컴포넌트를 라우트로 설정했을 때 props 로 전달받는 match 객체 내부에 있다.
위 코드를 저장 후 'http://localhost:3000/about/이름' 주소를 입력하고 들어간다면 About 컴포넌트가 중복된다.
// src/App.js
(...)
<Route exact path="/about" component={About} />
(...)
exact를 설정하여 해결하거나
// src/App.js
(...)
<Route path="/about/:name?" component={About} />
(...)
:name 값을 선택적으로 입력받을 수 있게 params 뒷부분에 ? 를 입력하는 것이다.
파라미터가 여러 개일 경우에는
// src/App.js
(...)
<Route path="/about/:name?:/anotherValue" component={About} />
(...)
":name/:anotherValue" 와 같이 입력하면 된다.

Query String
Quert String 은 URL 뒤에 /about/something?key=value&anotherKey=value 형식으로 들어가는 정보이다. 이 문자열로 된 쿼리를 객체 형태로 파싱하려면 query-string 라이브러리를 설치해야한다.
Query String 은 App.js 에서 라우트를 절정할 때 정의하지 않고, 라우트 내부에서 정의한다.
Query 내용을 받아 오려면 라우트로 설정된 컴포넌트에서 받아 오는 props 중 하나인 location 객체의 search 값을 조회해야한다.
$ yarn add query-string

// src/pages/About.js
import React from 'react';
import queryString from 'query-string';

const About = ({location, match}) => {
const query = queryString.parse(location.search);
console.log(query);
return(
<div>
<h2>소개</h2>
<p>
{match.params.name} 리액트 라우트 소개 페이지.
{location.search}.
</p>
</div>
);
};

export default About;
query string 으로 받은 정보에 따라 폰트 색상을 변경해본다.
// src/pages/About.js
import React from 'react';
import queryString from 'query-string';

const About = ({location, match}) => {
const query = queryString.parse(location.search);

const {color} = query;

return(
<div>
<h2 style={{color}}>소개</h2>
<p>
{match.params.name} 리액트 라우트 소개 페이지.
{location.search}.
</p>
</div>
);
};

export default About;
http://localhost:3000/about/dd?color=red
소개 부분의 색상이 변경되었다. query string 을 사용할 때는 값들이 모두 문자열이라는 것에 주의해야한다. 따라서 query string 을 사용하여 받아 온 값을 비교해야 할 때는 Boolean 형태의 값을 불러오든, 숫자 형태로 불러오든 간에 문자열 형태로 비교를 하거나 알맞은 형태로 변환시킨 후 비교해야 한다.

01.17.2.4. 라우트 이동

Link 컴포넌트
애플리케이션 안에서 다른 라우트로 이동할 때는, 다른 페이지로 이동하는 링크를 작성할 때 사용하는 일반적인 태그인 <a href="">link</a> 형식으로 하면 안된다. a 태그를 클릭하면 페이지를 새로고침하면서 로딩하기 때문이다.
새로고침을 방지하려면 리액트 라우트에 있는 Link 컴포넌트를 사용해야 한다. 페이지를 새로고침하지 않고 주소 창 상태를 변경하고 원하는 라우트로 화면을 전환한다.
// src/component/Menu.js
import React from 'react';
import {Link} from 'react-router-dom';

const Menu = () => {
return (
<div>
<ul>
<li><Link to="/"></Link></li>
<li><Link to="/about">소개</Link></li>
<li><Link to="/about/이름">이름 소개</Link></li>
</ul>
</div>
);
};

export default Menu;
Link 컴포넌트는 react-router-dom 에서 불러온다. 이 컴포넌트를 사용할 때는 이동할 주소를 컴포넌트의 to 으로 지정한다.
// src/App.js
import React from 'react';
import {Route} from 'react-router-dom';

import {Home, About} from 'pages';

import Menu from 'components/Menu';

const App = () => {
return (
<div>
<Menu />
<Route exact path="/" component={Home} />
<Route path="/about/:name?" component={About} />
</div>
);
}

export default App;

NavLink 컴포넌트
NavLink 컴포넌트는 Link 컴포넌트와 비슷하지만, 추가 기능이 있다. 현재 주소와 해당 컴포넌트의 목적지 주소가 일치한다면 특정 스타일 또는 클래스를 지정할 수 있다.
// src/components/Menu.js
import React from 'react';
import {NavLink} from 'react-router-dom';

const Menu = () => {
const activeStyle = {
color: 'green',
fontSize: '2rem'
}
return (
<div>
<ul>
<li><NavLink exact to="/" activeStyle={activeStyle}></NavLink></li>
<li><NavLink exact to="/about" activeStyle={activeStyle}>소개</NavLink></li>
<li><NavLink to="/about/이름" activeStyle={activeStyle}>이름 소개</NavLink></li>
</ul>
</div>
);
};

export default Menu;
NavLink 컴포넌트를 사용하면 해당 링크를 활성화했을 때 activeStyle로 스타일을 지정할 수 있다. CSS 클래스를 적용하고 싶다면 activeClassName 값을 지정한다.
NavLink 컴포넌트를 사용할 때 exact 키워드도 포함해 주었는데, 이 키워드의 용도는 라우트를 설정할 때와 동일하다.

자바스크립트에서 라우팅
링크를 클릭하는 단순한 경우가 아니라. 자바스크립트에서 페이지를 이동해야 하는 로직을 작성해야할 때 라우트로 사용된 컴포넌트가 받아 오는 props 중 하나인 history 객체의 push 함수를 활용한다.
// src/pages/Home.js
import React from 'react';

const Home = ({history}) => {
return(
<div>
<h2></h2>
<button onClick={() => {
history.push('/about/javascript');
}}>자바스크립트 라우팅</button>
</div>
);
};

export default Home;

01.17.2.5. 라우트 안의 라우트

라우트 안에 또 다른 라우트를 정의하는 방법이다.
// src/pages/Post.js
import React from 'react';

const Post = ({match}) => {
return (
<p>
포스트 #{match.params.id}
</p>
);
};

export default Post;

// src/pages/index.js
export { default as Home } from './Home';
export { default as About } from './About';
export { default as Post } from './Post';
포스트 목록을 보여 줄 Posts 페이지 컴포넌트를 생성한다.
// src/pages/Posts.js
import React from 'react';
import {Post} from 'pages';
import {Link, Route} from 'react-router-dom';

const Posts = ({match}) => {
return (
<div>
<h3>포스트 목록</h3>
<ul>
<li><Link to={`${match.url}/1`}>포스트 #1</Link></li>
<li><Link to={`${match.url}/2`}>포스트 #2</Link></li>
<li><Link to={`${match.url}/3`}>포스트 #3</Link></li>
</ul>
<Route exact path={match.url} render={() => (<p>포스트를 선택하세요</p>)} />
<Route exact path={`${match.url}/:id`} component={Post}/>
</div>
);
};

export default Posts;
링크를 설정하는 부분에서 match.url 을 사용했다. 이 컴포넌트는 '/posts'라는 라우트로 등록할 것이다.
match.url 은 현재 라우트에 설정된 경로 '/posts' 를 알려준다. 링크의 to 값을 지정할 때 '/posts/1' 로 설정해도 동일하게 작동한다.
차이점은 나중에 Posts 컴포넌트의 라우트 주소를 '/blog-posts' 로 변경했다고 가정했을 때, 내부 주소도 자동으로 반영하기 때문에 따로 변경할 필요가 없다는 것이다.
아래쪽에서 Route 의 path 를 설정할 때도 같은 이유로 match.url 을 사용하였다. 
첫 번째 라우트에는 id 값이 주어져 있지 않고, /posts 와 정확히 일치할 때만 render 에 있는 내용을 보여 주도록 설정했다. 따로 컴포넌트를 만들어 등록하는 것이 아니라 무엇을 보여 줄지 JSX 를 직접 작성하는 경우에는 이렇게 render 라는 props 를 설정하면 된다.
두 번째 라우트에서는 현재 라우트의 주소에 :id 가 붙었을 때 Post 컴포넌트를 보여 주도록 설정했다.
// src/pages/index.js
export { default as Home } from './Home';
export { default as About } from './About';
export { default as Post } from './Post';
export { default as Posts } from './Posts';

// src/App.js
import React from 'react';
import {Route} from 'react-router-dom';

import {Home, About, Posts} from 'pages';

import Menu from 'components/Menu';

const App = () => {
return (
<div>
<Menu />
<Route exact path="/" component={Home} />
<Route path="/about/:name?" component={About} />
<Route path="/posts" component={Posts} />
</div>
);
}

export default App;

// src/components/Menu.js
import React from 'react';
import {NavLink} from 'react-router-dom';

const Menu = () => {
const activeStyle = {
color: 'green',
fontSize: '2rem'
}
return (
<div>
<ul>
<li><NavLink exact to="/" activeStyle={activeStyle}></NavLink></li>
<li><NavLink exact to="/about" activeStyle={activeStyle}>소개</NavLink></li>
<li><NavLink to="/about/이름" activeStyle={activeStyle}>이름 소개</NavLink></li>
<li><NavLink to="/posts" activeStyle={activeStyle}>포스트 목록</NavLink></li>
</ul>
</div>
);
};

export default Menu;
메뉴에 포스트 목록이 생성되었다. 라우트 안에 라우트가 잘 동작한다.

01.17.2.6. 라우트로 사용된 컴포넌트가 전달받는 props

location, match, history 값들을 props로 받아와 사용해보았다. 각 객체가 어떤 역할을 하는지 알아본다.

location
location 은 현재 페이지의 주소 상태를 알려 준다. Post 페이지 컴포넌트에서 location 을 조회하면 다음 과 같은 결과가 나온다.
{
"pathname": "/posts/3",
"search": "",
"hash": "",
"key": "xmsczi",
}
location 값은 어떤 라우트 컴포넌트에서 조회하든 같다. 주로 search 값에서 URL Query 를 읽는데 사용하거나 주소가 바뀐 것을 감지하는데 사용한다.
componentDidUpdate(prevProps, prevState) {
if(prevProps.location != this.props.location) {
// 주소가 바뀜
}
}

match
match 는 <Route> 컴포넌트에서 설정한 path 와 관련된 데이터들을 조회할 때 사용한다. 현재 URL이 같을지라도 다른 라우트에서 사용된 match 는 다른 정보를 알려준다. Post 라우트와 Posts 라우트에서 match 값을 기록해 확인한다.
// src/pages/Post.js
import React from 'react';

const Post = ({match}) => {
console.log('Post : ',match);
(...)

// src/pages/Posts.js
import React from 'react';
import {Post} from 'pages';
import {Link, Route} from 'react-router-dom';

const Posts = ({match}) => {
console.log('Posts : ',match);
(...)

다른 라우트에서 기록한 match 객체는 다른 정보를 보여준다. match 객체는 주로 params를 조회하거나 서브 라우트를 만들 때 현재 path 를 참조하는데 사용한다.

history
history 는 현재 라우터를 조작할 때 사용한다. 페이지를 뒤로 이동하거나 앞으로 이동, 새로운 주소로 이동해야 할 대 이 객체가 지닌 함수를 호출한다.
리액트 개발자 도구를 열어 Home 컴포넌트를 조회한다.

이 객체에서 헷갈리 수 있는 함수는 push 와 replace 이다. replace 는 replac('/posts') 형식으로 작성한다. push 와 차이점은 페이지 방목 기록을 남기지 않아서 페이지 이동 후 뒤로가기 버튼을 눌렀을 때 방금 전 페이지가 아닌 방금 전의 전 페이지가 나타난다. action은 현재 history 상태를 알려준다. 페이지를 처음 방문했을 때는 POP가 나타나고 링크를 통한 라우팅 또는 push 를 통한 라우팅을 했을 때는 PUSH 가 나타나며, replace 를 통한 라우팅을 했을 때는 REPLACE 가 나타난다.
block 함수는 페이지에서 벗어날 때, 사용자에게 정말 페이지를 떠나겠냐고 묻는 창을 띄운다.
const block = history.block('정말로 떠나시겠습니까?');
unblock(); // 막는 작업을 취소할 때
go, goBack, goForward 는 이전 페이지 또는 다음 페이지로 이동하는 함수이다. go 함수에서 go(-1)로 뒤로가기를 할 수 있고, go(1)로 다음으로 가기를 할 수 있다.

01.17.2.7. withRouter로 기타 컴포넌트에서 라우터 접근

위에서 학습한 세 가지 props 는 라우트로 사용된 컴포넌트에서만 접근할 수 있었다. 즉, 라우트 내부 또는 외부 컴포넌트에서 history, location, match 등 값을 사용할 수 없다.
Menu 컴포넌트는 라우트 외부에 있기 때문에 이 세 가지 props 를 사용할 수 없다.
이때는 withRouter 를 사용하여 해당 props 에 접근할 수 있다.
Menu 컴포넌트 위쪽에서 withRouter 를 리액트 라우터에서 불러온 후, 컴포넌트를 내보낼 때 withRouter 함수로 감싸 주면 Menu 컴포넌트에서도 history 등 객체를 사용할 수 있다.
// src/component/Menu.js
import React from 'react';
import {NavLink, withRouter} from 'react-router-dom';
(...)
export default withRouter(Menu);
리액트 개발자 도구를 이용해 Menu 컴포넌트를 조회하면 세 가지 props 들이 확인된다.

withRouter 를 사용한 컴포넌트에서 match 값은 해당 컴포넌트가 위치한 상위 라우트의 정보이다. 지금은 Menu 컴포넌트가 라우트 외부에 있으니 path 는 / 이다. Menu 컴포넌트를 PostPage 내부에 렌더링 한다면 path 는 /posts/:id 형식이 될 것이다.

withRouter 는 주로 history 에 접근하여 컴포넌트에서 라우터를 조작하는데 사용한다.

큰 규모의 프로젝트를 진행하다 보면 리액트 라우터의 한 가지 문제가 발생한다. 바로 웹 브라우저에서 사용할 컴포넌트, 상태 관리를 하는 로직들, 여러 기능을 구현하는 함수들이 점점 쌓이면서 컴포넌트 코드를 많이 입력하기 때문에, 최종 결과물인 자바스크립트 파일 크기매우 커진다는 점이다.

이를 보완하는 것이 코드 스플리팅이다.


01.16 리덕스 미들웨어와 외부데이터 연동 0


React.js

01.16. 리덕스 미들웨어와 외부데이터 연동

웹 애플리케이션을 만들 때는 대부분 서버와 데이터를 연동해야한다. 데이터를 연동하려면 일반적으로 서버에 구현된 REST API에 Ajax를 요청하여 데이터를 가져오거나 입력해야한다. 

01.16.1. 리덕스 미들웨어(middleware)란?

액션을 디스패치했을 때 리듀서에서 이를 처리하기 전에 사전에 지정된 작업들을 실행한다. 
(미들웨어는 액션과 리듀서 사이의 중간자라고 볼 수 있다.)

미들웨어가 할 수 있는 작업은 여러 가지가 있다. 단순히 전달받은 액션을 콘솔에 기록할 수도 있고, 전달받은 액션 정보를 기반으로 액션을 취소해 버리거나 다른 종류의 액션을 추가로 디스패치할 수도 있다.

01.16.1.1. 미들웨어 생성

// src/lib/middlewareTest.js
const middlewareTest = store => next => action => {
// 현재 스토어 상태 값 기록
console.log('현재 상태', store.getState());
// 액션 기록
console.log('액션',action);

// 액션을 다음 미들웨어 또는 리듀서에 전달
const result = next(action);

// 액션 처리 후 스토어의 상태를 기록
console.log('다음 상태', store.getState());

return result; // 여기에서 반환하는 값은 store.dispatch(ACTION_TYPE)했을 때 결과로 설정한다.

}

export default middlewareTest;// 내보내기
store 와 action 은 익숙하지만, next 는 익숙하지 않다. next는 store.dispatch 와 비슷한 역할이다. 차이점은 next(action) 을 했을 때는 그다음 처리해야 할 미들웨어로 액션을 넘겨주고, 추가로 처리할 미들웨어가 없다면 바로 리듀서에 넘겨준다는 것이다. 하지만 store.dispatch는 다음 미들웨어로 넘기는 것이 아니라 액션을 처음부터 디스패치한다.
//src/store.js
import { createStore, applyMiddleware } from 'redux';
import modules from './modules';
import middlewareTest from './lib/middlewareTest';

// 미들웨어가 여러 개일 경우 파라미터로 전달하면 된다. (applyMiddleware(a,b,c))
// 미들웨어 순서는 여기에서 전달한 파라미터 순서대로 지정한다.
const store = createStore(modules, applyMiddleware(middlewareTest));

export default store;
미들웨어는 store를 생성할 때 적용할 수 있다. 액션과 리듀서 사이에서 액션 정보를 가로채 수정한 후 리듀서로 전달할 수도 있고, 액션 정보에 따라 아예 무시할 수 있다(next를 호출하지 않고 return 하면 된다).

01.16.2. 비동기 작업을 처리하는 미들웨어 사용

미들웨어의 작동 방식이 이해되었다면, 오픈소스 커뮤니팅 공개된 미들웨어를 사용해 비동기 액션을 다루는 방법을 알아본다.

01.16.2.1. redux-thunk

리덕스를 사용하는 애플리케이션에서 비동기 작업을 처리할 때 가장 기본적인 방법은 redux-thunk 미들웨어를 사용하는 것이다. 리덕스 공식 매뉴얼에서도 이를 사용하여 비동기 작업을 다룬다.
※ thunk 는 특정 작업을 나중에 할 수 있도록 미루려고 함수 형태로 감싼 것을 의미한다.
const x = 1+2; // 바로 실행
const foo = () => 1+2 ; // foo()를 호출해야 실행
redux-thunk는 객체가 아닌 함수도 디스패치할 수 있다. 일반 액션 객체로는 특정 액션을 디스패치한 후 몇 초 뒤에 실제로 반영시키거나 현재 상태에 따라 아예 무시하게 만들 수 없다. 하지만 redux-thunk 미들웨어는 함수를 디스패치할 수 있게 함으로써 일반 액션 객체로는 할 수 없는 작업들도 할 수 있게 한다.
// src/modules/counter.js
import {handleActions, createAction} from 'redux-actions';

const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

export const increment = createAction(INCREMENT);
export const decrement = createAction(DECREMENT);

export const incrementAsync = () => dispatch => {
// 1초 뒤 액션 디스패치
setTimeout(
() => { dispatch(increment()) },
1000
);
}

export const decrementAsync = () => dispatch => {
// 1초 뒤 액션 디스패치
setTimeout(
() => { dispatch(decrement()) },
1000
);
}

export default handleActions({
[INCREMENT]: (state, action) => state +1,
[DECREMENT]: (state, action) => state -1
}, 0);

// src/App.js - 버튼
<button onClick={CounterActions.incrementAsync}>+</button>
<button onClick={CounterActions.decrementAsync}>-</button>
INCREMENT 와 DECREMENT 액션을 1초 뒤에 디스패치하는 액션 함수 incrementAsync 와  decrementAsync 를 만들었다. 그리고 App 컴포넌트에서 button 코드를 수정하면 함수형 액션을 먼저 디스패치하고, 1초 뒤에 지정한 액션을 디스패치한다.

01.16.3. 웹 요청 처리

axios 라는 Promise 기반 HTTP 클라이언트 라이브러리를 사용하여 웹 요청을 한다.

01.16.3.1. Promise 

Promise 는 ES6 문법에서 비동기 처리를 다루는데 사용하는 객체이다.
function promiseTest(number, fn) {
setTImeout(
function() {
console.log(number);
if(fn) fn();
},
1000
);
}

promiseTest(1, function() {
promiseTest(2, function() {
promiseTest(3, function() {
promiseTest(4);
})
})
});
위 코드를 실행하면 1초씩 간격을 두고 숫자 1,2,3,4를 콘솔에 표시한다. 이렇게 코드를 작성할 경우 콜백이 콜백을 불러 깊고 복잡해진다. 이런 문제를 해결해 주는 것이 Promise 이다.
function promiseTest(number) {
return new Promise( // Promise 생성 후 리턴
resolve => {
setTimeout( // 1초 뒤 실행
() => {
console.log(number);
resolve(); // promise가 끝났음을 알린다.
}, 1000
);
}
);
}

promiseTest(1)
.then( () => promiseTest(2) )
.then( () => promiseTest(3) )
.then( () => promiseTest(4) )
Promise 를 사용하면 코드를 더 간결하게 작성할 수 있다.

Promise 에서 결과 값을 반환할 때는 resolve(결과 값) 을 작성하고, 오류를 발생시킬 때는 reject(오류)를 작성한다. 여기에서 반환하는 결과 값과 오류는 .then() 또는 .catch() 에 전달하는 함수의 파라미터로 설정된다.
function promiseTest(number) {
return new Promise( // Promise 생성 후 리턴
(resolve, reject) => {
if ( number > 4 ) {
return reject('number is greater than 4'); // reject 는 오류를 발생시킨다.
}
setTimeout( // 1초 뒤 실행
() => {
console.log(number);
resolve(number + 1); // 현재 숫자에 1을 더한 값을 반환한다.
}, 1000
);
}
);
}

promiseTest(1)
.then( num => promiseTest(num) )
.then( num => promiseTest(num) )
.then( num => promiseTest(num) )
.then( num => promiseTest(num) )
.catch( e => console.log(e) );
비동기적으로 숫자 1씩 더하는 Promise 를 생성했다. 처음 promiseTest(1) 를 실행하면 숫자 2를 반환하고, 이 값은 .then 에 설정한 함수 파라미터로 전달한다. 순차적으로 5까지 숫자가 올라가면 오류를 발생한다.

01.16.3.1. axios

$ yarn add axios

import axios from 'axios';

axios.get('url')
.then(response => console.log(response));
axios 로 웹 요청을 했을 대 반환되는 객체는 해당 요청의 응답 정보를 지낸 객체이다.

01.16.3.2. redux-thunk와 axios 사용

// src/modules/post.js
import {handleActions, createAction} from 'reudx-actions';

import axios from 'axios';

funciton getPostAPI(postId) {
return axios.get('url/${postId}');
}

const GET_POST_PENDING = 'GET_POST_PENDING';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_FAILURE = 'GET_POST_FAILURE';

const getPostPending = createAction(GET_POST_PENDING);
const getPostSuccess = createAction(GET_POST_SUCCESS);
const getPostFailure = createAction(GET_POST_FAILURE);

export const getPost = (postId) => dispatch => {
dispatch(getPostPending()); // 요청 시작했다는 것을 알림
// 요청 시작. 여기에서 만든 promise를 return해야 나중에 컴포넌트에서 호출할 때 getPost().then(...)을 할 수 있다.
return getPostAPI(postId)
.then( () => {
// 요청이 성공했다면 서버 응답 내용을 payload로 설정하여 GET_POST_SUCCESS 액션을 디스패치한다.
dispatch(getPostSuccess(response));
// 후에 getPostAPI.then 을 했을 때 then에 전달하는 함수에서 response 에 접근할 수 있게 한다.
return response;
} )
.catch( error => {
// 오류가 발생하면 오류 내용을 payload로 설정하여 GET_POST_FAILURE 액션을 디스패치한다.
dispatch(getPostFailure(error));
// error를 throw하여 이 함수를 실행한 후 다시 한 번 catch를 할 수 있게 한다.
throw(error);
} );
}
웹 요청을 시작할 때 POST_PENDING 액션을 디스패치하고 서버가 응답할 때까지 대기한다.
요청이 끝나 성공했을 때는 POST_SUCCESS 액션을 디스패치하고
요청이 실패했을 때는 POST_FAILURE 액션을 디스패치한다.
리듀서의 초기 상태를 정의하고 handleActions를 사용하여 리듀서 함수를 구현한다.
// src/modules/post.js
(...)
const initialState = {
pending: false,
error: false,
data: {
title: '',
body: ''
}
}

export default handleAction({
[GET_POST_PENDING]: (state, action) => {
return {
...state,
pending: true,
error: false
};
},
[GET_POST_SUCCESS]: (state, action) => {
const {title, body} = action.payload.data;
return {
...state,
pending: false,
data: {
title,
body
}
};
},
[GET_POST_FAILURE]: (state, action) => {
return {
...state,
pending: false,
error: true
}
}
}, initialState);
이 모듈의 리듀서를 루트 리듀서에 넣어준다.
// src/modules/index.js
import {combineReducers} from 'redux';
import counter from './counter';
import post from './post';

export default combineReducers({
counter,
post
});

// scr/modules/counter.js
(...)
export default handleActions({
[INCREMENT]: (state, action) => state + 1,
[DECREMENT]: (state, action) => state - 1
},1);
App 컴포넌트에서 post 모듈의 액션 생성 함수를 불러와 PostActions에 바인딩하고, post 모듈 안의 상태인 data, pending, error 값을 연결한다.
// src/App.js
import React, {Component} from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import * as counterActions from './modules/counter';
import * as postActions from './modules/post';

class App extends Component {
loadData = () => {
const {PostActions, number} = this.props;
PostActions.getPost(number);
}

componentDidMount() {
this.loadData();
}

componentDidUpdate(prevProps, prevState) {
// 이전 number 와 현재 number 가 다르면 요청을 시작
if(this.props.number != prevProps.number) {
this.loadData();
}
}

render() {
const {CounterActions, number, post, error, loading} = this.props;
return (
<div>
<h1>{number}</h1>
{
( () => {
if (loading) return (<h2>로딩중...</h2>);
if (error) return (<h2>에러발생...</h2>);
return (
<div>
<h2>{post.title}</h2>
<p>{post.body}</p>
</div>
);
} )
}
<button onClick={CounterActions.increment}>+</button>
<button onClick={CounterActions.decrement}>-</button>
</div>
);
}
}

export default connect(
(state) => ({
number: state.counter,
post: state.post.data,
loading: state.post.pending,
error: state.post.error
}),
(dispatch) => ({
CounterActions: bindActionCreators(counterActions, dispatch),
PostActions: bindActionCreators(postActions, dispatch)
})
)(App);
해당 메서드는 PostActions.getPost 를 호출한다.
그리고 render 함수에서는 상황따라 다른 결과물을 렌더링하도록 설정되었다.
{} 내부에서 함수를 선언하고 그 함수 자체를 호출했다. 삼항 연산자가 여러 번 겹칠 때는 아예 함수를 만들어서 if 문을 사용하는 것이 더욱 가독성이 높다.
// src/App.js
PostActions.getPost(number).then(
(response) => {
console.log(response);
}
).catch(
(error) => {
console.log(error);
}
);
위 코드는 요청을 완료한 후나 오류가 발생했을 때 추가할 작업으로 redux-thunk 로 만든 액션 함수는 Promise 를 반환하기 때문에 해당 함수를 호출하고는 뒤에 .then 또는 .catch 를 입력해서 구현하면 된다.

리덕스의 정석대로 비동기 웹 요청을 하는 방법을 알아보았다. 모든 흐름을 다 이해한다 하더라도 각 요청마다 액션 타입을 세 개씩 선언하고 요청 전, 완료, 실패 상황에 따라 각각 다른 액션을 디스패치해야 하므로 조금 번거로울 수 있는 작업이다.

리덕스에서 비동기 작업을 처리하는 방법은 redux-thunk 외에도 여러 가지가 있다. redux-promise-middleware, redux-saga, redux-pender, redux-observable 등이 있는데 작동 방식과 설계 방식에 조금 차이가 있기 때문에 각 라이브러리를 한번 사용해 보는 것이 좋다.

01.16.4. redux-pender

redux-pender 는 Promise 기반 액션들을 관리하는 미들웨어가 포함되어 있는 라이브러리이다. 작동 방식은 redux-promise-middleware 와 유사하다. 액션 객체 안에 payload 가 Promise 형태라면 시작하기 전, 완료 또는 실패 했을 때 위에 PENDING, SUCCESS, FAILURE 접미사를 붙여 준다.

추가로 요청을 관리하는 리듀서가 포함되어 있으며, 요청 관련 액션들을 처리하는 액션 핸들러 함수들을 자동으로 만드는 도구도 들어있다.
그리고 요청 중인 액션을 취소할 수 있는 기능도 내장되어 있다.
$ yarn add redux-pender

// src/store.js
import {createStore, applyMiddleware} from 'redux';
import modules from './modules';

import {createLogger} from 'redux-logger';
import penderMiddleware from 'redux-pender';

/* 로그 미들웨어를 만들 때 설정을 커스터마이징 할 수 있다.
https://github.com/LogRocket/redux-logger#options
*/
const logger = createLogger();

const store = createStore(modules, applyMiddleware(logger, penderMiddleware));

export default store;

// src/modules/index.js
import {combineReducers} from 'redux';
import counter from './counter';
import post from './post';
import {penderReducer} from 'redux-pender';

export default combineReducers({
counter,
post,
pender: penderReducer
});
위 리듀서는 요청 상태를 관리한다. 이 리듀서가 가진 상태 구조는 다음과 같다.
{
pending: {},
success: {},
failure: {}
}
새 Promise 기반 액션을 디스패치하면 상태는 다음과 같이 변한다.
{
pending: {
'ACTION_NAME': true
},
success: {
'ACTION_NAME': false
},
failure: {
'ACTION_NAME': false
}
}
요청이 성공한다면 다음과 같이 변한다.
{
pending: {
'ACTION_NAME': false
},
success: {
'ACTION_NAME': true
},
failure: {
'ACTION_NAME': false
}
}
요청이 실패한다면 다음과 같이 변한다.
{
pending: {
'ACTION_NAME': false
},
success: {
'ACTION_NAME': false
},
failure: {
'ACTION_NAME': true
}
}
이런 작업은 pender 리듀서가 액션 이름에 따라서 자동으로 상태를 변경해 주기 때문에 요청과 관련된 상태는 더 이상 직접 관리할 필요가 없다.
redux-pender를 적용하면 액션 생성 함수와 리듀서의 액션 처리 관련 코드들을 간소화할 수 있다.
// src/modules/post.js
import {handleActions, createAction} from 'redux-action';
import {pender} from 'redux-pender';
import axios from 'axios';

function getPostAPI(postId) {
return axios.get('url/${postId}');
}

const GET_POST = 'GET_POST';

/* redux-pender 의 액션 구조는 Flux standard action(https://github.com/redux-utilities/flux-standard-action)을 따르기 때문에, createAction 으로 액션을 만들 수 있다.
두 번째로 들어가는 파라미터는 Promise 를 반환하는 함수여야 한다.
*/
export const getPost = createAction(GET_POST, getPostAPI);

const initialState = {
// 요청이 진행 중인지, 오류가 발생했는지 여부는 더 이상 직접 관리할 필요가 없다.
// penderReducer가 담당하기 때문이다.
data: {
title: '',
body: ''
}
}

export default handleActions({
...pender({
type: GET_POST, // type 이 주어지면 이 type에 접미사를 붙인 액션 핸들러들이 담긴 객체를 만든다
/* 요청 중일 때와 실패 했을 때 추가로 해야할 작업이 있다면
onPending: (state, action) => state,
onFailure: (state, action) => state
를 추가하며 된다.
*/
onSuccess: (state, action) => {
// 성공했을 때 해야할 작업이 따로 없다면, 이 함수도 생략할 수 있다.
const {title, body} = action.payload.data;
return {
data: {
title,
body
}
}
}
// 함수를 생략했을 때 기본 값으로는 (state, action) => state 를 설정한다.
// (state 를 그대로 반환한다는 의미.)
});
}, initialState);
신경 써야 할 상태가 줄었고, 코드의 길이도 짧아졌다. 리듀서에서 비동기 작업을 redux-pender 로 관리할 경우에는 ...pender 를 사용한다.
비동기 작업을 여러 개 관리한다면 ...pender 를 여러 번 사용하면 된다. 또 applyPenders 함수를 사용할 수도 있다.
// src/modules/post.js
import {handleActions, createAction} from 'redux-action';
import {pender, applyPenders} from 'redux-pender';
import axios from 'axios';

function getPostAPI(postId) {
return axios.get('url/${postId}');
}

const GET_POST = 'GET_POST';

export const getPost = createAction(GET_POST, getPostAPI);

const initialState = {
data: {
title: '',
body: ''
}
}

export default handleActions({
// 다른 일반 액션들을 관리 ...
}, initialState);

export default applyPenders(reducer, [
{
type: GET_POST,
onSuccess: (state, action) => {
// 성공했을 때 해야할 작업이 따로 없다면, 이 함수도 생략할 수 있다.
const {title, body} = action.payload.data;
return {
data: {
title,
body
}
}
},
/* 다른 pender 액션들
{ type: GET_SOMTHING, onSuccess: (state, action) => ... },
{ type: GET_SOMTHING, onSuccess: (state, action) => ... },
*/
}
]);
applyPenders 함수를 사용할 때 첫 번째 파라미터에는 일반 리듀서를 넣어주고, 두 번째 파라미터에는 pender 관련 객체들을 배열 형태로 넣어주면 된다.
리듀서에서 error 값과 pending 값을 더 이상 관여하지 않고, pender 리듀서가 대신 하게 되었다. App 컴포넌트 마지막 connect 하는 부분에 적용한다.
// src/App.js
(...)
export default connect(
(state) => ({
number: state.counter,
post: state.post.data,
loading: state.post.pending['GET_POST'],
error: state.post.failure['GET_POST']
}),
(dispatch) => ({
CounterActions: bindActionCreators(counterActions, dispatch),
PostActions: bindActionCreators(postActions, dispatch)
})
)(App);
Promise 기반 액션을 시작하면 액션 두 개를 디스패치한다. 하나는 GET_POST_PENDING이고, 다른 하나는 @@redux-pender/PENDING이다.
@@redux-pender/PENDING 의 payload 값에는 액션 이름이 들어가고, 이에 따라 pender 리듀서의 상태를 변화시킨다.

redux-pender 를 사용하면 Promise 기반 액션을 아주 쉽게 취소할 수 있다. Promise 기반 액션을 디스패치하고 나면 cancel 함수가 포함된 Promise 를 반환한다. 이 cancel 함수를 호출하면 미들웨어가 해당 요청을 처리하지 않는다.
// src/App.js
(...)

class App extends Component {
cancelRequest = null

handleCancel = () => {
if(this.cancelRequest) {
this.cancelRequest();
this.cancelRequest = null;
}
}

loadData = () => {
const {PostActions, number} = this.props;
PostActions.getPost(number);
}

componentDidMount() {
this.loadData();
// esc 키를 눌렀을 때 요청 취소
window.addEventListener('keyup', (e) => {
if(e.key === 'Escape') {
this.handleCancel();
}
});
}
(...)

// src/modules/post.js
...pender({
type: GET_POST,
onSuccess: (state, action) => {
const {title, body} = action.payload.data;
return {
data: {
title,
body
}
}
},
onCancel: (state, action) => {
return {
data: {
title: '취소됨',
body: '취소됨'
}
}
}
});

여기에서 cancel 함수를 호출한다고 해서 웹 요청을 취소하는 것은 아니다. 서버에 이미 요청을 보냈기 때문에 서버는 응답할 것이다. 이 응답을 미들웨어 쪽에서 무시할 뿐이다.

원본 글 - [https://kbennycc.blogspot.com/2019/02/0116.html]

01.15 리덕스 편하게 사용하는 방법 0


React.js


01.15. 리덕스를 편하게 사용하는 방법

리덕스를 사용하여 멀티 카운터를 만들면서 불편했던 점이 있다.
액션을 만들 때마다 세 가지 파일 ( 액션 타입, 액션 생성 함수, 리듀서)을 수정해야한다는 점, 전개 연산자(...)와 slice 함수로 배열 내부의 아이템을 수정하는 데 가독성이 낮다는 점 등이다.
이런 불편한 점을 해결하여 리덕스를 사용하는 방법을 알아본다.
  • Immutable.js 를 이용한 상태 업데이트
  • Ducks 파일 구조
  • redux-actions를 이용하여 액션 생성 함수 구현

01.15.1. Immutable.js

Immutable.js 는 자바스크립트에서 불변성 데이터를 다룰 수 있게 도와준다.

01.15.1.1. 자바스크립트의 객체 불변성

객체 불변성을 이해하려면 간단한 자바스크립트 코드를 실행해 보아야한다. 크롬 웹 브라우저에서 개발자도구를 이용해 코드를 확인한다.
let a = 7;
let b = 7;

let object1 = {a:1, b:2};
let object2 = {a:1, b:2};
a값과 b값은 같다. 둘을 === 연산자로 비교해보면 당연히 true를 반환할 것이다.
하지만 object1과 object2가 같은 값을 가지더라도 서로 다른 객체이기 때문에 둘을 비교하면 false를 반환한다.
object1 === object2 
// false

let object3 = object1;
object1 === object3
// true

/* object3에 object1을 넣고 두 값을 비교하면 true를 반환한다.
obejct1과 object3은 같은 객체를 가르키기 때문이다.
*/

object3.c = 3;

object1 === object3
// true
object1
// Object { a: 1, b: 2, c: 3 }
리액트 컴포넌트는 state 또는 상위 컴포넌트에서 전달받은 props 값이 변할 때 리렌더링되는데, 배열이나 객체를 직접 수정한다면 내부 값을 수정했을지라도 레퍼런스가 가르키는 곳은 같기 때문에 똑같은 값으로 인식한다.

전개연사자(...)를 사용해 기존 값을 가진 새 객체 또는 배열을 만든 이유이다.
Immutable.js를 사용하면 객체나 배열의 값을 바꾸는 것이 간편해진다.
let object1 = Map({
a: 1,
b: 2,
c: 3,
d: Map({
e: 4,
f: Map({
g: 5,
h: 6
})
})
});

let object2 = object1.setIn(['d','f','h'],10);

object1 === object2
// false

01.15.1.2. Map

Immutable의 Map은 객체 대신 사용하는 데이터 구조이다. 자바스크립트에 내장된 Map과는 다르다.

※ Map 사용
- fromJS
객체 내용을 네트워크에서 받아 오거나 전달받는 객체가 너무 복잡한 상태라면 일일이 내부까지 Map으로 만들기 힘들 것이다. 이때는 fromJS를 사용한다.
const data = fromJS({ a:1, b:2 });
- toJS
Immutable 객체를 일반 객체 형태로 변형하는 방법이다.
const deserialized = object1.toJS();
console.log(deserialized);
// {a: 1, b: 2}
- 특정 키의 값 불러오기
특정 키의 값을 불러올 때는 get
object.get('a'); // 1
- 깊이 위치한 값 불러오기
Map 내부에 Map 이 존재하고, 그 Map 안에 있는 키 값을 불러 올 때는 getIn
object1.getIn(['c','d']); // 3
- 값 설정
새 값을 설정할 때는 set. 데이터가 실제로 변하는 것이 아니라 변화를 적용한 새 Map 만드는 것이다.
const newData = object1.set('a',4);
newData === object1
// false
- 깊이 위치한 값 수정
깊이 위치한 값을 수정할 때는 setIn. 이때 내부에 있는 객체들도 Map 형태여야 사용할 수있다.
const newData = object1.setIn(['c','d'],10);
- 여러 값 동시 설정
여러 값을 동시에 설정해야 할 때는 mergeIn. 예를 들어 c값과 d값, c값과 e값을 동시에 바꾸어야할 때는 다음과 같이 입력한다.
const newData = object1.mergeIn(['c'], {d: 10, e: 10});
/* mergeIn 를 사용하면 c 안에 들어있는 f 값은 그대로 유지하면서
d 값과 e 값만 변경한다.
다른 방법은 setIn을 이용한다.
*/
const newData = object1.setIn(['c','d'],10)
.setIn(['c','e'],10);
- merge
값을 수정하는 방법으로 다른 방법으로 성능상으로 set을 여러번하는 것이 더 빠르기 때문에 사용하지 않을 예정이지만 알고는 있어야한다.
const newData = object1.merge({ a: 10, b: 10 });

01.15.1.2. List

List는 Immutable 데이터 구조로 배열 대신 사용한다. 
배열과 동일하게 map, filter, sort, push, pop 함수를 내장하고 있다. 이 내장 함수를 실행하면 List 자체를 변경하는 것이 아니라 새로운 List를 반환한다. 
또 리액트 컴포넌트는 List 데이터 구조와 호환되기 때문에 map 함수를 사용하여 데이터가 들어있는 List를 컴포넌트 List 로 변환하여 JSX 에서 보여주어도 제대로 렌더링된다.

※ List 사용
- 생성
const list = List([0,1,2,3,4]);

const list = List([
Map({a: 1}),
Map({a: 2}),
]);

const list = fromJS([
{a: 1},
{a: 2},
]);
- 값 읽어오기
list.get(0);

list.getIn([0, 'a']);
- 수정
// 해당 요소를 통째로 바꾸고 싶을 때는 set.
const newList = list.set(0, Map({a: 10}));
// 해당 요소의 값을 변경하고 싶을 때는 setIn.
const newList = list.setIn([0, 'value'], 10);
// 다른 방법으로는 update.
const newList = list.update(0, item => item.set('a', item.get('a') * 10));
/* 값을 업데이트해야 하는데 기존 값을 참조해야할 때는
update를 사용하면 펺다.
첫번째 파라미터는 선택할 인덱스 값,
두번째 파라미터는 선택한 아이템을 업데이트하는 함수이다.
*/
// update를 사용하지 않을 경우.
const newList = list.setIn([0, 'a'], list.getIn([0, 'a']*10));
- 추가
아이템을 추가할 때는 push. 이 함수를 사용한다고 해서 Array 처럼 기존 List 자체에 아이템을 추가하는 것이 아니라 새로운 List 를 만들어서 반환한다.
const newList = list.push(Map({a: 3}));
// 리스트 맨 뒤에 데이터를 추가할 때는 push.
// 맨 앞에 데이터를 추가할 때는 unshift.
const newList = list.unshift(Map({a: 3}));
- 제거
const newList = list.delete(index);
// 마지막 아이템을 제거할 때는 pop
const newList = list.pop();
- 크기
list.size();
// list가 비어있는지 확인할 때는 isEmpty.
list.isEmpty();

01.15.2. Ducks 파일 구조

리덕스에서 사용하는 파일들은 일반적으로 액션 타입, 액션 생성 함수, 리듀서 세 종류로 분리하여 관리한다. 파일을 세 종류로 나누어 리덕스 관련 코드를 작성하다 보면 액션 하나를 만들 때마다 파일 세 개를 수정해야한다.

'액션 타입, 액션 생성 함수, 리듀서를 모두 한 파일에서 모듈화하여 관리하면 어떨까?'라는 생각으로 만든 파일 구조가 Ducks 파일 구조 이다.
// 액션 타입
const CREATE = 'my-app/todos/CREATE'
const REMOVE = 'my-app/todos/REMOVE'
const TOGGLE = 'my-app/todos/TOGGLE'

// 액션 생성 함수
export const create = (todo) => ({
type: CREATE,
todo
});

export const remove = (id) => ({
type: REMOVE,
id
});

export const toggle = (id) => ({
type: TOGGLE,
id
});

const initialState = {
// 초기 상태
}

// 리듀서
export default function reducer(state = initialState, action) {
switch (action.type) {
// 리듀서 관련 코드
}
}
Ducks 구조에서는 파일 안에 액션타입, 액션 생성 함수, 리듀서를 한꺼번에 넣어서 관리하는데, 이를 모듈이라고 한다.

01.15.2.1. 규칙

Ducks 구조에서는 지켜야 할 규칙이 있다.
  • export default 를 이용하여 리듀서를 내보내야한다.
  • export 를 이용하여 액션 생성 함수를 내보내야한다.
  • 액션 타입 이름은 npm-module-or-app/reducer/ATCTION_TYPE 형식으로 만들어야한다.[라이브러리를 만들거나 애플리케이션을 여러 프로젝트로 나눈 것이 아니라면 맨 앞은 생략해도 된다.(예: counter/INCREMENT)]
  • 외부 리듀서에서 모듈의 액션 타입이 필요할 때는 액션 타입을 내보내도 된다.
Ducks 구조를 사용할 때는 이 규칙들을 준수해야한다.

01.15.3. redux-actions 를 이용한 액션 관리

redux-actions 패키지에는 리덕스 액션들을 관리할 때 유용한 createAction과 handleActions 함수가 있다.
yarn 을 통해 설치하고
$ yarn add redux-actions
import 를 통해 소스에 적용한다.
import {createAction, handleActions} from 'redux-actions';

01.15.3.1. createAction 을 이용한 액션 생성 자동화

리덕스에서 액션을 만들면 모든 액션에서 일일이 액션 생성자를 만드는 것이 번거로운 일이다.
export const increment = (index) => ({
type: types.INCREMENT,
index
});

export const decrement = (index) => ({
type: types.DECREMENT,
index
});
createAction 을 사용해 자동화할 수 있다.
export const increment = createAction(types.INCREMENT);
export const decrement = createAction(types.DECREMENT);

increment(3);

/* 결과:
{
type: 'INCREMENT',
payload: 3
}
*/
함수에 파라미터를 전달하면 payload 키에 파라미터로 받은 값을 넣어 객체를 생성한다. 파라미터가 여러 개일 경우 객체를 만들어 파라미터를 넣어주면 payload 에 객체가 설정된다.
/* 어떤 파라미터를 받는지 명시하지 않아 
헷갈릴 경우 코드상으로 명시할 수 있다.
*/
export const setColor = createAction(types.SET_COLOR, ({index, color}) => ({index, color}));

01.15.3.2. switch 문 대신 handleActions 사용

리듀서에 switch문을 사용하여 액션 타입에 따라 다른 작업을 하도록 했다.
scope 를 리듀서 함수로 설정했기 때문에 서로다른 case 에서 let 이나 const 를 사용하여 변수를 선언하려고 할 때, 같은 이름이 중첩되어 있으면 오류가 발생한다. 그렇다고 case마다 함수를 정의하면 코드의 가독성이 떨어진다. handelActions 를 사용하면 이 문제를 해결할 수 있다.
const reducer = handleActions(
// 첫 번째 파라미터는 액션에 따라 실행할 함수들을 가진 개체를 넣는다.
{
INCREMENT: (state, action) => ({
counter: state.counter + action.payload
}),
DECREMENT: (state, action) => ({
counter: state.counter - action.payload
})
},
// 두 번째 파라미터는 상태의 기본 값(initialState)을 넣는다.
{counter: 0}
);

위 세가지 방법을 사용하여 리덕스를 프로젝트에 적용했을 때 장점은 상태 관리를 하는 로직과 뷰에 관련된 로직을 완전히 다른 파일로 분리함으로써 프로젝트 가독성을 높이고 유지 보수를 하기도 쉽다는 것이다.


01.14 리덕스를 이용한 리액트 애플리케이션 생성 0


React.js


01.14. 리덕스를 이용한 리액트 애플리케이션 생성

리액트 애플리케이션에서 상태 관리를 할 때 소규모 프로젝트에서는 컴포넌트가 가진 state 기능을 사용하는 것으로 충분하지 모르지만, 규모가 커진다면 관리가 불편하다. 상태 관리 라이브러리를 사용하지 않고 state만 사용한다면 다음 문제점이 발생한다.
  • 상태 객체가 너무 복잡하고 크다.
  • 최상위 컴포넌트에서 상태 관리를 하는 메서드를 너무 많이 만들어 코드가 복잡하다.
  • 하위 컴포넌트에 props를 전달하려면 여러 컴포넌트를 거쳐야한다.

01.14.1. 작업 환결설정

-- create-react-app 로 프로젝트 생성
$ create-react-app redux-sample

yarn을 사용하여 redux와 react-redux를 설치
$ cd redux-sample
$ yarn add redux react-redux
$ yarn eject
react-redux는 리액트 컴포넌트에서 리덕스를 더욱 간편하게 사용할 수 있게 하는 라이브러리이다.

01.14.2. 프로젝트 초기 설정

src 디렉터리 내부에 필요없는 파일 제거
  • App.css
  • App.js
  • App.test.js
  • logo.svg
src 디렉터리 하위에 디렉터리 생성
  • actions: 액션 타입과 액션 생성자 파일을 저장
  • components: 컴포넌트의 뷰가 어떻게 생길지만 담당하는 프리젠테이셔널(presentational) 컴포넌트 저장
  • containers: 스토어에 있는 상태를 props로 받아 오는 컨테이너(container) 컴포넌트들을 저장
  • reducers: 스토어의 기본 상태 값과 상태의 업데이트를 담당하는 리듀서 파일들을 저장
  • lib: 일부 컴포넌트에서 함께 사용되는 파일을 저장

01.14.3. 프리젠테이셔널 컴포넌트와 컨테이너 컴포넌트

프리젠테이셔널 컴포넌트와 컨테이너 컴포넌트는 리덕스를 사용하는 프로젝트에서 자주 사용하는 구조로 멍청한(dumb) 컴포넌트와 똑똑한(smart) 컴포넌트라고도 알려져있다.

01.14.3.1. 프리젠테이셔널 컴포넌트

오직 뷰만 담당하는 프리젠테이셔널 컴포넌트. 안에 DOM 엘리먼트와 스타일이 있으며, 프리젠테이셔널 컴포넌트나 컨테이너 컴포넌트가 있을 수도 있다. 하지만 리덕스 스토어에 직접 접근할 권한은 없으며, 오직 props로만 데이터를 가져올 수 있다. 또 대부분은 state가 없다. 있다고 해도 데이터와 관련된 것이 아니라 UI와 관련된 것이어야 한다.

주로 함수형 컴포넌트로 작성하며, state가 있어야 하거나 최적화를 하려고 라이프사이클 메서드가 필요할 때는 클래스형 컴포넌트로 작성된다.

01.14.3.2. 컨테이너 컴포넌트

컨테이너 컴포넌트는 프리젠테이션 컴포넌트와 컨테이너 컴포넌트들의 관리를 담당한다. 내부에 DOM 엘리멘트를 직접적으로 사용할 때는 없고, 감싸는 용도일 때만 사용한다. 또 스타일도 가지고 있지 않아야 한다. 스타일은 모두 프리젠테이셔널 컴포넌트에서 정의해야한다. 상태를 가지고 있을 때가 많으며, 리덕스에 직접 접근할 수 있다.

※ 컴포넌트를 이렇게 두 카테고리로 나누면 사용자가 이용할 유저 인터페이스와 상태를 다루는 데이터가 분리되어 프로젝트를 이해하기 쉽고, 컴포넌트 재사용률이 높다.

※ 컨테이너 컴포넌트라고 해서 무조건 내부에 컴포넌트가 여러 개 있어야 하는 것은 아니다. 또 프리젠테이셔널 컴포넌트 내부에 컨테이너 컴포넌트를 넣어도 된다. 리덕스를 사용한다고 해서 이 구조를 무조건 따를 필요는 없다. 이는 리덕스 창시자 댄 아브라모프가 더 나은 설계를 하려고 공유한 구조이지만, 무조건 따라야 할 규칙은 아니다. 따라하면 유용한 팁일 수 있지만, 개발 흐름에 어울리지 않을 수도 있다.

01.14.4. 기본 Component 생성

containers 디렉터리에 App 컴포넌트를 생성한다.
// src/containers/App.js
import React, { Component } from 'react';

class App extends Component {
render() {
return (
<div>Counter</div>
);
}
}

export default App;
index.js 파일에 생성한 App 컴포넌트를 반영한다.
// src/index.js 파일 수정
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './containers/App'; // 생성한 App 컴포넌트 수정
import * as serviceWorker from './serviceWorker';

ReactDOM.render(<App />, document.getElementById('root'));

serviceWorker.unregister();
프로젝트 실행
$ yarn start
화면에 Counter 텍스트만 표시가 되면 성공적으로 프로젝트가 실행된 것이다.

01.14.5. Counter 컴포넌트 생성

첫 프리젠테이셔널 컴포넌트인 카운터 컴포넌트를 생성할 것이다. 이 컴포넌트는 숫자와 색상 값, 더하기, 빼기, 색상 변경 함수를 props로 전달 받는다.
import React from 'react';
import PropTypes from 'prop-types';
import './Counter.css';

const Counter = ({number, color, onIncrement, onDecrement, onSetColor}) => {
return (
<div
className='Counter'
onClick={onIncrement}
onContextMenu={(e)=>{e.preventDefault(); onDecrement();}}
onDoubleClick={onSetColor}
style={{backgroundColor:color}}
>
{number}
</div>
);
};

Counter.PropTypes = {
number : PropTypes.number
,color : PropTypes.string
,onIncrement : PropTypes.func
,onDecrement : PropTypes.func
,onSetColor : PropTypes.func
};

Counter.PropTypes = {
number : 0
,color : 'black'
,onIncrement : () => console.log('onIncrement not defined')
,onDecrement : () => console.log('onDecrement not defined')
,onSetColor : () => console.log('onSetColor not defined')
};

export default Counter;
onContextMenu는 마우스 오른쪽 클릭시 메뉴가 열리는 이벤트를 의미한다. 이 함수가 실행될 때 e.preventDefault() 함수를 호출하면 메뉴가 열리는 것을 방지한다. 컴포넌트 코드의 아래쪽에서는 props 기본 값을 설정해 주었다. 카운터의 기본 숫자는 0, 색상은 검색, 함수가 전달되지 않았을 때는 console에 log를 출력하도록 설정했다.
/* src/components/Counter.css */
.Counter {
/* 레이아웃 */
width: 10rem;
height: 10rem;
display: flex;
align-items: center;
justify-content: center;
margin: 1rem;
/* 색상 */
color: white;
/* 폰트 */
font-size: 3rem;
/* 기타 */
border-radius: 100%;
cursor: pointer;
user-select: none;
transition: background-color 0.75s;
}

01.14.6. Action Types 준비

actions 디렉터리에 ActionTypes.js 라는 디렉터리를 만들어서 상수들을 선언한다.
/* Action 종류들을 선언한다. 
앞에 export 를 붙이면 나중에 이것들을 불러올 때,
import * as types from './ActionTypes'; 를 할 수 있다.
*/
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const SET_COLOR = 'SET_COLOR';
액션을 선언할 때는 이처럼 대문자로 선언하면 된다.

※ export const 를 이용해 상수를 선언하면서 내보내기를 하였다.

01.14.7. 액션 생성 함수 만들기

액션을 만들 때마다 객체를 바로 생성하기는 번거로우므로 액션을 만들어 내는 함수를 만든다. 
/* action 객체를 만드는 액션 생성 함수들을 선언한다.(action creators)
여기에서 () => ({}) 는 function () {return {}} 와 동일한 의미이다.
*/
import * as types from './ActionTypes';

export const increment = () => ({
type: types.INCREMENT
});

export const decrement = () => ({
type: types.DECREMENT
});

// 파라미터를 갖는 액션 생성자
export const setColor = (color) => ({
type: types.SET_COLOR,
color
});

01.14.8. 리듀서 생성

리듀서는 액션의 type에 따라 변화를 일으키는 함수이다. 리듀서를 작성할 때는 최초 변화를 일으키기 전 가지고 있어야 할 초기 상태를 정의해야한다.
// src/reducers/index.js
import * as types from '../actions/ActionTypes';

// 초기 상태를 정의한다.
const initialState = {
color: 'black',
number: 0
};
리듀서 함수는 state와 action을 파라미터로 가지는 함수이며, 그 함수 내부에서 switch문으로 action.type에 따라 상태에 다른 변화를 일으킨다.
// src/reducers/index.js
import * as types from '../actions/ActionTypes';

// 초기 상태를 정의한다.
const initialState = {
color: 'black',
number: 0
};

/* 리듀서 함수를 정의한다.
리듀서는 state와 action을 파라미터로 받는다.
state가 undefined일 때 (스토어가 생성될 때) state 기본 값을 initialState로 사용한다.
action.type에 따라 다른 작업을 하고, 새 상태를 만들어서 반환한다.
이때 주의할 점은 state를 직접 수정하면 안되고,
기존 상태 값에 원하는 값을 덮어쓴 새로운 객체를 만들어 반환해야 한다.
*/
function counter(state = initialState, action) {
switch(action.type) {
case types.INCREMENT:
return {
...state,
number: state.number + 1
};
case types.DECREMENT:
return {
...state,
number: state.number - 1
};
case types.SET_COLOR:
return {
...state,
color: action.color
};
default:
return state;
}
}

export default counter;
state를 직접 수정하면 절대 안된다. 기존 state 값에 새 상태를 엎어쓴 상태 객체를 만드는 방식으로 처리해야 한다는 것에 주의해야한다.

01.14.9. 스토어 생성

스토어는 리덕스에서 가장 핵심적인 인스턴스이다.
스토어 내부에 현재 상태가 내장되어 있고, 상태를 업데이트할 때마다 구독 중인 함수들을 호출한다.
리덕스에서 createStore를 불러와 해당 함수에 생성한 리듀서를 파라미터로 넣어 스토어를 생성한다.
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';

// 리덕스 관련
import {createStore} from 'redux';
import reducers from './reducers';

// 스토어 생성
const store = createStore(reducers);

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();

01.14.10. Provider 컴포넌트로 리액트 앱에 store 연동

Provider는 react-redux 라이브러리에 내장된 리액트 애플리케이션에 손쉽게 스토어를 연동할 수 있도록 도와주는 컴포넌트이다. 
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';

// 리덕스 관련
import {createStore} from 'redux';
import reducers from './reducers';
import {Provider} from 'react-redux';

// 스토어 생성
const store = createStore(reducers);

ReactDOM.render(
<Provider store={store}>
<App />
</Provider>
, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();
리액트 컴포넌트에서 스토어를 사용할 준비는 완료되었다.

01.14.11. CounterContainer 컴포넌트 생성

컨테이너 컴포넌트에는 스토어가 연동되어 있다. react-redux 라이브러리의 connect 함수를 사용하여 컴포넌트를 스토어에 연결시킨다. connect 함수에는 파라미터가 세 개 들어간다.
connect([mapStateToProps],[mapDispatchToProps],[mergeProps]);
각 파라미터는 optional이기 때문에 불필요하다면 생략해도 된다. 이 파라미터들은 함수 형태이며, 컴포넌트에서 사용할 props를 반환한다.
  • mapStateToProps: store.getState() 결과 값인 state를 파라미터로 받아 컴포넌트의 props로 사용할 객체를 반환한다.
  • mapDispatchToProps: dispatch를 파라미터로 받아 액션을 디스패치하는 함수들을 객체 안에 넣어서 반환한다.
  • mergeProps: state와 dispatch가 동시에 필요한 함수를 props로 전달해야 할 때 사용하는데, 일반적으로 잘 사용하지 않는다.
connect 함수를 호출하고 나면 또 다른 함수를 반환한다. 이때 반환하는 함수의 파라미터로 리덕스에 연결시킬 컴포넌트를 넣으면 mapStateToProps와 mapDispatchToProps에서 정의한 값들을 props로 받아오는 새 컴포넌트를 만든다.
// src/containers/CounterContainer.js
import Counter from '../components/Counter';
import * as actions from '../actions';
import {connect} from 'react-redux';

// 13가지 색상 중 랜덤으로 선택하는 함수
export function getRandomColor() {
const colors = [
'#F0F8FF',
'#FAEBD7',
'#00FFFF',
'#7FFFD4',
'#F0FFFF',
'#F5F5DC',
'#FFE4C4',
'#000000',
'#FFEBCD',
'#0000FF',
'#8A2BE2',
'#A52A2A',
'#DEB887'
];

// 0~12 랜덤 숫자
const random = Math.floor(Math.random() * 13);

// 랜덤 색상 변환
return colors[random];
}

// store 안의 state 값을 props로 연결한다.
const mapStateToProps = (state) => ({
color: state.color,
number: state.number
});

/* 액션 생성 함수를 사용하여 액션을 생성하고,
해당 액션을 dispatch 하는 함수를 만든 후 이를 props로 연결한다.
*/
const mapDispatchToProps = (dispatch) => ({
onIncrement: () => dispatch(actions.increment()),
onDecrement: () => dispatch(actions.decrement()),
onSetColor: () => {
const color = getRandomColor();
dispatch(actions.setColor(color));
}
});

// Counter 컴포넌트의 Container 컴포넌트
// Counter 컴포넌트를 애플리케이션의 데이터 레이어와 묶는 역할을 한다.
const CounterContainer = connect(
mapStateToProps,
mapDispatchToProps
)(Counter);

export default CounterContainer;
이렇게 하면 mapStateToProps의 color 값, number 값과 mapDispatchToProps의 onIncrement값, onDecrement 값, onSetColor 값이 Counter 컴포넌트의 props로 들어간다. 이렇게 리덕스와 연동된 컴포넌트를 CounterContainer 안에 담아 이를 내보낸 후, App 컴포넌트에서 CounterContainer 컴포넌트를 불러와 렌더링 한다.
// src/containers/App.js
import React, {Component} from 'react';
import CounterContainer from './CounterContainer';

class App extends Component {
render() {
return (
<div>
<CounterContainer/>
</div>
);
}
}

export default App;


웹 브라우저에 있는 동그라미를 마우스로 왼쪽 클릭, 오른쪽 클릭, 더블클릭을 한다. 상태가 변한다면 리덕스를 이용한 리액트 애플리케이션을 성공적으로 생성한 것이다.











01.14.12. 서브 리듀서 생성

현재 만든 리듀서는 색생과 숫자를 한 객체 안에 넣어서 관리했다. 이 리듀서를 서브 리듀서 두 개로 나누어 파일을 따로 분리시킨 후 combineReducers로 다시 합쳐 루트 리듀서를 만들어 본다.
// src/reducers/color.js
import * as types from '../actions/ActionTypes';

const initialState = {
color: 'black'
};

const color = (state = initialState, actions) => {
switch(actions.type) {
case types.SET_COLOR:
return {
color: actions.color
};
default:
return state;
}
}

export default color;

// src/reducers/number.js
import * as types from '../actions/ActionTypes';

const initialState = {
number: 0
};

const number = (state = initialState, actions) => {
switch(actions.type) {
case types.INCREMENT:
return {
number: state.number + 1
};
case types.DECREMENT:
return {
number: state.number - 1
};
default:
return state;
}
};

export default number;
src/reducers/index.js 에서 색상과 숫자를 color.js, number.js 2개 파일로 분리 시켰다.
src/reducers/index.js 에서 combineReducers 를 이용해 두개의 리듀서를 합쳐준다.
// src/reducers/index.js
import number from './number';
import color from './color';

import {combineReducers} from 'redux';

/* 서브 리듀서들을 하나로 합친다.
combineReducers를 실행하고 나면,
나중에 store 형태를 파라미터로 전달한 객체 모양대로 만든다.
*/
const reducers = combineReducers({
numberData: number,
colorData: color
});

export default reducers;
combineReducers 를 호출할 때는 객체를 파라미터로 전달하는데, 이 객체 구조에 따라 합친 리듀서 상태 구조를 정의한다.
// src/containers/CounterContainer.js - mapStateToProps
// 코드 수정
const mapStateToProps = (state) => ({
color: state.colorData.color,
number: state.numberData.number
});
코드를 저장하고, 웹브라우저를 확인하면 동일하게 작동한다.

※ 리덕스 개발자 도구 사용
멀티 카운터를 만들기 전에 리덕스에서 액션을 디스패치할 때마다 기록을 확인하고, 이전의 상태로 돌아갈 수도 있게 하는 리덕스 개발자 도구를 설치한다.
크롬웹스토어(https://chrome.google.com/webstore?hl=ko)에서 Redux DevTools를 검색하여 크롬에 추가한다. 크롬을 종료 후 재시작하면 개발자 도구에서 Redux 탭이 활성화되어있다.
프로젝트에서 스토어를 생성할 때는 별도로 개발자 도구를 활성화하는 작업을 해야 작동한다.
// src/index.js - 코드 수정
const store = createStore(reducers, window.devToolsExtension && window.devToolsExtension());
이 도구를 사용하면 현재 리덕스 상태는 어떤지, 방금 디스패치한 액션은 무엇인지, 액션으로 어떤 값을 바꾸엇는지 확인할 수 있다.

01.14.13. 멀티 카운터 생성

카운터 개수를 늘려 조금 더 복잡한 상태를 관리한다.

01.14.13.1. 액션타입수정

카운터를 추가하는 CREATE 와 카운터를 제거하는 REMOVE를 액션타입에 추가한다.
// src/actions/ActionTypes.js
/* Action 종류들을 선언한다.
앞에 export 를 붙이면 나중에 이것들을 불러올 때,
import * as types from './ActionTypes'; 를 할 수 있다.
*/
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const SET_COLOR = 'SET_COLOR';

export const CREATE = 'CREATE';
export const REMOVE = 'REMOVE';

01.14.13.2. 액션 생성 함수 수정

새로 생성한 액션 타입에 따라서 새 액션 생성 함수를 만든다. 기존 액션 생성 함수들도 앞으로 작동 방식이 달라지기 때문에 전체적으로 수정해야한다.
// src/actions/index.js
/* action 객체를 만드는 액션 생성 함수들을 선언한다.(action creators)
여기에서 () => ({}) 는 function () {return {}} 와 동일한 의미이다.
*/
import * as types from './ActionTypes';

export const create = (color) => ({
type: types.CREATE,
color
});

export const remove = () => ({
type: types.REMOVE
});

export const increment = (index) => ({
type: types.INCREMENT,
index
});

export const decrement = (index) => ({
type: types.DECREMENT,
index
});

// 파라미터를 갖는 액션 생성자
export const setColor = ({index,color}) => ({
type: types.SET_COLOR,
index,
color
});

01.14.14. Reducers 수정

현재까지 만든 리듀서들과 작동 방식이 다르기 때문에 reducers 디렉터리 안에 있는 color.js , number.js를 삭제하고 index.js 파일 내용도 비우고 새로 작성한다.

01.14.14.1. 초기상태정의

// src/reducers/index.js
import * as types from '../actions/ActionTypes';

// 초기 상태 정의
const initialState = {
counters: [
{
color: 'black',
number: 0
}
]
}

01.14.14.2. 리듀서 함수에서 카운터 추가 및 삭제 구현

// src/reducers/index.js
import * as types from '../actions/ActionTypes';

// 초기 상태 정의
const initialState = {
counters: [
{
color: 'black',
number: 0
}
]
}

function counter(state = initialState, action) {
// 레퍼런스 생성
const {counters} = state;

switch(action.type) {
case types.CREATE:
return {
counters: [
...counters,
{
color: action.color,
number: 0
}
]
};
case types.REMOVE:
return {
counters: counters.slice(0, counters.length - 1)
};
default:
return state;
}
}

export default counter;
배열을 업데이트하는 것은 setState로 컴포넌트의 state 안에 있는 배열을 다룰 때와 동일하다. 기존 배열에 배열 함수를 사용하여 값을 변경하면 안되고, 전개 연산자(...)를 사용하거나 slice함수로 배열을 잘라서 새로 생성해야한다.

01.14.14.3. 리듀서 함수에 증가, 감소, 색상 변경 구현

// src/reducers/index.js
(...)
function counter(state = initialState, action) {
// 레퍼런스 생성
const {counters} = state;

switch(action.type) {
(...)
case types.INCREMENT:
return {
counters: [
...counters.slice(0, action.index), // 선택한 인덱스의 전 아이템들
{
...counters[action.index], // 기존 객체에
number: counters[action.index].number + 1 // 새 number 값 덮어쓰기

},
...counters.slice(action.index + 1, counters.length), // 선택한 인덱스의 다음 아이템들
]
};
case types.DECREMENT:
return {
counters: [
...counters.slice(0, action.index),
{
...counters[action.index],
number: counters[action.index].number - 1
},
...counters.slice(action.index + 1, counters.length)
]
};
case types.SET_COLOR:
return {
counters: [
...counters.slice(0, action.index),
{
...counters[action.index],
color: action.color
},
...counters.slice(action.index + 1, counters.length)
]
};
default:
return state;
}
}

export default counter;
배열 내부 아이템들을 수정하는 자업이 그렇게 어렵지는 않지만, 간단한 작업을 하나 하자고 가끔 코드를 필요 이상으로 많이 작성하는 것은 사실이다.
멀티 카운터 후에 Immutable 라이브러리 사용할 것이다. Immutable 라이브러리를 사용하면 배열을 수정할 때나 여러 층으로 깊이 감싼 객체를 수정할 때 더욱 가독성이 높고 쉽게 구현도 할 수 있다.

01.14.15. 프리젠테이셔널 컴포넌트 생성

카운터 생성 및 제거를 담당하는 Buttons 컴포넌트와 카운터 여러 개를 렌더링할 CounterList를 만든다.

01.14.15.1. 생성, 제거 버튼 - Buttons 컴포넌트 생성

이 컴포넌트는 버튼 두 개를 내장하고 있으며, 새 카운터를 생성하는 onCreate 함수, 마지막 카운터를 제거할 onRemove 함수를 props로 전달받는다.
import React from 'react';
import PropTypes from 'prop-types';

import './Buttons.css';

const Buttons = ({onCreate, onRemove}) => {
return (
<div className="Buttons">
<div className="btn add" onClick={onCreate}>생성</div>
<div className="btn remove" onClick={onRemove}>제거</div>
</div>
);
}

Buttons.propTypes = {
onCreate: PropTypes.func,
onRemove: PropTypes.func
};

Buttons.propTypes = {
onCreate: () => console.warn('onCreate not defined'),
onRemove: () => console.warn('onRemove not defined')
};

export default Buttons;

/* src/components/Buttons.css */
.Buttons {
display: flex;
}

.Buttons .btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 3rem;

color: white;
font-size: 1.5rem;
cursor: pointer;
}

.Buttons .add {
background: lightblue;
}

.Buttons .add:hover {
background: lightskyblue
}

.Buttons .remove {
background: lightpink;
}

.Buttons .remove:hover {
background: lightcoral;
}

01.14.15.2. 여러 카운터를 렌더링: CounterList 컴포넌트 생성

여러 카운터를 렌더링할 CounterList 컴포넌트를 만든다. 이 컴포넌트는 카운터 객체들의 배열 counters 와 카운터 값을 조작하는 onIncrement, onDecrement, onSetColor 함수를 props로 전달받는다.

이 컴포넌트 내부에서 counters 배열을 Counter 컴포넌트의 배열로 map 할 것이다. key는 배열의 index로 설정하고,  index 값도 컴포넌트에 props로 전달한다. 그리고 color 값과 number 값을 일일이 설정하는 대신 {...counter} 를 JSX 태그 내부에 넣어주면 해당 값들을 풀어서 각 값을 한꺼번에 전달할 수 있다.
// src/components/CounterList.js
import React from 'react';
import Counter from './Counter';
import PropTypes from 'prop-types';

import './CounterList.css';

const CounterList = ({counters, onIncrement, onDecrement, onSetColor}) => {
const counterList = counters.map(
(counter, i) => (
<Counter
key={i}
index={i}
{...counter}
onIncrement={onIncrement}
onDecrement={onDecrement}
onSetColor={onSetColor}/>
)
);

return (
<div className="CounterList">
{counterList}
</div>
);
};

CounterList.propTypes = {
counters: PropTypes.arrayOf(PropTypes.shape({
color: PropTypes.string, number: PropTypes.number
})),
onIncrement: PropTypes.func,
onDecrement: PropTypes.func,
onSetColor: PropTypes.func
};

CounterList.defaultProps = {
counters: []
};

export default CounterList;

/* src/components/CounterList.css */
.CounterList {
margin-top: 2rem;
display: flex;
justify-content: center;
flex-wrap: wrap;
}

01.14.15.2. Counter 컴포넌트 수정

CounterList 에서 전달받은 index 를 각 이벤트를 실행할 때 함수의 파라미터로 넣어서 실행할 수 있게 한다.
// src/components/Counter.js
import React from 'react';
import PropTypes from 'prop-types';
import './Counter.css';

const Counter = ({number, color, index, onIncrement, onDecrement, onSetColor}) => {
return (
<div
className="Counter"
onClick={() => onIncrement(index)}
onContextMenu={(e) => {
e.preventDefault();
onDecrement(index);
}}
onDoubleClick={() => onSetColor(index)}
style={{backgroundColor:color}}
>
{number}
</div>
);
};

Counter.propTypes = {
index: PropTypes.number,
number: PropTypes.number,
color: PropTypes.string,
onIncrement: PropTypes.func,
onDecrement: PropTypes.func,
onSetColor: PropTypes.func
};

Counter.propTypes = {
index: 0,
number: 0,
color: 'black',
onIncrement: () => console.warn('onIncrement not defined'),
onDecrement: () => console.warn('onDecrement not defined'),
onSetColor: () => console.warn('onSetColor not defined')
};

export default Counter;
프리젠테이셔널 컴포넌트를 생성해서 어떻게 컴포넌트를 보여 줄 것인지 준비가 되었다.

01.14.16. 컨테이너 컴포넌트

기존 컨테이너 컴포넌트인 CounterContainer는 삭제한다. Buttons는 따로 컨테이너 컴포넌트를 만들어 주지 않고, App 컴포넌트를 리덕스에 연결하여 액션 함수도 연결시키고, 해당 함수들을 Buttons 컴포넌트에 전달한다.
// src/containers/CounterListContainer.js
import CounterList from '../components/CounterList';
import * as actions from '../actions';
import {connect} from 'react-redux';

// 색상 랜덤 선택 함수
export function getRandomColor() {
const colors = [
'#FFFFF0',
'#F0E68C',
'#E6E6FA',
'#FFF0F5',
'#7CFC00',
'#FFFACD',
'#ADD8E6',
'#F08080',
'#E0FFFF',
'#FAFAD2',
'#D3D3D3',
'#90EE90',
'#FFB6C1',
'#FFA07A',
'#20B2AA',
'#87CEFA',
'#778899',
'#B0C4DE',
'#FFFFE0'
];

// 랜덤 숫자
const random = Math.floor(Math.random() * 20);

// 랜덤 색상 반환
return colors[random];
}

// store 안의 state 값을 로 연결한다.
const mapStateToProps = (state) => ({counters: state.counters});

/* 액션 생성자를 사용하여 액션을 만들고,
해당 액션을 dispatch 하는 함수를 만든 후 이를 props로 연결한다.
*/
const mapDispatchToProps = (dispatch) => ({
onIncrement: (index) => dispatch(actions.increment(index)),
onDecrement: (index) => dispatch(actions.decrement(index)),
onSetColor: (index) => {
const color = getRandomColor();
dispatch(actions.SetColor({index, color}));
}
})

// 데이터와 함수들이 props로 붙은 컴포넌트 생성
const CounterListContainer = connect(mapStateToProps, mapDispatchToProps)(CounterList);

export default CounterListContainer;
생성한 CounterListContainer.js 에서 랜덤 색상 생성 함수는 다음에 수정할 App 컴포넌트에서도 사용한다. 코드가 중복되므로 해당 함수는 lib 디렉터리를 따로 만들어 저장한 후 불러와 사용한다.
// src/lib/getRandomColor.js
// 색상 랜덤 선택 함수
export default function getRandomColor() {

    const colors = [
'#FFFFF0',
'#F0E68C',
'#E6E6FA',
'#FFF0F5',
'#7CFC00',
'#FFFACD',
'#ADD8E6',
'#F08080',
'#E0FFFF',
'#FAFAD2',
'#D3D3D3',
'#90EE90',
'#FFB6C1',
'#FFA07A',
'#20B2AA',
'#87CEFA',
'#778899',
'#B0C4DE',
'#FFFFE0'
];

// 랜덤 숫자
const random = Math.floor(Math.random() * 20);

// 랜덤 색상 반환
return colors[random];
}
CounterListContainer 컴포넌트에서 랜덤 색상 생성 함수를 제거하고, 위쪽에 함수를 불러온다.
// src/containers/CounterListContainer.js
import CounterList from '../components/CounterList';
import * as actions from '../actions';
import {connect} from 'react-redux';
import getRandomColor from '../lib/getRandomColor';

// store 안의 state 값을 로 연결한다.
const mapStateToProps = (state) => ({counters: state.counters});

/* 액션 생성자를 사용하여 액션을 만들고,
해당 액션을 dispatch 하는 함수를 만든 후 이를 props로 연결한다.
*/
const mapDispatchToProps = (dispatch) => ({
onIncrement: (index) => dispatch(actions.increment(index)),
onDecrement: (index) => dispatch(actions.decrement(index)),
onSetColor: (index) => {
const color = getRandomColor();
dispatch(actions.SetColor({index, color}));
}
})

// 데이터와 함수들이 props로 붙은 컴포넌트 생성
const CounterListContainer = connect(mapStateToProps, mapDispatchToProps)(CounterList);

export default CounterListContainer;

01.14.17. App 컴포넌트 수정

App 컴포넌트를 리덕스에 연결한다. 이 컴포넌트에는 store 에서 필요한 값이 없으니 mapStateToProps는 null 로 설정하고, 버튼용 mapDispatchToProps 를 만든다.

이 컴포넌트에서 onCreate와 onRemove를 만들고, Buttons 컴포넌트의 props 로 전달한다.
// src/containers/App.js
import React, {Component} from 'react';
import Buttons from '../components/Buttons';
import CounterListContainer from './CounterListContainer';
import getRandomColor from '../lib/getRandomColor';

import {connect} from 'react-redux';
import * as actions from '../actions';

class App extends Component {
render() {
const {onCreate, onRemove} = this.props;
return (
<div className="App">
<Buttons
onCreate={onCreate}
onRemove={onRemove}
/>
<CounterListContainer/>
</div>
);
}
}

// 액션 생성 함수 준비
const mapToDispatch = (dispatch) => ({
onCreate: () => dispatch(actions.create(getRandomColor())),
onRemove: () => dispatch(actions.remove())
});

// 리덕스에 연결시키고 내보내기
export default connect(null, mapToDispatch)(App);

리덕스를 사용해서 리액트 프로젝트를 생성해보았다. 프로젝트를 만드는 과정이 복잡해졌다고 느낄 수 있으나 프로젝트에 필요한 상태가 복잡할 때를 대비하기 위해서 리덕스를 사용한다.


1 2 3 4 5