Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

## SSR, SSG, 그리고 하이드레이션

하이드레이션이 무엇인지 근본적으로 알 수 있게 되었다. 전에는 그냥 그런 게 있나보다 했는데 이제 왜 하는 것인지 알게 되었다.
나의 서버 사이드 렌더링은 과거의 PHP 정도 지식에 머물러있어서 그 SSR과 Next와 같은 프레임워크의 SSR의 차이를 알 수 있었다.
서버에서 렌더링한 HTML과 클라이언트의 HTML을 일치 시켜야 한다는 것과,
왜 그렇게 Next.js를 쓰면 하이드레이션 오류가 발생하는 것인지도 알 수 있었다.

서버로 인한 레이스 컨디션과 같은 문제도 왜 발생하는지, 독립적 컨텍스트가 왜 필요하는지도 알 수 있었다.

추가로 VITE에서 꽤 많은 걸 해준다는 것을 배웠다.
https://ko.vite.dev/guide/ssr

사실 회사에서 내가 맡은 프로젝트는 대부분 CSR이었다. 홈페이지성 프로젝트가 아니어서 굳이 사용할 필요도 없다고 생각했고, 인프라 쪽 문제도 있을 거 같았다.
Nuxt2 프레임워크를 사용한 프로젝트도 있었으나 서버 사이드 기능을 사용하지 않았다. SSG로 빌드했었는데 왜 굳이 SSG로 만들었을까 항상 궁금했었다. 금주 과제를 진행하면서, 그래도 SEO나 첫 화면의 빠른 렌더링 면에서는 SSG였어서 이득이 있었을 것이라는 생각이 들었다.

과제를 뭐부터 시작해야 할지 모르겠어서 무작정 VITE의 SSR문서를 보았더니 대략적으로 감을 잡을 수 있었다.
서버에서 HTML을 만들어서 내려준다는 것 자체는 이전의 PHP라든가...그런 것들이 있었기 때문에 낯설지 않았는데
CSR는 CSR대로 잘 되면서 SSR로도 호환되게 만든다는 게 어렵게 느껴졌다.
MSW와 같은 설정이 너무 힘들게 느껴지기도 했다.

SSR 프레임워크는 깊게 다뤄보지 않았어서 별로 자신 없는 분야였는데 과제를 하면서 전체적인 컨셉을 알 수 있어서 좋았다.

리액트로 전환하는 작업은 더 어렵게 느껴지는데.... 넥스트가 없었다면 서버사이드 렌더링을 하기 위해 참 힘든 작업을 해야겠구나 하고 느꼈다.
프레임워크라는 게 있어야 사람을 구하기도 편하고 유지보수하기도 편한 것 같다.
100 changes: 79 additions & 21 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,90 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import compression from "compression";
import sirv from "sirv";
import { createServer as createViteServer } from "vite";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5174;

const port = process.env.PORT || 5173;
const base = process.env.BASE || (prod ? "/front_7th_chapter4-1/react/" : "/");

const app = express();

app.get("*all", (req, res) => {
res.send(
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React SSR</title>
</head>
<body>
<div id="app">${renderToString(createElement("div", null, "안녕하세요"))}</div>
</body>
</html>
`.trim(),
);
});
async function renderSSR(template, render, url, query) {
const { head, html, initialDataScript } = await render(url, query);

return template
.replace("<!--app-head-->", head)
.replace("<!--app-html-->", html)
.replace("</head>", `${initialDataScript}</head>`);
}

function normalizeUrl(url, base) {
if (base && url.startsWith(base)) {
url = url.slice(base.length - 1);
}
if (!url.startsWith("/")) {
url = "/" + url;
}
return url;
}

// 개발 모드
if (!prod) {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
base,
});

app.use(vite.middlewares);

app.use("*all", async (req, res, next) => {
const url = req.originalUrl;
const query = req.query;

try {
let template = fs.readFileSync(path.resolve(__dirname, "index.html"), "utf-8");
template = await vite.transformIndexHtml(url, template);

const { render } = await vite.ssrLoadModule(path.resolve(__dirname, "src/main-server.tsx"));

const finalHtml = await renderSSR(template, render, url, query);

res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml);
} catch (e) {
vite.ssrFixStacktrace(e);
next(e);
}
});
} else {
// 프로덕션 모드
app.use(compression());
app.use(base, sirv(path.resolve(__dirname, "./dist/react"), { extensions: [] }));

app.use("*all", async (req, res, next) => {
try {
const url = normalizeUrl(req.originalUrl, base);
const query = req.query;

const template = fs.readFileSync(path.resolve(__dirname, "./dist/react/index.html"), "utf-8");
const { render } = await import(path.resolve(__dirname, "./dist/react-ssr/main-server.js"));

const finalHtml = await renderSSR(template, render, url, query);

res.status(200).set({ "Content-Type": "text/html" }).end(finalHtml);
} catch (e) {
console.error("SSR Error:", e);
next(e);
}
});
}

// Start http server
app.listen(port, () => {
console.log(`React Server started at http://localhost:${port}`);
console.log(`React SSR Server started at http://localhost:${port}`);
});
25 changes: 16 additions & 9 deletions packages/react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { router, useCurrentPage } from "./router";
import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
import { useLoadCartStore } from "./entities";
import { useCurrentPage } from "./router";
import { ModalProvider, ToastProvider } from "./components";

// 홈 페이지 (상품 목록)
router.addRoute("/", HomePage);
router.addRoute("/product/:id/", ProductDetailPage);
router.addRoute(".*", NotFoundPage);
import { useLoadCartStore } from "./entities";
import { useEffect, useState } from "react";

const CartInitializer = () => {
const [isClient, setIsClient] = useState(false);

useEffect(() => {
setIsClient(true);
}, []);

if (!isClient) return null;

return <CartLoader />;
};

const CartLoader = () => {
useLoadCartStore();
return null;
};
Expand All @@ -24,7 +31,7 @@ export const App = () => {
<ToastProvider>
<ModalProvider>{PageComponent ? <PageComponent /> : null}</ModalProvider>
</ToastProvider>
<CartInitializer />
{typeof window !== "undefined" && <CartInitializer />}
</>
);
};
12 changes: 6 additions & 6 deletions packages/react/src/components/modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ const Header = ({ children }: PropsWithChildren) => {
const Container = ({ children }: PropsWithChildren) => {
return (
<div className="flex min-h-full items-end justify-center p-0 sm:items-center sm:p-4">
<div className="relative bg-white rounded-t-lg sm:rounded-lg shadow-xl w-full max-w-md sm:max-w-lg max-h-[90vh] overflow-hidden">
<div
className="relative bg-white rounded-t-lg sm:rounded-lg shadow-xl w-full max-w-md sm:max-w-lg max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
Expand All @@ -41,11 +44,8 @@ const ModalRoot = ({ children }: Readonly<PropsWithChildren>) => {
}, [modal]);

return (
<div className="fixed inset-0 z-50 overflow-y-auto cart-modal">
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity cart-modal-overlay"
onClick={modal.close}
/>
<div className="fixed inset-0 z-50 overflow-y-auto cart-modal" onClick={modal.close}>
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity cart-modal-overlay" />

{children}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useStore } from "@hanghae-plus/lib";
import { useUniversalStore } from "../../../hooks";
import { cartStore } from "../cartStore";

type CartState = ReturnType<(typeof cartStore)["getState"]>;

export const useCartStoreSelector = <T>(selector: (cart: CartState) => T) => {
return useStore(cartStore, selector);
return useUniversalStore(cartStore, selector);
};
35 changes: 31 additions & 4 deletions packages/react/src/entities/carts/storage/cartStorage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
import { createStorage } from "@hanghae-plus/lib";
import type { Cart } from "../types";

export const cartStorage = createStorage<{
items: Cart[];
selectedAll: boolean;
}>("shopping_cart");
const createDummyStorage = <T>() => {
let data: T | null = null;
const listeners: Set<() => void> = new Set();

return {
get: () => data,
set: (value: T) => {
data = value;
listeners.forEach((fn) => fn());
},
reset: () => {
data = null;
listeners.forEach((fn) => fn());
},
subscribe: (fn: () => void) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
};

export const cartStorage =
typeof window !== "undefined"
? createStorage<{
items: Cart[];
selectedAll: boolean;
}>("shopping_cart")
: createDummyStorage<{
items: Cart[];
selectedAll: boolean;
}>();
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from "./useProductFilter";
export * from "./useProductURLSync";

export * from "./useLoadProductDetail";
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { loadProductDetailForPage } from "../../productUseCase";
export const useLoadProductDetail = () => {
const productId = useRouterParams((params) => params.id);
useEffect(() => {
loadProductDetailForPage(productId);
// SSR/SSG 데이터가 없으면 로드
const hasInitialData = window.__INITIAL_DATA__;
if (!hasInitialData) {
loadProductDetailForPage(productId);
}
}, [productId]);
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { useEffect } from "react";
import { useRouterQuery } from "../../../../router";
import { loadProducts } from "../../productUseCase";

export const useProductFilter = () => {
const { search: searchQuery, limit, sort, category1, category2 } = useRouterQuery();
const category = { category1, category2 };

useEffect(() => {
loadProducts(true);
}, [searchQuery, limit, sort, category1, category2]);
// Data fetching is now handled by useProductURLSync in HomePage
// useEffect(() => {
// loadProducts(true);
// }, [searchQuery, limit, sort, category1, category2]);

return {
searchQuery,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect, useRef } from "react";
import { useRouterQuery } from "../../../../router";
import { loadProducts } from "../../productUseCase";

export const useProductURLSync = () => {
const query = useRouterQuery();
const prevQueryString = useRef("");

useEffect(() => {
// current 파라미터는 무한 스크롤 시 변경되므로 제외하고 비교
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { current, ...restQuery } = query;

// 쿼리 객체를 문자열로 변환하여 비교 (deep compare 효과)
const currentQueryString = JSON.stringify(restQuery);

// 이전 쿼리와 다르면 로드
if (prevQueryString.current !== currentQueryString) {
loadProducts(true);
prevQueryString.current = currentQueryString;
}
}, [query]);
};
4 changes: 2 additions & 2 deletions packages/react/src/entities/products/hooks/useProductStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useStore } from "@hanghae-plus/lib";
import { useUniversalStore } from "../../../hooks";
import { productStore } from "../productStore";

export const useProductStore = () => useStore(productStore);
export const useProductStore = () => useUniversalStore(productStore);
7 changes: 3 additions & 4 deletions packages/react/src/entities/products/productUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,9 @@ export const loadRelatedProducts = async (category2: string, excludeProductId: s
};

export const loadNextProducts = async () => {
// 현재 라우트가 홈이 아니면 무한 스크롤 비활성화
if (router.route?.path !== "/") {
return;
}
// router.route 정보가 없거나 path가 일치하지 않아도
// HomePage에서 등록한 scroll 이벤트에 의해 호출되므로
// 별도의 path 체크 없이 실행하도록 변경 (테스트 환경 호환성)

if (isNearBottom(200)) {
const productState = productStore.getState();
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./useUniversalStore";
export * from "./useUniversalRouter";
50 changes: 50 additions & 0 deletions packages/react/src/hooks/useUniversalRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useRef, useSyncExternalStore } from "react";
import type { RouterInstance } from "@hanghae-plus/lib";
import type { AnyFunction } from "@hanghae-plus/lib";

const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useUniversalRouter = <T extends RouterInstance<AnyFunction>, S>(
router: T | null,
selector = defaultSelector<T, S>,
) => {
const isServer = typeof window === "undefined";
const getSnapshotRef = useRef<S | undefined>(undefined);

// Create a dummy subscribe function for when router is null
const subscribe = router?.subscribe ?? (() => () => {});
const getSnapshot = () => {
if (!router) {
return null as S;
}
const nextSnapshot = selector(router);
if (getSnapshotRef.current === undefined) {
getSnapshotRef.current = nextSnapshot;
return nextSnapshot;
}

if (getSnapshotRef.current === nextSnapshot) {
return getSnapshotRef.current;
}

if (
typeof nextSnapshot === "object" &&
nextSnapshot !== null &&
JSON.stringify(getSnapshotRef.current) === JSON.stringify(nextSnapshot)
) {
return getSnapshotRef.current;
}

getSnapshotRef.current = nextSnapshot;
return getSnapshotRef.current as S;
};

// Hooks must be called unconditionally
const snapshot = useSyncExternalStore(subscribe, getSnapshot, isServer ? getSnapshot : getSnapshot);

if (isServer || !router) {
return getSnapshot();
}

return snapshot;
};
11 changes: 11 additions & 0 deletions packages/react/src/hooks/useUniversalStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useSyncExternalStore } from "react";
import type { createStore } from "@hanghae-plus/lib";

type Store<T> = ReturnType<typeof createStore<T>>;

const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useUniversalStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
return selector(state);
};
Loading