프로젝트 구조
bin/www
포트 3000에서 서버를 실행하고 기본적인 에러를 처리하는 app.js 진입 파일
❗️초기 프로젝트는 CommonJS 문법으로 생성된다. ES6 문법으로 바꾸어주면 www.js처럼 확장자를 붙여줘야 정상 실행된다.
routes
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express' });
});
프로젝트를 진행하면서 pug 템플릿 엔진을 통한 SSR을 처음 사용해보았다. index.js
초기 파일의 해당하는 부분이 응답으로 view/index.pug
를 렌더링한다는 의미다.
views
템플릿 엔진이 사용할, 즉 위의 res.render()
에서 서버가 클라이언트에게 전달하는 HTML로 변환될 파일들
public
클라이언트가 페이지를 로드할 때 필요한 JS, CSS, images 등의 assets
위 구조로 템플릿 엔진을 사용하면서 고민되었던 부분이 있었다. 서버 쪽에서 기능을 갖춘 HTML을 만들다보니 비효율적이고 사용자 경험에 좋지 못한 설계가 되어버렸다. 특히 SSR-only로는 모든 세부적인 변경에 서버-클라이언트 통신과 페이지 리프레시가 필요하기 때문에 엥..스러웠다. 작은 상태 변화 하나를 위해 페이지 전체를 다시 그린다니.. 진짜 용납할 수 없었다..
No, pug is a server-side only HTML processor. As such there is no way to do what you describe here - to get pug to process more means another round trip to the server to re-render the page.
Although technically you could do this with plain JavaScript, you should look into a client-side library like jquery, React, Angular, or Vue to do what you want. A lot of us use pug in combination with those toolsets to build modern web apps, but with those frameworks pug becomes a quicker way to write HTML and is no longer a server-side pre-processor.
Rendering a portion of a PUG template without refreshing page
위 질문글을 보고 페이지 일부만을 렌더링하기 위해서는 클라이언트 로직을 주입해야 한다는 점을 알게됐다. SSR과 CSR을 함께 사용해야 한다는 것이다. 사실 'technically you could do this with plain JavaScript'보고 흐린눈 해보려고 했는데 구현을 할수록 이대로 만들 수는 없다는 결론을 내렸다.
하지만 어플리케이션이 사용하는 서버가 외부에 존재하는 구조만 많이 봐와서 감이 잘 안잡혔다. 클라이언트 로직의 진입점을 찾기도, 스크립트 태그로 로직을 주입하는 경우 데이터를 어떻게 관리해야 하는지 생각하기도 어려웠다. 고민하던 중 관련된 내용에 대한 배경을 이해하고 실습까지 해볼 수 있는 글을 읽게되어 정리해보았다.
Vue SSR 제대로 적용하기 (feat. Vanilla SSR)의 일부 내용을 요약하고 내용을 덧붙여서 작성했습니다.
렌더링이란 화면에 HTML을 그리는 작업이다. 이걸 누가 하는지에 따라서 Client-Side Rendering과 Server-Side Rendering으로 나뉜다.
CSR
기존에 내가 접해보았던 SPA에서 사용하는 방식. root div를 가진 최초 페이지에 클라이언트 쪽에서 필요한 내용들을 렌더링한다.
SSR
클라이언트가 받는 응답에 페이지 내용들이 포함되어 있는 방식. <div id='app'>
부분을 서버 쪽에서 채워서 보내준다.
뼈대만 받고 브라우저에서 동적으로 DOM을 그리게 되면 CSR, 이미 다 그려진 DOM을 받게 되면 SSR
🔑 브라우저 환경과 서버 환경
우선 두 가지를 구분할 수 있어야 한다. 어떤 웹 페이지 서버의 클라이언트는 브라우저이다. 위 사진에서 CSR이 이루어지는 곳은 브라우저, SSR이 이루어지는 곳은 노드 서버다.
window, document 등의 브라우저 객체를 사용하는 DOM에 대한 접근은 일반적으로 CSR 시점에 이루어진다.
CSR은 하나의 클라이언트 대상이지만 SSR은 라우터에서 특정한 요청에 대해 렌더링 결과를 반환하는 방식으로 여러 클라이언트가 존재할 수 있음을 고려해야 한다.
상태를 저장해두는 store를 요청마다 생성해야 한다. store가 하나인 경우, 클라이언트 A의 요청으로 변경된 내용이 나머지 클라이언트들에게 보내는 응답에도 적용된다.
초기의 웹
C, C++, Perl 등의 프로그램에서 사용자의 요청에 응하는 표준 출력을 브라우저에 되돌려주는 방식
`printf(<html>...</html>);`
JSP
1990년대 Java가 주류가 되며, Java Server Pages를 이용해 동적으로 웹 페이지를 만들기 시작
사용자가 페이지를 요청하고 서버에서 전체 html을 만드는 방식. 네트워크 속도가 아무리 빨라도 비효율적
브라우저의 역사를 살펴보면, 전통적인 웹 개발은 SSR로 발전해왔다. 약 10년 전 쯤 웹 개발 생태계의 주류는 PHP
JSP
ASP
였다고 한다. 모두 동적으로 HTML을 만들어낼 수 있는 전처리기이며, HTML을 서버에서 정제하여 출력해주는 도구로 사용되었다.
Ajax
Asynchronous Javascript And XML
하지만 JS로 할 수 있는 일들이 점점 많아지며 DOM을 점점 더 정교하게 다룰 필요성이 생겨났다.
특정 페이지 전체를 리프레시하지 않고 수행되는 비동기성을 가진 Ajax
의 등장으로 하나의 페이지에서 DOM 일부분만을 업데이트 하는 것이 가능해졌다.
CSR
이를 활용한 다양한 프레임워크들의 등장으로 현재는 많은 렌더링이 클라이언트에게 위임되었다.
MVC 및 MVVM AngularJS
Backbone.js
단방향 데이터 흐름과 Virtual DOM React
Vue.js
❗️ 서비스적인 한계
최근 프레임워크들은 CSR을 하고있고 이렇게 발전한 데에는 이유가 있는데 왜 SSR에 대해 알아야 하냐면.. 서비스에 따라 SSR
이 필요한 상황이 있기 때문이다.
대표적으로 검색 엔진 최적화(SEO) 문제가 있다. 브라우저가 받아오는 최초 URL이 <div id="app"></div>
혹은 <div id="root"></div>
한 줄이라서 검색 엔진이 정보를 수집하기 어렵다.
이런 경우 SSR을 고려해보면 좋다.
🚧 주의할 부분들
로그인한 사용자 화면을 SSR로 로딩하는 것은 정보 노출의 위험
관리자 페이지도 마찬가지
JS를 서버에서 HTML로 변환하는 작업이 필요하기 때문에, SSR에는 Node.js
가 꼭 필요하다.
Java에서도 가능하지만 퍼포먼스가 좋지 않거나, 호환성 또는 너무 많은 폴리필이 필요하다는 문제가 있음
render.js
export const render = (RootComponent) => `
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Server Side Rendering</title>
</head>
<body>
<div id="app">${RootComponent}</div>
</body>
</html>
`;
app.js
import express from "express";
import { TodoList } from "./src/components.js";
import { render } from "./src/render.js";
const app = express();
app.get("/", (req, res) => {
res.send(
render(TodoList())
);
});
app.listen(3000, () => {
console.log('listen to http://localhost:3000');
})
위에서 봤던 routes/index.js
처럼 요청에 대한 응답으로 렌더링할 페이지의 HTML을 보내주면 된다.
server
├─ package.json
├─ app.js # 어플리케이션 entry point
└─ src
├─ main.js # 브라우저(HTML)에 삽입될 스크립트. 즉, client의 entry point
├─ components.js # 어플리케이션이 사용하는 컴포넌트들
├─ serverRenderer.js # SSR. HTML을 구성
└─ store.js # 어플리케이션이 사용하는 state
위와 같은 구조로 SSR과 CSR을 함께 사용할 수 있다. 중요한 포인트들은 아래와 같다.
Component가 DOM을 사용하지 않도록 Client 코드 구성하기
Server에서 해당 Client 코드를 import하여 사용할 수 있도록 구성
Server에서는 Component를 HTML 문자열로 변환하여 response로 반환
원문을 참고해서 실습을 진행하고, 지금 내가 해결해야하는 문제에 필요한 내용들을 정리해두었다.
export const serverRenderer = (RootComponent) => `
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Server Side Rendering</title>
</head>
<body>
<div id="app">${RootComponent}</div>
<script src="./src/scriptForCsr.js" type="module" />
</body>
</html>
`;
두 가지를 함께 사용할 수 있는 방법은 생각보다 어렵지 않다. 기존에 CSR하는 방법을 똑같이 적용하면 되는데, 서버에서 렌더링하는 HTML에 <div id='app'>
를 포함하고 이 부분의 CSR에 필요한 스크립트도 함께 넣어주면 된다. 이 때 서버에서 렌더링해도 괜찮은, ui 변경이 잦지 않은 부분들은 서버 쪽에서 미리 만들어두고 사용할 수 있다.
routing
// app.js
app.get("/*", (req, res) => {
res.send(
serverRenderer(
App({ path: req.path }) // 렌더링을 할 때 App에 path 정보 전달. App은 path에 따라 다른 컴포넌트 리턴
)
);
});
store
// app.js
// state 수정할 수 있는 api를 만들어서 클라이언트와 서버 sync 맞추기
app.put('/api/state', (req, res) => {
store.hydration(req.body);
res.status(204).send();
})
// serverRenderer.js
export const serverRenderer = (RootComponent, state) => `
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Server Side Rendering</title>
<script>
window.state = ${
JSON.stringify(state) // window 객체를 활용해 state 주입
}
</script>
</head>
<body>
<div id="app">${RootComponent}</div>
<script src="./src/main.js" type="module"></script>
</body>
</html>
`;
// store.js
export const store = {
// window를 직접적으로 사용하면 server side에서 오류가 발생
// 브라우저에서는 window, node에서는 global인 globalThis를 통해서 변수 가져오기
state: globalThis.state || {
todoItems: [
{ id: 1, content: 'CSR을 만들어보자', activation: true },
{ id: 2, content: 'CSR 코드 분할', activation: false },
{ id: 3, content: 'SSR을 만들어보자', activation: false },
],
},
// server side
hydration (state) {
this.state = state;
},
// setState를 실행하면 server에서 관리중인 state에도 반영
setState (newState) {
this.state = { ...this.state, ...newState };
fetch('/api/state', {
method: 'put',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(this.state),
})
},
toggleActivation (index) {
const todoItems = [ ...this.state.todoItems ];
todoItems[index].activation = !todoItems[index].activation;
this.setState({ todoItems });
}
};
실습을 따라가다보니 프레임워크 내부가 어떻게 생겼을지에 대해서도 생각해보게 되었다.
물론 리액트는 훨씬 복잡하겠지만 create-react-app
으로 만든 프로젝트와도 비슷하다고 느꼈는데, 지금까지는 내부에 대한 이해 없이 겉부분만 사용하고 있었다.
그렇다보니 처음에 마주했던 ‘어떻게 서버 템플릿과 클라이언트 로직을 함께 사용할 수 있을까?’라는 문제를 해결하기 어려웠던 것 같다. 기회가 된다면 리액트 내부에 대해서도 공부해보고 싶어졌다.
References
https://zuminternet.github.io/vue-ssr/
https://github.com/JunilHwang/simple-ssr
https://heecheolman.tistory.com/38
https://tech.weperson.com/wedev/frontend/csr-ssr-spa-mpa-pwa/#csr-client-side-rendering-vs-ssr-server-side-rendering