Study/Next.js

Next.js에서 React Suspense 실전 적용기

haseulla 2025. 11. 28. 09:00

Next.js에서 React Suspense 실전 적용기

🤔 Suspense란?

React의 Suspense는 컴포넌트가 렌더링되기 전에 필요한 데이터를 기다릴 수 있게 해주는 기능입니다.

기존에는 각 컴포넌트에서 loading 상태를 관리했지만, Suspense를 사용하면 로딩 UI를 부모 레벨에서 선언적으로 처리할 수 있습니다.

기존 방식 vs Suspense

// 기존: 각 컴포넌트마다 로딩 처리
function ExhibitionList() {
  const [loading, setLoading] = useState(true);

  if (loading) return <div>로딩 중...</div>;
  return <div>전시 목록</div>;
}
// Suspense: 선언적으로 처리
<Suspense fallback={<div>로딩 중...</div>}>
  <ExhibitionList />
</Suspense>

이번 글에서는 Next.js 프로젝트에 Suspense를 적용하면서 겪었던 실제 문제들과 해결 과정을 공유합니다.


🎯 Suspense를 써보게 된 계기

최근 면접에서 "Suspense를 사용해본 경험이 있나요?"라는 질문을 받았습니다.
개념은 알고 있었지만 실제로 적용해본 적은 없어서 제대로 답변하지 못했죠...

그래서 진행 중인 프로젝트에 직접 적용해보기로 했습니다!

적용 전 코드 상황

전시 정보 무한스크롤 기능에서 이런 패턴이 반복되고 있었습니다:

const { data, isLoading, isError, error } = useExhibitions();

// 로딩 상태
if (isLoading) {
  return (
    <div>
      <p>로딩 중...</p>
    </div>
  );
}

// 에러 상태
if (isError) {
  return (
    <div>
      <p>에러가 발생했습니다: {error?.message}</p>
    </div>
  );
}

return <ExhibitionList data={data} />;

Suspense를 적용하면 로딩/에러 처리를 더 선언적으로 할 수 있다는 걸 알고, 실전 경험을 쌓기 위해 도입을 결정했습니다.

목표: 이렇게 바꾸기

export default function ExhibitionPage() {
  return (
    <section>
      <ErrorBoundary fallback={<div>에러가 발생했습니다</div>}>
        <Suspense fallback={<div>로딩 중...</div>}>
          <ExhibitionContainer />
        </Suspense>
      </ErrorBoundary>
    </section>
  );
}

컴포넌트에서 로딩/에러 처리 코드를 제거하고, 부모 레벨에서 선언적으로 처리하는 구조입니다.

그런데 그 과정에서 예상치 못한 문제들을 만났습니다. 😅


🚨 문제 1: API URL 에러

에러 발생

Suspense를 적용하고 페이지를 새로고침하니 콘솔에 이런 에러가 나타났습니다:

[TypeError: Failed to parse URL from /api/exhibitions?page=1&limit=20]
{
  digest: '1099032911',
  [cause]: [TypeError: Invalid URL] {
    code: 'ERR_INVALID_URL',
    input: '/api/exhibitions?page=1&limit=20'
  }
}

신기한 건 에러가 나타났다가 곧바로 정상 작동했다는 점이었습니다. 🤔

원인 분석

문제는 상대 경로에 있었습니다.

// hooks/useExhibitions.ts
async function fetchExhibitions(page: number) {
  const response = await fetch(`/api/exhibitions?page=${page}&limit=20`);
  //                            👆 상대 경로
  return response.json();
}

왜 에러가 났다가 성공할까?

  1. 첫 시도 (서버): Next.js 서버에서 실행 → 상대경로 /api/... 인식 못함 → 에러
  2. 재시도 (클라이언트): 브라우저에서 실행 → window.location 기준으로 URL 완성 → 성공

Next.js의 Suspense SSR 때문에 서버에서 먼저 시도했다가 실패하고, 클라이언트에서 다시 시도해서 성공한 것이었습니다.

해결 방법

서버 환경에서도 절대 URL로 요청하도록 수정했습니다.

// hooks/useExhibitions.ts
async function fetchExhibitions(page: number): Promise<ExhibitionsResponse> {
  // 서버 환경인지 확인
  const baseUrl = typeof window === 'undefined' 
    ? process.env.BASE_URL || 'http://localhost:3000'  // 서버: 절대 URL
    : '';                                               // 클라이언트: 상대 URL

  const response = await fetch(
    `${baseUrl}/api/exhibitions?page=${page}&limit=20`
  );

  if (!response.ok) {
    throw new Error("Failed to fetch exhibitions");
  }
  return response.json();
}

핵심: typeof window === 'undefined'로 서버 환경을 감지하고, 서버에서는 명확한 절대 URL을 사용해야 합니다.


🚨 문제 2: FallbackComponent 에러

에러 발생

react-error-boundary를 사용하면서 FallbackComponent를 설정했더니 이런 에러가 발생했습니다

Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
<... FallbackComponent={function ErrorFallback} ...>
                     ^^^^^^^^^^^^^^^^^^^^^^^^

원인 분석

// page.tsx (Server Component)
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="text-center p-8">
      <p className="text-red-500">전시 정보를 불러올 수 없습니다</p>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  );
}

export default function ExhibitionPage() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      {/* 함수를 props로 직접 전달 */}
    </ErrorBoundary>
  );
}

Next.js App Router에서 page.tsx는 기본적으로 Server Component입니다.

문제는 FallbackComponent={ErrorFallback} 처럼 함수 자체를 props로 전달하는 것입니다. 서버 컴포넌트에서 클라이언트 컴포넌트(ErrorBoundary)로 함수를 직접 전달하는 것은 금지되어 있습니다.

💡 왜 금지될까?
서버에서 생성된 함수는 클라이언트로 직렬화(serialize)할 수 없기 때문입니다. 함수는 네트워크를 통해 전송할 수 없는 값이죠.

해결 방법

FallbackComponent 대신 JSX를 직접 전달하는 fallback prop을 사용했습니다:

export default function ExhibitionPage() {
  return (
    <section className="flex justify-center">
      <ErrorBoundary 
        fallback={
          <div className="w-full flex justify-center items-center min-h-screen">
            <p className="text-xl text-red-500">에러가 발생했습니다</p>
          </div>
        }
      >
        <Suspense fallback={<div>로딩 중...</div>}>
          <ExhibitionContainer />
        </Suspense>
      </ErrorBoundary>
    </section>
  );
}

차이점: 함수를 전달하는 게 아니라, 이미 렌더링된 JSX 요소를 전달하기 때문에 직렬화가 가능하고 서버 컴포넌트에서도 문제없이 작동합니다.

대안: FallbackComponent를 꼭 써야 한다면?

만약 에러 정보나 재시도 함수가 필요해서 FallbackComponent를 꼭 써야 한다면, 별도 파일로 분리하고 "use client"를 추가해야 합니다:

// components/ErrorFallback.tsx
"use client";

export default function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div>
      <p>에러: {error.message}</p>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  );
}

// page.tsx
import ErrorFallback from './components/ErrorFallback';

export default function Page() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      {/* 이제 가능! */}
    </ErrorBoundary>
  );
}

📚 배운 점

1. Suspense는 SSR과 함께 동작한다

  • Next.js에서 Suspense를 사용하면 서버에서도 렌더링 시도
  • API 호출이 서버/클라이언트 양쪽에서 실행될 수 있음을 고려해야 함

2. Server/Client Component 경계를 이해해야 한다

  • 함수는 직렬화 불가능 → props로 전달 불가
  • JSX는 직렬화 가능 → props로 전달 가능
  • 필요하면 "use client"로 명시적으로 클라이언트 컴포넌트 지정

3. 이론과 실전은 다르다

면접 질문 하나가 실제 프로젝트 적용으로 이어졌고, 예상치 못한 문제들을 해결하면서 더 깊이 이해하게 되었습니다.

다음 면접에서는 자신있게 "Suspense 실전 경험 있습니다!"라고 말할 수 있을 것 같습니다. 😊


🔗 참고 자료