소설위키(FDBS)를 개발하며 진행한 렌더링 및 최적화에 대한 고민과 해결방법들에 대해 기록하였다.
주요 고민은 다음과 같다.
주요 구현 목표는 item detail page, itmes list page 이다.
우선 서비스의 핵심이 되는 Item Detail Page는 가장 많은 유저가 방문할 것으로 예상되는 페이지이다.
유저가 방문할 때 마다, 새로운 데이터를 요청하는 것은 향후 서버에 큰 부담이 될 수 있다.
따라서, 페이지의 대부분이 정적인 데이터로 구성되어 있음을 고려하여, Next.js의 static-site-generation(이하 SSG) 방식으로 페이지를 구현하였다.
고민이 필요한 부분은 평점과, 댓글 등 유저의 상호작용에 의해 변화가 일어나는 부분이었다.
댓글 작성이나 평점 등록 등 페이지의 데이터가 변경될 경우 클라이언트 사이드에서 API 엔드포인트로 데이터를 다시 요청하여, 갱신된 데이터로 최초 렌더링에 사용된 데이터를 교체시켜 줄 수 있다.
하지만 이렇게 할 경우 최초 표시되는 데이터(CDN에 캐싱된 데이터)가 old data가 되어 유저가 페이지에 접속을 할 때, old data -> new data 순으로 리렌더링이 발생한다.
이 문제를 방지하기 위해선 주기적으로 페이지를 다시 빌드 할 필요가 있다.
Next.js의 on-demand-revalidation(이하 ODR)을 사용하면 CDN에 캐싱된 데이터를 수동으로 갱신시킬 수 있다.
이를 통해 게시글 수정, 코멘트 및 평점 등록 등 캐싱된 데이터에 변경이 일어날 경우 ODR을 통해 Revalidation이 일어나게 하였다.
// /pages/api/revalite.ts
...
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { method, query, body } = req;
if (query.secret !== process.env.REVALIDATION_TOKEN) {
return res.status(401).json({ message: "Invalid token" });
}
try {
if (!body) {
res.status(400).send("Bad reqeust (no body)");
return;
}
const idToRevalidate = body.id;
const type = body.type;
if (type === "edit") {
await res.revalidate(`/fictions/${idToRevalidate}`);
} else {
await res.revalidate(`/fictions`);
await res.revalidate(`/fictions/${idToRevalidate}`);
}
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).send("Error revalidating");
}
}
이렇게 정적인 페이지에 CSR을 혼합하여 사용할 경우 Origin 서버에 주기적으로 데이터 요청을 한다는 점에서 서버사이드 렌더링과 큰 차이점이 없다고 생각할 수 있다.
하지만 유저들의 대다수가 코멘트, 레이팅 등 상호작용 하지 않음을 고려할 때, 좀 더 로직이 복잡해지긴 하지만 SSG에 CSR을 더한 방식을 사용하면, 1차적으로 서버의 부담을 크게 완화할 수 있으며, 또한 TTFB(Time to First Byte)도 크게 향상시켜 UX 측면에서 큰 이점이 있다.
그렇다면 Iem list page는 어떤 렌더링 방법을 적용해야 할까?
렌더링 방법을 선택하기에 앞서
Item list page를 다음 2가지 범주로 분류하였다.
1) Options tab
2) Items List
우선 옵션 탭은 item의 카테고리, 국적 등 자주 변동되지 않는 데이터로 구성되어 있으며, 모든 item들을 순회해야하기 때문에 (O(n)) 연산에 적지않은 서버 자원이 소모된다.
따라서 Options Tab은 CDN에서 캐싱을 하되, ISR (Incremental static regeneration)을 통해 주기적인 텀으로 데이터를 갱신을 하는 것으로 결정하였다.
export async function getStaticProps() {
const fictions = await client.fiction.findMany({
select: {
nationality: true,
},
});
.....
return {
props: {
keywords: JSON.parse(JSON.stringify(keywords)),
nationalities: JSON.parse(JSON.stringify(nationalities)),
categories: JSON.parse(JSON.stringify(categories)),
},
//하루 간격으로 revalidate
revalidate: 60 * 60 * 12,
};
}
Items List는 Data는 새로고침을 하지않고 데이터를 쿼리조건이 바뀔 때마다 페칭해야하기 때문에, 기본적으로 클라이언트 사이드에서 데이터 페칭이 필요하다.
따라서 items List도 Item detail page와 같이 SSG + CSR 형식으로 렌더링 하기로 결정하였다.
const { data, isValidating } = useSWR<FictionsResponse>(queryString, {});
브라우저의 렌더링 과정은 요약하면 다음과 같다.
서버 리소스 페칭 -> 파싱(DOM 및 CSSOM 기반으로 Render Tree 생성) -> 레이아웃 -> 페인트.
또한 React를 사용할 경우 DOM update를 react에 위임하게 되고, 그 과정은 크게 Render -> Commit 로 이루어진다.
따라서 각 단계별로 필요한 렌더링 최적화 작업을 수행하였다.
SWR을 사용하여 불필요한 request를 방지하여 서버의 부담을 줄이고, 캐싱된 데이터를 유저에게 제공하여 응답속도를 높일 수 있었다.
캐싱 등을 통한 효율적인 데이터 페칭도 중요하지만, 그 이전에 데이터 자체를 간소화 하는 것이 중요하다.
Next.js의 Next/Image 컴포넌트를 사용하면 이미지 최적화를 포함한 다음과 같은 이점을 누릴 수 있다.
또한 Next.js의 dynamic import(혹은 react의 lazy loading)를 통한 코드 스플리팅으로 특정 컴포넌트를 필요할 때만 로드하는 식으로 페이로드를 줄일 수 있다.
React에서 컴포넌트는 state나 props가 변경되거나 부모 컴포넌트가 렌더링될 경우 리렌더링 된다.
따라서 UseMemo, UseCallback 같은 내장 Hook이나 React.memo 등을 사용하면, 커밋과정 등을 생략함으로써 불필요한 렌더링을 최소화 하였다.
마지막으로 Navbar나 이미지 태그의 배치를 absolute, fixed를 사용하는 등 reflow 및 repaint를 최소화 하였다.
프론트 사이드에서의 렌더링 최적화가 끝났지만, 여전히 최초 렌더링 시 딜레이 데이터 페칭에 적지 않은 시간이 소모된다.
프론트에서 더 이상 최적화할 부분이 없을 경우 서버에서의 최적화가 필요한 시점이지만, Vercel을 통한 serverless 환경으로 서비스를 배포하였기 때문에 때문에, 서버사이드 캐싱이 불가능하다.
고민 끝에 다음과 같은 방법으로 속도를 개선할 수 있었다.
인덱싱 작업으로 쿼리 작업의 속도 향상시킬 수 있다.
PrismORM을 사용하면 Schema파일에서 column 단위로 indexing 하는 것이 가능하다.
model Fiction {
....
@@index([userId])
@@index([nationality,createdAt])
@@index([authorId])
}
model Keyword {
....
fictions KeywordsOnFictions[]
@@index([name])
}
하지만 콜드 스타트로 인한 딜레이와 함께, 쿼리 조건이 다양하고 특히 키워드의 가짓 수가 지나칠 경우 데이터 페칭 작업이 생각 이상으로 시간이 소요되었다.
좀 더 속도를 개선하기 위해 Redis를 사용할 수 있는 다른 무료 데이터베이스를 활용하기로 결정하였다.
Vercel을 BFF로 하여 캐시 데이터를 다른 데이터베이스에 저장하였다.
query값을 key로 하여, 캐시 값이 존재하는 경우 쿼리를 실행하지 않고 Redis에 저장된 value를 가져옴으로써 복잡한 상황 에서의 쿼리 속도를 향상 시킬 수 있었다.
/// ../../someApiFile.ts
import { Redis } from "@upstash/redis";
const redis = new Redis(redisConfig);
const cache: any = await redis.get(req.query);
if (cache) {
return res.json(cache);
} else {
const fiction = client.findMany(
// 조건 쿼리
)
return res.json(fiction)
}
Api 폴더에 있는 route 파일들은 Vercel의 serverless function을 통해 실행된다.
기본적으로 Washington, D.C., USA (iad1) 있기 때문에,
데이터베이스가 물리적으로 저장된 장소를 체크하여 serverless function의 location을 최대한 데이터베이스와 가깝게 일치시켜야 한다.
FDBS의 경우 Placetscale DB가 Tokyo에 위치해 있기 떄문에, Vercel 환경도 똑같이 통일시켜 주었다.