[4팀 안소은] Chapter 4-1 성능최적화: SSR, SSG, Infra#29
Open
ahnsummer wants to merge 8 commits intohanghae-plus:mainfrom
Open
[4팀 안소은] Chapter 4-1 성능최적화: SSR, SSG, Infra#29ahnsummer wants to merge 8 commits intohanghae-plus:mainfrom
ahnsummer wants to merge 8 commits intohanghae-plus:mainfrom
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
과제 체크포인트
배포 링크
https://ahnsummer.github.io/front_7th_chapter4-1/vanilla/
기본과제 (Vanilla SSR & SSG)
Express SSR 서버
<!--app-html-->,<!--app-head-->)서버 사이드 렌더링
클라이언트 Hydration
window.__INITIAL_DATA__스크립트 주입Static Site Generation
심화과제 (React SSR & SSG)
React SSR
renderToString서버 렌더링React Hydration
Static Site Generation
아하! 모먼트 (A-ha! Moment)
AsyncLocalStorage를 통한 요청 격리의 중요성
처음에는 서버 렌더링에서
globalThis를 사용해 요청 컨텍스트를 저장했습니다. 그러다 동시 요청이 들어올 때 데이터가 섞이는 버그를 발견했고, Node.js의AsyncLocalStorage를 알게 되었습니다. 이를 통해 각 요청이 독립적인 컨텍스트를 가질 수 있다는 것을 깨달았습니다.withLifecycle의 서버/클라이언트 분기
라이프사이클을 HOC 패턴으로 추상화하면서, 서버에서는 async 함수를, 클라이언트에서는 동기 함수를 반환해야 한다는 것을 알게 되었습니다. 서버는 데이터 프리페칭을 기다려야 하지만, 클라이언트는 즉시 렌더링 후 비동기로 업데이트하는 것이 UX에 더 좋기 때문입니다.
자유롭게 회고하기
구현하면서 집중한 부분들
1. 요청별 컨텍스트 격리
서버 사이드 렌더링의 핵심은 동시 요청 처리입니다. 각 요청이 독립적인 상태를 유지해야 하므로
AsyncLocalStorage를 활용했습니다.2. 라이프사이클 관리
기존 클라이언트 전용 라이프사이클을 서버에서도 동작하도록 확장했습니다.
withLifecycleHOC를 통해:onMount를 실행하고 데이터를initialData에 저장window.__INITIAL_DATA__에서 초기 데이터 복원 후 필요시 재요청3. 메타태그 동적 생성
상품 상세 페이지의 경우, SEO를 위해 동적 메타태그가 필수입니다.
updateInitialData를 통해 렌더링 중에 메타 정보를 수집하고, 서버에서 HTML에 주입하는 방식을 구현했습니다.4. Universal Code
가능한 한 많은 코드를 서버/클라이언트에서 공유하도록 설계했습니다:
Router: 동일한 라우팅 로직withLifecycle: 환경에 따라 다른 동작이지만 동일한 인터페이스createStorage: 서버에서는 no-op, 클라이언트에서는 localStorage 사용5. Static Site Generation (SSG)
SSR 인프라 100% 재사용: 기존
render(),runWithContext(),withLifecycle등 모든 SSR 로직을 그대로 사용했습니다. 별도의 SSG 전용 코드를 작성하지 않고, 빌드 타임에 SSR을 실행하는 방식으로 구현했습니다.Global fetch 폴리필: MSW는 실제 네트워크 요청을 보내려고 해서 샌드박스 환경에서 실패했습니다. 대신
globalThis.fetch를 직접 폴리필해서items.json데이터를 반환하도록 구현했습니다.340개 페이지 자동 생성:
items.json에서 상품 ID를 추출해 각 상품마다/product/:id/index.html생성. 홈페이지와 404 페이지까지 총 342개의 정적 HTML 파일이 생성됩니다.SEO 최적화 완료: 각 상품 페이지는 동적 메타태그가 포함되어 있고, 완전히 렌더링된 HTML을 제공하므로 검색 엔진 크롤러가 내용을 바로 인덱싱할 수 있습니다.
아쉬운 부분들
1. Store의 서버 격리 미흡
productStore,cartStore등은 싱글톤으로 동작합니다. 서버 환경에서는 각 요청마다 독립적인 store 인스턴스가 필요할 수 있는데, 현재는initialData를 통해 클라이언트에 전달하는 방식으로만 해결했습니다. 동시 요청이 많아지면 race condition 가능성이 있습니다.2. 에러 바운더리 부재
서버 렌더링 중 에러 발생 시 적절한 fallback이 없습니다. 현재는 try-catch로 잡힌 에러만 처리하고 있어, 예상치 못한 에러가 발생하면 서버가 크래시할 수 있습니다.
3. 성능 최적화 여지
기술적 도전과 해결
1. AsyncLocalStorage 동작 원리 이해하기
문제 상황:
초기에는 각 요청의 컨텍스트를
globalThis에 저장했는데, 동시에 2개의 요청이 들어오면 나중 요청이 먼저 요청의 데이터를 덮어쓰는 문제가 발생했습니다.해결 방법:
AsyncLocalStorage는 비동기 콜백 체인 전체에서 격리된 스토리지를 제공합니다.async_hooks모듈의 실행 컨텍스트 추적을 활용해, 같은 요청에서 파생된 모든 비동기 작업이 동일한 컨텍스트를 공유하도록 했습니다.핵심 포인트:
asyncLocalStorage.run()으로 시작된 비동기 체인 내부의 모든 함수는 같은 store에 접근await,Promise,setTimeout등을 거쳐도 컨텍스트가 유지됨2. Universal Router - 하나의 코드, 두 가지 환경
도전 과제:
클라이언트의
window.location과 서버의req객체는 완전히 다른 API입니다. 하지만 라우팅 로직은 동일해야 합니다.구현 전략:
환경 감지 레이어를 통해 통일된 인터페이스를 제공했습니다.
이렇게 하면 Router 클래스의 다른 메서드들은 환경에 상관없이
getPathname(),getOrigin()만 호출하면 됩니다.쿼리 파라미터 통합:
Express는
req.query로 객체를 주지만, 클라이언트는location.search로 문자열을 줍니다.URLSearchParams로 통일했습니다.서버에서는
context.search객체를 받아서 다시 쿼리 문자열로 변환해URLSearchParams에 넣으므로, 파싱 로직이 완전히 동일하게 동작합니다.3. withLifecycle의 서버/클라이언트 이중 동작
설계 결정:
withLifecycle은 동일한 HOC지만, 반환하는 함수의 동작 방식이 환경에 따라 달라야 합니다.왜 이렇게 분기했나:
await로 데이터 로딩 완료를 기다림await없이 비동기 실행4. initialData를 통한 Hydration
Hydration 불일치 방지:
서버에서 렌더링한 HTML과 클라이언트의 첫 렌더링이 다르면 React Hydration Error와 비슷한 문제가 발생합니다.
해결 방법:
클라이언트는 페이지 로드 시
window.__INITIAL_DATA__가 있으면 API 재호출을 건너뜁니다:메타태그 동적 생성도 동일한 패턴:
렌더링 중에
initialData.meta를 설정하면, 서버가 이를 읽어서 HTML<head>에 주입합니다.5. createStorage의 환경별 no-op 처리
문제:
localStorage는 브라우저에만 존재하므로, 서버에서 실행하면 에러가 발생합니다.해결:
이렇게 하면
cartStorage.get()같은 코드를 서버/클라이언트 양쪽에서 안전하게 호출할 수 있습니다.6. SSG - Global fetch 폴리필로 네트워크 격리
문제:
MSW의
setupServer를 사용하려 했으나, 실제 네트워크 요청을 시도해서 샌드박스 환경에서EPERM에러가 발생했습니다.해결:
빌드 타임에는 네트워크가 필요 없습니다.
globalThis.fetch를 직접 폴리필해서items.json데이터를 반환하도록 구현했습니다.왜 이 방법이 좋은가:
items.json데이터를 직접 사용하므로 SSR handlers와 로직이 완전히 일치SSG와 SSR 코드 100% 재사용:
SSG는 본질적으로 "빌드 타임에 실행하는 SSR"입니다. 기존 SSR 인프라를 전혀 수정하지 않고, fetch만 폴리필해서 340개 페이지를 자동 생성했습니다.
배운 점
window체크만으로 대부분의 환경 차이를 흡수할 수 있음getStaticPaths와getStaticProps의 편리함을 실감리뷰 받고 싶은 내용
1. AsyncLocalStorage와 Store의 관계
현재
AsyncLocalStorage로 요청 컨텍스트(origin,pathname,params,initialData)는 격리했지만,productStore자체는 여전히 전역 싱글톤입니다.서버 렌더링 중에
productStore.dispatch()를 호출하는데, 동시에 두 요청이 다른 상품을 조회하면 store 상태가 섞일 수 있습니다.질문:
productStore도AsyncLocalStorage에 넣어서 각 요청마다 독립적인 인스턴스를 만들어야 할까요?initialData에 직접 데이터를 담는 방식으로 리팩토링해야 할까요?createStore()를 새로 호출하는 게 정답일까요?현재는 다행히 렌더링이 빠르게 끝나서 실질적인 충돌이 없지만, 부하 테스트를 하면 문제가 발생할 것 같습니다.
2. Router의 params 접근 방식
Router에서
params를 가져올 때 두 가지 방법을 혼용하고 있습니다:문제점:
서버에서는 Express의
req.params를 컨텍스트에 저장했지만, 클라이언트에서는 자체 정규식 매칭으로 추출합니다. 두 방식의 결과가 항상 일치한다고 보장할 수 있을까요?특히 인코딩 문제(
/product/한글같은 URL)나 특수문자가 있을 때 차이가 생길 수 있을 것 같습니다. 서버에서도 Express Router가 아닌 자체 정규식으로 통일해야 할까요?3. initialData의 Hydration 타이밍
현재
window.__INITIAL_DATA__를 확인하는 로직이 각 서비스에 흩어져 있습니다:질문:
main.js에서 앱 시작 시window.__INITIAL_DATA__를 읽어서 모든 store를 한 번에 초기화하는 게 더 깔끔하지 않을까요?status === "done"체크를 하는데, 이게 정말 안전할까요?window.__INITIAL_DATA__를 사용한 후에는 삭제해서 메모리를 해제하는 게 좋을까요?