Rendering과 Isomorphic Components
2024년 5월 14일, 프론트엔드 챌린지 강의 요약
SSR(Server-Side Rendering)
웹 사이트 패러다임에 큰 변화를 가져온 'CSR(Client-Side Rendering)'
그러나, 모든 기술에는 양면이 있다.
SSR 동작 과정
Server-side Rendering
Generate HTML to be rendered on the server in response to a user request
www.patterns.dev
SSR 정의
제공하고자 하는 웹 서비스의 화면을 서버 측에서 그리는 방법
웹 서비스는 화면으로 보여주어야 하며, 그것을 그리기 위해서 클라이언트 단에서 그리냐 서버 단에서 그리냐의 차이
쉽게 비유하자면, 배달 요리가 있다.
배달을 통신으로, 음식 재료를 페이지를 그리기 위한 Asset으로, 음식을 조리하는 것을 브라우저가 렌더링하는 것으로 보자. 비조리형 포장으로 배달을 시키게 되면, 재료들을 불만 올리면 먹을 수 있는 형태로 포장하는 것을 생각하면 'CSR'과 구조적으로 동일하다.
식당에서 요리를 조리한 다음 배달하는 방식은 'SSR' 방식과 동일하다.
`식당(서버)`에서 `음식을 조리(렌더링)`가 완료된 다음 `손님(브라우저)`에 `배달(응답)`
SSR은 CSR에 비해 SEO(Search Engine Optimization)에 유리?
CSR - 배달 전문 매장 | SSR(SSG) - 홈 매장 |
검색 엔진 크롤러가 노크 한다 | 검색 엔진 크롤러가 노크 한다 |
문이 열린다 (페이지 요청) | 문이 열린다 (페이지 요청) |
빈 집 (첫 페이지) | 주인이 검색 엔진을 맞이하며 인사 (첫 페이지) |
조사 못하고 크롤러 퇴장 | 조사를 마친 크롤러 퇴장 |
JS 파일 다운로드되며 페잉지 로드 (뒷북 | |
→ 검색 노출 안 됨 | → 검색 노출 |
크롤러 입장에서 "렌더링(조리) 되지 않은 정보값이 없는 페이지를 보고 색인(Indexing, 검색 엔진 사전에 등록) 해둘 수 없다."는 것을 이해하면 SSR, SSG가 SEO에 유리한 지 알 수 있게 된다.
검색이 된다는 것은 어딘가 DB에 정보가 적재되어 있다는 의미이고, 정보가 적재되어 있다는 것은 가져갈 원본 정보가 있다는 뜻이다.
SSR은 CSR에 비해 서버 부하가 크다?
홈 매장에 손님이 많아지면 그 만큼 운영이 힘들어 지지만, 배달 전문은 미리 준비해둔 재료들을 포장 용기에 담아 전해주기만 하면 되기 때문에 덜 힘들 수 있다.
이것처럼, 'SSR'은 서버 측에서 처리해야 하는 렌더링 로직 때문에 반드시 응답을 처리해줄 서버가 필요하다.
'CSR Only' 서비스보다 서버가 할 일이 많고 바빠, 트래픽이 많을 경우에 응답이 느려지거나, 메모리가 한도를 초과해 서버가 동작을 멈추기도 한다.
'CSR only' 서비스의 경우 미리 빌드해둔 HTML, JS, CSS 파일을 S3 등의 저장소에 올려두고, Cloudfront 등의 CDN을
붙여 별도의 컴퓨팅 자원 없이 정적으로 제공 가능하다.
서버에서 렌더링하는 로직이 없고, 동일한 응답을 돌려주어 캐싱이 용이한 특징 덕분에 'SSR'보다 많은 트래픽을 효과적으로 받는다.
정말 중요하고 배포 환경을 구성하기 위해서 'CDN'에 대해 꼭 공부해야 한다.
CSR과 SSR의 성능 차이
CSR
- 서버에 최초 GET 요청
- 빈 HTML
- JavaScript, CSS 등 Asset 다운로드
- JavaScript 파일 실행
- React 실행 후 React에 의한 렌더링
- 화면 출력
화면을 그리기 시작하는 첫 번째 피드백인 'FCP(First Contentful Paint)'는 빠르게 렌더링되지만, 실제로 사용자가 상호작용할 수 있는 'TTI(Time To Interact)'까지 걸리는 시간이 더 필요하다. 즉, 초기 렌더링은 빠르지만, 실제로 이를 사용하기까지 시간이 더 필요하다는 이야기이다.
정적 생성(Static Site Generation)
- 서버에 최초 GET 요청
- CDN
- 미리 완성된 HTML 응답
- 화면 출력
'SSR' 로직도 주간에 서버 로직이 들어가는 것 외에는 같다.
화면을 처음으로 그리는 'FCP(First Contentful Paint)'까지 시간이 조금 걸리지만, 'TTI(Time To Interact)'까지 걸리는 시간이 짧다. 즉, 초기 렌더링은 CSR보다 오래 걸리지만, 초기 렌더링을 하고 나서 빠른 시간 내에 사용자가 사용할 수 있다.
이와 관련된 UX 관련 성능 지표(Metrics)들 중 중요하게 다뤄지는 지표들을 'Core Web Vitals'라고 한다.
→ Lighthouse 등 도구에서 측정 가능
- TTFB(Time to First Byte) → 알맹이 없이 출력
- 어떤 리소스를 요청하고 난 뒤, 해당 요청에 대한 첫 번째 바이트가 도착하기 까지 걸리는 시간
- FCP(First Contentful Paint) → 알맹이를 느리게
- 텍스트, 이미지 등 페이지가 로드되기 시작한 시점으로부터, 콘텐츠 일부가 화면에 렌더링 되기 시작한 시점의 시간 측정
- FCP가 빠르면 사용자가 ‘콘텐츠가 로드되었음’을 인지하고 서비스를 더 빠르게 이용 가능
- 1.8초 이하면 좋은 점수
- TTI(Time to Interactive) → 알맹이가 많으면 느려짐
- 앱이 사용자와 상호작용하기에 준비가 된 시점 (화면이 그려지는 것과는 거의 무관)
- 자바스크립트 이벤트가 걸린 버튼을 눌렀을 때, 해당 버튼이 제대로 이벤트 리스너에 연결된 함수를 호출하는 최초의 시점
렌더링 성능 측면에서 CSR과 SSR
CSR | SSR |
빈 화면 (TTFB → Fast, TTI → Slow) | 느린 응답 (TTFB → Slow, TTI → Fast) |
번들 크기가 커질수록 두드러짐 | 싱글 스레드 'renderToString' 메서드의 특징 상 최초 응답이 늦어질 가능성 |
코드 스플리팅, 번들 압축, 트리 쉐이킹의 중요성 | 번들 크기가 커질 경우, TTI까지 속도가 느려져 CSR과 마찬가지 단점 보유 |
Next.js를 프레임워크로 만드는 것
'Next.js'는 현재 많은 다운로드 수를 기록하고 있으며, 이 배경에는 활발한 업데이트 진행이 있다. 실제로 프로덕션 레벨에서 'Next.js'를 도입하는 경우가 늘고 있다.
그러나, 일반적으로 'Next.js'가 어떻게 돌아가고 있는지 알 수가 없을 것이다.
이 '알 수 없음'의 의미는 금방 알아챌 수 있다.
// React에서 화면을 그리는 실질적인 코드
ReactDOM.createRoot().render(document.getElementById("root"), <App />)
이러한 코드가 눈 앞에 드러나있기 때문에 이를 지울 경우에 컴포넌트가 렌더링되지 않을 뿐, HTML 자체는 정상적으로 로드된다. 하지만, 'Next.js'에서는 너무 많은 것들이 숨겨져 있고, React를 잘 알아도 코드가 어디서 시작하는지 확인하기 어렵다.
'react-router-dom'을 설치하지 않아도 라우팅이 알아서 일어나는 것처럼.
왜 'pages' 폴더에 라우트를 작성해야 하며, 'api' 폴더 이하 파일들은 왜 API가 될까?
'app.js', '_document.js'는 'getServerSideProps' 같은 함수들이 사용된다는 것을 어디서 어떻게 이용되는지 알 수 없다.
이것이 바로, 프레임워크의 특징이다. 라이브러리는 사용자가 라이브러리를 불러와서 사용했지만, 프레임워크는 사용자가 작성한 코드를 프레임워크가 가져가 대신 실행시켜 준다.
필기를 하는 경우, 연필과 같은 도구는 사용자가 선택하여 원하는 방식으로 사용할 수 있지만, 프린터의 경우 선택은 하나 프린터가 허용하는 방식으로 사용해야 하는 것처럼.
위에서 보면, 'Next.js'는 자유도가 React에 비해 떨어져 보인다.
라우트를 예시로, 'pages' 폴더 안에 있어야 하며, 동적 라우팅을 하기 위해서는 파일 이름을 '[slug].js' 방식으로 만들어야 한다. 또한, 라우터를 정할 수 없다.
라우터 기능은 대단하지 않으면서도 대단하다. 'Next.js'는 라우트마다 코드 스플리팅을 자동으로 해주기 때문이다.
'Next.js'는 프로덕션 레벨의 프론트엔드 앱을 만들기 위해 필요한 기능들을 미리 구현해두어 편하게 사용해 생산성을 올리기 위한 의도로 다양한 기능들을 제공하고 있으며, 프로덕션 배포 시 안정적인 서비스를 위해 권장하는 사항들이 있다.
- 가급적 캐싱 사용하기
- 프론트엔드와 백엔드를 동일한 리전에 배포하기
- 자바스크립트 번들 사이즈 줄이기
- 무거운 번들의 로드를 최대한 지연시키기
- 서비스에서 일어나는 이벤트들을 로깅하기
- 에러 핸들링 하기
- 404, 500 페이지를 세팅해두기
- 웹 페이지 성능을 측정하기
- Lighthouse를 사용하여 성능 측정하기
- 크로스 브라우징 관리하기
- 이미지 최적화하기
- 폰트 최적화하기
- 스크립트 로딩 최적화하기
- Core Web Vitals 기반하여 로딩 성능 최적화하기
Universal Rendering
자바스크립트는 서버, 클라이언트 가리지 않고 실행되는 언어로, 'Universal Rendering'은 하나의 환경에서 SSR과 CSR을 함께 지원하는 것을 말한다.
React의 경우 서버 측 React(react-dom/server)에서 한 번 앱을 렌더링하고, HTML 문자열로 변환(serialization)하여 클라이언트로 전달한다. 클라이언트는 한 번 더 렌더링 하지만, 받은 결과물에 JavaScript 이벤트 리스너를 연결하는 동작을 하는데, 이를 'Hydration(수화)'라고 한다.
하나의 언어를 사용한다는 것은 이해할 수 있지만, 하나의 환경을 공유한다는 것은 어떤 의미일까?
- ReactDOM API
- createRoot().render()
- hydrateRoot()
- ReactDomServer API
- renderToString(), React 컴포넌트를 직렬화하여 HTML 문자열로 변환(SSR, 구글맵 마커) / Suspense를 지원하지 않는 동기 렌더링 함수
- 동기 렌더링 함수! 자바스크립트는 싱글 스레드 언어로 비동기 작업을 계속 이벤트 루프를 돌리면서 최대한 CPU 집약적인 무거운 작업 피하기
- SSR 작업(서버 사이드 API 호출 등)을 동기 함수로 돌린다면 매우 무거운 작업이 되어 전반적인 응답 속도 하락
- renderToString() 자체를 개선하지 않으면 피할 수 없는 구조적인 문제로 Next.js 13버전부터 renderToString() 그대로 사용하지 않는 상태
- renderToString(), React 컴포넌트를 직렬화하여 HTML 문자열로 변환(SSR, 구글맵 마커) / Suspense를 지원하지 않는 동기 렌더링 함수
const renderToString = concurrentFeatures
? async (element: React.ReactElement) => {
const result = await renderToStream(element) // 좁은 범위
return await resultsToString([result])
}
: ReactDOMServer.renderToString // 광범위
// renderToStream
const renderToStream = (element: React.ReactElement) =>
new Promise<RenderResult>((resolve, reject) => {
const stream = new PassThrough()
let resolved = false
const doResolve = () => {
if (!resolved) {
resolved = true
resolve(({ complete, next }) => {
stream.on('data', (chunk) => {
next(chunk.toString('utf-8'))
})
stream.once('end', () => {
complete()
})
startWriting()
return () => {
abort()
}
})
}
}
const {
abort,
startWriting,
} = (ReactDOMServer as any).pipeToNodeWritable(element, stream,
onError(error: Error) {
if (!resolved) {
resolved = true
reject(error)
}
abort()
},
onReadyToStream() {
if (!generateStaticHTML) {
doResolve()
}
},
onCompleteAll() {
doResolve()
},
})
}).then(multiplexResult)
// Next.js 최신 버전 renderToString()
async function renderToString(element: React.ReactElement) {
const renderStream = await ReactDOMServer.renderToReadableStream(ele
await renderStream.allReady
return streamToString(renderStream)
}
- renderToStaticMarkup()
- renderToString()과 비슷
- `data-reactroot`와 같이 React에서 내부적으로 사용하는 추가적인 DOM 어트리뷰트 생성 X
- 여분의 어트리뷰트를 제거하여 약간의 바이트를 절약 → 간단한 정적 페이지 생성기로 사용하고 싶은 경우 유용
<div data-reactroot="">
<h1>Hello, world!</h1>
<p>This is a static page.</p>
</div>
- Web Streams
- renderToReadableStream()
- Node.js Streams, 렌더링을 스트리밍으로
- renderToPipeableStream()
- renderToStaticNodeStream() → renderToStaticMarkup과 동일하나 스트리밍
Serialize / Hydrate
자바스크립트에서 Key-Value 쌍으로 되어 있는 자료형을 '객체(Object)'라고 부른다.
(파이썬에서는 '딕셔너리(Dictionary)')
어떤 객체를 HTTP 통신에서 body 값으로 활용하기 위해서는 `JSON.stringify()` 사용하여 문자로 변환 필요!
이러한 과정을 '직렬화(Serialize)'라고 부른다.
직렬화는 어떠한 객체의 내용을 바이트 단위로 변환한 다음, 네트워크를 통한 송수신이 가능하도록 만드는 작업이다.
객체를 그대로 송수신할 수 없어 통용 가능한 형태로 한 번 변화해주는 것으로, 이 규격으로 `JSON(JavaScript Object Notation)`, `XML(Extensible Markup Language)` 등이 사용되고 있다.
'수화(Hydrate)'는 반대되는 과정을 말한다.
자바스크립트 함수 등 JSON 포맷에 속하지 않아 `JSON.stringify()` 등을 사용할 경우에 해당하는 값이 사라지는 JSON 포맷으로 직렬화 할 수 있는 값에는 제약이 있다.
→ SSR에서는 중간에 함수가 사라지면 곤란한 상황이 많이 발생한다.
함수들이 사라지는 경우를 방지하기 위해서 'Hydrate'를 한다. 이를 사용하면, 클라이언트 측 React는 서버 측에서 React로 렌더링 된 HTML을 읽고, 이벤트 리스너가 달려 있어야 하는 DOM들의 위치를 찾아 이벤트 리스너를 달아주게 된다.
즉, 서버에서 미리 렌더링한 HTML 파일이 먼저 화면에 그려지더라도 'Hydrate'가 끝나기 전까지는 이벤트 리스너가 동작하지 않는다는 것이다.
클라이언트 앱 구동에 필요한 자바스크립트 번들 크기가 클수록 다운로드와 실행할 자바스크립트 양이 많아지기 때문에 'TTI(Time To Interactive)'는 늦어지게 된다.
서버 사이드 렌더링은 서버 측 React로 렌더링을 하여 얻은 '가상 DOM(자바스크립트 객체)'를 HTML 문자열로 변환하여 서버 응답으로 내보내는 과정이다. 만약, 클라이언트 측 인터렉션이 있다면, 'Hydrate' 과정을 통해 이벤트 리스너를 달아주는 과정이 추가된다.
서버 사이드 렌더링은 HTML 템플릿을 문자열로 읽고, 해당 문자열의 적절한 위치에 값을 넣어 문자열을 수정한 뒤, 서버 응답으로 실어서 내보내는 것이 전부이다.
한편, SSR 동작하는 코드에는 캐시처럼 보이는 코드가 있다. 이는 간단하게 만든 서버에서도 그 성능을 체감할 수 있다.
const cache: { [path: string]: string } = {};
import React from "react";
import ReactDOM from "react-dom/server";
import express from "express";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import About from "./src/pages/about";
import { getByteLengthByUTF8 } from "./src/utils";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
// 메모리 캐시이므로 서버 재시작하면 cache 날아감
const cache: { [path: string]: string } = {};
app.use("/assets", express.static("dist/assets"));
app.get("*", (req, res) => {
let indexHTML = fs.readFileSync(
path.resolve(__dirname, "dist/index.html"), "utf8");
const currentPath = req.path;
const cachedHTML = cache[currentPath];
const ssrPaths = ["/about"]; // about 페이지에만 SSR 적용
const isSSR = ssrPaths.includes(currentPath);
if (isSSR && !cachedHTML) {
const result = ReactDOM.renderToString(<About />);
const initialData = { name: "ssr" };
indexHTML = indexHTML
.replace(
'<div id="root"></div>', // replace the placeholder
`<div id="root">${result}</div>` // replace with the actual content
)
.replace("__DATA_FROM_SERVER__", JSON.stringify(initialData)); // head scrip
cache[currentPath] = indexHTML;
}
if (cachedHTML) {
console.log({ length: getByteLengthByUTF8(cachedHTML) });
return res.status(200).send(cachedHTML);
}
return res.status(200).send(indexHTML);
});
app.listen(5500, () => {
console.log("Server started on http://localhost:5500");
});
Next.js의 Pre-rendering 함수
Pre-rendering Method
Next.js로 만든 앱을 실행하기 위해서는 '빌드(Build)' 과정을 거쳐야 한다. 빌드를 하게 되면 여러 가지의 결과물을 확인할 수 있다.
결과물들에는 `getInitialProps`를 제외하고 Next.js에서 지원하는 모든 rendering 방식이 들어 있다.
- Static: 말 그대로 순수한 정적 페이지
- 서버 측 로직을 스킵하며, 서버 측에서 미리 처리하여 내려주는 props가 없음
- SSG: 정적 생성된 페이지
- `getStaticPaths` 와 `getStaticProps` 메서드의 조합으로 서버 사이드 로직 사용
- `getStaticPaths` 를 사용하여 생성 가능한 경로의 가짓수를 미리 받아 놓고, 그 다음 해당 경로의 배열을 모두 순
회하며 `getStaticProps` 로직을 타며 `props`를 생성 - 따라서 클라이언트에서 해당 페이지를 열어보았을 때는 이미 `props`가 존재하며, 이 메서드를 사용하면 결과물은 정적 페이지로 변경
- Server: SSR 혹은 API Route가 이에 해당
- 람다 기호(λ)가 그려져 있는 만큼, 클라이언트 측에서 요청이 들어오면 그 때마다 서버 로직이 개별 함수처럼 즉시 실행(Lambda) 되고 각 경로에 맞는 응답
- `getServerSideProps` 를 사용하면 SSR 페이지 생성 가능
- ISR (Incremental Static Regeneration) : 기본적으로는 SSG에 해당하나 빌드 시에만 결과물을 생성하는 SSG의
단점을 보완하기 위해 고안- 수천개 페이지 규모의 SSG 사이트가 있다면, 이를 정기적으로 업데이트하고, 페이지별로 관리하고 싶다고 하더라도 페이지를 업데이트 하려면 전체 페이지를 빌드해야만 했지만, ISR을 사용하면 이제 점진적으로 업데이트 상황을 추가(Incremental) 가능
- 이 개념을 조금 더 이해하려면 'stale-while-revalidate' 라는 캐싱 전략을 공부
Data Fetching에 사용되는 서버 사이드 메서드
- getInitialProps: 가급적 `getServerSideProps`, `getStaticProps` 사용 권장
- 그러나, `_document.js` 등 어쩔 수 없이 사용해야 하는 경우 발생
- 최초 호출 페이지만 SSR + 나머지 CSR / 모든 SSR 경로에 대해 무조건 서버 측에서 렌더링 사이에서 미묘한 성능 차이
- getServerSideProps: 페이지 컴포넌트 측으로 진입했을 때 서버 측에서 실행되며 SSR에 사용
- 페이지 진입 시, 'cookie' 등을 처리하거나, 페이지를 보여주기 전에 미리 받아와야 하는 데이터가 있을 경우 사용
- 요청 헤더에 접근할 수 있기 때문에 캐싱 등의 설정 가능
- getStaticProps: 페이지를 정적 생성(SSG)할 때 사용
- getStaticPaths: 'Dynamic Route'를 사용할 때 `getStaticProps`가 생성할 경로의 목록들을 정의하는 함수
`getServerSideProps`의 동작 과정
function Page({ data }) {
// Render data...
}
// This gets called on every request
export async function getServerSideProps({ req, res }) {
// Fetch data from external API
const res = await fetch(`https://.../data`)
const data = await res.json()// Pass data to the page via props
return { props: { data } }
}
export default Page
`getServerSideProps`를 사용하여 'Pre-rendering'을 하고 있다.
// 서버 사이드 렌더링을 구현한 것 같지만
if (isSSR && !cachedHTML) {
const preRenderedProps = await About.getServerSideProps({ res, req });
const result = ReactDOM.renderToString(<About data={preRenderedProps} />);
indexHTML = indexHTML
.replace(
'<div id="root"></div>', // replace the placeholder
`<div id="root">${result}</div>` // replace with the actual content
)
}
// 단순한 함수일 뿐
import { useRouter } from "../libs/Router";
const About = (props: any) => {
const { push } = useRouter();
return (
<div style={{ textAlign: "center" }}>
// ...
</div>
);
};
export default About;
About.getServerSideProps = async (data: any) => {
data.hi = "ssr data";
return data;
};
서버 사이드 렌더링 관련 메서드는 단순히 페이지 컴포넌트 파일 내에 `export`로 내보낸 모듈 중 특수한 이름을 가진 함수들을 'Next.js'가 코드를 가져가서 대신 사용해줄 뿐이다.
위 밑줄 친 부분이 중요하다. 'Next.js'가 프레임워크라는 증거이기 때문이다. 그렇다면, 아래의 질문을 답할 수 있을까?
- `getServerSideProps`와 같은 메서드 안에서 document 등 객체를 호출할 수 없는 이유?
- `getServerSideProps`와 같은 메서드를 페이지 컴포넌트 내에서만 호출할 수 있는 이유?
- `getServerSideProps`와 같은 메서드의 리턴값으로 직렬화 가능한 형태의 값만 넣어줘야 하는 이유?
useQuery 사용으로 컴포넌트가 자동으로 업데이트되는 이유
요즘 `Redux` 사용이 줄었지만, 그럼에도 불구하고 여전히 많은 선택을 받고 있다. 그러나, `redux-thunk`, `redux-saga`를 가지고 API Call을 제어하는 코드들을 생각하면 정신이 아득하다.
지금은 `Redux`를 반드시 거쳐가야 했지만, 지금은 이보다 `Flux 패턴` 정도 공부해야 하는 정도가 됐다.
`Redux`는 `Context-API`보다 우월하다기 보다 이를 기반으로 만들어진 것이기 때문에, `Context-API`를 무시하거나 안 좋게 보는 경향은 없애야 한다. 더 놀라운 것은, `react-thunk`는 굉장히 '지연 계산'을 구현하는 순수함을 가졌다.
export default function thunkMiddleware({ dispatch, getState }) {
return (next) => (action) =>
typeof action === "function" ? action(dispatch, getState) : next(action);
}
// createStore 함수 구현
const createStoreFromScratch = (reducer) => {
let state;
let listeners = [];
const getState = () => state;
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
};
dispatch({});
return { getState, subscribe, dispatch }; // store
};
위 코드에는, 자바스크립트의 필수 지식인 '클로저(Closure)'의 존재와 동작 과정을 파악할 수 있다.
이 후, `react-query` 라이브러리가 나타나지만, 편한 것을 알지만 `stale-while-revalidate`가 무엇인지, HTTP 스펙이 자바스크립트 로직을 어떻게 업데이트하는지 알 수 없다.
그러나, 코드를 뜯어보면 조금은 알 수 있게 된다.
// useQuery 내부의 useBaseQuery
const [observer] = React.useState(
() =>
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
queryClient,
defaultedOptions
)
);
const result = observer.getOptimisticResult(defaultedOptions);
위 코드를 간단하게 보면, `Redux`와 매우 흡사하다. `useBaseQuery` 기반으로 `useQuery`, `useMutation` 등 Hook이 만들어지고, `Mount` 될 시점에 `Observer`를 하나 생성하여 `useState`에 담는다
인자로 넘겨지고 있는 `queryClient`는 `useQueryClient`에서 왔고, 다시 내부적으로 `useContext`를 사용하고 있음을 알 수 있다. Client는 `Provider`가 `Mount` 되었을 때 생성자 함수에서 내부 `Cache` 등 값을 초기화한다.
또 다른 코드를 보자.
// queryObserver
protected onSubscribe(): void {
if (this.listeners.length === 1) {
this.currentQuery.addObserver(this)
if (shouldFetchOnMount(this.currentQuery, this.options)) {
this.executeFetch()
}
this.updateTimers()
}
}
// ...
private notify(notifyOptions: NotifyOptions): void {
notifyManager.batch(() => {
// First trigger the configuration callbacks
if (notifyOptions.onSuccess) {
this.options.onSuccess?.(this.currentResult.data!)
this.options.onSettled?.(this.currentResult.data!, null)
} else if (notifyOptions.onError) {
this.options.onError?.(this.currentResult.error!)
this.options.onSettled?.(undefined, this.currentResult.error!)
}
// Then trigger the listeners
if (notifyOptions.listeners) {
this.listeners.forEach((listener) => {
listener(this.currentResult)
})
}
// Then the cache listeners
if (notifyOptions.cache) {
this.client.getQueryCache().notify({
query: this.currentQuery,
type: 'observerResultsUpdated',
})
}
})
}
`Subscribable`은 구독 관련 기본 기능과 자식 클래스에서 상속으로 구현해야 하는 두 메서드들의 자리만 만들어두고 있다. `QueryObserver`에서는 이를 상속받고 추가적으로 `notify`를 구현한다. 이는, `Redux`의 `Dispatch`로 보면 된다.
이 코드들은 옵저버 패턴의 디자인 패턴을 기반으로 작성되었다.
'옵저버 패턴'은 'Subject(주제)'와 'Observer(옵저버)'로 구성된다.
- Subject(주제): 상태 변경 가능한 객체이며, 옵저버 객체들을 관리(등록, 제거)하며, 서브젝트가 변경되면 옵저버 상태 변경 전파
- 옵저버 패턴
- 등록
- 알림
- 반응
- 옵저버 패턴
// Tanstack-Query의 Query 로직을 옵저버 패턴을 기반으로 단순화한 예시
const App = () => {
const [query] = useState(() => new Query());
const state = useQueryObserver(query);
useEffect(() => {
setTimeout(() => {
query.setState("New state updated!");
}, 2000);
}, []);
return <div>{state || 'Waiting for update...'}</div>;
};
export default App;
import React, { useEffect, useState } from 'react';
const useQueryObserver = (query: Query) => {
const [state, setState] = useState(query.state);
useEffect(() => {
const observer: Observer = {
// 반응: 상태 변화에 대한 반응으로 컴포넌트 상태를 업데이트
update: (state: any) => setState(state)
};
// 등록: Query 객체에 이 컴포넌트를 옵저버로 등록
query.attach(observer);
return () => {
// 제거: 컴포넌트 unmount 시 옵저버 등록 해제
query.detach(observer);
};
}, [query]);
return state;
};
interface Subject {
attach(observer: Observer): void;
detach(observer: Observer): void;
notify(): void;
}
interface Observer {
update(state: any): void;
}
class Query implements Subject {
private observers: Observer[] = [];
private state: any = null;
// 등록: 옵저버를 observers 배열에 추가
attach(observer: Observer) {
this.observers.push(observer);
}
// 제거: 옵저버를 observers 배열에서 제거
detach(observer: Observer) {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
notify() {
this.observers.forEach(observer => observer.update(this.state));
}
setState(newState: any) {
this.state = newState; // 상태 설정
this.notify(); // 상태가 변경된 후, 모든 옵저버에게 알림
}
}
정리
- 원리를 파악하기 위해 부지런히 분석하고 눈으로 확인
- 본 것 자체가 중요한 것이 아닌, 그 안에서 무엇을 봤는지, 무엇을 건져나왔는지가 중요
- Next.js에서 `next/image`의 내부 구현을 보는 것은 서버, 캐시, HTTP 헤더, 이미지 프로세싱 등 개념을 익히는 것에 목적
- React 내부 메커니즘을 전부 외우기 위해 보는 것이 아닌, React 렌더링 메커니즘을 공부하다가 반응성(Reactivity)을 어떤 관점에서 바라보고 있는지, 내부에 스케줄러라는 개념을 알게되는 목적
- `useMemo`에 대해 알아보다가 `React Forget`에 대해 알아보다가 컴파일러에 대해 호기심
Isomorphic Components
내용으로 들어가기 전, `React Server Components(RSC)`에 대해 알아보자.
React Server Components(RSC)
- 서버에서만 실행되는 컴포넌트
- 결과 값이 HTML이 아닌 JSX인 레더링 프로세스
서버에서만 실행되는 컴포넌트
Next.js Page Router까지의 React 컴포넌트도 서버에서 실행되었다는 의미지만, 정확하게는 '서버에서만' 실행되는 컴포넌트는 아니다.
이러한 로직을 'Isoporphic(동형)'이라고 부른다.
// useLayoutEffect는 서버에서 실행되지 않지만, 삼항연사자로 서버 환경일 경우 useEffect로 동작하도록 처리
import { useEffect, useLayoutEffect } from 'react'
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect
import db from 'db';
import NoteEditor from 'NoteEditor';
async function Note({ id, isEditing }) {
const note = await db.posts.get(id);
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
{isEditing && <NoteEditor note={note} />}
</div>
);
}
// DB에 직접 엑세스 코드
// Node.js 서버 측 환경에 강하게 결합되어 클라이언트에서 실행 불가
// 그렇다면 어떻게 해결?
Isomorphic was the new black
좋지 않아 보이는 `Isomorphic` 컴포넌트 설계를 왜 도입한 것일까?
<!-- index.html -->
<!doctype html>
<html ng-app="myApp">
<head>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.7.2/angular.min
<script src="app.js"></script>
</head>
<body>
<div ng-controller="MyController">
{{ message }}
</div>
</body>
</html>
// app.js
angular.module('myApp', [])
.controller('MyController', function($scope) {
$scope.message = 'Hello, World!';
});
위 코드의 흐름을 먼저 파악하자.
- 클라이언트가 서버로 GET 요청
- 서버에서 `index.html` 응답하고, 브라우저에는 `head` 태그 내 `script` 태그를 파싱하여 앵귤러 라이브러리 코드와 `App.js` 다운로드 후 화면에 `{{ message }}` 렌더링
- `JavaScript` 로드 후 `ng-app`와 `ng-controller`를 찾아 DOM과 연결 뒤 `ng-controller='MyController'`가 하나의 `$scope`로 전달되고 머스태쉬 문법으로 작성된 `message` placeholder를 `hello, Wolrd!`로 교체
- CSR 종료 후 유저의 화면에서 페이지 완성
HTML과 JavaScript라는 서로 다른 두 가지 멘탈 모델을 하나의 페이지에 섞어 사고하고 있는 코드이다. React 관점에서는 복잡해보이는 코드이다.
만약, 여기에 SEO 점수를 위해 서버 사이드 렌더링을 도입하게 된다면?
안타깝게도 'AngularJS'에서는 'SSR'을 지원하지 않는다.
서버 역할을 하기 위해 여러가지 도구 중 '루비 온 레일즈(Ruby On Rails)' 풀스택 프레임워크를 발견할 수 있다. 그렇지만, 이는 SSR이 가능하지만 CSR은 불가능한 것이 단점이다. '루비 온 레일즈'는 전용 템플릿 언어(ERB, Embedded Ruby)를 사용한다.
// ERB 예시 코드
<h1>Welcome, <%= @user.name %></h1>
템플릿 파일 하단에 스크립트를 추가하고 앵귤러에서 이 코드를 읽을 수 있도록 코드를 추가한다면?
이는, 'SSR' 개념과 동일하다.
<script>
window.__INITIAL_DATA__ = <%= @initial_data.to_json.html_safe %>;
</script>
// AngularJS application
angular.module('myApp', [])
.controller('MyController', function($scope) {
$scope.data = window.__INITIAL_DATA__;
});
그러나, 서버 측 SSR용 ERB 템플릿, 클라이언트 측 CSR 용 템플릿을 번갈아 사용해야 한다는 불편함이 존재한다. 이를 해결할 수 있는 방법이 있을까?
import React from "react"
const App = ({ name }) => (
<div>Hello, {name}!</div>
)
export default App
React는 이런 기술적인 환경에서 JSX를 통해 서버 / 클라이언트 로직을 동형으로 만들어 개발자가 간단하게 클라이언트와 서버 로직을 작성하도록 돕고 컴포넌트의 재활용성을 높였다.
동형 모델로 멘탈 모델을 간결하게 만들 수 있다는 포인트가 애매하게 서버 컴포넌트를 제공하는 것은 그다지 매력적이지 않았을 것이다. 하나의 언어로 동형 컴포넌트를 쓰기 위해서 말이다.
그래서 현재의 'SSR'은 이런 제약을 받아들이면서 진정한 서버 렌더링이 아닌 'Client Prerendering'으로 불려야 한다는 주장이 나오고 있다.