

OTT 서비스, 대용량 영상 파일을 빠른 속도로 시청하는 기능을 제공. 이를 가능하게 해주는 것이 바로 스트리밍Streaming.
서버에서 클라이언트로 전달해야하는 파일의 용량이 너무 크면, 전송에 걸리는 시간과 자원이 많이 필요하다.
그렇다면, 큰 파일을 여러 개의 조각으로 개천에서 흐르듯이Streaming 나누어서 전송한다면? 잘게 쪼개진 파일을 연속적으로 전송하는 기술을 스트리밍Streaming이라 한다.



이런 구조의 페이지가 있다고 가정해보자. 페이지를 렌더링하기 위해서는 4개 컴포넌트가 모두 존재해야하는데, 네트워크 상태나 파일 크기에 따라서 페이지가 사용자에게 보여지는 시간이 지연될 가능성이 있다. 스트리밍Streaming 기술을 사용하지 않는다면, 모든 컴포넌트가 전송될 때까지 사용자 화면에서는 아무것도 표시할 수 없을 것이다.
스트리밍Streaming 기술을 사용한다면, 우선적으로 도착하는 컴포넌트만 화면에 렌더링 하면 된다. 아직 도착하지 않은 컴포넌트는 스켈레톤 UI나 로딩 UI 등을 활용해서 사용자에게 아직 로딩이 완료되지 않았다는 점을 명시적으로 보여주기만 하면 된다.
식당에 가서 음식을 시키면, 밑반찬들을 먼저 제공해주는 것이라고 생각하면 이해가 빠르다. 본 음식이 아직 나오지 않았어도 밑반찬들을 뒤적거리거나 하나씩 집어먹으면서 기다릴 수 있다는 점이 스트리밍Streaming 기술의 의의와 매우 흡사하기 때문.



export default function Loading() {
return <div>Loading ...</div>;
}
스트리밍Streaming을 적용하는 방법. 해당 컴포넌트가 존재하는 폴더에 Loading 컴포넌트를 작성해준다.
Loading.tsx는 search 페이지가 아직 로딩되지 않았을 때, 대체 UI로써 자동적으로 사용된다.

만약 search 폴더 내부에 하위 경로 페이지를 추가할 경우, 상위 페이지에 스트리밍Streaming이 적용되어 있기 때문에 하위 페이지에서도 스트리밍Streaming 및 로딩 UI가 자동적으로 적용된다.
다만 async 키워드가 사용된, 비동기 작업을 수행하는 (데이터 페칭 작업이 있는) 컴포넌트에만 스트리밍Streaming 기술이 자동적용된다.
-> 비동기 작업이 없는 경우에는 스트리밍Streaming을 할 이유가 없기 때문.
또한 스트리밍Streaming 기술은 페이지 컴포넌트에만 적용된다. 그냥 컴포넌트들은 기본적으로 스트리밍Streaming 기술이 적용되지 않는다. React Suspense 컴포넌트를 활용할 경우에만 적용된다.
Loading.tsx로 설정한 스트리밍Streaming은 브라우저에서 쿼리스트링이 변경될 때에는 동작하지 않는다.
-> Search 페이지가 1번 렌더링 된 이후, 검색어를 바꿔서 기능을 실행시켜보면 페이지가 변경된 게 아니기 때문에 스트리밍Streaming이 동작하지 않는다. React Suspense 컴포넌트를 사용하면 쿼리스트링 변경시에도 스트리밍Streaming이 동작하게 만들 수 있다
export async function delay(ms: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve("");
}, ms);
});
}
export default async function Page({
searchParams,
}: {
searchParams: {
q?: string;
};
}) {
await delay(1500);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${searchParams.q}`,
{ cache: "force-cache" }
);
}
개발 중, Streaming을 테스트해보려고 할 때. 개인 프로젝트 단위에서 대용량 파일을 인위적으로 만드는 것은 조금 복잡한 일이다. 따라서 이럴 때 사용할 수 있는 방법 중 하나가 setTimeout 함수를 이용해서 컴포넌트 렌더링을 일부러 지연시키는 것.
delay 함수는 전달받은 시간만큼 Timeout을 작동시키고, 그 이후에는 resolve를 받아 동작을 정지시키는 역할을 수행한다. 일정 시간동안 작업을 멈췄다가 다시 동작시킨다는 것.
import { Suspense } from "react";
async function SearchResult({ q }: { q: string }) {
await delay(1500);
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>
);
}
export default function Page({
searchParams,
}: {
searchParams: {
q?: string;
};
}) {
return (
<Suspense
key={searchParams.q || ""}
fallback={<div>Loading ...</div>}
>
<SearchResult q={searchParams.q || ""} />
</Suspense>
);
}
Suspense를 사용하기 위해 코드를 SearchResult라는 별도의 컴포넌트로 분리해보자.
Suspense에 의해 SearchResult 컴포넌트는 Streaming이 적용되게 된다.
Suspense, 미완성/미결. Suspense로 비동기 작업이 포함된 컴포넌트는 렌더링 과정에서 실행이 지연되기 때문에, Next.js에서 Streaming을 자동으로 적용하게 된다.
페이지 스트리밍에서의 Loading.tsx의 역할은 Suspense fallback 옵션이 대신한다. 여기에 로딩 UI를 따로 만들어서 전달해도 된다.
<Suspense
key={searchParams.q || ""}
fallback={<div>Loading ...</div>}
>
<SearchResult q={searchParams.q || ""} />
</Suspense>
페이지 스트리밍에서는 쿼리스트링 변경 시에 Streaming을 동작하게 할 방법이 없다.
Suspense를 이용할 때에는 가능하다. key 옵션을 통해 다시 로딩을 실행하게 할 수 있기 때문. 학습 프로젝트의 경우 searchParams.q가 변화할 때마다 로딩을 반복하게 하고 있다.
원래 Suspense는 최초 렌더링 이후에는 다시는 로딩 상태로 돌아갈 수 없지만, Key 옵션을 통해 다시 로딩으로 돌아가게 할 수 있는 것.
React.js에서는 Key 값이 바뀌면, 해당 컴포넌트가 다른 것으로 변했다고 간주하고 리렌더링을 실시하기 때문.
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>
);
}
학습 프로젝트의 복잡도로는 Streaming의 진가를 명확하게 확인하기 어렵다. 그래도 할 수 있는 선에서 실습을 진행해보자.
이렇게 하나의 컴포넌트에서 여러 Streaming이 적용될 경우, 각 작업들이 병렬적으로 진행되면서 더 나은 사용자 경험을 제공해줄 수 있다.
그래서 보통 페이지 단위보다는 Suspense를 사용하여 컴포넌트 단위로 Streaming을 사용하는 경우가 많다. 더욱 세밀하게 조정할 수 있기 때문.
