밈 검색 만족도를 높이기 위한 여정

오정진 Jeongjin Oh·2024년 3월 31일
7


그밈 프러덕트는 밈 검색 서비스이기 때문에 검색 만족도를 높여주는 것이 목표다. 이를 위해 밈 검색 결과 페이지에 다양한 렌더링 패턴을 시도했고, 현재도 여러 시도를 하고 있다. 이 글에서 다양한 렌더링 패턴을 시도해본 과정을 적어보려 한다. 여러 시도를 하며 어떤 고민을 하고 문제를 겪었는지를 따라가며 읽어보면 좋을 거 같다!

읽기 전에 우리 서비스 한번 구경해보세요 :)
2024. 4. 9. 이후로 서비스를 종료했어요. 대신 github에서 소스코드는 확인해볼 수 있습니다 :)

Github 👉 https://github.com/thismeme-team/thismeme-web

목표 : 우리 서비스의 밈 검색 만족도를 높여주자!!

시도1. CSR

첫 시도는 CSR(Client-side rendering)로 검색 결과 페이지를 렌더링했다. 가장 간단한 방식이지만 아래 이유로 SSR로 전환하기로 했다.

  1. meta 데이터, SEO 에 취약하다. 서비스 요구사항을 만족하기 힘들다.
  2. 검색 결과 데이터를 받아오기 전까지 빈 화면이 깜빡인다.

CSR은 무엇보다도 SEO, meta 데이터 수집에 취약하다는 단점이 있었다. 그밈 서비스 요구사항 중 검색 결과 페이지에 마다 동적인 meta 데이터가 필요했기에 SSR이 불가피했다. 아래와 같이 open graph 를 설정해 SNS에 공유 시 미리보기를 보여줘야 했다. SNS 의 크롤링 봇이 서비스의 meta 데이터를 읽어 미리보기를 생성하기 때문에 meta 태그가 포함된 HTML을 서버에서 그려줘야 했다.

CSR을 적용하면 빠르게 클라이언트 단에서 데이터를 받아 렌더링을 할 수 있다는 장점이 있다. 하지만 JS번들을 받아 데이터 페칭 과정이 waterfall로 진행된다. JS번들이 크거나 데이터 페칭이 오래걸리면 사용자가 빈 HTML을 보는 시간이 길어진다.

https://user-images.githubusercontent.com/33178048/193198237-1d73d741-5860-452c-a7e7-09fd773b1ca6.png

시도2. SSR, SSG

CSR의 단점 해결과 서비스 요구사항 충족을 위해 검색 결과 페이지에 SSR(Server-side rendering)을 도입하기로 했다. 실제로 네이버 포털에서 검색 결과가 서버에서 HTML로 내려오는 것을 보며 초기 검색 결과를 빠르게 제공해줄 수 있겠다고 생각했다. 아래와 같이 네이버는 검색 결과가 렌더링된 HTML로 내려준다.

그밈 서비스는 Next.js 를 이용하고 있어 SSR을 적용하는 건 어렵지 않았다. page 컴포넌트에 getServerSideProps 함수만 정의해주면 된다.

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
  const tagId = params?.tagId;
  const queryClient = new QueryClient();

  if (typeof tagId !== "string") {
    return {
      notFound: true,
    };
  }

  try {
    const { name: tagName } = await useGetTagInfo.fetchQuery(Number(tagId), queryClient);
    await useGetMemesByTag.fetchInfiniteQuery(tagName, queryClient);

    return {
      props: {
        hydrateState: JSON.parse(JSON.stringify(dehydrate(queryClient))),
        searchQuery: tagName,
        tagId: Number(tagId),
      },
    };
  } catch (e) {
    return {
      notFound: true,
    };
  }
};

이렇게 하면 아래처럼 검색 결과가 포함된 HTML이 서버에서 내려오게 된다.

하지만 위 getServerSideProps 에는 문제가 있다.

  // 1️⃣ 태그 이름을 받아오기 위해 태그 정보 API 호출
  const { name: tagName } = await useGetTagInfo.fetchQuery(Number(tagId), queryClient);

  // 2️⃣ 태그 이름을 기반으로 첫번째 페이지 검색 결과 요청
  await useGetMemesByTag.fetchInfiniteQuery(tagName, queryClient);

  return {
    props: {
      hydrateState: JSON.parse(JSON.stringify(dehydrate(queryClient))),
      searchQuery: tagName,
      tagId: Number(tagId),
    },
  };

밈 검색을 위해 태그 이름이 필요하다. 그러기 위해서 위와 같이 2개의 API를 waterfall로 요청할 수 밖에 없었다. 여기에 더해 서버에서 HTML을 내려주는 과정까지 모두 waterfall 이기 때문에 페이지 로드 시간이 6 ~ 10초까지 지연되었다.

그래서 SSG 를 도입해보고자 했다. 즉, 서버에서 생성한 HTML을 cache 해두기로 했다. Next.js 를 이용하면 getStaticProps 를 이용해서 SSG를 쉽게 구현할 수 있다.

diff --git a/src/pages/explore/tags/[tagId].tsx b/src/pages/explore/tags/[tagId]
.tsx
index d1e2e6c..5b1fdf5 100644
--- a/src/pages/explore/tags/[tagId].tsx
+++ b/src/pages/explore/tags/[tagId].tsx
@@ -1,7 +1,7 @@
 import { dehydrate, QueryClient } from "@tanstack/react-query";
-import type { GetServerSideProps, NextPage } from "next";
+import type { GetStaticPaths, GetStaticProps, NextPage } from "next";

import { fetchTagInfo, prefetchMemesByTag } from "@/application/hooks";
 import { TITLE } from "@/application/util";
 import { ExplorePageNavigation } from "@/components/common/Navigation";
 import { NextSeo } from "@/components/common/NextSeo";
@@ -33,7 +33,14 @@ const ExploreByTagPage: NextPage<Props> = ({ searchQuery, tagId }) => {
   );
 };

-export const getServerSideProps: GetServerSideProps = async ({ params }) => {
+export const getStaticPaths: GetStaticPaths = () => {
+  return {
+    paths: [],
+    fallback: "blocking",
+  };
+};

+export const getStaticProps: GetStaticProps = async ({ params }) => {
   const tagId = params?.tagId;
   const queryClient = new QueryClient();

@@ -46,9 +53,13 @@ export const getServerSideProps: GetServerSideProps = async ({ params }) => {
   try {
     const { name: tagName } = await fetchTagInfo(Number(tagId), queryClient);
     await prefetchMemesByTag(tagName, queryClient);

     return {
       props: {
         hydrateState: JSON.parse(JSON.stringify(dehydrate(queryClient))),
         searchQuery: tagName,
         tagId: Number(tagId),
       },

cache hit인 경우 0.5초 내로 페이지가 로드되는 것을 확인했다.

하지만 여기에도 2가지 문제가 있었다.

  1. 검색 결과가 시간에 따라 달라질수도 있다.
  2. 검색 결과 페이지가 cache miss 인 경우 SSR 처럼 페이지 로드 시간이 오래걸린다.

1번을 해결하기 위해서는 무조건 cache하면 안된다. 하지만 Next.js는 SSG된 페이지 cache를 purge하기 위해서는 다시 배포해야 한다. 따라서 cache를 revalidate 해주는 메커니즘이 필요하다. Next.js 는 ISR(Incremental Static Regeneration) 을 통해 특정 페이지를 다시 생성(regenerate)할 수 있는 기능을 제공해준다.

2번은 전통적인 서버사이드 렌더링의 문제점이다. 이 부분은 글 마지막에 다시 살펴보겠다.

시도3. ISR

Data Fetching: Incremental Static Regeneration (ISR) | Next.js

ISR은 앱 전체를 빌드하지 않고도 SSG된 페이지를 다시 생성할 수 있도록 해준다.

getStaticProps 의 반환값에 revalidate 만 추가하면 된다.

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const tagId = params?.tagId;
  const queryClient = new QueryClient();

  if (typeof tagId !== "string") {
    return {
      notFound: true,
    };
  }

  try {
    const { name: tagName } = await useGetTagInfo.fetchQuery(Number(tagId), queryClient);
    await useGetMemesByTag.fetchInfiniteQuery(tagName, queryClient);

    return {
      props: {
        hydrateState: JSON.parse(JSON.stringify(dehydrate(queryClient))),
        searchQuery: tagName,
        tagId: Number(tagId),
      },
      // 20분 지나기 전 요청인 경우 캐시된 페이지를 내려줌
      // 20분 후에 stale한 페이지를 내려주고 background에서 페이지를 재생성함
      revalidate: 60 * 20,
    };
  } catch (e) {
    return {
      notFound: true,
    };
  }
};

revalidate를 20분으로 설정해두었다.

이렇게 하니 시간에 따라 달라지는 검색 결과를 반영할 수도 있게 되고, cache hit인 경우 페이지 로드도 빠르게 할 수 있었다.

하지만 문제는 남아있었다.

  1. revalidate 를 하더라도 여전히 바뀐 검색 결과를 반영 못할 수 있다
  2. cache miss일 때 여전히 페이지 로딩 지연이 있다

아래처럼 cache miss인 경우는 2 ~ 6초의 지연이 발생한다. 페이지 로드 시간이 길어지면 이탈률이 증가한다는 통계가 있기에 페이지 로드를 줄여야 한다. 통계가 아니더라도 실제로 체감되는 지연시간이 너무 길었다. 이 문제는 전통적인 SSR의 문제점이다. 아래에서 한번 더 언급하겠다.

시도4. ISR + CSR

시도3에서 남아있는 문제를 해결하기 위해 ISR과 CSR를 혼합해서 사용하기로 결정했다.

언제 ISR하고 언제 CSR할까?

먼저 맨 처음 잡았던 목적을 다시 상기해보면, ‘검색 만족도를 높여주는 것’이다. 즉, 병목이 되는 부분을 찾아서 사용자에게 느끼지 못하게끔 만들어줘야 한다.

SSR의 문제점은 데이터 페칭부터 hydrate 과정까지 모두 waterfall로 진행된다는 것이다. Next.js 의 Page router는 페이지 단위로만 SSR을 지원해주고 있어 waterfall로 인한 지연 문제를 가지고 있다.

그밈의 검색 결과 페이지도 위 구조와 같은 SSR를 하고 있어 지연 문제가 생기고 있었다. 특히, 태그 정보 API와 검색 API를 연달아 호출하며 지연이 길어졌다.

검색 결과 페이지가 miss나면

1) 태그 정보 API 호출
2) 검색 API 호출
3) 페이지 생성

이 세 과정이 waterfall로 진행된다.

결과적으로 페이지 로드 시간의 총합은 페이지 요청/응답 RTT + API 요청/응답 RTT + 페이지 생성 시간이 된다.

이 과정에서 지연 되는 부분을 제거하고자 했다.

그밈 서비스는 FPM(Firebase Performance Monitoring)으로 API 속도를 측정하고 있었다. 측정을 해보니 검색 API의 응답시간이 전체 샘플 중 50% 비율로 1초 이상 나오는 것으로 확인되었다.

검색 API를 Nextjs 서버에서 호출하는 것이 페이지 로드를 지연하는 원인이라 생각했다. 또한 검색 결과 페이지를 SSG하기 때문에 검색 결과가 캐시되는 문제도 있어 서버에서 호출하지 말고 클라이언트(브라우저)에서 호출하는 것으로 변경하기로 했다. 즉, 페이지의 필요한 부분(meta 데이터)만 SSR하고, 검색 결과와 같은 부분은 CSR하기로 결정했다.

diff --git a/src/pages/explore/tags/[tagId].tsx b/src/pages/explore/tags/[tagId].tsx
index 095d632..e924353 100644
--- a/src/pages/explore/tags/[tagId].tsx
+++ b/src/pages/explore/tags/[tagId].tsx
@@ -6,36 +6,43 @@ import { useGetTagInfo } from "@/api/tag";
 
 interface Props {
   tagName: string;
   tagId: number;
 }
 
const ExploreByTagPage: NextPage<Props> = ({ tagName, tagId }) => {
   return (
     <>
       <NextSeo
         description={DEFAULT_DESCRIPTION}
         title={`'${tagName}' 밈`}
         openGraph={{
           siteName: SITE_NAME,
           imageUrl: `/api/og?tag=${tagName}`,
         }}
         twitter={{
           cardType: "summary_large_image",
         }}
       />
 
       <ExplorePageNavigation title={`#${tagName}`} />
 
       <PullToRefresh>
-        <Thumbnail tag={tagName} />
-        <MemesByTag tagName={tagName} />
+        <SSRSuspense fallback={<MemeListSkeleton />}>
+          <Thumbnail tag={tagName} />
+          <MemesByTag tagName={tagName} />
+        </SSRSuspense>
       </PullToRefresh>
-      <TagBookmarkButton tagId={tagId} />
+      <SSRSuspense fallback={<></>}>
+        <TagBookmarkButton tagId={tagId} />
+      </SSRSuspense>
     </>
   );
 };
@@ -61,13 +68,13 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
     const { name: tagName } = await useGetTagInfo.fetchQuery(Number(tagId), queryClient);
 
-    await useGetMemesByTag.fetchInfiniteQuery(tagName, queryClient);
 
     return {
       props: {
         // NOTE: useInfiniteQuery 사용 시 queryCache에 undefined 프로퍼티가 있으므로 에러 방지를 위해 직렬화/역직렬화가 필요합니다
-        hydrateState: JSON.parse(JSON.stringify(dehydrate(queryClient))),
         tagName: tagName,
         tagId: Number(tagId),
       },
       revalidate: 60 * 20, // 20분

getStaticProps 에서 호출하던 검색 API를 제거하고 클라이언트에서 호출하도록 변경했다.

이렇게 하니 페이지 로드 시간이 miss 인 경우 1.5 ~ 2초로 줄었다. hit인 경우는 0.3 ~ 0.6초 정도 걸렸다.

결과

  • ISR과 CSR을 함께 사용해 검색 결과 페이지를 렌더링한다.
  • 페이지 로드 시간이 6 ~ 10초 걸리던 것을 1.5초로 줄였다.

느낀 점

SSR의 한계

SSR은 아래처럼 데이터 페칭부터 hydrate 까지 모든 과정이 waterfall로 진행된다. 이전 과정이 완료되어야 다음 과정을 진행할 수 있는 것이다.

SSR은 초기 로딩 속도가 빠르다는 장점이 있지만, 위 과정 중 하나라도 지연이 발생하면 오히려 로딩 속도가 느려진다.

이 문제를 해결하기 위해 React 팀은 Streaming HTML과 Selective hydration 이라는 새로운 SSR 아키텍처를 소개했다. 이를 통해 페이지를 잘게 쪼개 컴포넌트 단위로 SSR하고 hydrate해줄 수 있다. Next.js 의 App router 가 이 기능들을 제공해준다. Page router는 페이지 단위의 SSR만 지원한다.

이번 밈 검색 결과 페이지의 성능을 개선하며 Page router 의 한계를 느끼기 시작했다. 컴포넌트 단위로 렌더링 패턴을 설계할 수 있다면 지금보다 더 빠르게 검색 결과 페이지를 받아올 수 있겠다는 생각이 들었다. 그래서 우리 프로젝트에도 streaming HTML, Selective hydration, 서버 컴포넌트 기능을 이용해볼 예정이다. App router를 사용하면 컴포넌트 단위 로 서버 사이드 렌더링을 해줄 수 있고 세밀하게 caching도 해줄 수 있다. 추후 기능을 적용해보는 과정을 블로그에 남겨보겠다.

Vercel의 서버성능을 알 수 없다

우리 프로젝트에서 서버사이드 렌더링을 활용하고 있기에 서버 성능을 아는 것이 중요했다. 최적화와 더불어 서버 성능을 올려주면 서버사이드 렌더링 성능도 올라갈 것이기 때문이다. 하지만 아무리 찾아봐도 vercel의 서버 성능에 대한 문서는 없었다. 서버 성능이 블랙박스라는 점이 아쉽다.

참고

profile
단 한사람의 불편함이라도 해결해 줄 수 있는 개발자가 되고 싶습니다.

0개의 댓글