요번 공모전 프로젝트는 반려동물 케어 및 소셜 네트워크 서비스다.
우리의 웹 서비스에는 라우팅 가드를 통해 조건부 라우팅을 하는데, 대체로 로그인 이후에 사용할 수 있는 서비스가 다를 이룬다.
그 와중에 소셜네트워크 (이하 Gallery )는 비 로그인 유저와 로그인 유저가 사용 할 수 있고, SEO가 중요하다 보니 Next.js
Page Router를 사용하여 SSR을 통해 SEO와 빠른 pre-rendering을 하였다.
우리 프로젝트의 소셜네트워크는 두 가지의 페이지로 구성되어 있다.
pages/gallery/index.tsx
+ SSR
pages/gallery/detail/[id].tsx
+ SSR
위에서 소개한 갤러리 페이지들의 문제점은
prefetch
한 QueryClient
가 계속해서 새롭게 생성과 호출을 한다는 점이전 포스팅 Vercel에서 Next.js SSR의 과정을 읽고 오면 이해가 잘 됩니다. ( 반박시 님들 말이 맞음 )
어떻게 하면 Next.js의 SSR과 SEO 그리고 React와 같이 부드러운 CSR의 이점을 살릴 수 있느냐라는 문제에 직면 했습니다.
웹 서비스의 목적은 결국 유저에게 필요한 서비스를 제공하는 목표를 가지고 있다 생각합니다. 다 같이 집을 짓고 인테리어를 멋지게 해도, 교통과 인프라가 제한되어 불편함을 야기시키게 되는 과정이 있다면 유저가 집을 살지 의문입니다.
결국 멋진 집이더라도 회사, 슈퍼, 백화점 등... 거리가 멀다면 , 유저가 진입 하지 않을 것이기 때문입니다.
물론 아닐 수도 있겠지만, devOps부터 시작해 Next.js가 왜 React기반이고,
React와 같이 CSR을 할 수 있는지, 그리고 서버와의 통신을 공부했습니다.
서버 호출 최소화와 렌더링 최적화를 위한 idea는 Link shallow={true}
파일구조 그리고 React-Query Cache
였습니다.
Link Component에는 shallow 기능이 있는데, 이 기능을 true로 하면, 현재 url path를 SSR
을 실행하지 않고 업데이트 합니다.
이 shallow기능을 활용하면 불필요한 서버 호출(SSR) 과 부드러운 페이지 전환을 할 수 있을 거라는 힌트를 얻게 되었습니다.
Link Component의 shallow
기능을 활용해 Gallery Main.tsx
과 Gallery Detail.tsx
, 그리고 Gallery Detail Pagination.tsx
이 3가지에서 SSR 호출 없이 CSR을 적용 하고 싶다면 파일 구조를 바꿔야 했습니다.
기존에는 위의 3가지의 .tsx
사이를 routing하면 SSR
이 호출 후에 렌더링 되기 때문에 SSR과 깜빡(비동기통신)거리는 현상이 생기기 때문입니다.
따라서 페이지의 구조를 [[...id]].tsx
으로 바꾸어 url query에 값에 따라 조건부로 Component를 보여주면 shallow를 통해 SSR호출을 바꿀 수 있다 생각했습니다.
" 어떻게 하면 Next.js의 SSR과 SEO 그리고 React와 같이 부드러운 CSR의 이점을 살릴 수 있느냐라는 문제에 직면 했습니다. "
이 물음에 대한 저의 해결 책은
Server Request
Client Navigated
두 가지의 시나리오를 가지고 접근 하는 것이였습니다.
Link / useRouter로 페이지 전환에는
/next/static
이 붙습니다.
이해 안되면 이전 포스팅 참고해주세요, 배포 하면, server에서 사용할 것 client에서 사용할 것등으로 나뉘기 때문입니다.
getServerSideProps안에서 조건문으로 추가해도 되지만, HOC Function으로 만들었습니다.
페이지의 파일 구조를 하나의 파일 안에서 Gallery.tsx
와 GalleryDetail.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
를 사용하면 infinityQuery
의 pageParam
값을 useRef
로 잡아둘 수있습니다. 물론 Link shallow
도 같이 적용해야 한다 판단합니다.
파일의 구조 변경 문단에서 보면 has.ctx.query.id
가 보이는데,
< Gallery Main />
컴포넌트와 < Gallery Detail/>
Component가 url
에 따른 조건부 컴포넌트가 보여지도록 query.id
에 따라 SSR을 해주도록 하였습니다.
아래의 코드를 보면 queryClient
의 prefetch
가 아닌 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 해주면 됩니다.
},
};
});
먼저 가장 크게 두 개의 컴포넌트로 나누었습니다.
Gallery Main.tsx
ComponentGalleryDetail.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 가지의 작업을 하였습니다.
react-query 캐싱
하는 작업shallow={true}
작업<Link href={..., query: {id} } />
작업<Link prefetch={false}
작업 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
를 통해 데이터가 바뀌면 다시 재렌더링 하는 쪽으로 데이터 일관성 문제를 해결 하였습니다.
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
이 호출되므로 데이터 일관성은 문제 없으리라 생각합니다.
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
창을 보여주는 것이 빠르게 보여줄 수 있는 것부터 보여주고 싶기 위함입니다.
vercel에서는 Next.js의 Serverless Function의 default 지역이 미국으로 되어있습니다.
그렇기에 Gateway의 위치를 이전 시켰습니다. 바로 인천으루
Faas (Function as a Service)의 단점은 cold start가 있습니다. 함수가 실행되는데 걸리는 시간도 있지만, 물리적으로 호출 되는 지역을 한국으로 바꾸어 cold start라는 단점을 우회하였습니다.
실질적으로 우리가 주고 받는 서버의 지역도 중요합니다.
저희 공모전 프로젝트에서는 AWS EC2를 사용하고 있고, nest.js
노드 프레임워크를 사용하고 있던 터라, 프리티어가 적용이 되지 않아 서버를 닫았습니다.
그러나 저는 저의 최적화 설계의 가설을 검증하기 위해
저희 프론트에 유능한 만물박사 리동탁 (이하:도라에몽) 님한테
"
nest
가 무거워서 프리티어가 적용이 안되면express
로 디그레이드 하여 올리면 되지 않겠냐 "
라고 의견을 제시 했고,
그 분께서는 가능 할것 같다라는 답변을 주어, 그분께서 도와주셨습니다.
많은 것을 알려주셨고, express logic
은 리동탁님께서 짜주셨습니다.
리팩토링 이후에 무엇이 변했는지 측정하는 것은 예비 개발자/개발자 에게 중요한 덕목 이라 생각합니다.
결과는 대단했습니다. ( 취업하고 싶다.~)
측정은
_app.tsx
안에 router 이벤트를 통한 측정(transition, PageLoadTime등...) , 위의 두가지로 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
이 뜻하는 것은
/
에서 Gallery Main
Gallery Main
에서 Gallery Detail
Detail
에서 다른 Detail
5가지 입니다.
무엇보다 가장 큰 성과는 Serverless Function
호출수가 N번에서 1번으로 고정시켰다는 점입니다.
처음에는 로컬로 서버를 돌리면서 test를 하고, 그 다음에는 aws 서버를 배포하고, aws EC2를 이전하면서 AWS로 배포를 하는 이유가 무엇인지 아주 아주 약간은 알것 같았습니다. 비록 서비스 페이지를 아마존에 배포는 아직 안해봤지만, 서버를 배포해보면서 백엔드에서 배포하고 port를 열어주는 수고로움을 알 수 있었습니다.
한 가지의 아이디어를 통해 가설을 세우고 검증하기 위해 로컬 서버부터 ~ 아마존 서버배포하면서 가설이 맞아 들었을 때, 정말 황홀한 기분이였습니다. 그리고 가장 중요한 express 로직과 서버 배포에서 쉽게 설명해주시는 역할을 해주신 이동탁님께 이 자리를 빌어 감사함을 드립니다.