Study/Next.js

Next.js에서 Toast 알림이 안 보일 때 - Suspense로 해결하기

haseulla 2025. 12. 15. 22:50

🚨 문제 상황

Next.js 16 App Router에서 Gallery Hub 개인프로젝트를 진행하던 중, sonner 라이브러리를 사용한 Toast 알림이 화면에 나타나지 않는 문제가 발생했습니다.

// LoginForm.tsx
import { toast } from "sonner";

export function LoginForm() {
  const handleLogin = () => {
    // 로그인 성공
    toast.success("로그인 성공!"); //  보이지 않음!
  };
  
  return (
    // ... 폼 UI
  );
}

분명히 코드는 실행되는데 (console.log로 확인), Toast가 화면에 렌더링되지 않았습니다.

 

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
  		// ...
          <ToasterProvider />
        // ...   
      </body>
    </html>
  );
}

코드는 실행되는데 화면에 렌더링 되지 않아 혹시 Toast Provider를 누락했는지 여러번 확인해봤습니다.

"use client";
import { Toaster } from "sonner";

export default function ToasterProvider() {
  return (
    <Toaster
      position="top-right"
      richColors
      toastOptions={{ style: { zIndex: 9999 } }}
    />
  );
}

Toast 요소가 z-index가 낮아서 화면에 보이지 않을까봐 z-index 값도 최대로 주고 여러번 확인해봤으나 계속해서 확인이 어려웠습니다. 

 

그래서 혹시나 테스트 요소로 테스트 버튼을 클릭했을때 toast 알람이 뜨도록 설정한 코드에서는 toast 알람이 제대로 뜨는 것을 확인하고, 불러오는 방식에서 문제가 있는 것은 아닐지 확인하게 되었습니다.

 

현재 Toast 알람은 useSearchParams의 값을 가져와서 redirect의 값이 true인 경우에만 실행되도록 하였습니다. 

 


🔍 원인 분석

문제의 원인은 Next.js의 useSearchParams 훅 때문이었습니다.

// LoginForm.tsx
import { useSearchParams } from "next/navigation";

export function LoginForm() {
  const searchParams = useSearchParams(); // 문제
  
  useEffect(() => {
    const redirect = searchParams.get("redirect");
    if (redirect === "true") {
      console.log("리다이렉트 필요!"); // console.log는 실행됨
      toast.error("로그인이 필요합니다."); // Toast UI는 안 보임
    }
  }, [searchParams]);
  
  // ...
}

 

 

💡 브라우저 렌더링 순서와 Hydration 불일치

이 문제를 이해하려면 Next.js의 SSR (서버 렌더링)과 Hydration (클라이언트 수화) 과정을 살펴봐야 합니다.

 

1. 서버 렌더링 (SSR): 서버가 최초 HTML을 만들 때, useSearchParams와 같은 클라이언트 전용 훅의 최종 값을 알 수 없습니다. 따라서 LoginForm 컴포넌트는 불완전한 DOM 구조로 HTML에 포함되어 클라이언트에게 전달됩니다.

2. 클라이언트 수화 (Hydration): 브라우저에서 React가 실행되어 서버가 보낸 HTML에 생명력을 불어넣으려 합니다.

3. DOM 불일치 발생: 이 과정에서 클라이언트가 useSearchParams의 실제 값을 얻으면서 LoginForm의 DOM 구조가 서버가 보낸 HTML과 달라집니다. 이 DOM 불일치 때문에 Hydration Error가 발생하고, React는 해당 컴포넌트 트리의 렌더링을 불안정하게 처리합니다.

 

 

위와 같은 이유로 console.log()는 실행되었지만 Toast 알림은 확인 할 수 없었습니다. useEffect 내부의 console.log()는 자바스크립트 논리가 실행되었다는 것을 의미할 뿐, Toast UI를 DOM에 성공적으로 삽입할 렌더링 환경을 보장하지는 않았습니다. 

 

Next.js 공식 문서에 따르면:

useSearchParams를 사용하는 컴포넌트는 반드시 <Suspense> boundary로 감싸야 합니다.

useSearchParams는 클라이언트 사이드에서 동적으로 URL의 쿼리 파라미터를 읽어오는 훅입니다. 이 과정에서 컴포넌트가 일시적으로 렌더링을 "중단"하고 기다릴 수 있는데, 이때 Suspense가 없으면 렌더링 타이밍 문제가 발생할 수 있습니다.

Toast가 보이지 않았던 이유:

  • Suspense 없이 useSearchParams 사용
  • 컴포넌트 렌더링이 불완전하게 처리됨
  • Toast UI가 DOM에 제대로 마운트되지 않음

✅ 해결 방법

페이지 컴포넌트를 Suspense로 감싸주면 됩니다!

// app/login/page.tsx
import { LoginForm } from "./components/Form";
import { Suspense } from "react";

export default function LoginPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LoginForm />
    </Suspense>
  );
}

변경 전 vs 변경 후

변경 전:

export default function LoginPage() {
  return <LoginForm />; // Toast 안 보임
}

변경 후:

export default function LoginPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LoginForm /> {/* Toast 정상 작동! */}
    </Suspense>
  );
}

 

예쁘게 잘 적용된 Toast를 확인할 수 있었습니다- !

🤔 왜 Suspense가 필요할까?

Suspense의 역할

Suspense는 React 18에서 도입된 기능으로, 비동기 작업을 처리하는 동안 fallback UI를 보여주는 역할을 합니다.

Next.js App Router에서 useSearchParams는 다음과 같은 특징이 있습니다:

  1. 동적 렌더링: URL 파라미터가 변경될 때마다 동적으로 값을 읽어옴
  2. 클라이언트 사이드 전용: 서버에서는 값을 알 수 없음
  3. 비동기적 특성: 초기 렌더링 시 값이 즉시 준비되지 않을 수 있음

따라서 Next.js는 useSearchParams를 사용하는 컴포넌트에 대해 Suspense boundary를 필수로 요구합니다.

공식 문서 권장사항

// 올바른 사용법
<Suspense fallback={<Loading />}>
  <ComponentUsingSearchParams />
</Suspense>

// 잘못된 사용법
<ComponentUsingSearchParams /> // Warning 발생

📚 배운 점

  1. Next.js App Router에서 useSearchParams 사용 시 Suspense는 필수
    • 공식 문서를 꼼꼼히 읽어야 함
    • 경고 메시지를 무시하지 말 것
  2. Toast가 안 보이는 문제는 다양한 원인이 있을 수 있음
    • Toaster 컴포넌트 누락
    • z-index 문제
    • 렌더링 타이밍 문제 ← 이번 케이스!
  3. 에러가 없어도 동작하지 않는다면 렌더링 라이프사이클 문제를 의심
    • React DevTools로 컴포넌트 마운트 확인
    • Suspense boundary 확인

🔗 참고 자료

마무리

작은 설정 하나로 몇 시간을 헤맬 수 있다는 걸 다시 한번 깨달았습니다. 특히 Next.js App Router는 아직 배워가는 단계라 공식 문서를 더 꼼꼼히 읽어야겠다고 생각했습니다.

비슷한 문제를 겪고 계신 분들께 도움이 되었으면 좋겠습니다