다음은 NextJS의 공식문서에서 인용한 글이다.
The React Framework for the Web
Used by some of the world's largest companies, Next.js enables you to create high-quality web applications with the power of React components.
그리하여 우리는 프론트엔드를 위한 서버를 두어 고수준의 웹 어플리케이션을 유저에게 제공한다.
예를들면 Next에서 제공하는 Proxy나 middleWare를 이용하여 client에서 혹은 server에서 적절한 처리를 할 수 있다. 자세한 내용은 아래에서 챕터로 다루겠다.
혹은 필요한 데이터를 서버에서 받아 우리가 원하는 html에 데이터값이 포함된걸 서버사이드에서 제공하고, 또는 한번 생성된 페이지를 정적인 데이터로 들고있다가 필요한 유저에게 제공할 수 있다.
// 이 경우 응답으로 받는 초기 html에는 clientSide에서 데이터를 받는 datas를 제외하고 내려온다.
import { useEffect, useState } from 'react';
export default function Page() {
const [count, setCount] = useState(0);
const [datas, setDatas] = useState();
useEffect(() => {
setCount(10);
getData();
}, []);
async function getData() {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/ditto`);
const data = await res
.json()
.then((data) => setDatas(data.sprites.front_default));
}
return (
<div>
<p>Client side rendering</p>
{datas && <p>{datas}</p>}
<p onClick={() => setCount(count + 1)}>{count}</p>
</div>
);
}
hydrate하는 개념은 NextJS에서 나온 개념이 아니다.
https://react.dev/reference/react-dom/hydrate
페이지에 대한 초기 렌더링을 서버에서 실행하여 값을 내려준다.
1. Next.js에서 서버 사이드 렌더링(SSR)을 사용하면, 초기 렌더링은 서버에서 이루어지고, 이후에 클라이언트에서 추가적인 렌더링이 이루어집니다.
2. 이후 클라이언트에서 페이지가 로드되면, React는 Hydration 과정을 거치게 됩니다.
3. 이후에는 클라이언트에서 발생하는 사용자의 상호작용에 따라 상태값이 업데이트되고, 이에 따라 화면이 재렌더링됩니다. 이러한 과정은 모두 클라이언트에서 이루어지게 됩니다.
// getServerSideProps를 이용하여 데이터를 받고 아래 코드처럼 client로직과 혼재돼있을경우
//Next에서 렌더링 한 값이 오므로 return문에 적힌 모든 값이 내려온다.
import { useEffect, useState } from 'react';
export default function Page({ data }) {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(10);
}, []);
return (
<div>
<p>Server side rendering</p>
<p>{data.sprites.front_default}</p>
<p onClick={() => setCount(count + 1)}>{count}</p>
</div>
);
}
// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/ditto`);
const data = await res.json();
// Pass data to the page via props
return { props: { data } };
}
//SSR 컴포넌트처럼 props를 받아서 사용한다.
export async function getStaticProps() {
const res = await fetch('https://.../posts')
const posts = await res.json()
return {
props: {
posts,
},
}
//SSR 컴포넌트처럼 props를 받아서 사용하고 만약 revalidate시간에 따라 새롭게 데이터를 가져온 값으로 변경된다.
export async function getStaticProps() {
const res = await fetch('https://.../posts')
const posts = await res.json()
return {
props: {
posts,
},
// Next.js will attempt to re-generate the page:
// - When a request comes in
// - At most once every 10 seconds
revalidate: 10, // In seconds
}
}
// ISR
await fetch(`${baseURL}/...`,{
next:{revalidate:16}
})
// SSG
await fetch(`${baseURL}/...`,{
cache:"force-cache"
})
//SSR
await fetch(`${baseURL}/...`,{
cache:"no-store"
})
Axios와 다르게 fetch함수에서는 기본적으로 baseURL과 헤더값을 설정하지 못하는데 라이브러리를 이용하면 보완이 가능하다.
관련하여 좋은 글과 fetch에 대한 글을 링크한다.
ServerComponent 내부에 ClientComponent가 들어가 있는 구조가 가능한데 이럴 경우에 초기 HTML은 ServerComponent로 렌더링할 수 있는 만큼 SSR한 결과값을 내려준다. 그리고 CSR에 필요한 js 청크파일과 리액트가 hydrate하여 클라이언트의 화면에서 동적으로 적용될 수 있는 어플리케이션으로 바뀐다. 이 부분은 NextJS 12와 크게 다르지 않으며 달라지는것은 SSR, ISR, SSG에 대한 API가 fetch 함수의 revalidate에 위임됐다는것이다.
//page.tsx
import React from 'react';
import Csr from './csr';
const page = () => {
return (
<div>
<p>서버페이지</p>
<Csr />
</div>
);
};
export default page;
//csr.tsx
'use client';
import React, { useState } from 'react';
const Csr = () => {
const [count, setCount] = useState(0);
return (
<div>
<p>클라이언트페이지</p>
<p onClick={() => setCount(count + 1)}>{count}</p>
</div>
);
};
export default Csr;
//서버에서 응답 온 값
<div>
<p>서버페이지</p>
<div>
<p>클라이언트페이지</p>
<p>0</p>
</div>
</div>
NextJS에서 어떻게 캐싱을 하는지에 대해서 알기 위해선 간단하게 서버가 어떻게 돌아가는지에 대해서 알 필요가 있다.
우선 NextJS는 서버자원을 이용하는 프론트엔드 백엔드 서버라고 생각하면 된다. 옛날에 프론트엔드와 백엔드 구분이 없는 시절에는 서버에서 데이터를 처리해주고(Model) 사용자에게 컴퓨팅된 html을 내려주는(View)로 개발하는 경우가 흔했다.
하지만 요 몇년 전에 리액트나 뷰 앵귤러같은 SPA프레임워크가 빠르게 발전하고 웹 어플리케이션이 복잡해짐에 따라 클라이언트 로직을 분리하는 프론트엔드 개발자가 많아졌다.
하지만 웹이 더 복잡해짐에따라 또 다시 최근에는 SEO의 문제와 사용자 경험, 데이터 처리등을 끌어올리는 이유때문에 다시 서버사이드렌더링을 고려하게 됐다.
그리하여 일반적으로 이미지나 정적파일같은 정적 생성은 서버의 디스크자원을, 서버 사이드 렌더링같은 동적 생성은 RAM(메모리자원)을 사용하고 캐싱 전략은 위 사진의 NextJS 캐싱 방식을 참고바란다.
우리가 제일 잘 알고잇는 캐싱은 CloudFront와같은 CDN을 사용하는건데 그럼 이 둘중에 뭘 사용해야할까?
우선 CDN과 NextJS에서 Cache를 hit하는것은 똑같지만 그 주체가 다르다. 아래 두번째 사진은 cloudFront에서 캐시를 Hit하고 첫번째사진은 NextJS 서버에서는 내부 저장소에서 캐시를 hit한다.
AWS를 예를들어 설명하겠다.
우리가 ECS같은 호스팅 서비스로 서버를 제공할 때 우리는 컴퓨팅 자원을 빌릴 수 있다. 이 서비스의 과금 체계는 요청에 대한 값과 우리가 컴퓨팅 자원을 빌릴 때 든다. 자 그럼 우리는 애초에 컴퓨팅 자원을 빌리는데 돈을 과금했고 요청에 대한 값을 과금해야하는데 이는 어차피 CloudFront에서도 요청에 대한 값을 과금한다. 둘의 차이는 CloudFront는 데이터 전송의 크기 비용이 들고 서버 호스팅에서는 컴퓨팅 자원을 빌릴때 든다는것이다.
정리하기 위해 우리가 왜 ECS에 NextJS서버를 호스팅했는지와 CloudFront를 사용하게 됐는지에 대한 본질로 돌아가보자.
NextJS를 쓰게된 이유 중 하나인 동적인 데이터에 대해서 서버사이드에서 이를 제공하나이고, CloudFront는 S3같은 객체 스토리지 서비스(정적인 데이터)에 접근할 때 드는 비용을 최소화하고 여러 나라들에 대한 지원을 하기 위해서이다.
그래서 우리는 동적인 데이터(SSR로 생성된 페이지 등)은 ECS에서 정적인 데이터(Image나 CSS JS)는 CloudFront에서 관리하면 된다.
예를들어 SSG파일들을 빌드시에 CloudFront에서 관리하면 위에서 설명한 장점을 누릴 수 있다.
https://nextjs.org/docs/app/building-your-application/caching#data-cache
둘은 기능은 조금 다르지만 의도는 비슷하여 하나로 묶었다.
Proxy
프록시는 클라이언트와 서버 사이에서 트래픽을 중계하는 역할을 합니다. 특히 Next.js에서는 개발 서버에서 API 요청을 특정 경로로 리디렉션하는 데 프록시를 사용할 수 있습니다. 이는 주로 CORS 문제를 해결하거나, API 요청의 엔드포인트를 숨기는 데 사용됩니다.
Middleware
미들웨어는 요청과 응답 사이의 특정 단계에서 실행되는 함수나 프로그램을 의미합니다. Next.js 12 버전부터는 사용자가 직접 미들웨어를 작성하여 사용할 수 있게 되었습니다. 미들웨어는 요청이 들어올 때마다 실행되며, 요청이나 응답을 변경하거나, 특정 조건에 따라 요청을 중단할 수 있습니다.
구현방법은 더 잘 설명한 글과 문서가 있어 챕터 마지막에 링크로 대체하고 언제 미들웨어를 사용해야되는지에 사례를 들어 공유하겠다.
예를들어 여러국적의 서비스를 다뤄야하고 나라마다 특정 로직을 실행해야하는 경우가 있다.
이럴때 CloudFront에서 Geo값을 Header로 보내주고 이를 미들웨어에서 캐치하여 적절한 처리를 하여 Response를 반환한다.
그럼 누군가 질문할 수 있다. 위같은 로직을 AWS Lambda같은 serverless를 사용해도 되지 않냐고?
맞는 말이다. 근데 왜 Middleware를 사용하냐면 이를 NextJS에서 기능으로 제공한다. 그리고 이를 프론트엔드 코드에 위임한다.
정리하자면 비슷하게 기능이 동작하지만 Next 서버쪽에서 제공하는 Middleware에 역할을 위임하는것이다.
우리는 serverless를 사용할 때 이 로직이 어디에 위임되는지에 대한 고민을 충분히하여 lambda나 Middleware에서 적절히 처리해주면 된다.
Next.js의 미들웨어는 모든 HTTP 요청에 대해 실행되므로, 페이지 렌더링에 필요한 요청뿐만 아니라, 이미지나 CSS, 자바스크립트 파일 등 정적 자원에 대한 요청에 대해서도 실행된다.
그러므로 간혹 미들웨어를 처음 이용했을때 함수가 여러번 실행되는데 이는 matcher에서 필요한 처리를 해줘야한다. 이해를 돕기위해 NextJS에서의 응답 요청 순서에 대해서 공유하겠다.
1. headers from next.config.js
2. redirects from next.config.js
3. Middleware (rewrites, redirects, etc.)
4. beforeFiles (rewrites) from next.config.js
5. Filesystem routes (public/, _next/static/, pages/, app/, etc.)
6. afterFiles (rewrites) from next.config.js
7. Dynamic Routes (/blog/[slug])
8. fallback (rewrites) from next.config.js
NextJS를 사용하여 우리는 복잡한 웹 어플리케이션을 상황에 맞게 유저에게 적절히 제공할 수 있게 됐다.
그리고 NextJS 13으로 오면서 우리는 더 쉽게 그리고 컴포넌트별로 서버사이드인지 클라이언트사이드인지 구분하기 더 쉬워졌다.
하지만 이것이 과연 리액트에서 추구하고자 하는 방식인지에 대한 의문을 가질 수 있다.
가령 한 페이지내에 ServerComponent와 ClientComponent가 혼재돼있을때 어떻게 해야할까?
기존 React에서는 선언적인 코드를 작성하기 위해 비슷한 수준의 컴포넌트를 추상화하고 재사용하는 컴포넌트를 적절히 대입해주어 해결했다.
즉 React는 UI(User Interface)를 구축하는데 주로 사용되는 라이브러리로, 컴포넌트 기반의 구조를 통해 사용자 인터페이스를 효과적으로 만들고 관리할 수 있다.
하지만 NextJS 13에서는 컴포넌트를 비슷한 수준으로 나누는게 아니라 이 데이터의 흐름에 따라 분리할 때도 있다.
그래서 Next.js는 React보다 데이터를 다루는데 더욱 집중하며, 사용자에게 데이터를 보다 효율적으로 제공할 수 있다.
최종적으로는 React의 UI를 구축하는 이점과 데이터를 다루는 로직을 혼용하여 더 좋은 서비스를 제공하는데에 목적이 있는 것 같다.
그리고 더더욱 프론트엔드 개발자가 캐싱에 대한 고민을 할 필요가 있다고 생각한다. 만약에 ISR을 사용한다면 우리 서비스에서 특정페이지는 어떻게 revalidate해야하는지 혹은 fetch함수를 호출할때 캐싱 시간은 언제둘것인지 등등. 알아야할게 정말 많은것 같다.
그리하여 프론트엔드에서 어떤 서비스가 있고 어떻게 트래픽이 발생하는지에 대해서 미리 캐치하여 인프라팀과 협업하거나 본인이 구성하여 적절한 전략을 취하는것이 좋을것이다.
끝으로 서비스마다 어떤 방식으로 서비스를 제공해야할지에 대한 자원을 충분히 고민해보고 백오피스같은 프로젝트는 React로 다국어 지원이나 데이터의 흐름에 대해서 많은 고민이 필요한 프로젝트는 NextJS로 하는등 고민(물적 자원)을 하고 현재 개발팀 구성원(인적 자원)들을 생각하여 적절히 잘 구성할 필요가 있다.