과제 체크포인트
배포 링크
vanilla: https://yeongseoyoon-hanghae.github.io/front_6th_chapter4-1/vanilla/ react: https://yeongseoyoon-hanghae.github.io/front_6th_chapter4-1/react/
기본과제 (Vanilla SSR & SSG)
Express SSR 서버
- Express 미들웨어 기반 서버 구현
- 개발/프로덕션 환경 분기 처리
- HTML 템플릿 치환 (
<!--app-html-->,<!--app-head-->)
서버 사이드 렌더링
- 서버에서 동작하는 Router 구현
- 서버 데이터 프리페칭 (상품 목록, 상품 상세)
- 서버 상태관리 초기화
클라이언트 Hydration
-
window.__INITIAL_DATA__스크립트 주입 - 클라이언트 상태 복원
- 서버-클라이언트 데이터 일치
Static Site Generation
- 동적 라우트 SSG (상품 상세 페이지들)
- 빌드 타임 페이지 생성
- 파일 시스템 기반 배포
심화과제 (React SSR & SSG)
React SSR
-
renderToString서버 렌더링 - TypeScript SSR 모듈 빌드
- Universal React Router (서버/클라이언트 분기)
- React 상태관리 서버 초기화
React Hydration
- Hydration 불일치 방지
- 클라이언트 상태 복원
Static Site Generation
- 동적 라우트 SSG (상품 상세 페이지들)
- 빌드 타임 페이지 생성
- 파일 시스템 기반 배포
구현 과정 돌아보기
가장 어려웠던 부분과 해결 과정
그놈의 서버
예전에 express를 통해 노드개발을 했었을때 어려웠던 부분이 node 명령어를 통해 서버를 실행하면 핫리로드가 바로바로 되지 않아서 반영한 값이 제대로 변경되지 않는다는 것이었는데요. 이번에 vanilla(기본과제)를 진행할때 서버의 값이 변경되지 않았다는 사실을 모르고(터미널을 여러번 껐는데도 서버가 살아있었음...) 4시간동안 서버 설정이 잘못된 것인가 하는 생각에 이전으로 서버설정을 돌리고 기능 구현을 다시 하는 등 온갖 삽질을 했습니다...
그러다가 기억 저 너머에 있던 nodemon을 준일님께서 말씀주셔서...심화과제부터는 nodemon으로 진행했습니다...
게다가 e2e테스트에서 node를 실행하는데 playwright ui 및 익스텐션의 node 버전이 20버전으로 고정되어있었는지 아무리 nvm use 22로 실행을 해도..다시 20버전으로 돌아가는 이슈가 있었습니다... 그래서 테스트가 계속해서 미통과했는데 이부분은 그냥 cli에서만 테스트를 돌리는 것으로...합의 보았습니다..ㅎㅎ
그 외에도... cli에서 테스트를 돌릴때 부하가 오면 간헐적으로 timeout이 나면서 모든 테스트가 실패해버리는 이슈 등등... 이번에 테스트 관련 이슈들이 좀 있었던 것 같습니당...
라우팅 및 baseurl 관련 부분
ssg에서 icon url로 요청을 할때 처음엔 "/"를 바라봐다가 -> 다른곳에 다녀오면 그제서야 "front_6th_chapter4-1"로 요청이 가게되어 정상적으로 아이콘이 보여지는 이슈가 있었습니다. 계속 baseUrl을 바꿔보고 하니 다른 url에 영향을 주게 되어 고민하다 저희팀 지훈님께 여쭤보았는데요. 다행히도 지훈님께서 같은 이슈가 있으셔서 'node_env에 production을 빌드시에 넣어주게 되니 해결되었다'고 말씀주셨고, 그런 방법으로 저도 해결했습니다. (근데 ssg 빌드시에도 node_env에 production을 넣어주도록 스크립트가 작성되어있는데 왜 안되는지 모를..)
서버에서 간 자원 공유
기존 코드처럼 전역 상태 공유를 위해 싱글톤 패턴을 사용했는데요. SSR환경에서는 이 방식이 위험하게 동작할 여지가 있고, 실제로도 e2e 테스트에서 실패가 발생했습니다. 서버는 사용자의 요청을 동시에 여러개 처리하는데, 싱글톤으로 자원이 공유되면 한 사용자의 데이터가 다른 사용자에게 노출될 위험이 있습니다. 또 서버와 클라이언트의 데이터가 다른 초기상태를 가질 수 있어 하이드레이션의 불일치가 일어날 수 있습니다. 그리고 서버에서 요청이 종료되었음에도 싱글톤 인스턴스가 메모리에 남아있을 수 있습니다.
따라서 이를 방지하기 위한 수단이 필요했고, context를 통해 격리를 하기로 마음먹었습니다. 서버의 자원(파라미터, 초기 데이터 등)을 클라이언트가 동일하게 이어받아야하는데 여기서 context가 그러한 역할을 합니다.
제가 설계한 구조는 위와 같은데요. 사실 관리를 편하게 하기 위해서 product외에 router도 컨텍스트를 통해 주입해주도록 변경했습니다.
export const render = async (pathname: string, query: Record<string, string>) => {
const router = new Router(routes, BASE_URL);
router.start(pathname);
router.query = query;
const params = { pathname, query, params: router.params };
const target = router.target as unknown as SSRComponent;
if (target?.ssr) {
const result = await target.ssr(params);
return {
head: `<title>${(await target.metadata?.(params))?.title ?? ""}</title>`,
html: renderToString(
<RouterProvider router={router}>
<ProductProvider productStore={createProductStore(result ?? {})}>
<App />
</ProductProvider>
</RouterProvider>,
),
__INITIAL_DATA__: result,
};
}
const metadata = await target?.metadata?.(params);
return {
head: `<title>${metadata?.title ?? ""}</title>`,
html: target({ ...params, data: {} }),
__INITIAL_DATA__: {},
};
};
위 코드는 제가 구현한 서버사이드인데요. 위처럼 요청마다 새로운 컨텍스트를 생성해서 격리를 보장할 수 있도록 했습니다. 이렇게하면 각 요청마다 독립적인 router, product인스턴스가 생성됩니다.
그 뒤에
function main() {
const rootElement = document.getElementById("root")!;
// 1. 서버 초기 데이터 확인
const initData = window.__INITIAL_DATA__;
const hasSSRData = hasInitialData();
const hasServerContent = rootElement.innerHTML.trim() !== "";
// 2. 초기 데이터로 인스턴스 복원
const router = createRouter();
if (initData?.router) {
router.state = { ...router.state, ...initData.router };
}
const productStore = createProductStore(initData?.productStore || {});
// 3. 동일한 Provider 구조로 앱 구성
function renderApp() {
return (
<RouterProvider router={router}>
<ProductProvider productStore={productStore}>
<App />
</ProductProvider>
</RouterProvider>
);
}
// 4. SSR 여부에 따른 하이드레이션/마운트 분기
if (hasSSRData || hasServerContent) {
hydrateRoot(rootElement, renderApp());
} else {
createRoot(rootElement).render(renderApp());
}
}
하이드레이션 불일치 방지를 위한 것이고 정확하게 서버에서 온 데이터를 복원하려고 했습니다.
vanilla에서도 같은 서버 간 상태 공유 이슈가 있었는데요, 이땐 router내부에다가 context를 만들어 관리해서 분리하도록 구현했습니다.
export class ServerRouter {
#routes;
#route;
#notFoundHandler;
#createContext;
constructor(routes = null, createContext = null) {
this.#routes = new Map();
this.#route = null;
this.#notFoundHandler = null;
this.#createContext = createContext;
if (routes) {
this.addRoutes(routes);
}
}
위와 같은 느낌..
구현하면서 새롭게 알게 된 개념
ssr에서의 useSyncExternalStore
이전까지 useSyncExternalStore은 csr에서만 사용하고 있었고 동시성 렌더링이라는 우선순위 렌더링(lane) 개념을 도입하면서 외부상태를 읽는 컴포넌트가 일관된 스냅샷을 보장할 수 있도록 tearing을 방지하기 위한 훅이라고만 알고 있었는데요.
useSyncExternalStore의 세번째 파라미터같은 경우엔 getServerSnapshot인데, 서버 HTML을 만들때의 스냅샷을 제공하고 서버가 만든 HTML과 클라이언트 하이드레이션 상태가 같도록 보장할 수 있음을 개념적으로만 받아들이다가 이번에 이해하게 된 것 같습니다.
성능 최적화 관점에서의 인사이트
ssg generate에서의 성능 최적화
const { getProducts } = await viteServer.ssrLoadModule("./src/api/productApi.js");
const { products } = await getProducts();
const productTasks = products.map((product) => writePage(`/product/${product.productId}/`, render, template));
await Promise.all(productTasks);
위 코드 같은 경우에는 api 모듈을 가져와서 product 상세 페이지를 다 만들게 되는데요. 만약 1000개의 상품 페이지가 있다면, 1000개의 렌더링 프로세스가 동시에 실행될 것이라고 예상됩니다. 렌더링 프로세스가 각각의 상품에 대해서 돌아가면 메모리를 상당량 소비할 것으로 생각이 되는데요.
그래서 이부분을 어떻게 해결할 수 있을지를 클로드에 물어보니 세마포어를 사용하라고 이야기해주더라고요. 동시에 실행될 수 있는 작업의 수를 제한하는 동기화 방법이라고 이해하고 있는데요. 만약에 10개까지만 렌더링을 허용하게되면 메모리가 예측 가능한 범위 내에서만 유지되고, 10개가 완료되면 다음 작업을 처리하는 방식으로 리소스 사용이 통제된다고 이해하면 된다고 했습니다.
class Semaphore {
constructor(max) {
this.max = max;
this.current = 0;
this.waiting = [];
}
async acquire() {
return new Promise((resolve) => {
if (this.current < this.max) {
this.current++;
resolve();
} else {
this.waiting.push(resolve);
}
});
}
release() {
this.current--;
if (this.waiting.length > 0) {
const resolve = this.waiting.shift();
this.current++;
resolve();
}
}
}
머 이런식으로해서... 근데 이거 뭔가 지금 생각해보니 슬라이딩 윈도우랑 비슷한것 같기도 하네용
학습 갈무리
Q1. 현재 구현한 SSR/SSG 아키텍처에서 확장성을 고려할 때 어떤 부분을 개선하시겠습니까?
renderToString 대신 renderToPipeableStream를 써보자!
renderToString는 동기적이기 때문에 renderToPipeableStream을 쓰라는 얘기가 있는데요. 아무래도 동기적이기 때문에 전체적인 컴포넌트 트리를 메모리에서 렌더링하고 완성된 HTML 문자열을 반환하는 블로킹 방식입니다. 당연하게도 예측 가능하고 단순하지만 React 18에서 서스펜스가 등장하면서 이에 대한 지원이 제한적입니다. Suspense가 suspend되면 즉시 fallback HTML을 방출하고 데이터 로딩을 기다리지 않습니다.
renderToPipeableStream은 동시성 렌더링을 기반으로 설계된 api로 Node.js 스트림 API와 직접 통합되어 백프레셔 메커니즘을 활용하고, 스트림 에러를 React 에러 boundary와 연동합니다라고는 하는데...요건 먼말인지 몰겠슴다. 아무튼 Suspense boundary 외부와 내부로 분리해서 HTML을 청크 단위로 스트리밍합니다.
renderToPipeableStream을 사용하면 TTFB(Time to First Byte)의 성능이 좋다고 얘기하는데요. 당연하게도 HTML을 스트리밍 방식으로 보여주면서 사용자가 콘텐츠를 점진적으로 확인할 수 있기 때문이라고 합니다. 아무튼 이 스트리밍은 워터폴을 제거하기 때문에 병렬적 처리가 가능하게 되었습니다.
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover /> {/* Shell에 포함되어 즉시 렌더링 */}
<Suspense fallback={<PostsGlimmer />}>
<Posts /> {/* 데이터 준비되면 스트리밍 */}
</Suspense>
</ProfileLayout>
);
}
만약 renderToPipeableStream을 사용하게 된다면 더 복잡하지만 강력한 제어 옵션을 사용할 수 있습니다.
암튼 지금 수준의 프로젝트에서는 renderToString만으로도 충분하지만 추후에 좀 더 앱이 커지게 된다면 TTFB에서도 이점이 충분하고 메모리 사용량이 적은 renderToPipeableStream을 선택해볼수는 있을 것 같습니다.
Q2. Express 서버 대신 다른 런타임(Cloudflare Workers, Vercel Edge Functions 등)을 사용한다면 어떤 점을 수정해야 할까요?
만약 서버리스/에지 런타임을 사용한다면 정적 파일들은 CDN/Storage로 해당 에셋들의 성격에 따라 분리하고, SSG는 빌드타임으로 옮기면 될 것 같습니다. 또 함수 초기화시에 번들 로딩등의 이유로 첫 로딩 지연이 일어나게 되는데요. 이때 런타임 별로 많은 비용이 발생합니다. 이런 부분은 번들 크기를 최소화하고, 의존성을 캐싱해서 처리할 수 있을 것 같습니다.
Q3. 현재 구현에서 성능 병목이 될 수 있는 지점은 어디이고, 어떻게 개선하시겠습니까?
지금같은 경우에는 서버와 클라이언트 상태가 불일치할 경우 전체 재렌더링이 발생하고 있습니다. 만약 불일치하는 부분이 있다면 부분적으로 재렌더링이 발생하도록 개선할 수 있을 것 같습니다. 또 매 요청마다 서버인지 아닌지를 판단하는 로직이 있어서...이부분에 대해서도 판단 비용을 줄일 수 있을지?를 알 수 있다면 개선할 수 있을 것 같습니다.
Q4. 1000개 이상의 상품 페이지를 SSG로 생성할 때 고려해야 할 사항은 무엇입니까?
오 위에서 얘기했던 부분인데 여기서도 같은 내용이 있었네요!
위에서 언급했듯이 세마포어를 사용하면 된다고 합니다...~ 암튼 추가적인 내용을 찾아보니, 증분 빌드를 통해서 구현하면 된다고 얘기하는데요. 증분 빌드란 빌드 타임에 변경된 부분만 다시 빌드하는 기법입니다. 보통적으로 빌드 시간 단축이 목적이고, 이때 변경 감지 시스템이 필요합니다. 예를들어 기존 방식이 전체를 빌드해서 1000개의 페이지를 모두 재생성하는 방식이라면, 증분 빌드는 변경된 페이지만 재생성해서 시간을 단축할 수 있다고 합니다.
요걸 찾아보다가 증분이라는 단어가 들어가서 어 그거 그러면 ISR이랑 다른게 뭐지?라는 생각이 들어서 찾아봤는데요. ISR같은 경우에는 런타임에 사용자 요청 시점에 페이지를 점진적으로 재생성하는 방식이기 때문에 빌드 타임에 재생성하는 증분 빌드와는 또 차이가 있다는 것을 알게되었습니다.
#### 증분 빌드
1. 상품 100개 중 5개 수정됨
2. git commit & push
3. CI/CD가 변경 감지
4. 5개 페이지만 재빌드 → 배포
#### ISR
1. 사용자가 /product/123 방문
2. 마지막 생성 후 60초 지났음 확인
3. 기존 페이지 즉시 제공
4. 백그라운드에서 새 데이터로 재생성
5. 다음 방문자는 업데이트된 페이지 확인
Q5. Hydration 과정에서 사용자가 느낄 수 있는 UX 이슈는 무엇이고, 어떻게 개선할 수 있을까요?
일단 기본적으로는 불일치로 인한 화면 깜빡임과 리렌더링 문제가 발생할 수 있습니다. main.tsx를 보면 hydrateRoot를 사용하고 있지만, 서버와 클라이언트 사이의 HTML 구조나 데이터 불일치가 발생하면 hydration 경고가 나타나고, 심한 경우 전체 페이지를 다시 렌더링하게 됩니다. 이는 사용자에게 순간적인 깜빡임이나 콘텐츠 재배치 현상을 일으켜 불편함을 줍니다.
그리고 지금 코드로는 인터랙션 차단 시간이 길어지는 문제가 있습니다. 현재 hydration.ts에서 hydrateFromSSR() 함수가 동기적으로 실행되는데, 데이터 복원 과정에서 복잡한 상태 계산이나 DOM 조작이 발생하면 사용자가 버튼 클릭이나 스크롤 같은 인터랙션을 할 수 없는 기간이 길어집니다. 특히 상품 목록이나 장바구니 데이터가 많을수록 이 시간이 증가합니다. 그리고 현재 코드를 보면 hydration 실패 시 별도의 폴백 UI가 없고, JavaScript가 비활성화된 환경에서는 완전히 동작하지 않을 수 있습니다. 특히 vanilla 버전의 경우 hydration 개념 자체가 명시적으로 구현되지 않아 CSR과 SSR 사이의 전환이 매끄럽지 않습니다.
개선 방안은 사실 잘 떠오르지 않는데요, 만약 해봄직하다면... Intersection Observer를 활용해서 뷰포트에 들어오는 컴포넌트만 hydration을 수행해서 초기 로딩 성능을 개선할 수 있지 않을까? 합니다.
Q6. 이번 과제에서 학습한 내용을 실제 프로덕션 환경에 적용할 때 추가로 고려해야 할 사항은?
지금같은 경우에는 에러핸들링이 매우 부족한 편이라 에러 핸들링을 고려해야할 것 같습니다. 예를들어 페이지에서 ssr이라는 옵션이 부여되지 않았을 경우에 대한 에러라던가 renderToString을 할때 에러가 나는경우에 대해서 폴백처리가 불분명한 상태입니다.
클로드한테 어떻게 해결할 수 있냐고 물어보니 만약 SSR이 실패하는 경우 CSR로 전환이 가능하도록 하면 된다고는 하는데, 어떻게 가능할지 머리에 그려지지는 않는것 같습니다. 클로드한테 물어보니까
// 1. SSR 데이터 로딩 실패
try {
const result = await target.ssr(params);
} catch (ssrError) {
// CSR 모드로 전환 플래그 설정
return { ssrSuccess: false, fallback: 'csr' };
}
// 2. React 렌더링 실패
try {
html = renderToString(<App />);
} catch (renderError) {
// 최소한의 폴백 HTML 생성
html = createFallbackHtml(params, result);
}
요런식으로 플래그를 설정해주고 플래그를 감지하게 해서 CSR로 전환이 가능하게 하라는데...요게 맞는지...
Q7. Next.js 같은 프레임워크 대신 직접 구현한 SSR/SSG의 장단점은 무엇인가요?
직접 구현한 SSR/SSG의 장점
이번에 직접 구현을 해보면서 SSR, SSG를 어떻게 구현할 수 있을지에 대해서 이해할 수 있었습니다. 그 전까지는 막연하게만 이해하고 있던 하이드레이션이 실제로 서버에서 생성된 HTML이 클라이언트에서 React로 연결되는 과정이라는 것을 직접 코드를 작성하면서 이해할 수 있었습니다. Next.js의 getServerSideProps나 getStaticProps 같은 함수들이 내부적으로 어떻게 동작하는지, 왜 특정 시점에 데이터를 fetch해야 하는지도 이해가 됐던 것 같습니다.
가장 큰 장점은 완전한 제어권을 갖는다는 점이라고 생각합니다. 프레임워크에 대해서 설명할때 라이브러리와의 차이점을 '제어의 역전'으로 이야기하고는 하는데요. 프레임워크를 사용하면 정해진 규칙 안에서 개발해야 하지만, 직접 구현하면 프로젝트의 특수한 요구사항에 맞춰 좀 더 자유도 높은 개발이 가능하다고 생각합니다. 특히 성능 면에서도 불필요한 기능들을 모두 제거하고 정말 필요한 부분만 구현해서 더 빠르고 가벼운 애플리케이션을 만들 수 있을거라고 생각합니다.
직접 구현한 SSR/SSG의 단점 🥹
하지만 직접 구현하면서 느낀 가장 큰 어려움은 복잡성이었습니다. 단순히 HTML을 서버에서 렌더링하는 것부터 시작해서 동적 라우팅, 동적 메타데이터 부여까지 프레임워크가 자동으로 해주던 수많은 일들을 모두 직접 구현해야 했습니다. 특히 하이드레이션 과정에서 서버와 클라이언트의 상태가 일치하지 않는 테스트를 해결해야할때가 너무 까다로웠습니다. (매직아이..)
개발 시간은 당연하게도 오래 걸렸습니다. Next.js로만 하면 ai없이도 딸깍하고 끝날 일이 직접 구현하니 일주일이 걸렸습니다. 그리고 나중에 유지보수할 때도 문제라고 생각합니다. 지금과 같은 코드로는... 팀에 새로운 사람이 들어오는 경우를 방지하기 위해서 문서화를 빡세게해야할 것 같다는 생각이 들었습니다. 또 직접 구현하게 되면 생태계가 너무너무 좁은 것이 문제라고 생각합니다. Next.js를 쓰다가 문제가 생기면 구글링을 하면 바로 답을 찾을 수 있지만, 직접 구현한 시스템에 문제가 생기면 혼자서 해결해야 합니다. 보통적으로 프레임워크를 사용하면 새로운 기능을 도입하려고할때 문제가 없는데, 직접 구현하게 되면 기능 구현을 위한 기능을 구현해야할일이 생기는 점도... 기능에서의 병목이라고 생각합니다.
그렇기 때문에 대부분의 프로젝트에서는 Next.js 같은 검증된 프레임워크를 사용하는 것이 현명하겠다는 생각을 했습니다...ㅎㅎ 정말 Next.js에서 벗어나고 싶은게 아니라면...(그마저도 리믹스를 사용하면..?) 학습목적으로는 한번쯤은 구현해봄직하다고 생각합니다...ㅎㅎ...🥹
Q8. Next.js 를 이용하여 SSG 방식으로 배포하려면 어떻게 해야 좋을까요?
SSG 설정과 기본 구성
먼저 Next.js 프로젝트를 SSG로 설정하는 것부터 시작해야 합니다. next.config.js 파일에서 정적 export를 활성화를 해야하는데요. 다음과 같이 설정할 수 있습니다.
const nextConfig = {
output: 'export',
trailingSlash: true,
skipTrailingSlashRedirect: true,
distDir: 'dist',
images: {
unoptimized: true,
},
// 추가로 알게 된 유용한 설정들
experimental: {
optimizePackageImports: ['lucide-react'],
staticGenerationRetryCount: 3, // 빌드 실패 시 재시도
staticGenerationMaxConcurrency: 8, // 동시 생성 페이지 수
}
}
여기서 output: 'export'를 설정하면 next build 실행 시 정적 파일들이 생성됩니다. 추가로 trailingSlash: true를 설정하면 URL 끝에 슬래시가 붙어서 정적 호스팅에서 더 안정적으로 작동합니다. /blog/my-post/처럼 끝에 슬래시가 붙은 URL로 변환되고, 이게 정적 호스팅에서는 /blog/my-post/index.html 파일과 매칭됩니다.
추가적으로 staticGenerationRetryCount를 하면 빌드 실패시에 재시도를 하는 횟수, staticGenerationMaxConcurrency는 동시에 생성하는 페이지수를 지정가능하다고합니다. 메모리 부족이나, api 레이트 리밋이 있을때 조절해서 사용하면 될 것 같습니다.
Next.js Image
Next.js의 Image 컴포넌트는 이미지 최적화 서버를 통해 최적화를 진행하는데, 정적 배포에서는 사용할 수 없어서 에러가 발생합니다. 대신 images: { unoptimized: true }로 설정하거나, 외부 이미지 최적화 서비스를 사용해야 합니다. 찾아보니 Cloudinary나 ImageKit 같은 서비스를 활용하면 정적 사이트에서도 최적화된 이미지를 제공할 수 있다고 하긴하는데...요건 안써보고 듣기만해봐서 모르겠슴다..
getStaticProps와 getStaticPaths 활용법
Pages Router에서는 getStaticProps로 빌드 타임에 데이터를 가져와서 정적 페이지를 생성할 수 있습니다. API에서 데이터를 가져오는 코드를 이 함수 안에 작성하면, 빌드할 때 실행되어서 그 결과가 HTML에 미리 포함됩니다. 동적 라우팅이 필요하다면 getStaticPaths를 함께 사용해서 어떤 경로들을 미리 생성할지 지정할 수 있습니다. 여기서 중요한 것은 모든 가능한 경로를 다 생성하려고 하면 빌드 시간이 너무 길어진다는 점입니다. 그리고 정적 배포에서는 fallback이 작동하지 않으므로 반드시 fallback: false로 설정해야 합니다.
App Router 방식 (Next.js 13+)
최신 Next.js에서는 App Router를 사용하는 것이 권장됩니다. 여기서는 generateStaticParams를 사용해서 더 직관적으로 정적 경로를 생성할 수 있다고 합니다.(안써봄...)
성능 최적화 전략
SSG의 가장 큰 장점은 빠른 로딩 속도입니다. SSG에서는 빌드할 때 모든 데이터를 한 번에 가져와야 하는데, 이때 병렬 처리가 엄청 중요합니다. 순차처리되지 않고 병렬처리하여 빌드하도록 구현해야합니다. 또 앞서서 Next/Image에 대해 언급한 것처럼
images: {
unoptimized: true, // 런타임 최적화 대신
formats: ['image/webp'], // 빌드시 WebP 변환
}
로 런타임이 아닌, 빌드타임에 이미지를 최적화하는 것이 중요합니다.
그리고 CDN에서 파일 캐싱에 대한 최적화 전략을 잘 구성하는 것도 중요하다고 생각합니다.
// Cloudflare, CloudFront 등에서 설정
const cacheRules = {
// HTML 파일들
"*.html": {
"cache-control": "public, max-age=3600, s-maxage=86400",
"browser-cache": "1시간",
"cdn-cache": "24시간"
},
// 정적 에셋들
"/_next/static/*": {
"cache-control": "public, max-age=31536000, immutable",
"browser-cache": "1년",
"cdn-cache": "1년"
}
}
이렇게 구성해주면 SSG의 장점을 최대한 활용이 가능하다고 생각합니다.
출처
https://nextjs.org/docs/pages/building-your-application/rendering/static-site-generation
코드 품질 향상
자랑하고 싶은 구현
withServerSideProps HOC 구현
관심사를 분리하고 선언적으로 사용하고 싶었습니다. 각 페이지마다 거의 비슷한 구조를 가져가게 될 것이라고 예상되어 HOC를 사용했습니다.
export interface PageWithServer<P = Record<string, unknown>> extends FC<P> {
ssr?: (params: ServerParams) => Promise<unknown>;
metadata?: (params: ServerParams) => Promise<{ title: string }>;
}
PageWithServer 인터페이스를 통해 컴파일 타임에 타입 안전성을 보장하면서도, Object.assign을 사용하여 런타임에 메소드를 동적으로 할당하도록 했습니다.
ssr 속성을 통해서 페이지를 전체적으로 불러오기 전에 데이터 프리로딩을 하게하고, metadata를 통해 페이지 타이틀을 넣어줄 수 있도록 했습니다.
export const ProductDetailPage = withServerSideProps(
{
ssr: async ({ params: { id } }) => {
if (id === "error-test") {
throw new Error("SSR intentionally failed");
}
// ... 정상 로직
}
},
() => {
const { error, loading } = useProductStore();
if (loading) return <LoadingSpinner />;
if (error) return <ErrorPage />;
return <ProductDetail />;
}
);
ssr 미지원 페이지에서의 폴백
export const render = async (pathname: string, query: Record<string, string>) => {
const router = new Router(routes, BASE_URL);
router.start(pathname);
router.query = query;
const params = { pathname, query, params: router.params };
const target = router.target as unknown as SSRComponent;
// SSR 모드 시도
if (target?.ssr) {
try {
const result = await target.ssr(params);
return {
head: `<title>${(await target.metadata?.(params))?.title ?? ""}</title>`,
html: renderToString(/* React 컴포넌트 */),
__INITIAL_DATA__: result,
};
} catch (error) {
// SSR 실패 시 CSR로 폴백
console.error("SSR failed, falling back to CSR:", error);
const metadata = await target?.metadata?.(params);
return {
head: `<title>${metadata?.title ?? ""}</title>`,
html: target({ ...params, data: {} }), // 클라이언트 사이드 렌더링
__INITIAL_DATA__: {},
};
}
}
// SSR을 지원하지 않는 페이지
const metadata = await target?.metadata?.(params);
return {
head: `<title>${metadata?.title ?? ""}</title>`,
html: target({ ...params, data: {} }),
__INITIAL_DATA__: {},
};
};
ssr실패시 자동으로 csr로 전환할 수 있도록 구현하였습니다.
개선하고 싶은 부분
만약 개선한다면 공통되는 코드들을 리팩토링하고 싶다는 생각을 했습니다...(지금은 기력이 없음..ㅠㅠ) 또 기능적인 개선이 있다고하면 코드스플리팅을 고려해볼 수 있지 않을까?하는 생각을 했습니다.
리팩토링 계획
vanilla와 Client Router와 Server Router를 분기해서 탈 수 있도록 구현했는데요. React에서는 Router의 처리가 너무 어려워서 하나의 만능 유니버설(?)라우터에서 분기를 처리하고 인스턴스를 내보내는 방식으로 구현했습니다. 이부분이 좀 아쉬워서 둘을 분리해서 작성해보고 싶다는 생각을 했던 것 같습니다.
학습 연계
다음 학습 목표
다른 주제가 떠오르기보다는 좀 더 처음부터 SSR, SSG를 구현해보고 싶습니다.
실무 적용 계획
실무에 해당 코드를 직접적으로 사용할 수는 없겠지만, 제가 ssr을 지원하는 프레임워크를 사용하게 되었을때, 혹은 그에 대한 고려를 할때 좀 더 각각의 특징에 대해 생각하며 고려해볼 수 있지 않을까 생각합니다.
리뷰 받고 싶은 내용
-
SSR, CSR 판단비용 지금 현재로는 모든 함수에서 SSR인지 CSR인지를 window객체가 존재하는지를 확인하는 판단 비용을 소비하게 되는데요. 어떻게하면 매번 그에 대해 판단을 하지 않고도 가능하게 할 수 있을지? 그런 방법이 있는지가 궁금합니다.
-
Hydration 이슈 지금 현재 로직으로는 하이드레이션시 그렇게까지 무겁게 동작하진 않지만, 만약 데이터가 좀 더 무거워진다면 인터렉션을 차단하는 요소가 분명히 있게 될 것 같은데요. 어떻게하면 이부분을 해소할 수 있을까요??? 보통적으로 어떤 방식을 택하는 편인지 궁금합니다.
-
SSR에서의 에러 핸들링 위에서 질문을 보고 하게된 고민이라 복사해서 남깁니다! 지금같은 경우에는 에러핸들링이 매우 부족한 편이라 에러 핸들링을 고려해야할 것 같습니다. 예를들어 페이지에서 ssr이라는 옵션이 부여되지 않았을 경우에 대한 에러라던가 renderToString을 할때 에러가 나는경우에 대해서 폴백처리가 불분명한 상태입니다.
클로드한테 어떻게 해결할 수 있냐고 물어보니 만약 SSR이 실패하는 경우 CSR로 전환이 가능하도록 하면 된다고는 하는데, 어떻게 가능할지 머리에 그려지지는 않는것 같습니다. 클로드한테 물어보니까
// 1. SSR 데이터 로딩 실패
try {
const result = await target.ssr(params);
} catch (ssrError) {
// CSR 모드로 전환 플래그 설정
return { ssrSuccess: false, fallback: 'csr' };
}
// 2. React 렌더링 실패
try {
html = renderToString(<App />);
} catch (renderError) {
// 최소한의 폴백 HTML 생성
html = createFallbackHtml(params, result);
}
요런식으로 플래그를 설정해주고 플래그를 감지하게 해서 CSR로 전환이 가능하게 하라는데...요런식으로 해소가 가능할까요??? 바닐라js 혹은 React로 SSR을 구현하는 경우에 initial data를 사용한 것처럼... 폴백을 줘서 자동 감지하게 하는게 맞을지?아니면 다른 방법이 있을지 궁금합니다.
과제 피드백
영서님 고생하셨습니다~ 마지막까지 알찬 회고를 작성해주시네요. 과제는 잘 작성해주셨고 구현도 필요한 부분 적절한 방식으로 잘 진행해주셨습니다. 자랑하고 싶은 구현은 뭔가 지훈님의 구현과도 비슷한 느낌도 드는 부분이 있네요 ㅎㅎ 잘 작성해주셨고 결국 인터페이스를 강제하면서 누릴 수 있는 효과들을 잘 누린것 같아요.
SSR, CSR 판단비용
지금 방식도 일반적으로 쓸 수 있는 방식일 것 같지만 가장 쉬운 방식은 플래그같은걸 두거나, 프레임워크화 시키면서 자체에서 각각 기능을 다르게 제공하는게 있지 않을까 싶어요 ㅋㅋ 실제로 Next나 다른 프레임워크를 사용하더라도 이게 SSR 형태로 제공이 되어야 한다면 사용되는 API들의 형태 자체가 달라지기 때문에 추상화된 메서드를 제공한다면 매번 판단하지 않고 사용자에게 판단 주체를 넘기는 방식이 될 수 있지 않을까 싶습니다.
Hydration 이슈
직접 최적화를 해본 경험은 따로 없지만, 관련해서 점진적으로 하이드레이션을 발생시키는 방식이나 선택적으로 하이드레이션을 할 수 있는 방식이 있다고 해서 해당 부분에 대해서도 확인해보면 좋을 것 같고, 말씀해주신 데이터 스트리밍 방식이 이런 부분을 해결하는데 탁월하지 않을까 싶어요~
SSR 에러 핸들링
상황에 따라 대응이 다를 것 같은데요. 이게 서버에서 에러가 처리되어야 하는 부분과 클라이언트 단에 throw해서 처리되어야하는 방식으로도 나눠서 동작을 구분하면 좋을 것 같고 각 단계에 대해서도 나눠보면 좋을 것 같아요.
지금 작성해주신것처럼 해당 방식이 실패하는 경우 CSR로 처리하는 형태도 좋은 폴백 동작 처리인 것 같고요. 특히 서버 레벨에서는 데이터 페칭 실패나 API 타임아웃 같은 인프라 문제를 즉시 처리하고여. 단계별로 첫 번째 SSR 데이터 로딩 실패 시에는 스켈레톤으로 렌더링하고, 두 번째 React 렌더링까지 실패하면 정적 HTML만 전송 후 CSR 전환하는 방식이 효과적일 것 같아요. 구현 방식은 사실 여러가지가 될 수 있을것 같긴 한데 지금 방식도 좋아보여요!
고생하셨고 마지막주차도 화이팅입니다.