과제 체크포인트
배포 링크
리액트 : https://hwirin-kim.github.io/front_6th_chapter4-1/react/ 바닐라 : https://hwirin-kim.github.io/front_6th_chapter4-1/vanilla/
기본과제 (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 (상품 상세 페이지들)
- 빌드 타임 페이지 생성
- 파일 시스템 기반 배포
구현 과정 돌아보기
가장 어려웠던 부분과 해결 과정
가장 힘들었던점은 테스트가 통과하지 않았던점이였다.. 무엇이 문제였을까? 하지만 오직 테스트일뿐, 결국 이것저것 시도하다보니 테스트는 갑자기 잘 돌아가서 통과되었다.
그 외 가장 어려웠던 부분은 SSG를 사용할 때, nextJS는 분명 빌드시 모든 경로를 정적으로 만들고 그 데이터를 기반으로 SSG가 되는것 같았는데, 우리 과제는 최초 1번만 SSG이고 상세페이지 이동은 CSR로 동작하는 구조였다.
물론 무한스크롤로 이뤄진 사이트라서 모든 페이지를 정적으로 만들긴 어렵지만, 최초 로드되는 20개의 상세페이지라도 정적페이지로 만들고 이동시키고 싶었다.
그래서 정적페이지로 만들고 html을 어떻게 뿌려주지..? 이미 만든 페이지를 어떻게 구분해서 주지? 생각을하다가 next는 어떻게 동작할까? 하고 팀원 및 여러 사람과 논의를 해봤다.
그런데 알아낸것은 next 또한 최초 1번만 정적페이지이고 나머지는 어떤 데이터를 받는다는것을 알게되었다.. 그런데 내가 만들어왔던 next는 분명 데이터를 fetch하지 않는것같았는데.. 어떤 이유인지 알아보니 내부적으로 data를 json형태로 저장해둔다는것을 알았다.
그래서 그것에 힌트를 얻어 html뿐만 아니라 내부에서 사용될 데이터도 json으로 저장시켰다. 그렇게 최초 접속에서는 SSG, 그 다음 이동시 json데이터가 존재하면 그때도 데이터 페칭 없이 SSG, 그리고 미리 빌드되지 않은 파일만 CSR로 이동되는 구조를 만들게 되었다.
구현하면서 새롭게 알게 된 개념
지금까지 나는 nextJS로 SSG사이트를 만들면서 빌드 시, html이 생성되니 내부적으로 해당 html을 잘 라우팅하며 보여준다고 생각했다. 하지만 생각해보니 next로 빌드된 사이트는 새로고침없이 CSR처럼 동작한다.. 왜 나는 여기서 이상하다는것을 눈치채지 못했을까..
알고보니 next또한 우리과제에서처럼 __NEXT_DATA__에 초기 하이드레이션용 데이터가 삽입된다. 이 후 브라우저가 JS번들을 로드하고 해당 데이터를 하이드레이션하여 리액트 앱으로 전환하게 된다.
그 후 빌드시점에서 생성해뒀던 json데이터를 페이지라우터에서는 prefetch 혹은 클릭 직후 fetch하고 앱라우터에서는 대상 경로의 RSC(Flight) 응답을 prefetch 한다.
따라서 실제 빌드시점에 결정된 데이터들을 미리 저장해두고 내부 페이지 전환 시 백엔드 api를 호출할 필요 없이 바로 제공받아 CSR처럼 빠른 전환효과와 데이터 일관성, 그리고 불필요한 api 호출을 없앨 수 있다.
즉 SSG의 장점인 SEO 및 초기 빠른로딩과 CSR의 장점인 빠른 내부전환을 매끄럽게 섞었다고 볼 수 있다.
성능 최적화 관점에서의 인사이트
위에서도 적었던것 처럼 CSR로 내부 이동을 할 때, 미리 몇 페이지정도를 json데이터를 만들어 불필요한 api요청을 제거하고 빠른 페이지 전환을 시도했습니다. (일단 이 과정은 vanilla에서만 진행했습니다. React에서도 적용하려했는데.. test오류로 인해 과제 제출 시간에 대한 압박이 너무 커서.. 중간에 포기하였습니다 ㅠㅠ)
// static-site-generater.js
// 홈페이지 생성
const homeResult = await render("/");
fs.writeFileSync("../../dist/vanilla/index.html", homeResult.html);
fs.writeFileSync("../../dist/vanilla/index.json", JSON.stringify(homeResult.initialData));
// 상품 상세페이지들 생성
const { getProducts } = await vite.ssrLoadModule("./src/api/productApi.js");
const { products } = await getProducts();
for (const product of products) {
const productResult = await render(`/product/${product.productId}/`);
const productDir = `../../dist/vanilla/product/${product.productId}`;
fs.mkdirSync(productDir, { recursive: true });
fs.writeFileSync(`${productDir}/index.html`, productResult.html);
fs.writeFileSync(`../../dist/vanilla/product/${product.productId}.json`, JSON.stringify(productResult.initialData));
}
위 코드처럼 일단 정적페이지와 함께 initialData를 json형태로 만들었습니다. html은 최초 진입 및 새로고침 시 사용하게 되고 json은 내부 전환시 사용하게 됩니다.
// HomePage.js의 onMount
onMount: async () => {
const state = productStore.getState();
// ✅ 이미 SSR 하이드레이션 된 경우 → 패스
if (state.products && state.products.length > 0) return;
// ✅ SSG JSON 먼저 시도
const staticData = await loadInitialData("/");
if (staticData) {
hydrateStores(staticData); // 정적 데이터로 상태 복원
return;
}
// ✅ 없으면 API fallback
loadProductsAndCategories();
}
// ProductDetailPage.js의 onMount
onMount: async () => {
const state = productStore.getState();
const currentId = state.currentProduct?.productId;
// 같은 상품이면 패스
if (currentId === router.params.id) return;
// ✅ SSG JSON 먼저 시도
const staticData = await loadInitialData(`/product/${router.params.id}/`);
if (staticData) {
hydrateStores(staticData); // 정적 데이터로 상태 복원
return;
}
// ✅ 없으면 API fallback
loadProductDetailForPage(router.params.id);
}
위처럼 각 페이지가 mount될 때 하이드레이션이 되어있는 정보가 있는지 검사합니다. 상세페이지의 경우 한 번 접속하면 하이드레이션 정보가 남아있게되므로 id값까지 비교하여 동일한 값인지 확인합니다. 그렇지 않으면 계속 같은 상세페이지 정보를 보게 되는 문제가 있습니다.
그리고 만약 스토어에 정보가 없다면 빌드된 json 데이터가 있는지 확인합니다. 그 코드는 다음과 같습니다.
// loadinitialData.js
const BASE_PATH = "/front_6th_chapter4-1/vanilla";
export const loadInitialData = async (pathname) => {
try {
let jsonPath;
// basePath 제거
const relativePath = pathname.replace(BASE_PATH, "") || "/";
if (relativePath === "/" || relativePath === "") {
// 홈
jsonPath = `${window.location.origin}${BASE_PATH}/index.json`;
} else if (relativePath.startsWith("/product/")) {
// 상세
const segments = relativePath.split("/").filter(Boolean); // ["product", "85067212996"]
const productId = segments[1];
jsonPath = `${window.location.origin}${BASE_PATH}/product/${productId}.json`;
}
if (jsonPath) {
const res = await fetch(jsonPath, { cache: "force-cache" });
if (res.ok) {
const data = await res.json();
return data;
}
}
} catch (e) {
console.warn("👉 Static JSON not found, falling back to API:", e);
}
return null; // JSON 없으면 API fallback
};
입력받은 경로에 해당하는 json을 찾는 함수입니다. 없다면 null을 반환합니다.
그래서 위 함수를 토대로 만약 store가 비어있는데 정적빌드된 데이터가 있다면 또 다시 하이드레이션을 하게 됩니다.
// hydrateStore.js
import { PRODUCT_ACTIONS, productStore } from "../stores";
export const hydrateStores = (data) => {
if (data.products) {
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: {
products: data.products,
categories: data.categories,
totalCount: data.totalCount,
loading: false,
status: "done",
error: null,
},
});
}
if (data.currentProduct) {
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT,
payload: data.currentProduct,
});
if (data.relatedProducts) {
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS,
payload: data.relatedProducts,
});
}
}
};
이렇게 데이터를 하이드레이션 시키고, 만약 정적빌드된 데이터조차 없다면 그때서야 data fetch를 시도하는 구조입니다.
위 과정을 통해 미리 빌드된 페이지로의 이동은 로딩없이 매우 빠르고, 마치 next js의 동작 과정과 매우 비슷한 효과를 낼 수 있었습니다.
학습 갈무리
Q1. 현재 구현한 SSR/SSG 아키텍처에서 확장성을 고려할 때 어떤 부분을 개선하시겠습니까?
제가 구현한 SSG에서 좀 더 확장성있게 구현해보려고 한 부분은 무한스크롤로 데이터를 새롭게 fetch를 받을 때, 미리 여분의 상품 상세 데이터를 20개 정도 구비하여 PREFETCH_DATA 이런식으로 올려두고, 상세페이지 이동을 하는 시점에 데이터를 따로 받아오는것이 아닌 prefetch data로 또한번 빠른 이동을 할 수 있겠다는 생각이 들었습니다.
그렇다면 즉시 전환되는 화면을 통해 UX를 증가시킬 수 있고, 여러번 왔다갔다 해도 받아둔 데이터를 재활용하므로 api 부하가 감소할 수 있습니다.
하지만 메모리 사용량이 증가되고 데이터 최신성의 문제가 있을 수 있으며 때에 따라서 오히려 네트워크 낭비가 될 수도 있습니다.
Q2. Express 서버 대신 다른 런타임(Cloudflare Workers, Vercel Edge Functions 등)을 사용한다면 어떤 점을 수정해야 할까요?
서버 대신 다른 런타임을 사용하면 로컬환경에 직접 접근할 수 없으므로 정적데이터를 클라우드 스토리지에 배포해두고 해당 스토리지에서 가져오는 방식을 취할 수 있습니다.
Q3. 현재 구현에서 성능 병목이 될 수 있는 지점은 어디이고, 어떻게 개선하시겠습니까?
이번 구현 과정에서도 많이 고민했던 부분인데, 모든 상품을 빌드해두려면 빌드 시간 자체가 오래걸리게 됩니다.
그래서 아까 위에서 언급했던것처럼 최근 데이터만 정적으로 빌드하고 나머지는 prefetch 개념을 도입할 수 있을것 같습니다.
이때 ISR을 적용하여, 빌드되어있는 데이터의 최신성을 보장해준다면 더욱 좋다고 생각이 듭니다.
또한 캐시된 데이터의 만료 정책을 구성하여 메모리 누수를 막을 수 있을것으로 생각됩니다.
Q4. 1000개 이상의 상품 페이지를 SSG로 생성할 때 고려해야 할 사항은 무엇입니까?
아마도 빌드시간이 굉장히 오래 걸릴것으로 생각됩니다. 만약 한번에 많은 데이터를 빌드해야한다면 백그라운드에서 빌드 될 수 있도록 합니다. 하지만 이미 1000개의 데이터가 있고, 점차 늘려가는 상황이라면 증분 빌드를 활용할 수 있습니다. 변경된 페이지만 빠르게 다시 빌드하는 방식으로 전체 대신 부분 갱신을 노리는 방식입니다. 또한 ISR방식은 증분빌드 방식에 데이터 최신성까지 유지시켜주는데, 설정해둔 주기마다 페이지를 백그라운드에서 갱신하고 CDN 캐시를 무효화 시켜 다시 최신성을 유지하게 해줍니다. 부분 재빌드를 구현하는 방식으로는 변경된 데이터만 감지해서 빌드하거나, Webhook 기반의 온디맨드 빌드를 도입해 특정 상품만 재생성하는 방법이 있습니다. 이렇게 하면 전체 빌드 시간이 길어지더라도 확장성 있게 대응할 수 있습니다.
Q5. Hydration 과정에서 사용자가 느낄 수 있는 UX 이슈는 무엇이고, 어떻게 개선할 수 있을까요?
만약 하이드레이션해야할 데이터가 너무 많으면 해당 하이드레이션이 완료될 때 까지 JS가 동작하지 않는것처럼 느껴질 것입니다. 그렇게 되면 사용자 인터랙션이 동작하지 않을 것이고, 이는 사용자로 하여금 페이지가 고장난 것 같은 느낌을 주어 UX에 치명적일 수 있습니다. 따라서 너무 많은 데이터를 한 번에 내려주지 않고, 페이지 진입에 꼭 필요한 정도만 보내주는 것이 좋습니다. 또한 하이드레이션이 되는 시간동안 로딩 인디케이터, 스켈레톤UI 등을 통해 마치 아직은 인터랙션 하지 마세요!~ 하는 느낌을 주어 UX를 올릴 수 있을것이다. 또한 Progressive Enhancement를 적용하여, 가장 중요한 부분 (블로그로 치면 글 내용 등)은 바로 보여주고, 그 다음 글씨 크기나 색상등 덜 중요한 부분을 적용시킨 다음, 마지막으로 없어도 페이지 자체의 본질은 유지되는 일들 (좋아요, 댓글 등)을 수행할 수 있도록 만들면 그래도 사용자가 필요한 일은 할 수 있어 UX가 높아진다.
Q6. 이번 과제에서 학습한 내용을 실제 프로덕션 환경에 적용할 때 추가로 고려해야 할 사항은?
정적페이지라면 실제 서버응답시간이나 CPU부하 보다는 빌드 및 배포, 캐시 등의 모니터링이 필요할 것이다. 상품의 수가 많아질수록 빌드 시간이 매우 오래걸릴 수 있고, 실패한 페이지가 존재할 수 있으므로 로그로 남겨야 한다. 또한 총 빌드시간을 기록하여 다음 배포시 유리한 전략을 채택해야한다. 또한 캐시된 데이터가 언제 무효화 되었는지 기록하여 데이터의 최신성을 관리해야한다. 그리고 캐시가 만료된 데이터등이 존재할 때, API Fallback을 통해 직접 서버에서 데이터를 로드해줄 필요가 있고, 아예 사라져버렸다면 404페이지를 보여주거나 에러메시지를 띄워주는 전략을 사용할 수 있다. 또한 A/B테스트를 적용한다면 빌드시점에 두 가지 정적 버전을 모두 만든 뒤, 사용자마다 다른 페이지를 주는 방법, 혹은 정적페이지는 그대로 내려주되 JS에서 사용자별로 다른 UI렌더하도록 하는 방식도 있을 수 있다. 목적은 더 나은 방안은 무엇인지 비교해야하므로 꼭 로깅체계를 갖추고 데이터를 모아야 한다. 정적페이지는 보안에 안전할 것 같지만 그렇지 않다. XSS(cross-site scripting)은 사용자 입력이 들어가면 스크립트같은 악성 코드도 함께 유입될 수 있는데, 리액트에서 dangerouslySetInnerHTML을 사용하는 경우 발생할 수 있고, 빌드 단계에서 사용자 입력 데이터에 악성 코드가 유입된다면 escape처리 되도록 하는것이 중요하다. 또한 CSP(content security policy)로 막을 수 있는데, 이는 브라우저가 어떤 자원을 실행해도 되는지 정책을 정해두고 내 서버 혹은 지정된 CDN에서 온 스크립트만 허용하는 방식이 있다. 또한 정적빌드된 json안에 민감한 데이터가 포함되면 그대로 노출이 되므로 꼭 화면 표시와 관련된 데이터만 포함하는것이 좋다.
Q7. Next.js 같은 프레임워크 대신 직접 구현한 SSR/SSG의 장단점은 무엇인가요?
일단 학습 효과 면에서 next의 구현 원리까지 살펴보는 등 스스로 궁금증을 많이 자아내는 과제였다. 그리고 하이드레이션과 정적 데이터 구성등을 미리 구현해보고, json데이터를 통한 빠른 화면전환을 구현해보는 등 스스로 깊은 이해를 했다고 느껴진다. 그리고 내가 직접 구현한 코드인 만큼 prefetch방식이나 여러가지 적용하고 싶은 방식을 자유롭게 구성할 수 있다는 점에서 장점이 있다고 생각한다. 하지만 개발은 대부분 혼자서 하는것이 아니고, 유지보수가 제일 큰 비용이 드는 부분인 만큼 직접 구현한 SSR/SSG보다는 next와 같은 인기있는 프레임워크를 사용하는것이 더욱 좋다고 생각한다. 생태계 면에서 매우 큰 next는 활용할 수 있는 인원도 손쉽게 구할 수 있고, 커뮤니티도 매우 커서 어려움을 쉽게 극복해 나갈 수 있다는 장점이 있다.
Q8. Next.js 를 이용하여 SSG 방식으로 배포하려면 어떻게 해야 좋을까요?
과거 Page Router방식에서는 getStaticProps로 빌드시 데이터를 불러와 html과 json데이터를 생성했고, getStaticPaths로 동적 라우트의 모든 경로를 미리 지정했다. 하지만 Next.js 13이후 도입된 App Router에서는 generateStaticParams로 빌드 시 정적으로 생성할 경로를 반환하고, server component내부에서 fetch를 실행하면 기본적으로 """cache: 'force-cache'"""가 설정되어 빌드 시 데이터를 불러와 정적으로 캐시하게 된다. 이렇게 빌드된 파일은 CDN과 같은 정적 호스팅에 올려두는것이 좋다. vercel에 배포한다면 ISR을 지원받을 수 있고, S3+CloudFront를 이용한다면 빌드 후 s3업로드 시 CloudFront의 캐시를 무효화 시켜줘야 한다. 만약 vercel처럼 ISR을 쓰려면 next server 와 ISR핸들러를 배포하여 사용해야한다. s3+CloudFront조합은 저렴하고 안정적이며 데이터가 자주 바뀌지 않을때 유리하고, Vercel은 ISR 지원이 되어야하거나 배포 자동화가 필요한 경우 유리하다.
코드 품질 향상
자랑하고 싶은 구현
이미 위에서도 계속 언급해왔던 빌드시 json데이터를 저장하여 로딩없는 빠른 화면 전환을 한 부분을 자랑하고 싶습니다.. 이미 위에서 코드를 다 자랑했기때문에 이곳에 다시 언급하진 않겠습니다..😅😅
개선하고 싶은 부분
해당 프로젝트가 상세페이지가 상당히 많으면서 무한스크롤로 구현되어 있으므로 prefetch 방식을 도입하고 싶었습니다. 최초에 20개 데이터 + 다음페이지 20개 데이터까지 미리 가져와서 INITIAL_DATA, PREFETCH_DATA 이렇게 가져오고, 미리 상세페이지까지 데이터로 가지고 있는다면 사용자 입장에서 계속하여 로딩없는 빠른 화면을 볼 수 있을거라고 생각합니다.
만약 상세데이터를 모두 가지고 있기 어렵다면 뷰포트 앞뒤로 몇 개의 데이터만 prefetch 받는 전략을 쓸 수 있을것같습니다.
또한 무한 스크롤에서 문제가 되는 DOM의 거대화를 막기 위해 가상스크롤링을 적용하여 특정 구간만큼만 UI로 가지고 있고 나머지는 데이터로만 가지고 있는 전략을 취해볼 수 있을것 같습니다.
리팩토링 계획
데이터 페칭 로직을 직접 하드코딩하여 분기하였는데, 그 방식보다 라우트 핸들러 방식을 사용하여 라우트별로 fetchData, hydrateStore, getPageTitle등을 모아놨더라면 더욱 깔끔한 코드가 될것 같습니다.
// main-server.tsx - 기존
if (path === "/") {
initialData = await fetchProductsDataSSR(query);
pageTitle = "쇼핑몰 - 홈";
productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: {
products: initialData.products,
categories: initialData.categories,
totalCount: initialData.totalCount,
loading: false,
status: "done",
error: null,
},
});
} else if (path === "/product/:id/") {
initialData = await fetchProductDataSSR(params.id);
pageTitle = initialData?.currentProduct?.title ? `${initialData?.currentProduct?.title} - 쇼핑몰` : "쇼핑몰";
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT,
payload: initialData.currentProduct,
});
if (initialData.relatedProducts) {
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS,
payload: initialData.relatedProducts,
});
}
}
기존은 위처럼 라우트마다 비슷한 패턴이 반복되는 구조입니다. 또한 새 라우트가 추가되면 이 파일까지 수정해줘야 합니다.
그래서 아래와 같이 개선할 수 있을것 같습니다.
// 개선된 방식
interface RouteHandler<T = any> {
fetchData: (params: any, query: any) => Promise<T>;
hydrateStore: (data: T) => void;
getPageTitle: (data: T) => string;
}
const routeHandlers: Record<string, RouteHandler> = {
"/": {
fetchData: fetchProductsDataSSR,
hydrateStore: (data) => productStore.dispatch({
type: PRODUCT_ACTIONS.SETUP,
payload: {
products: data.products,
categories: data.categories,
totalCount: data.totalCount,
loading: false,
status: "done",
error: null,
},
}),
getPageTitle: () => "쇼핑몰 - 홈"
},
"/product/:id/": {
fetchData: (params) => fetchProductDataSSR(params.id),
hydrateStore: (data) => {
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT,
payload: data.currentProduct,
});
if (data.relatedProducts) {
productStore.dispatch({
type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS,
payload: data.relatedProducts,
});
}
},
getPageTitle: (data) => data?.currentProduct?.title
? `${data.currentProduct.title} - 쇼핑몰`
: "쇼핑몰"
}
};
// 렌더링 로직 단순화
export const render = async (url: string) => {
const matched = router.match(url);
if (!matched) return { head: "<title>404</title>", html: NotFoundPage(), initialData: null };
const { path, params, component, query } = matched;
const handler = routeHandlers[path];
if (!handler) {
throw new Error(`No handler found for route: ${path}`);
}
const initialData = await handler.fetchData(params, query);
handler.hydrateStore(initialData);
const pageTitle = handler.getPageTitle(initialData);
return {
head: `<title>${pageTitle}</title>`,
html: renderToString(
<QueryProvider initialQuery={query}>
<PageComponent />
</QueryProvider>
),
initialData: { ...initialData, query },
};
};
이렇게 하면 각 라우트의 로직이 분리되고 새 라우트가 추가되어도 라우트핸들러에만 추가하면 되어 main-server를 수정할 일이 없습니다.
학습 연계
다음 학습 목표
SSG에 대한 이해와 확장성은 많이 살펴 보았는데 SSR에 대한 성능 최적화 방식등은 많이 살펴보지 않은것같습니다. 실제로 SSR이 다시금 많이 쓰이는 만큼 데이터 페칭 전략이라던지 어느것을 SSG, CSR, SSR로 해야 유리한지를 더욱 면밀하게 공부해 보도록 하겠습니다.
실무 적용 계획
그동안 nextJS를 활용하여 단순히 SSG페이지를 많이 만들어왔는데, 이제는 단순히 SSG로만 만드는 것이 아닌 상황에 알맞게 SSG/SSR/ISR을 고민하여 설계하도록 하고, 하이드레이션 최적화를 적용해 초기 로딩 시 사용자 인터랙션 차단을 최소화하거나 스켈레톤UI등을 도입하여 UX를 개선해 나갈 계획입니다.
리뷰 받고 싶은 내용
이번 과제중에 e2e테스트를 진행하면 playwright에서도 간헐적 실패가 존재하고, 배포후 CI를 통한 검사에서도 같은 코드에서 어쩔땐 실패 어쩔땐 성공하는 운에 맡기는? 현상이 자주 발생하였습니다.
내 코드가 문제인지, 테스트의 오류인지 초반엔 알 수 없어서 테스트코드에 대한 불편함이 더 많았던것같습니다.
이런 경우 오히려 테스트코드가 개발 유지보수 비용을 늘린다고 생각이 드는데, 어떤 이유로 실패했을까요..?
위 사진과 같은 오류였고, 제 코드 기준으로 로컬에서는 10번 시도하여 8번정도가 실패하였고, CI에서는 50%정도 확률로 통과가 되었습니다.
디스코드와 ZEP에서 보니 저 말고도 이러한 문제를 겪은 사람이 꽤 많았던것 같은데, 아무도 원인을 찾지 못했습니다.. ㅠㅠ
과제 피드백
수고했습니다. 이번 과제는 SSR과 SSG를 직접 구현해보면서 렌더링 전략의 차이점과 성능 최적화 방법을 체험하는데 목적이 있었습니다.
NextJS 동작 원리를 궁금해서 직접 파보다가 __NEXT_DATA__까지 발견하고 JSON 저장 방식을 구현한 부분 잘했어요. 과제가 의도한 바가 단순히 이론의 내용이 아니라 아주 구체적이 구현 부분등을 발견하면서 왜 이렇게 했을까에 대해 온전히 이해하는데 있었는데 잘 해주었네요.
HTML과 JSON을 분리해서 SSG + CSR 장점을 섞은 하이브리드 방식 좋습니다. 실제 프레임워크들이 쓰는 패턴을 스스로 잘 발견했어요.
loadInitialData.js에서 정적 JSON 먼저 찾고 없으면 API fallback 하는 전략도 현실적이고 실용적이에요. 이런 점진적 향상 패턴이 실무에서도 중요한데 잘 적용했네요.
React 쪽에서 테스트 오류로 중간에 포기한 건 시간 압박이 있어서 어쩔 수 없었겠지만, Vanilla에서라도 제대로 구현해본 게 의미있었다고 생각해요. 다음에 기회 되면 한 번 더 도전해보세요.
테스트 간헐적 실패는 정말 골치아픈 문제죠. 이런 게 쌓이면 테스트에 대한 신뢰가 떨어지게 되는데요. 저도 자세히 알 수는 없지만 e2e의 경우 네트워크의 지연 속도나 타이밍 그리고 실행 순서가 꼬이는 등의 문제라 대개 초반에 대기 시간을 준다거나 모든 데이터의 로딩이 끝나고 ready가 된 이후에 실행한다거나 하는 식으로 조정해볼 수 있겠네요.
"이제는 단순히 SSG로만 만드는 것이 아닌 상황에 알맞게 SSG/SSR/ISR을 고민하여 설계하도록 하고, 하이드레이션 최적화를 적용해 초기 로딩 시 사용자 인터랙션 차단을 최소화하거나 스켈레톤UI등을 도입하여 UX를 개선해 나갈 계획입니다." 훌륭합니다.
이번 과제를 통해서 프레임워크가 왜 이렇게 만들어졌는지 직접 체험해보셨을 텐데, 이런 경험이 앞으로 성능 최적화할 때 큰 자산이 될 거예요. 수고하셨습니다. 다음 과제도 화이팅입니다!