1. 서버에서 렌더링할 수 있는 컴포넌트는 Suspense로 감싸지 않고, 서버단에서 렌더링할 수 있도록 하기
📚 Suspense 다이브
Suspense는 React16에서 처음 등장했으며,
React.lazy()와 함께 사용하여 Code Splitting을 할 수 있었다.`React.lazy()`를 사용하여 초기에 불필요한 컴포넌트를 렌더링하지 않을 수 있다.React18로 넘어오면서 data fetching에도 확대 적용이 되었다. 비동기 처리에 대한 책임을 Suspense에 위임할 수 있다.
❓ Suspense는 하위 children 컴포넌트들의 비동기 상태를 어떻게 감지할까?
핵심은 하위 컴포넌트에서 Promise를 throw한다는 것이다. promise가 pending 혹은 error라면 상위로 throw하고, 데이터가 준비된 시점에는 response를 return한다. error를 throw하면 errorboundary와 상호작용을 하는 것이다.
아래의 wrapPromise함수는 Suspense와 자식 컴포넌트가 어떻게 소통하는 지에 대한 힌트를 얻을 수 있다.
function wrapPromise(promise) { let status = "pending"; let response; const suspender = promise.then( (res) => { status = "success"; response = res; }, (err) => { status = "error"; response = err; } ); const read = () => { switch (status) { case "pending": throw suspender; case "error": throw response; default: return response; } }; return { read }; } export default wrapPromise;wrapPromise는 promise를 한 번 감싸서, promise가 pending 혹은 erorr상태라면 상위로 throw하고, 데이터가 준비된 시점에는 response를 return한다.
이렇게 Promise 상태에 따라 상위로 throw함으로써 상위에 존재하는 Suspense, ErrorBoundary컴포넌트와 커뮤니케이션할 수 있다.
기존 코드
<>
<Suspense fallback={<Loading />}>
<GroupingHeader />
<HydrationProvider queryFn={() => getGroups(0)} queryKey={Keys.getGroups()} isInfiniteQuery>
<GroupingCardList />
<CreateGroupButton />
</HydrationProvider>
</Suspense>
<Spacing size={60} />
</>
위 코드에서, Suspense는 데이터를 미리 서버에서 받아오는 HydrationProvider컴포넌트를 감싸고 있다. 이 컴포넌트가 Next.js서버에서 먼저 실행되고, 그 안의 비동기 함수가 pending 상태라면 Suspense의 fallback으로 넘긴 컴포넌트를 보여주는 것이다.
수정 후 코드
<>
<GroupingHeader />
<Suspense fallback={<Loading />}>
<HydrationProvider queryFn={() => getGroups(0)} queryKey={Keys.getGroups()} isInfiniteQuery>
<GroupingCardList />
</HydrationProvider>
</Suspense>
<CreateGroupButton />
<Spacing size={60} />
</>
2. Grouping 상세 페이지 여러 개의 Suspense로 감싸기
현재 상황
현재 컴포넌트 구조는 다음과 같다.
<>
<GroupDetailHeader />
<Suspense fallback={<Loading className="h-[calc(100dvh-48px)]" />}>
<HydrationProvider
queryMultipleFn={[
() => getGroupDetail(groupId),
() => getGroupMembers(groupId),
() => getNotices(groupId),
]}
queryMultipleKey={[
Keys.getGroupDetail(groupId),
Keys.getGroupMembers(groupId),
Keys.getNotices(groupId),
]}
>
<GroupDetailPage />
</HydrationProvider>
</Suspense>
</>
Suspense > HydrationProvider가 모든 하위 컴포넌트를 감싸고 있다.
1,2번 째 api인 getGroupDetail과 getGroupMembers가 첫 번째 탭, 3번째 api인 getNotices는 두 번째 탭에서 필요한 데이터이다.
이 데이터들을 모두 첫 페이지 로드할 때부터 불러올 필요가 있을까?
이렇게 구현할 때는 세 개의 api를 서버 단에서 모두 병렬적으로 받아오기에 성능상 차이가 없을 것이라 생각했다. 정말로 과연 그럴까?
3개의 데이터를 정말로 모두 병렬적으로 가져올까?
// HydrationProvider
if (queryMultipleFn && queryMultipleKey) {
await Promise.all(
queryMultipleFn.map((queryFn, index) => {
return queryClient.prefetchQuery({ queryKey: queryMultipleKey[index], queryFn });
})
);
}
여러 함수를 넘기면 서버 단에서는 Promise.all을 사용한다. Promise.all은 병렬로 비동기 처리를 수행한다. 단, 가장 마지막으로 응답이 돌아온 api까지 완료되었을 때 resolve처리를 한다.
이러한 병렬 처리가 성능상 문제는 없을까?
Promise.all에 대해 다이브해보자.
📚 Promise.all 다이브
Promise.all은 모든 비동기 작업을 동시에 수행한다. 10개의 비동기 작업을 Promise.all을 통해 처리할 경우 자바스크립트는 모든 비동기 작업을 동시에 시작한 후, 10개의 작업이 모두 종결되는 순간 결과를 취합해 배열로 만든다.
단, 배열 속 Promise 중 하나라도 에러로 처리될 경우 결과는 에러를 리턴한다.
![]()
브라우저도 모두 지원한다.
Promise.all의 리턴값 역시 마찬가지로 Promise이다.
성능상은 문제가 없다. 하지만, Promise.all을 사용했을 때 단점은 인자로 넘긴 배열의 요소 중 하나라도 실패하면, Promise.all의 리턴값 또한 실패라는 점이다. 이러한 단점을 해결하기 위해서는 Promise.allSettled라는 메서드를 활용할 수도 있다.
3. 초기 로딩 시간 줄이기
1. 앱 실행 시 썸네일 보여주는 시간 줄이기
-
기존 3초 → 0.5초
확실히 시간이 감소한 것이 눈에 보인다. 진작 줄일껄 그랬다.
2. page.tsx가 아니라 grouping 페이지로 우선 이동하기
- 이미 속도가 충분히 빠른 듯하다.
4. 이미지 placeholder
빠른 3G환경에서 테스트해보았을 때 이미지가 보여지는 영상이다. 상당히 느리다. placeholder로 넣은
이미지의 placeholder를 구현하자
1. s3로부터 전달받은 이미지 url을 buffer로 변환한다.
// getBufferFromS3Url.ts
export const getBufferFromS3Url = (s3Url: string): Promise<Buffer> => {
return new Promise((resolve, reject) => {
https
.get(s3Url, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
res.on('end', () => {
const buffer = Buffer.concat(chunks);
resolve(buffer);
});
})
.on('error', (error: Error) => {
reject(error);
});
});
};
2. buffer를 getPlaiceholder의 인자로 넘겨, base64를 반환한다.
// getBase64.ts
const getBase64 = async (src: string) => {
const buffer = await getBufferFromS3Url(src);
const {
metadata: { height, width },
...plaiceholder
} = await getPlaiceholder(buffer, { size: 10 });
return {
...plaiceholder,
img: { src, height, width },
};
};
export default getBase64;
3. 전달받은 base64를 Image태그의 placeholder로 넘긴다.
// ImageWithPlaceholder.tsx
export default async function ImageWithPlaceholder({ src }) {
const { base64, img } = await getBase64(src);
return (
<Image
src={src}
alt={src}
width={img.width}
height={img.height}
sizes="65vw"
style={{ height: 'auto' }}
placeholder="blur"
blurDataURL={base64}
/>
);
}
결과
현재 우리 프로젝트에 도입이 어려운 이유
현재 데이터를 받아오는 우리 프로젝트 방식은 이러하다.
- HydrationProvider로 데이터를 prefetching 후 dehydrate하여 브라우저에 보낸다.
- 이 데이터를 기반으로 서버에서 렌더링을 수행한다.
- 렌더링이 되는 대로 토큰으로 쪼개어 브라우저 스트림 형식으로 전송한다.
- HydrationProvider로 감싼 컴포넌트 안에서 useQuery를 사용하여 캐싱된 데이터를 가져온다.
- useQuery를 사용하려면, 클라이언트 컴포넌트여야한다.
- 클라이언트 컴포넌트에서는 서버 컴포넌트를 import할 수 없다.
여기서, ImageWithPlaceholder컴포넌트는 서버 컴포넌트이다. 전달받은 imagrUrl을 src props로 넘겨주면 서버에서 blur64등 정보를 제공하는 컴포넌트이다. 하지만, useQuery를 사용하기에 무조건 클라이언트 컴포넌트어야 하는 컴포넌트에서 import하여 사용할 수는 없다.
모든 우리 프로젝트의 데이터fetching은 위와같은 방식으로 동작한다.
이러한 이유로 도입이 어렵다고 판단하였다.