Section 7. 스트리밍과 에러핸들링

OlMinJe·2025년 10월 21일

Next.js

목록 보기
17/20
post-thumbnail

인프런 "한 입 크기로 잘라먹는 Next.js" 수강

스트리밍이란?

데이터를 흘려보내는 기술 즉, "강물처럼 데이터를 흘려보낸다!"

스트리밍이란 말 그대로 데이터를 잘게 쪼개서 연속적으로 전달하는 기술이다.
즉, 모든 데이터를 다 받기 전에 일단 보낼 수 있는 만큼 먼저 렌더링해주는 방식!

그럼 왜 사용할까?
👉 사용자에게 빠르게 ‘뭔가’를 보여줄 수 있기 때문이다.

데이터를 기다리는 동안 아무것도 안 보이는 대신, 로딩바나 스켈레톤처럼 대체 UI를 먼저 보여주면 사용자는 "앱이 멈췄나?"라는 불안감 없이 기다릴 수 있다.


스트리밍 이전의 문제

예를 들어, 검색 페이지(/search) 같은 Dyanamic Page는 사용자가 요청할 때마다 서버가 모든 컴포넌트를 실행해서 매번 새롭게 렌더링해야 한다.

스트리밍 이전 로직

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

스트리밍 적용 후

스트리밍 적용 로직

스트리밍을 적용하면 느리게 렌더링되는 부분은 일단 대체 UI로 보여주고,
데이터가 준비되면 그 부분만 교체해서 빠르게 화면을 완성한다.

페이지 스트리밍(Page Streaming)
오래 걸리는 컴포넌트의 렌더링을 기다리는 동안, 빠르게 렌더링할 수 있는 컴포넌트들을 먼저 보여주는 기술


1. 페이지 스트리밍 적용하기

동적 페이지와 같은 폴더에 loading.tsx 파일을 만들어주면 끝!

export default function Loading() {
 return <div>Loading</div>
}

⚠️ 주의할 점

  1. loading.tsx는 비동기 페이지에만 적용된다.
    비동기 컴포넌트가 아니면 데이터를 불러오지 않고 있다는 의미이기 때문에, async 키워드가 있는 비동기 컴포넌트에서만 적용된다.
  2. 페이지 컴포넌트 전용이다.
    일반 컴포넌트 폴더 안에서는 사용할 수 없고, 대신 Suspense를 이용해야 한다.
  3. Query String 변경 시엔 트리거되지 않음.
    예를 들어 검색창의 쿼리만 바뀌면 전체 페이지가 로딩 상태로 가지 않는다. 그래서 페이지 전체가 한 번에 업데이트되어 UX가 살짝 어색할 수 있다.

2. 컴포넌트 스트리밍 적용하기

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>
  );
}

3. 💀 스켈레톤 UI로 사용자 경험 업그레이드

스켈레톤 = 뼈대
데이터를 불러오기 전, 페이지 구조만 먼저 보여주는 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 라이브러리를 쓰면 편하다고 하네용!


4. 에러 핸들링(error.tsx)

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 라는 값이 있기 떄문에 메시지만 따로 출력할 수 있다.


Reset Props

에러 컴포넌트에서 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()
클라이언트 측에서 서버에서 전달받은 데이터를 토대로 다시 렌더링하는 메서드로,
서버 컴포넌트를 다시 실행하지 않아서 데이터 페칭을 다시 불러오지는 않는다.

그러면 서버를 다시 실행시키려면 어떻게 해야할까?

--

서버 다시 실행하기

  1. 브라우저를 새로고침한다.
    reset() 함수가 있는 곳에 window.location.reeload()를 대신 넣어준다.
    하지만 이 방식은 오류가 발생하지 않은 곳들도 새로 가져오기 떄문에 그렇게 좋은 방식은 아니다.

  2. useRouterrefresh 메서드를 이용한다.

    '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 파일은 하위까지 적용된다는 점 유의! (레이아웃과는 다르게 중첩되지 않고 덮어씌어짐.)

마지막으로 에러가 발생한 지점의 레이아웃까지만 렌더링을 시켜주기 때문에 위치를 잘 조정해야 한다.

profile
큐트걸

0개의 댓글