
우선 개념 정리를 해보자..!
서버는 기본적으로 빈 HTML + JS 파일을 내려주고 브라우저가 JS를 실행해 화면을 그리는 방식
서버가 요청을 받을 때마다 완성된 HTML을 만들어 내려주고, 브라우저가 바로 렌더링하는 방식
빌드 시점에 HTML을 미리 생성해두고, 사용자가 접속하면 즉시 정적 파일을 내려주는 방식
클라이언트에서 JS가 실행되면서 기존 HTML에 이벤트 바인딩을 입히는 과정
const prod = process.env.NODE_ENV === "production";
const app = express();
// node 환경에서 msw 서버 세팅
server.listen({
onUnhandledRequest: "bypass",
});
let vite;
if (!prod) { // prod를 사용한 환경 분기 처리
const { createServer } = await import("vite");
// 개발 환경 - vite 미들웨어 주입
vite = await createServer({
server: { middlewareMode: true },
appType: "custom",
});
app.use(vite.middlewares);
} else {
const compression = (await import("compression")).default;
const sirv = (await import("sirv")).default;
app.use(compression());
app.use(
base,
sirv("dist/vanilla", {
extensions: [],
}),
);
}
app.use("*all", async (req, res) => {
// ~
const rendered = await render(url, req.query);
// HTML 템플릿 치환
const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-html-->`, rendered.html ?? "")
.replace(
`<!--app-initial-data-->`,
`<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData)}</script>`,
);
res.status(200).set({ "Content-Type": "text/html" }).send(html);
} catch (e) {
vite?.ssrFixStacktrace(e);
console.log(e.stack);
res.status(500).end(e.stack);
}
});
// Start http server
app.listen(port, () => {
console.log(`React Server started at http://localhost:${port}`);
});
import { Router, ServerRouter } from "../lib";
import { BASE_URL } from "../constants.js";
export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new ServerRouter("");
router.addRoute("/", HomePage);
router.addRoute("/product/:id/", ProductDetailPage);
export async function render(url, query = {}) {
// ~
const { path, params } = matched;
let initialData;
// 서버 데이터 프리페칭
if (path === "/") {
initialData = await fetchProductsDataSSR(query);
} else if (path === "/product/:id/") {
initialData = await fetchProductDataSSR(params.id);
}
let pageTitle;
let pageHtml;
// 페이지 상태 결정 및 HTML 렌더링
if (path === "/") {
pageTitle = "쇼핑몰 - 홈";
pageHtml = HomePage({ initialData, query });
} else if (path === "/product/:id/") {
pageTitle = initialData?.currentProduct?.title ? `${initialData?.currentProduct?.title} - 쇼핑몰` : "쇼핑몰";
pageHtml = ProductDetailPage({ initialData });
} else {
pageHtml = NotFoundPage();
}
// 렌더링 결과 반환 (initialData - 하이드레이션에 필요한 초기 상태)
return {
head: `<title>${pageTitle}</title>`,
html: pageHtml,
initialData,
};
}
const createMemoryStorage = () => {
let value = {};
return {
getItem: (key) => (key in value ? value[key] : null),
setItem: (key, value) => {
value[key] = value;
},
removeItem: (key) => {
delete value[key];
},
clear: () => {
value = {};
},
};
};
const memoryStorage = createMemoryStorage();
// 브라우저 환경이 아닐 때를 위한 메모리 저장 storage 추가
export const createStorage = (key, storage = typeof window === "undefined" ? memoryStorage : window.localStorage) => {
export const HomePage = withLifecycle(
{
onMount: () => {
if (typeof window === "undefined") return;
loadProductsAndCategories(); // csr
},
watches: [
[
() => {
const { search, limit, sort, category1, category2 } = router.query;
return [search, limit, sort, category1, category2];
},
() => {
loadProducts(true);
},
],
],
},
// 서버에서 렌더링한 초기 데이터를 클라이언트에서 가져옴
({ initialData = window.__INITIAL_DATA__, query = router.query } = {}) => {
if (!productStore.getState().products.length && initialData) {
// initialData를 전역 상태에 넣어 SSR와 클라이언트 상태를 일치시킴
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: initialData,
});
}
// SSR - initialData, CSR - store
const productState = typeof window === "undefined" ? initialData : productStore.getState();
const {
search: searchQuery,
limit,
sort,
category1,
category2,
} = typeof window === "undefined" ? query : router.query;
return (
// ~
)
}
)
async function generateStaticSite(url, query) {
const rendered = await render(url, query);
const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-html-->`, rendered.html ?? "")
.replace(
`</head>`,
`
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData || {})};
</script>
</head>
`,
);
if (url == "/404") {
fs.writeFileSync("../../dist/vanilla/404.html", html);
} else {
// 지정한 경로에 폴더가 없으면 생성
if (!fs.existsSync(`../../dist/vanilla${url}`)) {
fs.mkdirSync(`../../dist/vanilla${url}`, { recursive: true });
}
// 렌더링된 HTML을 해당 폴더 안에 index.html 파일로 저장 - 정적 배포 가능한 파일
fs.writeFileSync(`../../dist/vanilla${url}/index.html`, html);
}
}
const { products } = await getProducts();
// 홈 페이지와 404 페이지를 빌드 타임에 미리 생성
await generateStaticSite("/", {});
await generateStaticSite("/404", {});
// 상품 목록을 순회하여 각 상품 페이지를 url 기준으로 HTML 생성
for (let i = 0; i < products.length; i++) {
await generateStaticSite(`/product/${products[i].productId}/`, {});
}
vite.close();
으음
여기에 적어도 되는건진 모르겠지만..

윈도우에서는 한번에 여러 서버를 띄우는게 정상 작동하지 않아 라이브러리를 2개 추가한 뒤 스크립트 명령어를 수정해줘야 했다.. 이를 알기 전엔 테스트가 돌아가지 않는게 내가 코드를 잘못 건드려서 그런건지 이유를 알 수가 없어서 너무 스트레스를 받았다.
항해 매니저님께서 직접 원격 제어로 도와주신 덕분에 테스트는 돌아가게 되었으나.. 이미 의욕을 잃은 나는 기본 과제만 마무리하고 심화 과제는 통과하지 못했다.. ^^;

그리고 다음날 맥북 삼
심화 과제 꼭 시도해보기..!!
맥북 유저 되신걸 환영해요~~ 🙌