SSR에서 await을 썼는데 undefined가 나오는 이유와 해결법

김현준·2025년 6월 6일
0

넥스트JS 이모저모

목록 보기
15/23

넥스트js로 프로젝트를 하며 이상한 현상이 발생했다.

선요약

  • 넥스트js의 App Router의 SSR은 기존의 "전체 다 기다렸다 렌더" 방식이 아니다.
  • 서버 컴포넌트 내부에서 await을 써도, 준비된 컴포넌트부터 클라이언트로 스트리밍
  • 따라서 하위 컴포넌트에서 await로 fetch할 경우, 데이터가 아직 안 온 상태로 먼저 렌더될 수 있다.
  • 해결법: 비동기 fetch는 부모에서 먼저 수행하고, 데이터를 props로 넘기 or Suspense로 감싸서 명시적으로 로딩 경계 처리

문제

부모 컴포넌트

const LogDetailPage = async ({ params }: LogDetailPageProps) => {
  const { logId } = await params;
  const result = await fetchLog(logId);
  
  const { data: logData } = result;
  return (
    <div>
      <LogThubmnail logData={logData} isAuthor={isAuthor} />
      <main className="flex flex-col px-4 web:px-[50px]">
        //문제의 부분(LogAuthorIntro)
        <LogAuthorIntro userId={logData.user_id} logDescription={logData.description ?? ''} />
        ...
    </div>
  );
};

LogAuthorIntro를 보면 유저 아이디를 받고 있다.

LogAuthorIntro(사진의 동그라미 친 부분)

interface LogAuthorIntroProps {
  userId: string
  logDescription: string;
}

const LogAuthorIntro = async ({ userId, logDescription }: LogAuthorIntroProps) => {
  const user = await getPublicUser(userId);
  return (
    <div className="web:grid grid-cols-[1fr_4fr] gap-[15px] py-5 space-y-1">
      <LogProfile
        userId={String(user?.user_id)}
        userImage={String(user?.image_url)}
        userNickname={String(user?.nickname)}
      />
      <pre className="text-light-400 text-text-sm web:text-text-lg py-1.5 pre">
        {logDescription}
      </pre>
    </div>
  );
};

export default LogAuthorIntro;

예상한 동작

  • 서버 컴포넌트니까 await로 데이터를 다 받아온 후 렌더링될 거라고 기대

실제 동작

  • 헐적으로 user 값이 undefined로 내려옴
  • 새로고침하거나 시간 지나면 다시 보임

원인

App Router의 SSR은 Streaming 기반

  • 서버에서 모든 데이터를 받아 HTML을 완성한 뒤 응답하는 방식이 아닌다.

App Router의 SSR의 흐름

  • 서버 컴포넌트는 각각 독립적으로 비동기 처리
  • 준비된 컴포넌트부터 순차적으로 스트리밍 전송
  • 어떤 컴포넌트의 데이터가 아직 도착하지 않았다면, 해당 부분은 비어 있거나 undefined 상태일 수 있음

문제가 생길 수 있는 구조

// 하위에서 직접 await fetch → 이 시점엔 데이터가 안 왔을 수 있음
const LogAuthorIntro = async ({ userId }) => {
  const user = await getUser(userId); // undefined 위험
  return <div>{user.nickname}</div>;
};

공식문서에서의 주의사항 요약

  • 중첩 서버 컴포넌트에서 직접 데이터를 await할 수는 있다.
  • 그러나 그런 컴포넌트는 반드시 <Suspense>로 감싸야 한다.
  • 그렇게 하면 해당 컴포넌트는 준비될 때까지 대기하고, 나머지 UI는 먼저 보여줄 수 있다.

해결

방법 1: 부모에서 먼저 fetch

const LogDetailPage = async ({ params }) => {
  const log = await fetchLog(params.logId);
  const user = await getPublicUser(log.user_id); // 부모에서 먼저 fetch

  return (
    <LogAuthorIntro user={user} logDescription={log.description ?? ''} />
  );
};

const LogAuthorIntro = ({ user, logDescription }) => {
  return (
    <div>
      <LogProfile userId={user.user_id} ... />
      <pre>{logDescription}</pre>
    </div>
  );
};

방법 2: Suspense로 감싸기

import { Suspense } from 'react';

<Suspense fallback={<div>Loading author...</div>}>
  <LogAuthorIntro userId={logData.user_id} logDescription={logData.description} />
</Suspense>
  • 이 방식은 공식문서에서도 사용하는 패턴이며, 하위 컴포넌트의 await fetch를 안전하게 처리할 수 있게 함

방법 정리

  • 부모에서 fetch 후 props 전달: SSR에서 가장 예측 가능하고 안정적인 방식
  • 하위에서 fetch + Suspense: 공식문서도 허용하지만 로딩 UI 필요
  • 하위에서 fetch + Suspense 없음: RSC 스트리밍 특성상 undefined 문제 발생 가능

후기

서버 컴포넌트 + await라고 무조건 안전한 건 아니다.
App Router의 SSR은 스트리밍 기반이기 때문에, 데이터를 기다리지 않고 먼저 렌더링될 수 있다.

서버 컴포넌트라서 안전하겠지라는 기대는 App Router에선 반드시 다시 점검해봐야 겠다.

참고자료

profile
기록하자

0개의 댓글