728x90
- 지금까지 다룬 리액트 앱들은 데이터를 브라우저에서 수집해 브라우저의 저장소에 저장했다. 리액트는 view 계층이기에 브라우저에서 모든 것을 처리해야 하기 때문이다. 하지만 대부분의 앱은 실제로는 백엔드 계층이 필요하다. 즉, 서버를 염두에 두고 구조를 잡아야 한다.
- 리액트를 isomorphic하게 렌더링한다는 것은 브라우저가 아닌 플랫폼에서 리액트를 렌더링한다는 것을 의미한다. 이는 UI를 서버에서 렌더링하고 브라우저에 보내 표시한다는 뜻이다. 서버 렌더링의 강점을 살리면 애플리케이션의 성능, 이식성, 보안을 향상시킬 수 있다.
1. isomorphism과 universalism 비교
- isomorphic과 universal이라는 용어는 클라이언트와 서버 양쪽에서 작동하는 애플리케이션을 의미한다.
- isomorphic 애플리케이션은 여러 플랫폼에서 렌더링되는 애플리케이션을 말하고, universal 코드는 완전히 같은 코드를 여러 환경에서 실행할 수 있다는 뜻이다.
- 노드를 사용하면 브라우저에서 작성한 javascript 코드를 서버나 CLI 등 다른 플랫폼에서 재사용할 수 있다.
// universal javascript 코드 예제
var printNames = response => {
var people = JSON.parse(response).results,
names = people.map({name}) => `${name.last}, ${name.first}`)
console.log(names.join('\n'))
}
- printNames 함수가 universal하다는 것은 같은 코드를 브라우저나 서버 모두에서 사용할 수 있어 노드를 사용해 서버를 구축하면 브라우저와 서버 환경에서 상당한 양의 코드를 재사용할 수 있다는 것을 뜻한다.
- universal javascript는 오류 없이 서버와 브라우저에서 실행될 수 있는 javascript 코드를 말한다.
- 모든 javascript 코드가 서버와 클라이언트에서 실행할 수 있지는 않다.
// 모든 javascript 코드가 universal하지 않다는 것의 예시
const request = new XMLHttpRequest()
request.open('GET', 'https://api.randomuser.me/?nat=US&results=10')
request.onload = () => printNames(request.response)
request.send()
- 위 코드는 브라우저에서 실행하면 정상 작동하지만 노드에서 실행하면 오류가 발생한다. 노드는 브라우저와 달리 XMLHttpRequest를 제공하지 않기 때문이다. 노드를 사용하는 경우 http 모듈을 사용해 요청을 보내야 한다.
// 노드를 사용하는 경우
const https = require('https')
https.get(
'https://api.randomuser.me/?nat=US&results=10',
res => {
let results = ''
res.setEncoding('utf8')
res.on('data', chunk => results += chunk)
res.on('end', () => printNames(results))
}
)
- 물론 if 문을 활용해 브라우저와 노드 양쪽 모두에서 console에 응답받은 이름을 제대로 출력하는 모듈을 만들 수도 있다. 코드가 전부 universal한 건 아니지만 양쪽 환경 모두에서 잘 작동한다.
// universal 코드 예제
var printNames = response => {
var people = JSON.parse(response).results,
names = people.map({name}) => `${name.last}, ${name.first}`)
console.log(names.join('\n'))
}
if (typeof window !== 'undefined') {
const request = new XMLHttpRequest()
request.open('GET', 'https://api.randomuser.me/?nat=US&results=10')
request.onload = () => printNames(request.response)
request.send()
} else {
const https = require('https')
https.get(
'https://api.randomuser.me/?nat=US&results=10',
res => {
let results = ''
res.setEncoding('utf8')
res.on('data', chunk => results += chunk)
res.on('end', () => printNames(results))
}
)
}
- 이전에 작성한 Star component의 경우 javascript로 컴파일되면 단순한 함수에 불과하므로 universal하다.
// Star component
const Star = ({ selected=false, onClick=f=>f }) =>
<div className={(selected) ? "star selected" : "star"}
onClick={onClick}>
</div>
// javascript로 컴파일된 Star component
const Star = ({ selected=false, onClick=f=>f }) =>
React.createElement(
"div",
{
className: selected ? "star selected" : "star",
onClick: onClick
}
)
- 이 component를 브라우저에서 직접 렌더링할 수도 있고 다른 환경에서 렌더링해서 HTML 출력 문자열을 만들 수도 있다.
// 브라우저에서 직접 HTML로 렌더링한다.
ReactDOM.render(<Star />)
// HTML 문자열로 렌더링한다.
var html = ReactDOM.readerToString(<Star />)
- 여러 플랫폼에서 component를 렌더링하는 isomorphic 애플리케이션을 만들 수 있다. 노드만 사용할 필요는 없다.
- ReactDOM.renderToString method를 사용하면 UI를 서버에서 렌더링할 수 있다.
- 서버에서 렌더링하면 브라우저에서 사용할 수 없는 자원을 활용할 수 있다. 이로 인해 더 안전하게 만들 수 있고 여러 보안 정보에 접근할 수도 있다.
- 여기서는 노드와 익스프레스를 사용해 기본적인 웹 서버를 만든다. 익스프레스는 웹 서버를 빠르게 개발할 때 사용할 수 있는 라이브러리다.
// express 설치
npm install express --save
// 간단한 express 앱 예제
import express from 'express'
const logger = (req, res, next) => {
console.log(`'${req.url}'에 대한 ${req.method} 요청`)
next()
}
const sayHello = (req, res) =>
res.status(200).send("<h1>Hello World</h1>")
const app = express()
.use(logger)
.use(sayHello)
app.listen(3000, () =>
console.log(`'http://localhost:3000'에서 조리법 앱 작동 중`)
)
- 위 코드는 항상 'Hello World' 메시지를 제공하는 웹 서버를 만든다. 들어온 요청에 대한 정보를 console에 logging하고 서버가 HTML 응답을 제공한다. 이 두 단계는 .use() method로 서로 연쇄된다. 익스프레스는 이 두 함수에 자동으로 요청과 응답을 주입한다.
- logger와 sayHello 함수는 미들웨어로 익스프레스에서는 .use() method를 사용해 미들웨어 함수를 파이프라이닝한다. 요청이 들어오면 각 미들웨어 함수가 호출되고 마지막에 응답이 전달된다.
- 책에서는 노드가 ES6 import 문을 지원하지 않기 때문에 babel-cli를 사용해 앱을 실행하나 현재는 노드가 ES6 import 문을 지원하므로 node로 앱을 실행해도 된다.
- Ctrl + C를 누르면 익스프레스 서버 실행을 중단시킬 수 있다.
- 조리법 앱을 렌더링하는 경우 ReactDOM.renderToString을 사용해 조리법 데이터가 들어 있는 Menu component를 렌더링하도록 앱을 변경할 수 있다.
// Menu component를 서버에서 렌더링하는 조리법 앱
import React from 'react'
import express from 'express'
import { renderToString } from 'react-dom/server'
import Menu from './components/Menu'
import data from './assets/recipes.json'
global.React = React
const html = renderToString(<Menu recipes={data} />)
const logger = (req, res, next) => {
console.log(`'${req.url}'에 대한 ${req.method} 요청`)
next()
}
const sendHTMLPage = (req, res) =>
res.status(200).send(`
<!DOCTYPE html>
<html>
<head>
<title>리액트 조리법 앱</title>
</head>
<body>
<div id="react-container">${html}</div>
</body>
</html>
`)
const app = express()
.use(logger)
.use(sendHTMLPage)
app.listen(3000, () =>
console.log(`'http://localhost:3000'에서 조리법 앱 작동 중`)
)
- 리액트를 글로벌 환경에 등록해 renderToString method가 제대로 작동하게 만든다.
- 위 코드는 Menu component를 서버 쪽에서 렌더링했다. 하지만 애플리케이션을 모두 서버에서 렌더링하기 때문에 이 애플리케이션은 isomorphic하지 않다. isomorphic 앱으로 만들려면 브라우저에서 같은 component를 렌더링할 수 있게 javascript 코드를 추가해야 한다.
// 브라우저에서 실행할 index-client.js
import React from 'react'
import { render } from 'react-dom'
import Menu from './components/Menu'
window.React = React
alert('bundle loaded, Rendering in browser')
render(
<Menu recipes={__DATA__} />,
document.getElementById("react-container")
)
alert('render complete')
- 브라우저가 이 script를 로딩할 때 __DATA__가 이미 글로벌 영역에 존재할 것이다.
- 이 client.js를 빌드할 때는 번들에 넣어서 브라우저에서 사용할 수 있게 해야 한다. 여기서는 기본적인 웹팩 설정으로 빌드를 처리한다.
// 기본 웹팩 설정
var webpack = require("webpack")
module.exports = {
entry: "./index-client.js",
output: {
path: "assets",
filename: "bundle.js"
},
module: {
rules: [
{
test: /\.js$/,
exclude: /(node_modules)/,
loader: 'babel-loader',
query: {
presets: ['env', 'stage-0', 'react']
}
}
]
}
}
- 앱을 시작할 때마다 클라이언트 번들을 빌드하기 위해 package.json 파일 안에 앱 시작 전에 실행할 prestart 스크립트를 추가한다.
// package.json 파일 안에 추가한다.
"script" : {
"prestart": "./node_modules/.bin/webpack --progress"
}
- 마지막 단계는 서버를 변경하는 것이다. 초기 __DATA__를 응답에 문자열로 넣을 필요가 있고, 클라이언트 번들을 가리키는 script 태그를 응답에 추가해야 한다. 또한 서버가 './assets/ 디렉토리에 있는 정적 파일들을 제대로 보내도록 해야 한다.
// isomorphic 앱으로 만들기 위해 변경한 조리법 앱
const sendHTMLPage = (req, res) =>
res.status(200).send(`
<!DOCTYPE html>
<html>
<head>
<title>리액트 조리법 앱</title>
</head>
<body>
<div id="react-container">${html}</div>
<script>
window.__DATA__ = ${JSON.stringify(data)}
</script>
<script src="bundle.js"></script>
</body>
</html>
`)
const app = express()
.use(logger)
.use(express.static('./assets'))
.use(sendHTMLPage)
- script 태그를 추가해 data를 첫 번째 script 태그에 썼고 두 번째 script 태그에서 번들을 로딩했다. 그리고 미들웨어를 요청 파이프라인에 추가해 /bundle.js에 대한 요청이 들어오면 서버에서 HTML을 렌더링하는 대신 express.static 미들웨어가 ./asset 디렉토리에 있는 파일을 제공한다.
- 이제 리액트 component를 먼저 서버에서 렌더링하고 나중에 브라우저에서 렌더링하는 방식으로 isomorphic하게 렌더링할 수 있다. 이 앱을 실행하면 component를 브라우저에서 렌더링하기 전과 후에 알림창이 뜬다. 첫 번째 알림창을 닫기 전에 content가 이미 브라우저에 들어오게 된다. 이는 서버에서 1차 렌더링한 결과를 브라우저에 응답으로 전달하기 때문이다.
- 같은 content를 굳이 2번이나 렌더링하는 이유는 이렇게 함으로써 모든 브라우저, 심지어 javascript가 꺼져 있는 브라우저에서도 같은 content를 제공할 수 있기 때문이다. 처음에 요청을 보내서 받은 응답으로 바로 content를 로딩하기 때문에 웹사이트가 더 빠르게 실행되고 모바일 사용자에게 더 빨리 content를 제공할 수 있다.
2. universal 색 관리 앱
- 리액트 component, redux store, 여러 action 생성기와 도우미 함수, redux router 등을 애플리케이션에 넣었는데, 이런 코드 중 상당 부분은 웹 서버를 만들 때도 재활용할 수 있다.
- 여기서는 색 관리 앱에 대한 익스프레스 서버를 만들고 가능한 한 많은 코드를 재활용한다.
- 먼저 익스프레스 앱 인스턴스를 설정한다.
// ./server/app.js
import express from 'express'
import path from 'path'
import fs from 'fs'
const fileAssets = express.static(
path.join(__dirname, '../../dist/assets')
)
const logger = (req, res, next) => {
console.log(`'${req.url}'에 대한 ${req.method} 요청`)
next()
}
const respond = (req, res) =>
res.status(200).send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>유니버셜 색 관리 앱</title>
</head>
<body>
<div id="react-container">ready...</div>
</body>
</html>
`)
export default express()
.use(logger)
.use(fileAssets)
.use(respond)
- 이 모듈은 universal 애플리케이션의 시작점이다. 익스프레스 설정은 logging과 file asset을 위한 미들웨어를 사용하고, 모든 요청에 HTML 페이지로 응답한다.
- 이 파일에서 직접 HTML을 제공하기 때문에 ./dist/assets/index.html 파일을 없애야 한다. 이 파일이 존재하면 respond에 도달하기 전에 fileAssets에 의해 index.html이 서비스된다.
- 웹팩을 사용하면 CSS나 이미지 파일과 같은 asset을 import할 수 있다.
// ./src/server/index.js
// 노드에서 실행되도록 만든 서버의 시작점
import React from 'react'
import app from './app'
global.React = React
app.set('port', process.env.PORT || 3000)
.listen(
app..get('port'),
() => console.log('색 관리 앱 작동 중')
)
- index.js는 서버의 시작점으로 서버에 새 기능을 추가하고 싶다면 이 앱 설정 모듈 안에 추가한다.
- redux 라이브러리의 모든 javascript 코드는 universal 코드로 브라우저 애플리케이션 뿐만 아니라 CLI, 서버, 네이티브 애플리케이션 등 어떤 노드 애플리케이션에 사용해도 문제없다.
- 여기서는 기존 redux store를 사용해 상태를 서버에 JSON 파일로 저장하게 만든다.
- 먼저 storeFactory의 경우 node에서 실행하면 오류가 발생하는 logging 미들웨어가 있는데, 이는 console.groupCollapsed와 console.groupEnd method 때문이다. 그래서 이를 변경해 isomorphic하게 작동하도록 만든다.
// 서버에서 작동하도록 storeFactory 변경
import { createStore, combineReducers, applyMiddleware } from 'redux'
import { colors } from './reducers'
const clientLogger = store => next => action => {
let result
console.groupCollapsed("디스패칭", action.type)
console.log('이전 상태', store.getState())
console.log('액션', action)
result = next(action)
console.log('다음 상태', store.getState())
console.groupEnd()
}
const serverLogger = store => next => action => {
console.log('\n 서버 액션 디스패칭 \n')
console.log(action)
console.log('\n')
return next(action)
}
const middleware = server =>
(server) ? serverLogger : clientLogger
const storeFactory = (server = false, initialState={}) =>
applyMiddleware(middleware)(createStore)(
combineReducers({colors}),
initialState
)
export default storeFactory
- 이 storeFactory는 isomorphic하다. 서버에서 logging을 담당할 redux 미들웨어를 만들어서 storeFactory를 호출할 때 설정한 server parameter 값에 따라 서버나 클라이언트용 logger를 만들어 새 store 인스턴스에 추가해준다.
- 이제 isomorphic한 storeFactory와 초기 상태 데이터를 import해서 초기 상태가 지정된 store를 만든다.
// 서버에서 실행 가능한 store instance 생성
import storeFactory from '../store'
import initialState from '../../data/initialState.json'
const serverStore = storeFactory(true, initialState)
- action이 이 store 인스턴스에 dispatch될 때마다 subscribe method를 이용해 initialState.json이 갱신되게 만든다.
// store 인스턴스에 dispatch될 때마다 initialState 갱신
serverStore.subscribe(() =>
fs.writeFile(
path.join(__dirname, '../../data/initialState.json'),
JSON.stringify(serverStore.getState()),
error => (error) ?
console.log("상태 저장 오류!", error) :
null
)
)
- 모든 요청은 serverStore와 통신해서 가장 최신 색 정보를 얻어야 한다. 서버 store를 요청 파이프라인에 넣는 미들웨어를 더 추가해 요청을 처리하는 동안 다른 미들웨어에서 서버 store를 활용할 수 있게 한다.
// ./server/app.js에 serverStore를 요청 파이프라인에 넣는 미들웨어를 추가한다.
const addStoreToRequestPipeline = (req, res, next) => {
req.store = serverStore
next()
}
export default express()
.use(logger)
.use(fileAssets)
.use(addStoreToRequestPipeline)
.use(htmlResponse)
- 이제 addStoreToRequestPipeline 뒤에 들어 있는 미들웨어는 request 객체 안에서 서버 store를 얻을 수 있다.
- reducer와 store 코드는 환경과 관계없이 동일하기 때문에 이제 redux를 universal하게 사용할 수 있다.
- router를 서버에서 렌더링할 수도 있다. 단지 위치나 경로를 제공해주면 된다.
- 이전에는 HashRouter를 사용했으나 router를 isomorphic하게 사용하려면 HashRouter를 BrowserRouter로 바꿔야 한다. HashRouter는 자동으로 경로 앞에 #을 붙여줬으나 BrowserRouter는 이를 제거한다.
// BrowserRouter를 사용해 routing한다.
import { BrowserRouter } from 'react-router-dom'
...
render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('react-container')
)
- 이제 색 관리 앱은 더 이상 경로 앞에 #을 사용하지 않는다. 앱을 시작하고 색을 선택하면 정상적으로 작동하고 주소창에는 #을 제외한 경로가 나타난다.
- 이때 새로고침을 하면 현재 경로를 이용해 서버에 GET 요청을 보낸다. #을 사용하는 경로는 GET 요청의 재송신을 막는다. BrowserRouter를 사용하는 이유는 GET 요청을 서버에 다시 보내기 위함이다. 서버가 위치를 전달받아야 router를 렌더링하기 위한 경로를 찾을 수 있다. 경로를 isomorphic하게 렌더링하고 싶으면 BrowserRouter를 사용해야 한다.
- router를 서버에서 렌더링하려면 익스프레스 설정을 많이 바꿔야 한다. 우선 몇 가지 모듈을 import해야 한다.
// 서버에서 routing하기 위해 필요한 모듈
import { Provider } from 'react-redux'
import { compose } from 'redux'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
- 서버에서는 component tree를 HTML 문자열로 렌더링할 때 StaticRouter를 사용한다.
- HTML 응답을 생성할 때 다음 세 단계를 거친다.
- serverStore에서 받은 데이터를 사용해 클라이언트에서 실행할 store를 만든다.
- StaticRouter를 활용해 component tree를 HTML로 렌더링한다.
- 클라이언트로 보낼 HTML 페이지를 만든다.
- 각 단계를 별도 함수로 만들고 합성해서 htmlResponse 함수를 만든다.
// 서버에서 HTML 응답을 생성하는 함수를 compose를 이용해 표현
const htmlResponse = compose(
buildHTMLPage, // 3단계
renderComponentsToHTML, // 2단계
makeClientStoreFrom(serverStore) // 1단계
)
- makeClientStoreFrom는 serverStore를 인자로 받아 새로운 함수를 반환하는 고차 함수다. 이 함수는 요청을 처리할 때마다 불린다.
- htmResponse는 사용자가 요청한 url을 유일한 인자로 받는다. 현재 serverStore 상태를 사용해 만들어진 새로운 클라이언트 store를 사용해 url을 감싸고 그렇게 만든 store와 url을 한 객체 안에 넣어서 반환한다.
// makeClientStoreFrom method
const makeClientStoreFrom = store => url =>
({
store: storeFactory(false, store.getState()),
url
})
- renderComponentsToHTML은 url과 store가 들어 있는 객체를 인자로 받아 state와 html 프로퍼티가 있는 객체를 반환한다.
// renderComponentsToHTML method
const renderComponentsToHTML = ({url, store}) =>
({
state: store.getState(),
html: renderToString(
<Provider store={store}>
<StaticRouter location={url} context={{}}>
<App />
</StaticRouter>
</Provider>
)
})
- 새 클라이언트 store에서 state를 가져와서 renderToString method를 사용해 html을 만든다. 이 앱은 브라우저에서 여전히 redux를 실행해야 하기 때문에 Provider를 루트 component로 렌더링하고 새 클라이언트 store를 프로퍼티로 넘겨야 한다.
- 요청받은 위치에 따라 UI를 렌더링하기 위해 StaticRouter component를 사용한다. StaticRouter에는 location과 context가 필요하다. 요청받은 url을 location 프로퍼티에 전달하고 빈 객체를 context로 넘긴다. 렌더링 시 StaticRouter component는 location의 경로 정보를 참조해 어떤 식으로 App과 하위 components를 렌더링할지 판단한다.
- renderComponentsToHTML은 색 관리기의 현재 상태와 UI를 렌더링한 HTML 문자열을 반환한다. 웹 페이지를 만들려면 이 두 요소가 필요하다.
// buildHTMLPage method
const buildHTMLPage = ({html, state}) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>universal 색 관리 앱</title>
</head>
<body>
<div id="react-container">${html}</div>
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(state)}
</script>
<script src="/bundle.js"></script>
</body>
</html>
`
- 이제 색 관리 앱이 isomorphic해졌다. 색 관리 앱은 서버에서 UI를 렌더링해서 클라이언트에 HTML 텍스트로 전달한다. 이때 store의 초기 상태도 같이 전달한다.
- 브라우저는 초기에 서버에서 받은 HTML을 표시하지만 번들을 모두 읽고 나면 UI를 다시 렌더링하면서 클라이언트 쪽에서 모든 UI 처리를 진행한다. 이 시점부터 navigation 등 모든 사용자와의 상호작용은 클라이언트 쪽에서 처리된다. 클라이언트에서 단일 페이지 앱이 작동하다가, 브라우저를 새로고침하면 서버에서 모든 렌더링 프로세스가 다시 실행되고 방금 설명한 과정을 모두 다시 거친다.
3. 서버와 통신하기
- 현재 색 관리 앱은 서버에서 UI를 렌더링하고 브라우저에서 다시 렌더링하면 브라우저가 제어를 담당하게 되는데, 사용자가 action을 브라우저 안에서 local dispatch하면 local 상태가 바뀌고 UI도 local에서 갱신된다. 이로 인해 dispatch된 action이 서버에 영향을 끼치지 못한다.
- 그래서 데이터를 서버에 저장하게 만들 뿐 아니라 action 객체 자체를 서버에서 만들어서 서버와 클라이언트 양쪽 store에 dispatch하게 만든다.
- 이를 위해 데이터 처리를 위한 REST API를 통합한다. action은 클라이언트에서 시작돼 서버에서 완료되고, 서버와 클라이언트 양쪽 store에 dispatch된다.
- serverStore는 상태를 JSON에 저장하고 클라이언트 store는 UI 갱신을 발생시킨다. 양 store 모두 같은 action을 universal하게 dispatch한다.
- 예시로 ADD_COLOR action을 dispatch하는 과정을 나타내면 다음과 같다.
- addColor() action 생성기에 색 이름과 16진 값을 넘긴다.
- 데이터를 새 POST 요청으로 서버에 전달한다.
- 서버에서 POST 요청을 받아서 새 ADD_COLOR action을 만들고 dispatch한다.
- ADD_COLOR action을 응답 본문에 넣어서 클라이언트에 전달한다.
- 응답 본문을 parsing해서 ADD_COLOR action을 클라이언트에 dispatch한다.
- 먼저 REST API를 만들기 위해 ./src/server/color-api.js 파일을 만든다.
import { Router } from 'express'
import { v4 } from 'uuid'
// action이 생성되면 먼저 서버에 dispatch하고
// 응답 객체에 action을 넣어 클라이언트에 보낸다.
const dispatchAndRespond = (req, res, action) => {
req.store.dispatch(action)
res.status(200).json(action)
}
const router = Router()
// 서버의 상태에 있는 현재 색 배열을 응답한다.
// 이 경로는 단지 서버에 있는 색의 목록을 보기 위해 추가한 것이며
// 실제 브라우저에서는 이 API를 사용하지 않는다.
router.get("/colors", (req, res) =>
res.status(200).json(req.store.getState().colors)
)
// 새로운 COLOR action 객체를 만들어서 dispatchAndRespond에 보낸다.
router.post("/colors", (req, res) =>
dispatchAndRespond(req, res, {
type: "ADD_COLOR",
id: v4(),
title: req.body.title,
color: req.body.color,
timestamp: new Date().toString()
})
)
// 색의 평점을 바꾼다.
// 경로 파라미터에서 색 ID를 얻어서 새 액션 객체를 만들 때 사용한다.
router.put("/color/:id", (req, res) =>
dispatchAndRespond(req, res, {
type: "RATE_COLOR",
id: req.params.id,
rating: paseInt(req.body.rating)
})
)
// 경로 파라미터에 있는 ID에 해당하는 색을 삭제한다.
router.delete("/color/:id", (req, res) =>
dispatchAndRespond(req, res, {
type: "REMOVE_COLOR",
id: req.params.id
})
)
export default router
- 경로를 정의했으면 익스프레스 앱 설정에 추가해야 한다. 먼저 익스프레스 body-parser를 설치한다.
// 익스프레스 body-parser 설치
npm install body-parser --save
- body-parser를 사용해 요청 본문을 parsing해서 경로에 전달된 변수를 얻는다. 클라이언트가 보낸 새 색과 평점 정보를 parsing해서 얻어야 한다.
// ./server/app.js 파일에 새 경로와 body-parser import를 추가한다.
import bodyParser from 'body-parser'
import api from './color-api'
// bodyParser 미들웨어와 API 경로를 추가한다.
export default express()
.use(logger)
.use(fileAssets)
.use(bodyParser.json())
.use(addStoreToRequestPipeline)
.use('/api',api)
.use(matchRoutes)
- bodyParser.json()은 들어오는 요청 본문의 JSON을 parsing한다. color-api를 익스프레스 서버 파이프라인에 추가하고 /api로 시작하는 모든 경로를 처리하게 만든다. 예를 들어 http://localhost:3000/api/colors라는 URL로 요청을 보내면 현재의 색 목록을 JSON 배열로 받을 수 있다.
- 지금까지 익스프레스 앱에 HTTP 요청을 처리하는 endpoint를 추가했고 이제 프론트엔드의 action 생성기를 변경해서 이 endpoint와 통신하게 만들 수 있다.
- redux에는 비동기 action을 지원하는 redux-thunk 미들웨어가 있다. action 생성기를 이 미들웨어로 재작성한다. 이렇게 재작성한 action 생성기를 thunk라고 부른다. thunk를 사용해 action을 local dispatch하기 전에 서버 응답을 기다릴 수 있다. thunk는 고차 함수로 action 객체 대신 함수를 반환한다.
// redux-thunk 설치
npm install redux-thunk --save
- redux-thunk는 미들웨어이므로 storeFactory와 함께 사용한다. 먼저 ./src/store/index.js 파일의 앞부분에서 redux-thunk를 import한다.
// ./src/store/index.js 파일에서 redux-thunk를 import한다.
import thunk from 'redux-thunk'
- storeFactory에는 middleware라는 함수가 있는데, 이 함수는 새로운 store에 들어가야 할 미들웨어들을 배열로 반환한다. 이 배열에 우리가 원하는 redux 미들웨어를 추가할 수 있다.
// storeFactory의 redux middleware에 thunk를 추가한다.
const middleware = server => [
(server) ? serverLogger : clientLogger,
thunk
]
const storeFactory = (server = false, initialState={}) =>
applyMiddleware(...middleware(server))(createStore)(
combineReducers({colors}),
initialState
)
export default storeFactory
- 기존 action 생성기는 action 객체를 반환하고, 이를 store에 즉시 dispatch했었다.
- 반면 thunk를 이용한 action 생성기는 함수를 반환하고, 이 함수는 store의 dispatch와 getState method를 인자로 받아 action을 dispatch할 준비가 되면 인자로 받은 dispatch 함수로 action을 dispatch한다.
- thunk가 반환한 함수는 store의 getState를 인자로 받는데, 이 예제에서는 상태에 있는 색 개수를 index 필드에 지정할 때 getState를 사용한다. 상태 정보로부터 action을 생성해야 하는 경우 getState를 유용하게 써먹을 수 있다.
// thunk를 이용한 addColor
export const addColor = (title, color) =>
(dispatch, getState) => {
setTimeout(() =>
dispatch({
type: "ADD_COLOR",
index: getState().colors.length + 1,
timestamp: new Date().toString(),
title,
color
}), 2000)
}
...
store.dispatch(addColor("jet", "#000000"))
- thunk는 dispatch나 getState를 필요한 만큼 횟수 제한 없이 비동기적으로 실행할 수 있다. 또한 여러 다른 유형의 action을 dispatch할 수도 있다.
// thunk가 RANDOM_RATING_STARTED action을 즉시 dispatch한 뒤
// 반복적으로 특정 색의 평점을 임의로 변경하는 RATE_COLOR action을 dispatch한다.
export const rateColor = id =>
(dispatch, getState) => {
dispatch({ type: "RANDOM_RATING_STARTED" })
setInterval(() =>
dispatch({
type: "RATE_COLOR",
id,
rating: Math.floor(Math.random()*5)
}), 1000)
}
...
store.dispatch(
rateColor("f9005b4e-975e-433d-a646-79df172e1dbb")
)
- 이제 색 관리 앱의 현재 action 생성기를 대신할 실제 thunk를 만든다.
- 먼저 isomorphic-fetch를 사용해 웹 서비스에 요청을 보내고 자동으로 응답을 dispatch하는 fetchThenDispatch 함수를 만든다.
// fetchThenDispatch 함수
import fetch from 'isomorphic-fetch'
const parseResponse = response => response.json()
const logError = error => console.error(error)
const fetchThenDispatch = (dispatch, url, method, body) =>
fetch(
url,
{
method,
body,
headers: { 'Content-Type': 'applicaiton/json' }
}
).then(parseResponse)
.then(dispatch)
.catch(logError)
- fetchThenDispatch 함수는 dispatch 함수, URL, HTTP 요청 method, HTTP 요청 본문을 인자로 받고 이를 이용해 fetch를 호출한 뒤, 응답이 도착하면 결과를 parsing해서 dispatch한다. 오류가 발생하면 console에 log를 남긴다.
- thunk를 만들 때 fetchThenDispatch 함수를 활용한다. 각 thunk는 API에 요청과 요청에 필요한 정보를 보낸다. 우리가 만든 API는 action 객체를 응답으로 돌려주기 때문에 API 응답을 받아서 즉시 parsing하고 dispatch해도 된다.
// fetchThenDispatch를 활용해 만든 thunk들
export const addColor = (title, color) => dispatch =>
fetchThenDispatch(
dispatch,
'/api/colors',
'POST',
JSON.stringify({title, color})
)
export const removeColor = id => dispatch =>
fetchThenDispatch(
dispatch,
`/api/color/${id}`,
'DELETE'
)
export const rateColor = (id, rating) => dispatch =>
fetchThenDispatch(
dispatch,
`/api/color/${id}`,
'PUT',
JSON.stringify({rating})
)
- 예를 들어 addColor thunk는 http://localhost:3000/api/colors에 POST 요청을 보낸다. 이때 새로운 색의 이름과 16진 값을 본문에 넣어 보낸다. API는 ADD_COLOR action 객체를 반환하며, fetchThenDispatch는 그 객체를 parsing하고 dispatch한다.
출처 : learning react(2018)
728x90
'리액트' 카테고리의 다른 글
create-react-app에서 local file을 사용하고 싶을 때 (0) | 2021.04.13 |
---|---|
create-react-app으로 react app을 만들었을 때 한글 깨짐 현상 해결 (0) | 2021.04.07 |
#10. React Router (0) | 2021.04.04 |
#9. 테스팅(2) - 스냅샷 테스팅, 코드 커버리지 (0) | 2021.04.02 |
#9. 테스팅(1) - ESLint, Redux와 React Component 테스트하기 (0) | 2021.04.02 |