이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
서버 렌더링
서버 사이드 렌더링의 가장 일반적인 사용 사례는 사용자(또는 검색 엔진 크롤러)가 처음으로 우리 애플리케이션을 요청할 때 _초기 렌더링_을 처리하는 것입니다. 서버는 요청을 받으면 필요한 컴포넌트를 HTML 문자열로 렌더링한 다음 클라이언트에 응답으로 전송합니다. 이 시점부터 클라이언트가 렌더링을 담당합니다.
아래 예시에서는 React를 사용하지만, 서버에서 렌더링할 수 있는 다른 뷰 프레임워크에서도 동일한 기술을 적용할 수 있습니다.
서버에서의 Redux
서버 렌더링 시 Redux를 사용할 때는 애플리케이션 상태를 응답에 함께 전송해야 클라이언트가 이를 초기 상태로 사용할 수 있습니다. 이는 HTML 생성 전에 사전 로드한 데이터를 클라이언트에서도 접근할 수 있도록 하기 위해 중요합니다. 그렇지 않으면 클라이언트에서 생성된 마크업이 서버 마크업과 일치하지 않으며, 클라이언트가 데이터를 다시 로드해야 합니다.
데이터를 클라이언트로 전송하려면 다음 단계가 필요합니다:
-
매 요청마다 새로운 Redux 스토어 인스턴스를 생성합니다;
-
필요에 따라 일부 액션을 디스패치합니다;
-
스토어에서 상태를 추출합니다;
-
추출한 상태를 클라이언트에 전달합니다.
클라이언트 측에서는 서버에서 제공된 상태로 초기화된 새 Redux 스토어가 생성됩니다. 서버 측에서 Redux의 유일한 역할은 애플리케이션의 초기 상태를 제공하는 것입니다.
설정 방법
다음 절차에서는 서버 사이드 렌더링을 설정하는 방법을 살펴보겠습니다. 간단한 Counter 앱을 가이드로 사용하고, 요청을 기반으로 서버가 미리 상태를 렌더링하는 방법을 보여드리겠습니다.
패키지 설치
이 예시에서는 간단한 웹 서버로 Express를 사용합니다. 또한 Redux에 기본 포함되지 않은 React 바인딩을 설치해야 합니다.
npm install express react-redux
서버 측 구성
다음은 서버 측의 기본 구조입니다. app.use를 사용한 Express 미들웨어를 설정하여 서버에 들어오는 모든 요청을 처리합니다. Express나 미들웨어에 익숙하지 않다면, handleRender 함수가 서버가 요청을 받을 때마다 호출된다는 점만 이해하면 됩니다.
추가로 최신 JS 및 JSX 구문을 사용하므로 Babel(Node 서버와 Babel 사용 예시 참조)과 React 프리셋으로 컴파일해야 합니다.
server.js
import path from 'path'
import Express from 'express'
import React from 'react'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import counterApp from './reducers'
import App from './containers/App'
const app = Express()
const port = 3000
// Serve static files
app.use('/static', Express.static('static'))
// This is fired every time the server side receives a request
app.use(handleRender)
// We are going to fill these out in the sections to follow
function handleRender(req, res) {
/* ... */
}
function renderFullPage(html, preloadedState) {
/* ... */
}
app.listen(port)
요청 처리
매 요청 시 가장 먼저 해야 할 일은 새로운 Redux 스토어 인스턴스를 생성하는 것입니다. 이 스토어 인스턴스의 유일한 목적은 애플리케이션의 초기 상태를 제공하는 것입니다.
렌더링 시에는 루트 컴포넌트인 <App />을 <Provider>로 래핑하여 컴포넌트 트리의 모든 컴포넌트가 스토어에 접근할 수 있도록 합니다.
서버 사이드 렌더링의 핵심 단계는 컴포넌트의 초기 HTML을 클라이언트 측으로 전송하기 전에 렌더링하는 것입니다. 이를 위해 ReactDOMServer.renderToString()을 사용합니다.
그런 다음 store.getState()를 사용해 Redux 스토어에서 초기 상태를 가져옵니다. 이 상태가 renderFullPage 함수에서 어떻게 전달되는지 살펴보겠습니다.
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Create a new Redux store instance
const store = createStore(counterApp)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const preloadedState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, preloadedState))
}
초기 컴포넌트 HTML과 상태 주입
서버 측의 마지막 단계는 초기 컴포넌트 HTML과 상태를 클라이언트 측에서 렌더링할 템플릿에 주입하는 것입니다. 상태를 전달하기 위해 <script> 태그를 추가하여 preloadedState를 window.__PRELOADED_STATE__에 연결합니다.
이렇게 하면 클라이언트 측에서 window.__PRELOADED_STATE__를 통해 preloadedState에 접근할 수 있습니다.
또한 스크립트 태그를 통해 클라이언트 측 애플리케이션용 번들 파일을 포함시킵니다. 이는 번들링 도구가 클라이언트 진입점에 대해 생성하는 출력물로, 정적 파일이거나 핫 리로딩 개발 서버의 URL일 수 있습니다.
function renderFullPage(html, preloadedState) {
return `
<!doctype html>
<html>
<head>
<title>Redux Universal Example</title>
</head>
<body>
<div id="root">${html}</div>
<script>
// WARNING: See the following for security issues around embedding JSON in HTML:
// https://redux.js.org/usage/server-rendering#security-considerations
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(
/</g,
'\\u003c'
)}
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
`
}
클라이언트 측
클라이언트 측은 매우 간단합니다. window.__PRELOADED_STATE__에서 초기 상태를 가져와 createStore() 함수에 초기 상태로 전달하기만 하면 됩니다.
새로운 클라이언트 파일을 살펴보겠습니다:
client.js
import React from 'react'
import { hydrate } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './containers/App'
import counterApp from './reducers'
// Create Redux store with state injected by the server
const store = createStore(counterApp, window.__PRELOADED_STATE__)
// Allow the passed state to be garbage-collected
delete window.__PRELOADED_STATE__
hydrate(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Webpack, Browserify 등 선호하는 빌드 도구를 설정하여 번들 파일을 static/bundle.js로 컴파일할 수 있습니다.
페이지가 로드되면 번들 파일이 시작되고 ReactDOM.hydrate()가 서버에서 렌더링된 HTML을 재사용합니다. 이는 새로 시작된 React 인스턴스를 서버의 가상 DOM에 연결합니다. Redux 스토어의 초기 상태가 동일하고 모든 뷰 컴포넌트에 동일한 코드를 사용했기 때문에 실제 DOM도 동일하게 생성됩니다.
이것으로 끝입니다! 서버 사이드 렌더링을 구현하기 위해 필요한 모든 작업입니다.
하지만 결과는 매우 기본적입니다. 동적 코드에서 정적 뷰를 렌더링하는 것과 같습니다. 다음으로 할 일은 렌더링된 뷰가 동적이도록 초기 상태를 동적으로 구성하는 것입니다.
createStore에 직접 window.__PRELOADED_STATE__를 전달하고 사전 로드된 상태에 대한 추가 참조(예: const preloadedState = window.__PRELOADED_STATE__)를 생성하지 않는 것이 좋습니다. 이렇게 하면 가비지 컬렉션이 가능합니다.
초기 상태 준비
클라이언트 측은 지속적으로 코드를 실행하므로 빈 초기 상태로 시작하고 필요에 따라 시간이 지남에 따라 필요한 상태를 가져올 수 있습니다. 반면 서버 측 렌더링은 동기적이며 뷰를 렌더링할 기회는 한 번뿐입니다. 요청 중에 초기 상태를 구성할 수 있어야 하며, 이는 입력에 반응하고 외부 상태(API나 데이터베이스 등)를 가져와야 합니다.
요청 파라미터 처리
서버 측 코드의 유일한 입력은 브라우저에서 앱 페이지를 로드할 때 발생하는 요청입니다. 서버 부팅 시(개발 환경과 프로덕션 환경 실행 시 등) 서버를 구성할 수 있지만, 이 구성은 정적입니다.
요청에는 URL 정보와 함께 쿼리 파라미터가 포함되어 있어 React Router 사용 시 유용합니다. 또한 쿠키나 인증 정보 같은 헤더 또는 POST 본문 데이터도 포함될 수 있습니다. 쿼리 파라미터를 기반으로 초기 카운터 상태를 설정하는 방법을 살펴보겠습니다.
server.js
import qs from 'qs' // Add this at the top of the file
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Read the counter from the request, if provided
const params = qs.parse(req.query)
const counter = parseInt(params.counter, 10) || 0
// Compile an initial state
let preloadedState = { counter }
// Create a new Redux store instance
const store = createStore(counterApp, preloadedState)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const finalState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, finalState))
}
이 코드는 서버 미들웨어에 전달된 Express Request 객체에서 값을 읽습니다. 파라미터는 숫자로 파싱된 후 초기 상태에 설정됩니다. 브라우저에서 http://localhost:3000/?counter=100에 접속하면 카운터가 100에서 시작하는 것을 볼 수 있습니다. 렌더링된 HTML에서 카운터 출력값이 100으로 표시되고 __PRELOADED_STATE__ 변수에도 카운터가 설정되어 있습니다.
비동기 상태 가져오기
서버 사이드 렌더링에서 가장 흔히 발생하는 문제는 비동기적으로 수신되는 상태를 처리하는 것입니다. 서버 측 렌더링은 본질적으로 동기적이므로 모든 비동기적 가져오기 작업을 동기적 연산으로 매핑해야 합니다.
가장 쉬운 방법은 콜백 함수를 동기 코드에 전달하는 것입니다. 여기서는 응답 객체를 참조하고 렌더링된 HTML을 클라이언트로 보내는 함수가 됩니다. 걱정하지 마세요, 생각보다 어렵지 않습니다.
예시를 위해 카운터 초기값을 저장하는 외부 데이터 저장소(Counter As A Service, CaaS)가 있다고 가정하겠습니다. 이 서비스를 호출한 결과로 초기 상태를 구성할 것입니다. 먼저 API 호출을 구현해 보겠습니다:
api/counter.js
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min
}
export function fetchCounter(callback) {
setTimeout(() => {
callback(getRandomInt(1, 100))
}, 500)
}
이것은 모의 API이므로 setTimeout을 사용해 500밀리초 응답 지연을 시뮬레이션합니다(실제 API는 훨씬 빠를 것입니다). 비동기적으로 난수를 반환하는 콜백을 전달합니다. Promise 기반 API 클라이언트를 사용한다면 then 핸들러에서 이 콜백을 실행하면 됩니다.
서버 측에서는 기존 코드를 fetchCounter로 래핑하고 콜백에서 결과를 받습니다:
server.js
// Add this to our imports
import { fetchCounter } from './api/counter'
import { renderToString } from 'react-dom/server'
function handleRender(req, res) {
// Query our mock API asynchronously
fetchCounter(apiResult => {
// Read the counter from the request, if provided
const params = qs.parse(req.query)
const counter = parseInt(params.counter, 10) || apiResult || 0
// Compile an initial state
let preloadedState = { counter }
// Create a new Redux store instance
const store = createStore(counterApp, preloadedState)
// Render the component to a string
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
)
// Grab the initial state from our Redux store
const finalState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, finalState))
})
}
콜백 내부에서 res.send()를 호출하므로 서버는 연결을 유지하고 콜백 실행 전까지 데이터를 전송하지 않습니다. 새로운 API 호출로 인해 각 요청에 500ms 지연이 추가되었음을 알 수 있습니다. 고급 사용법에서는 잘못된 응답이나 타임아웃 같은 오류를 우아하게 처리해야 합니다.
보안 고려사항
사용자 생성 콘텐츠(UGC)와 입력에 의존하는 코드가 추가됨에 따라 애플리케이션의 공격 표면이 확대되었습니다. XSS(교차 사이트 스크립팅) 공격이나 코드 주입을 방지하려면 모든 애플리케이션에서 입력값을 적절히 살균(sanitize)하는 것이 중요합니다.
이 예시에서는 기본적인 보안 접근 방식을 취합니다. 요청에서 매개변수를 가져올 때 counter 매개변수에 parseInt를 적용해 숫자임을 보장합니다. 이렇게 하지 않으면 요청에 스크립트 태그를 제공해 렌더링된 HTML에 위험한 데이터를 쉽게 삽입할 수 있습니다. 예: ?counter=</script><script>doSomethingBad();</script>
간단한 예시에서는 입력값을 숫자로 강제 변환하는 것으로 충분히 안전합니다. 자유 형식 텍스트 같은 복잡한 입력을 처리할 때는 xss-filters 같은 적절한 살균 함수를 사용해야 합니다.
추가 보안 계층으로 상태 출력값을 살균할 수 있습니다. JSON.stringify는 스크립트 주입에 취약할 수 있습니다. 이를 방지하기 위해 JSON 문자열에서 HTML 태그와 위험한 문자를 제거할 수 있습니다. 간단한 텍스트 치환(예: JSON.stringify(state).replace(/</g, '\\u003c'))이나 serialize-javascript 같은 고급 라이브러리로 구현 가능합니다.
다음 단계
Redux에서 Promise와 thunk 같은 비동기 프리미티브를 사용한 흐름 표현에 대해 더 알아보려면 Redux Fundamentals Part 6: Async Logic and Data Fetching을 읽어보세요. 여기서 배운 모든 내용은 유니버설 렌더링에도 적용할 수 있습니다.
React Router를 사용한다면, 데이터 페칭 의존성을 라우트 핸들러 컴포넌트의 정적 fetchData() 메서드로 표현할 수 있습니다. 이 메서드는 thunk를 반환할 수 있으므로, handleRender 함수가 라우트와 핸들러 컴포넌트 클래스를 매칭하고, 각각에 대해 fetchData() 결과를 디스패치한 후 Promise가 해결된 후에만 렌더링할 수 있습니다. 이렇게 하면 서로 다른 라우트에 필요한 API 호출이 라우트 핸들러 컴포넌트 정의와 함께 배치됩니다. 클라이언트 측에서도 동일한 기법을 사용해 데이터 로딩 전까지 라우터가 페이지를 전환하지 못하도록 방지할 수 있습니다.