개요

요번 공모전 프로젝트는 반려동물 케어 및 소셜 네트워크 서비스다.
우리의 웹 서비스에는 라우팅 가드를 통해 조건부 라우팅을 하는데, 대체로 로그인 이후에 사용할 수 있는 서비스가 다를 이룬다.

그 와중에 소셜네트워크 (이하 Gallery )는 비 로그인 유저와 로그인 유저가 사용 할 수 있고, SEO가 중요하다 보니 Next.js Page Router를 사용하여 SSR을 통해 SEO와 빠른 pre-rendering을 하였다.

우리 프로젝트의 소셜네트워크는 두 가지의 페이지로 구성되어 있다.

  • Gallery Page : pages/gallery/index.tsx + SSR
  • GalleryDetail : pages/gallery/detail/[id].tsx + SSR

문제점

위에서 소개한 갤러리 페이지들의 문제점은

  • SSR 함수안에서 먼저 prefetchQueryClient 가 계속해서 새롭게 생성호출을 한다는 점
    이 문제는 useInfinityQuery의 Scroll에 영향을 미칠 수 있습니다.

  • 페이지 내에서 끊김 현상이 일어난다. ( 이전 포스팅에서 작성한 바와 같이 SSR 호출을 찍고 돌아오기 때문 )

이전 포스팅 Vercel에서 Next.js SSR의 과정을 읽고 오면 이해가 잘 됩니다. ( 반박시 님들 말이 맞음 )

  • Next.js는 React기반이기에 부드러운 페이지 전환이 특징인데 전혀 살리지 못함
  • TTFB 시간이 예상보다 늦음 ( 브라우저가 서버로 요청을 보낸 후 첫 번째 바이트를 받을 때까지의 시간. )
  • SEO와 빠른 렌더링을 위한 SSR 호출을 위함이였지만 오히려 역효과
  • 계속 되는 SSR 호출로 인한 서버 과부하 우려

어떻게 하면 Next.js의 SSRSEO 그리고 React와 같이 부드러운 CSR의 이점을 살릴 수 있느냐라는 문제에 직면 했습니다.

렌더링과 서버의 성능은 왜 중요한가

웹 서비스의 목적은 결국 유저에게 필요한 서비스를 제공하는 목표를 가지고 있다 생각합니다. 다 같이 집을 짓고 인테리어를 멋지게 해도, 교통과 인프라가 제한되어 불편함을 야기시키게 되는 과정이 있다면 유저가 집을 살지 의문입니다.

결국 멋진 집이더라도 회사, 슈퍼, 백화점 등... 거리가 멀다면 , 유저가 진입 하지 않을 것이기 때문입니다.

제가 생각한 방식을 소개 해보려고 합니다.

물론 아닐 수도 있겠지만, devOps부터 시작해 Next.js가 왜 React기반이고,
React와 같이 CSR을 할 수 있는지, 그리고 서버와의 통신을 공부했습니다.

서버 호출 최소화와 렌더링 최적화를 위한 idea는 Link shallow={true} 파일구조 그리고 React-Query Cache 였습니다.

Link Component에는 shallow 기능이 있는데, 이 기능을 true로 하면, 현재 url path를 SSR 을 실행하지 않고 업데이트 합니다.

이 shallow기능을 활용하면 불필요한 서버 호출(SSR) 과 부드러운 페이지 전환을 할 수 있을 거라는 힌트를 얻게 되었습니다.

index.tsx / [id].tsx -> [[...id]].tsx

Link Component의 shallow 기능을 활용해 Gallery Main.tsxGallery Detail.tsx , 그리고 Gallery Detail Pagination.tsx 이 3가지에서 SSR 호출 없이 CSR을 적용 하고 싶다면 파일 구조를 바꿔야 했습니다.

기존에는 위의 3가지의 .tsx 사이를 routing하면 SSR이 호출 후에 렌더링 되기 때문에 SSR과 깜빡(비동기통신)거리는 현상이 생기기 때문입니다.

따라서 페이지의 구조를 [[...id]].tsx 으로 바꾸어 url query에 값에 따라 조건부로 Component를 보여주면 shallow를 통해 SSR호출을 바꿀 수 있다 생각했습니다.



SEO와 ServerRequest 그리고 Client Navigate

" 어떻게 하면 Next.js의 SSRSEO 그리고 React와 같이 부드러운 CSR의 이점을 살릴 수 있느냐라는 문제에 직면 했습니다. "

이 물음에 대한 저의 해결 책은

  • Server Request
  • Client Navigated
    두 가지의 시나리오를 가지고 접근 하는 것이였습니다.

  • Server로 부터 요청하는 페이지에는 SSR의 데이터/SEO데이터를 내려 주도록 하고,
  • client Navigated된 페이지 요청은 즉 Serverless function이 호출이 되지만, 비동기 통신을 하지 않게 하여 Client 측에서 렌더링 delay 속도를 감축 시키도록 하였습니다.

Link / useRouter로 페이지 전환에는 /next/static 이 붙습니다.
이해 안되면 이전 포스팅 참고해주세요, 배포 하면, server에서 사용할 것 client에서 사용할 것등으로 나뉘기 때문입니다.

HOC Function

getServerSideProps안에서 조건문으로 추가해도 되지만, HOC Function으로 만들었습니다.

페이지의 파일 구조를 하나의 파일 안에서 Gallery.tsxGalleryDetail.tsx 의 SSR을 조건부 해줘야 하기 때문에 코드가 복잡해지고 장황해 지기에 HOC Function으로 만들었습니다.

import { GetServerSideProps, GetServerSidePropsContext } from 'next';

export const withConditionalSSR = (next: GetServerSideProps) => async (ctx: GetServerSidePropsContext) => {
  // url로 진입 한 것인지, 아니면 페이지 내에서 routing 한것인지
  const isCSR = ctx.req.url?.startsWith('/_next');

  if (isCSR) {
    return {
      props: {},
    };
  }

  return next?.(ctx);
};

이렇게 HOC Function을 만들었습니다.
url과 client Navigated에 따라 조건부로 queryClient를 사용하면 infinityQuerypageParam 값을 useRef로 잡아둘 수있습니다. 물론 Link shallow 도 같이 적용해야 한다 판단합니다.

ctx.query.postId에 따른 조건부 SSR

파일의 구조 변경 문단에서 보면 has.ctx.query.id 가 보이는데,

< Gallery Main /> 컴포넌트와 < Gallery Detail/> Component가 url 에 따른 조건부 컴포넌트가 보여지도록 query.id 에 따라 SSR을 해주도록 하였습니다.

아래의 코드를 보면 queryClientprefetch 가 아닌 fetch , fetchInfinity 인 이유는 Gallery의 Content는 SNS 성질과 거의 비슷합니다.

유저가 삭제하거나 하면 페이지를 에러 처리하기 위해 언급한 메소드를 사용하여 에러 처리를 SSR 호출 시 해줬습니다.

export const getServerSideProps = withConditionalSSR(async (ctx: GetServerSidePropsContext) => {
  const queryKey = ctx.query.postId;
  const queryClient = new QueryClient();

  if (queryKey) { // Gallery Detail SSR
    try {
      await queryClient.fetchQuery<Post>({
        queryKey: [PostQueryKey.posts, queryKey],
        queryFn: () => fetchGalleryDetail(queryKey as string),
      });
    } catch (err) {
      return {
        notFound: true,
      };
    }
  } else { // Gallery Main Infinity SSR
    try {
      await queryClient.fetchInfiniteQuery({
        queryKey: [PostQueryKey.posts],
        initialPageParam: 1,
        queryFn: ({ pageParam }) => fetchInfinityGalleries({ pageParam }),
        staleTime: 60 * 1000,
      });
    } catch (err) {
      return {
        notFound: true,
      };
    }
  }
// try 문에 넣어야 하는데 일딴 제 시나리오대로 되는 것이 먼저라 return문은 하나로 처리했씁니다.
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
      // data: queryClient로 받아온 데이터로 SEO 해주면 됩니다. 
    },
  };
});

[[...id]].tsx의 Component들의 구조


[[...id]].tsx

먼저 가장 크게 두 개의 컴포넌트로 나누었습니다.

  • Infinity Scroll Component : Gallery Main.tsx Component
  • GalleryDetail.tsx Component

이 2가지를 라우팅의 조건부로 해도 되지만, 맨 위에서 앞서 말씀드렸다 싶이 로딩속도를 빠르게 하기 위해 dynamic import 를 사용 하였습니다.

const LazyInfinityComponent = dynamic(() => import('@/components/galleryRefactor/main/GalleryList'));
const LazyDetailComponent = dynamic(() => import('@/components/galleryRefactor/detail/GalleryDetail'));
const BASE_PATH = '/galleryRefactor';

const GalleryRefactorPage = () => {
  const router = useRouter();
  return <div>
    {router.asPath === BASE_PATH  
      ? <LazyInfinityComponent /> 
      : <LazyDetailComponent />
    }
  </div>;
};

InfinityQuery를 사용하는 Component 에서는 4 가지의 작업을 하였습니다.

  • useInfinityQuery안에서 불러들인 데이터를 react-query 캐싱 하는 작업
  • 각각의 데이터를 Link컴포넌트로 감싸고, shallow={true} 작업
  • <Link href={..., query: {id} } /> 작업
  • <Link prefetch={false} 작업

react-query 캐싱 작업

 const client = useMemo(()=>useQueryClient(),[]);
  const { 
    	data,
        //...생략
        } = useInfiniteQuery(
    {
		//... 생략
      select: (data: IQueryData) => {
        const res = data.pages.map(pageData => pageData).flat();
        res.forEach(data => {
          const queryKey = [...props.queryKey, `${data.id}`];
          client.setQueryData(queryKey, data);
          })
        });

        return res;
      },
    },
  );

캐싱하는 데이터는 각각의 gallery 데이터 입니다. 편의상 post라 하면 이해하기 쉬울것입니다.

캐싱하는 이유는 detail페이지에 우선 적으로 빠르게 data를 보여주기 위함입니다.
물론 실시간 데이터 값이 변하기 때문에 걱정도 되지만, detail Component에서 말 씀드리겠습니다.

  client.fetchQuery({
                   queryKey,
                   queryFn:()=>data,
                   staleTime:...
                   gcTime:...
  })

각 데이터의 캐시와 stale 값을 조율 하고 싶으면 fetchQuery로 하면 됩니다.

const GalleryListItem = ({ posts }: IPostProps) => {
  const uniqueId = () => nanoid();
  return (
    <div className="...">
      {posts?.map((post: Post) => (
        <Link
          href={{
            href: `/galleryRefactor/${post.id}`,
            query: { postId: post.id },
          }}
          shallow={true}
          prefetch={false}
          key={uniqueId() + post.id}
        >
  • 하나의 [[...id]] .tsx 페이지 내에서 url조건에 따른 컴포넌트를 보여주는 것이기에 각 gallery 데이터에는 post.id 의 값을 넣었습니다.

  • SSR 호출을 막기 위해 shallow={true} 로 바꿨습니다.

  • prefetch={false} 를 하면 마우스 호버 할때 데이터를 가져오겠다는 설정입니다. ( 세세한 것 까지 챙기는 나... )


Detail Page에도 역시나 dynamic 을 적용 하였습니다.
서버측에서 불러오는 Detail Main 의 데이터 하나가 상대적으로 페이지내이션하여 보여주는 데이터 컴포넌트보다 더 중요하기 때문입니다.

LCP점수를 위해서 loading 컴포넌트도 넣어주었습니다.

const CSRPaginationComponent = dynamic(() => import('./GalleryPagination'), {
  ssr: false,
  loading: () => <GallerySkeleton />, //LCP
});

const GalleryDetail = () => {
  const router = useRouter();
  const id = router.query.postId as string;
  const queryClient = useQueryClient();
  const { data, isError, isLoading } = useFetchGalleryDetail({
    handleDetailApiRouter,
    id,
    queryKey: [PostQueryKey.posts],
    useQueryClient: queryClient,
  });
  const [specificData, setSpecificData] = useState<Post | undefined>(data ?? undefined);

  const updateSpecificData = (newData: Post | undefined) => {
  };
	
  useEffect(() => {
    updateSpecificData(data);
  }, [data]);

  return (
		//...생략
      <CSRPaginationComponent useQueryClient={queryClient} id={id} />
    </div>
  );
};

react-query 캐싱 작업 문단에서 각각의 detail 데이터를 캐싱해놓는다고 했는데, 데이터가 바뀌어 버리면 어떻하나 라는 걱정을 가질 수 있습니다.

그렇기에 useState에 데이터를 할당하고, useEffect를 통해 데이터가 바뀌면 다시 재렌더링 하는 쪽으로 데이터 일관성 문제를 해결 하였습니다.

useFetchGalleryDetail

hook에 내부를 한번 확인 하겠습니다.

const useFetchGalleryDetail = (props: PostDetailProps) => {
  const { handleDetailApiRouter, id, queryKey, useQueryClient } = props;
  const { data, isLoading, isError } = useQuery<TPostData>({
    queryKey: [...queryKey, id],
    queryFn: () => handleDetailApiRouter(id),
    initialData: useQueryClient?.getQueryData<TPostData>([...queryKey, id]),
    retry: false,
  });
  return {
    data,
    isLoading,
    isError,
  };
};

initialData에 useQueryClient의 메소드를 사용해서 데이터를 할당 하고 있습니다.

이는 데이터를 우선적으로 빠르게 보이게 해주려고 합니다.
initialData의 stale값이 변하면 자동적으로 queryFn이 호출되므로 데이터 일관성은 문제 없으리라 생각합니다.

CSRPaginationComponent : CSR Pagination

pagination은 각 데이터 카드 형식이므로 useQueries 를 사용 하였습니다.

const queries:생략이욤[] = queryKeys.map(key => {
    return {
      queryKey: [...queryKey, `${key}`],
      queryFn: () => handleDetailApiRouter(`${key}`),
      retry: false,
      //gcTime: whatever you want to reflect
      select(data) {
        if (!data)
          // 데이터가 없을 때 queryKey 삭제
          useQueryClient.removeQueries({
            queryKey: [...queryKey, `${key}`],
          });
        return data;
      },
    };
  });
  const results = useQueries({
    queries,
  });

각각의 데이터를 한번에 불러오는 것 보단, 각 데이터마다 병렬적으로 처리하여 loading 창을 보여주는 것이 빠르게 보여줄 수 있는 것부터 보여주고 싶기 위함입니다.

Region Function 이전

vercel에서는 Next.js의 Serverless Function의 default 지역이 미국으로 되어있습니다.

그렇기에 Gateway의 위치를 이전 시켰습니다. 바로 인천으루

Faas (Function as a Service)의 단점은 cold start가 있습니다. 함수가 실행되는데 걸리는 시간도 있지만, 물리적으로 호출 되는 지역을 한국으로 바꾸어 cold start라는 단점을 우회하였습니다.

Server 이전 (AWS)

실질적으로 우리가 주고 받는 서버의 지역도 중요합니다.
저희 공모전 프로젝트에서는 AWS EC2를 사용하고 있고, nest.js 노드 프레임워크를 사용하고 있던 터라, 프리티어가 적용이 되지 않아 서버를 닫았습니다.

그러나 저는 저의 최적화 설계의 가설을 검증하기 위해
저희 프론트에 유능한 만물박사 리동탁 (이하:도라에몽) 님한테

" nest가 무거워서 프리티어가 적용이 안되면 express로 디그레이드 하여 올리면 되지 않겠냐 "

라고 의견을 제시 했고,

그 분께서는 가능 할것 같다라는 답변을 주어, 그분께서 도와주셨습니다.

  • mysql workbench
  • aws ec2
  • aws ec2에 mysql 설치 및 db 연결
  • elastic ip

많은 것을 알려주셨고, express logic 은 리동탁님께서 짜주셨습니다.

결과

리팩토링 이후에 무엇이 변했는지 측정하는 것은 예비 개발자/개발자 에게 중요한 덕목 이라 생각합니다.
결과는 대단했습니다. ( 취업하고 싶다.~)

측정은

  • _app.tsx 안에 router 이벤트를 통한 측정(transition, PageLoadTime등...) ,
  • network 창

위의 두가지로 slow 3G , 4G등의 조건부로 측정하였습니다.
그리고 평균을 수치화 하였습니다.

origin / refactoring Main network Detail Network ` / home ` --> ` Gallery Main ` ` Gallery Main ` --> ` Gallery Detail ` ` Gallery Detail ` --> ` Gallery Detail `
TTFB (ms) 651 / 313 -> 50% 감소 619 / 304 -> 50% 감소 252.5 / 55 -> 78.17% 감소 - -
DOM Content Loaded (ms) 755 / 422 -> 44% 감소 740 / 437 -> 44% 감소 387.5 / 165 -> 57.36% 감소 - -
Page Transition (ms) - - 621 / 45 -> 92.75% 감소 408.2 / 8 -> 98% 감소 582.6 / 10.0 -> 98% 감소
Load Time (ms) 756 / 423 -> 44% 감소 756 / 438 -> 44% 감소 416 / 388 -> 6% 감소 - -
Finish (s) 1.96 / 1.13 -> 42.3% 감소 2.46 / 1.96 -> 20.33% 감소 - - -
Total Request 18 / 20 -> +2 증가 24 / 26 -> +2 증가 - - -
Call of Serverless Function 1 / 1 1 / 0 n / 0 n / 0 n / 0

column 이 뜻하는 것은

  • main network
  • detail network
  • / 에서 Gallery Main
  • Gallery Main 에서 Gallery Detail
  • Detail 에서 다른 Detail

5가지 입니다.

무엇보다 가장 큰 성과는 Serverless Function 호출수가 N번에서 1번으로 고정시켰다는 점입니다.

회고

처음에는 로컬로 서버를 돌리면서 test를 하고, 그 다음에는 aws 서버를 배포하고, aws EC2를 이전하면서 AWS로 배포를 하는 이유가 무엇인지 아주 아주 약간은 알것 같았습니다. 비록 서비스 페이지를 아마존에 배포는 아직 안해봤지만, 서버를 배포해보면서 백엔드에서 배포하고 port를 열어주는 수고로움을 알 수 있었습니다.

한 가지의 아이디어를 통해 가설을 세우고 검증하기 위해 로컬 서버부터 ~ 아마존 서버배포하면서 가설이 맞아 들었을 때, 정말 황홀한 기분이였습니다. 그리고 가장 중요한 express 로직과 서버 배포에서 쉽게 설명해주시는 역할을 해주신 이동탁님께 이 자리를 빌어 감사함을 드립니다.

0개의 댓글

Powered by GraphCDN, the GraphQL CDN