스트리밍
스트리밍 (Streaming)? : 잘게 쪼개진 데이터들을 연속적으로 보내주는 기능
→ 클라이언트 입장에서는 모든 데이터를 받지 않은 상태여도 지금까지 전달받은 데이터에 접근 OK! 사용자에게 긴 로딩없이 데이터 제공할 수 있게 되었다!
비동기작업이 없는 렌더링 작업이 빨리 되는 페이지는 먼저 보여주고, 비동기작업이 포함된 비교적 렌더링이 느린 경우 스트리밍을 이용한다. ⭐️ 스트리밍은 Dynamic page
에만 적용이 가능하다!!
Q : 느리게 렌더링 된 부분은 어떻게 되나요?
A : 로딩바 같은 대체 UI를 보여주면 된다!
따라서 스트리밍을 이용하면 단순한 레이아웃이라도 빠르게 먼저 보여줄 수 있다!
loading
페이지를 적용하고자 하는 파일과 동일 선상에 loading.tsx
파일을 만들어준다.
이렇게 만들면 자동적으로 page.tsx
는 먼저 렌더링 된 부분만 스트리밍이 적용되어 보여지게 되고, 렌더링이 덜 되어 로딩이 필요한 부분에는 loading.tsx
의 내용이 나타난다.
loading.tsx
를 만들게 되면 사실상 그 아래에 있는 page.tsx
만 영향을 미치는 것이 아니라, 마치 layout
처럼! 해당 경로 아래에 있는 모든 비동기 컴포넌트들을 스트리밍이 되도록 설정 해준다.
loading.tsx
로 만든 경우 모든 컴포넌트에 영향을 미치는게 아니라 async
라는 키워드가 붙어 비동기로 설정된 컴포넌트 페이지에만 스트리밍이 적용된다.
loading.tsx
파일은 무조건 페이지 컴포넌트에만 적용할 수 있다
→ layout
이나 components
폴더 내에 있는 일반적인 컴포넌트에는 loading.tsx
파일로는 스트리밍을 적용시킬 수 없다! (Suspense
컴포넌트 이용)
페이지의 경로가 바뀌는 것이 아닌, 쿼리스트링
의 값만 변경될 경우 loading.tsx 파일의 스트리밍이 동작하지 않는다. (Suspense
컴포넌트 이용)
loading.tsx
파일을 활용해서 로딩 UI를 구현하면 무조건 페이지 컴포넌트에만 적용할 수 있다는 것이 단점이었다. 이를 해결하기 위해, 그리고 일반적으로 로딩 페이지를 구현할 때 많이 사용되는 Suspense
를 사용해 컴포넌트에도 로딩 UI를 그릴 수 있게 적용해보려 한다.
3-1. 적용할 코드 보기
import BookItem from "@/components/book-item";
import { BookData } from "@/types";
import delay from "@/util/delay";
import { Suspense } from "react";
async function SearchReasult({ q }: { q: string }) {
**await delay(1500);** // ✅ 비동기적용 - 1.5초 이후 데이터 보여지기
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${q}`,
{ cache: "force-cache" }
);
if (!response.ok) {
return <div>오류가 발생했습니다.</div>;
}
const books: BookData[] = await response.json();
return (
<div>
{books.map((book) => (
<BookItem key={book.id} {...book} />
))}
</div>
);
}
스트리밍은 비동기작업이 실행될 때에 적용이 가능하기 때문에 코드를 살짝 수정해주었다.
async
/ await
키워드를 사용해서 비동기작업이 실행되게 하고 delay
함수를 사용해서 이 시간 이후에 데이터가 보여지도록 만들었다.
3-2. Page 컴포넌트에 Suspense 적용하기
export default function Page({
searchParams,
}: {
searchParams: {
q?: string;
};
}) {
<Suspense fallback={<div>Loading...</div>}>
return <SearchReasult q={searchParams.q || ""} />;{" "}
{/*스트리밍 적용 - 로딩적용 */}
</Suspense>;
}
Suspense
를 적용시키는 방법은 아주 간단하다.
SearchReasult
에 적용시키기 위해 해당 컴포넌트를 Suspense
로 감싸면 이제 SearchReasult
는 스트리밍이 적용이 된다.
div
태그로 글만 적어도 되고 컴포넌트
를 넣어도 된다.여기서 추가적으로 해야하는 작업이 있는데 앞에서 봤듯이 쿼리스트링
으로 값이 변경될 때에도 스트리밍이 적용되게 하려면 어떻게 해야할까?
<Suspense key={searchParams.q || ""} fallback={<div>Loading...</div>}>
return <SearchReasult q={searchParams.q || ""} />;{" "}
</Suspense>;
key={searchParams.q || ""}
처럼 key
값을 활용해주면 된다.
🫨 이게 어떻게 가능한걸까?
Suspense
는 이 내부에 어떤 컨텐츠가 변경이 되더라도 기본적으로는 새로운 로딩 상태로 돌아가지 않는다.
일종의 트릭같은건데, 이럴 때에는 Suspense
의 key
값을 바꿔서 쿼리스트링
이 바뀔 때마다 이 컴포넌트 자체를 리액트에게 새로운 컴포넌트로 인식하게 해서 Suspense
컴포넌트를 새롭게 그리게 만든다.
⭐️ Key 값 변경 ⇒ 새로운 컴포넌트로 인식 ⇒ Suspense 스트리밍 적용! ⇒ 로딩페이지 보여지기
사실 Suspense
는 위 예시보다 병렬적으로 적용이 가능하다는 점이 가장 큰 장점이다.
Suspense
를 여러 개 사용할 수 있는 코드를 보며 정리해보자.
3-3. 병렬적 Suspense 코드 예시
import BookItem from "@/components/book-item";
import style from "./page.module.css";
import { BookData } from "@/types";
import delay from "@/util/delay";
import { Suspense } from "react";
// 1. 모든 책 리스트가 보여지는 AllBooks 비동기 함수
async function AllBooks() {
await delay(1500); // ✅ 1.5초 delay 설정
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book`,
{ cache: "force-cache" }
);
if (!response.ok) {
return <div>오류가 발생했습니다.</div>;
}
const allBooks: BookData[] = await response.json();
return (
<div>
{allBooks.map((book) => (
<BookItem key={book.id} {...book} />
))}
</div>
);
}
// 2. 추천하는 책 리스트가 보여지는 RecoBooks 비동기 함수
async function RecoBooks() {
await delay(3000); // ✅ 3초 delay 설정
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/random`,
{
next: {
revalidate: 3,
},
}
);
if (!response.ok) {
return <div>오류가 발생했습니다.</div>;
}
const recoBooks: BookData[] = await response.json();
return (
<div>
{recoBooks.map((book) => (
<BookItem key={book.id} {...book} />
))}
</div>
);
AllBooks
함수와 RecoBooks
함수 모두 비동기작업이 적용된 함수이다. 각각 1.5초, 3초 delay
가 적용되어 최종적으로 모든 데이터가 불러와지는데는 3초가 소요된다.
export const dynamic = "force-dynamic";
//강제로 비동기페이지로 만듦 -> why? 스트리밍은 비동기페이지에만 적용할 수 있으니까
export default function Home() {
return (
<div className={style.container}>
<section>
<h3>지금 추천하는 도서</h3>
<Suspense fallback={<div>도서를 불러오는 중입니다...</div>}>
<RecoBooks />
</Suspense>
</section>
<section>
<h3>등록된 모든 도서</h3>
<Suspense fallback={<div>도서를 불러오는 중입니다...</div>}>
<AllBooks />
</Suspense>
</section>
</div>
);
}
우선 이 페이지는 정적페이지로 짜여져있어서 스트리밍을 적용할 수 없는 페이지였다. 하지만 병렬적으로 Suspense
가 적용되는 경우를 확인하기 위해 임의로 force-dynamic
옵션을 넣어주었다.
이렇게 코드를 작성한 후 브라우저에서 결과를 확인해보면 fallback
에 적은 “도서를 불러오는 중입니다…” 화면이 그려지게 된다.
✅ 결과화면 확인
스켈레톤 UI
스켈레톤 UI는 필수사항이 아니라 사용자에게 보다 나은 경험을 제공하기 위해 만들어 주기 때문에 권장(?)한다. 컴포넌트 같은 곳에 스켈레톤 UI를 만들 파일을 만든 후 Suspense
의 fallback
에 넣어주면 된다.
export default function Home() {
return (
<div className={style.container}>
<section>
<h3>지금 추천하는 도서</h3>
**<Suspense fallback={<BookListSkeleton count={3} />}>**
<RecoBooks />
</Suspense>
</section>
<section>
<h3>등록된 모든 도서</h3>
**<Suspense fallback={<BookListSkeleton count={10} />}>**
<AllBooks />
</Suspense>
</section>
</div>
);
}
BookListSkeleton
는 컴포넌트를 개수마다 여러 번 넣어줘도 되지만 효율적으로 사용하기 위해 count
라는 매개변수를 받는 컴포넌트를 하나 만들어주었다.
👩🏻💻 BookListSkeleton
export default function BookListSkeleton({ count }: { count: number }) {
//count 매개변수 전달 받음
return new Array(count) // count 수 만큼 배열 생성
.fill(0) // 처음에는 0, count가 3이면 fill도 3
.map((_, idx) => <BookItemSkeleton key={`book-item-skeleton-${idx}`} />);
}
위 코드에 있는 BookItemSkeleton
는 스켈레톤 UI 작업이 완성된 컴포넌트이다. 따라서 idx
값 만큼 map
메소드에 의해 반복되어 나타나게끔 만들었다.
✅ 브라우저 화면 확인
아까 글씨로 나타나는 것보다 훨씬 완성도 있게 보여진다.
이런 스켈레톤 UI를 직접 만들기 어려운 (귀찮은) 경우 react-loading-skeleton
라이브러리를 설치해서 사용할 수 있다.
참고 링크 : 스켈레톤 라이브러리
출처 : 한 입 크기로 잘라먹는 Next.js - 이정환
https://www.udemy.com/course/onebite-next/