인프런 "한 입 크기로 잘라먹는 Next.js" 수강
데이터를 흘려보내는 기술 즉, "강물처럼 데이터를 흘려보낸다!"
스트리밍이란 말 그대로 데이터를 잘게 쪼개서 연속적으로 전달하는 기술이다.
즉, 모든 데이터를 다 받기 전에 일단 보낼 수 있는 만큼 먼저 렌더링해주는 방식!
그럼 왜 사용할까?
👉 사용자에게 빠르게 ‘뭔가’를 보여줄 수 있기 때문이다.
데이터를 기다리는 동안 아무것도 안 보이는 대신, 로딩바나 스켈레톤처럼 대체 UI를 먼저 보여주면 사용자는 "앱이 멈췄나?"라는 불안감 없이 기다릴 수 있다.
예를 들어, 검색 페이지(/search) 같은 Dyanamic Page는 사용자가 요청할 때마다 서버가 모든 컴포넌트를 실행해서 매번 새롭게 렌더링해야 한다.

그런데 만약 특정 컴포넌트 안에서 API 요청이 오래 걸리면, 전체 페이지가 그 데이터를 기다리느라 하염없이 로딩 상태로 머물게 된다.

스트리밍을 적용하면 느리게 렌더링되는 부분은 일단 대체 UI로 보여주고,
데이터가 준비되면 그 부분만 교체해서 빠르게 화면을 완성한다.
페이지 스트리밍(Page Streaming)
오래 걸리는 컴포넌트의 렌더링을 기다리는 동안, 빠르게 렌더링할 수 있는 컴포넌트들을 먼저 보여주는 기술
동적 페이지와 같은 폴더에 loading.tsx 파일을 만들어주면 끝!
export default function Loading() {
return <div>Loading</div>
}
loading.tsx는 비동기 페이지에만 적용된다.async 키워드가 있는 비동기 컴포넌트에서만 적용된다.Suspense를 이용해야 한다.Suspense는 컴포넌트 단위로 스트리밍을 적용할 수 있게 해준다. 다음처럼 fallback UI를 함께 지정해주면 된다.
import BookItem from '@/components/book-item';
import { BookData } from '@/types';
import { Suspense } from 'react';
async function SearchResult({ q }: { q?: string }) {
const response = await fetch(`{process.env.NEXT_PUBLIC_API_SERVER_URL}/book?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 async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
const { q } = await searchParams;
return (
<Suspense>
<SearchResult q={q || ''} />
</Suspense>
);
}
이떄 key 값을 바꿔주면 Suspense가 다시 로딩 상태로 돌아간다. 검색어가 바뀔 떄마다 새로운 로딩 UI가 뜨는 이유!
//...
export default async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
const { q } = await searchParams;
return (
<Suspense key={q || ''} fallback={<div>Loading...</div>}>
<SearchResult q={q || ''} />
</Suspense>
);
}
스켈레톤 = 뼈대
데이터를 불러오기 전, 페이지 구조만 먼저 보여주는 UI이다.
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={3} />}>
<AllBooks />
</Suspense>
</section>
</div>
);
}
로딩 중에는 이렇게 스켈레톤 컴포넌트를 보여주면 된다.👇
import BookItemSkeleton from './book-item-skeleton';
export default function BookListSkeleton({ count }: { count: number }) {
return new Array(count)
.fill(0)
.map((_, idx) => <BookItemSkeleton key={`book-item-skeleton-${idx}`} />);
}
import style from '@/components/skeleton/book-item-skeleton.module.css';
export default function BookItemSkeleton() {
return (
<div className={style.container}>
<div className={style.cover_img}></div>
<div className={style.info_container}>
<div className={style.title}></div>
<div className={style.subTitle}></div>
<br />
<div className={style.author}></div>
</div>
</div>
);
}
스켈레톤 UI는 사용자에게 “앱이 살아있다”는 신호를 주는 UX 기술로,
직접 구현해도 좋지만 귀찮다면 react-loading-skeleton 라이브러리를 쓰면 편하다고 하네용!
error.tsx 파일을 같은 경로에 만들어주면, 해당 페이지에서 발생한 에러를 전용 화면으로 처리할 수 있다.
이떄, 에러 페이지의 경우 서버 연결의 실패 등을 표현해주기 떄문에 'use client'로 설정해줘야 한다.
'use client';
export default function Error() {
return (
<div>
<h3>오류가 발생했습니다.</h3>
</div>
);
}
추가로 error에 대해서 명시하고 싶다면, Next.js에서 제공하는 error props를 이용하면 된다.
'use client';
import { useEffect } from 'react';
export default function Error({ error }: { error: Error }) {
useEffect(() => {
console.error(error);
}, []);
return (
<div>
<h3>오류가 발생했습니다.</h3>
</div>
);
}
여기에서 Error 에는 message 라는 값이 있기 떄문에 메시지만 따로 출력할 수 있다.
에러 컴포넌트에서 error 이외에 추가적으로 reset이라는 props가 더 제공된다.
**reset props**
에러가 발생한 페이지를 복구하기 위해서 다시 한 번 컴포넌트들을 렌더링 시켜주는 기능을 가진 함수이다.
우리는 이걸 활용하여 다시 시도 버튼을 추가해보자!
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
useEffect(() => {
console.error(error.message);
}, []);
return (
<div>
<h3>오류가 발생했습니다.</h3>
<button onClick={() => reset()}>다시 시도</button>
</div>
);
}
reset()
클라이언트 측에서 서버에서 전달받은 데이터를 토대로 다시 렌더링하는 메서드로,
서버 컴포넌트를 다시 실행하지 않아서 데이터 페칭을 다시 불러오지는 않는다.
그러면 서버를 다시 실행시키려면 어떻게 해야할까?
--
브라우저를 새로고침한다.
reset() 함수가 있는 곳에 window.location.reeload()를 대신 넣어준다.
하지만 이 방식은 오류가 발생하지 않은 곳들도 새로 가져오기 떄문에 그렇게 좋은 방식은 아니다.
useRouter의 refresh 메서드를 이용한다.
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
const router = useRouter();
useEffect(() => {
console.error(error.message);
}, []);
return (
<div>
<h3>오류가 발생했습니다.</h3>
<button onClick={() => router.refresh()}>다시 시도</button>
</div>
);
}
현재 페이지에 필요한 서버 컴포넌트들을 다시 불러오는 역할을 수행한다.
👉 정리하면
router.refresh()는 서버 컴포넌트를 다시 불러오고,reset()은 클라이언트의 에러 상태를 리셋한다.
서버도 다시 불러오고 에러 상태인 클라이언트도 다시 리셋하자!
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
const router = useRouter();
useEffect(() => {
console.error(error.message);
}, []);
return (
<div>
<h3>오류가 발생했습니다.</h3>
<button
onClick={() => {
router.refresh();
reset();
}}
>
다시 시도
</button>
</div>
);
}
⚠️ 주의할 점은
refresh()메서드는 비동기로 실행되기 때문에, 해당 메서드의 동작이 끝난 후에reset()을 진행해야 한다.//... onClick={() => { startTransition(() => { router.refresh(); reset(); }); }} //...그리고
error.tsx파일은 하위까지 적용된다는 점 유의! (레이아웃과는 다르게 중첩되지 않고 덮어씌어짐.)마지막으로 에러가 발생한 지점의 레이아웃까지만 렌더링을 시켜주기 때문에 위치를 잘 조정해야 한다.