임동욱 임동욱 07-27
feat: 전역 피드백 상태 구조와 마운트 지점 추가
Co-Authored-By: Claude Opus 5 
@ac8def5c88837fba371f6e0ab100f8e5853181c0
 
app/_components/feedback-host.tsx (added)
+++ app/_components/feedback-host.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { Alert } from '@/components/ui/alert';
+import { Spinner } from '@/components/ui/spinner';
+import { Toast } from '@/components/ui/toast';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+
+/**
+ * FeedbackProvider의 상태를 design 레인의 Toast·Alert·Spinner 비주얼에 연결만 하는 역할 —
+ * 위치·스택·표시 타이머 등 정책은 FeedbackProvider가 가지고, 이 컴포넌트는 마운트만 담당한다.
+ */
+export function FeedbackHost() {
+  const { toasts, alert, spinnerCount, hideAlert } = useFeedback();
+
+  return (
+    <>
+      {toasts.length > 0 && (
+        <div className="fixed inset-x-0 top-4 z-50 flex flex-col items-center gap-2 px-4">
+          {toasts.map((toast) => (
+            <Toast key={toast.id} variant={toast.variant}>
+              {toast.message}
+            </Toast>
+          ))}
+        </div>
+      )}
+
+      {alert && (
+        <Alert
+          variant={alert.variant}
+          title={alert.title}
+          actions={
+            alert.actions ?? (
+              <button
+                type="button"
+                onClick={hideAlert}
+                className="rounded-md bg-primary px-4 py-2 text-body-md text-on-primary"
+              >
+                확인
+              </button>
+            )
+          }
+        >
+          {alert.message}
+        </Alert>
+      )}
+
+      {spinnerCount > 0 && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-scrim">
+          <Spinner size="lg" tone="inverse" />
+        </div>
+      )}
+    </>
+  );
+}
 
app/_components/feedback-provider.tsx (added)
+++ app/_components/feedback-provider.tsx
@@ -0,0 +1,136 @@
+'use client';
+
+import {
+  createContext,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+  type ReactNode,
+} from 'react';
+
+export type ToastVariant = 'info' | 'success' | 'danger' | 'warning';
+export type AlertVariant = 'info' | 'danger';
+
+const DEFAULT_TOAST_DURATION_MS = 3000;
+
+export interface ToastItem {
+  id: number;
+  variant: ToastVariant;
+  message: ReactNode;
+}
+
+export interface AlertItem {
+  variant: AlertVariant;
+  title: string;
+  message?: ReactNode;
+  actions?: ReactNode;
+}
+
+export interface ShowToastOptions {
+  variant?: ToastVariant;
+  message: ReactNode;
+  /** 자동 소멸까지의 시간(ms). 기본 3000ms. */
+  durationMs?: number;
+}
+
+export interface ShowAlertOptions {
+  variant?: AlertVariant;
+  title: string;
+  message?: ReactNode;
+  actions?: ReactNode;
+}
+
+export interface FeedbackContextValue {
+  toasts: ToastItem[];
+  alert: AlertItem | null;
+  spinnerCount: number;
+  showToast: (options: ShowToastOptions) => void;
+  dismissToast: (id: number) => void;
+  showAlert: (options: ShowAlertOptions) => void;
+  hideAlert: () => void;
+  showSpinner: () => void;
+  hideSpinner: () => void;
+}
+
+export const FeedbackContext = createContext<FeedbackContextValue | null>(null);
+
+/**
+ * 전역 피드백(토스트 큐 / 알럿 1건 / 스피너 카운트) 상태 보유 — 루트 레이아웃에 한 번만
+ * 마운트한다. 실제 비주얼(Toast·Alert·Spinner)은 FeedbackHost가 이 상태를 구독해 그린다.
+ * children은 Server Component가 렌더한 결과를 그대로 통과시켜 RSC를 유지한다.
+ */
+export function FeedbackProvider({ children }: { children: ReactNode }) {
+  const [toasts, setToasts] = useState<ToastItem[]>([]);
+  const [alert, setAlert] = useState<AlertItem | null>(null);
+  const [spinnerCount, setSpinnerCount] = useState(0);
+  const nextToastId = useRef(0);
+
+  const dismissToast = useCallback((id: number) => {
+    setToasts((prev) => prev.filter((toast) => toast.id !== id));
+  }, []);
+
+  const showToast = useCallback(
+    (options: ShowToastOptions) => {
+      const id = nextToastId.current++;
+      setToasts((prev) => [
+        ...prev,
+        { id, variant: options.variant ?? 'info', message: options.message },
+      ]);
+      setTimeout(() => {
+        dismissToast(id);
+      }, options.durationMs ?? DEFAULT_TOAST_DURATION_MS);
+    },
+    [dismissToast]
+  );
+
+  const showAlert = useCallback((options: ShowAlertOptions) => {
+    setAlert({
+      variant: options.variant ?? 'info',
+      title: options.title,
+      message: options.message,
+      actions: options.actions,
+    });
+  }, []);
+
+  const hideAlert = useCallback(() => {
+    setAlert(null);
+  }, []);
+
+  const showSpinner = useCallback(() => {
+    setSpinnerCount((prev) => prev + 1);
+  }, []);
+
+  const hideSpinner = useCallback(() => {
+    setSpinnerCount((prev) => Math.max(0, prev - 1));
+  }, []);
+
+  const value = useMemo<FeedbackContextValue>(
+    () => ({
+      toasts,
+      alert,
+      spinnerCount,
+      showToast,
+      dismissToast,
+      showAlert,
+      hideAlert,
+      showSpinner,
+      hideSpinner,
+    }),
+    [
+      toasts,
+      alert,
+      spinnerCount,
+      showToast,
+      dismissToast,
+      showAlert,
+      hideAlert,
+      showSpinner,
+      hideSpinner,
+    ]
+  );
+
+  return (
+    <FeedbackContext.Provider value={value}>{children}</FeedbackContext.Provider>
+  );
+}
 
app/_hooks/use-feedback.ts (added)
+++ app/_hooks/use-feedback.ts
@@ -0,0 +1,19 @@
+'use client';
+
+import { useContext } from 'react';
+import {
+  FeedbackContext,
+  type FeedbackContextValue,
+} from '@/app/_components/feedback-provider';
+
+/**
+ * 전역 피드백(토스트/알럿/스피너) 상태 소비 훅. FeedbackProvider 밖에서 호출되면 원인 불명의
+ * 런타임 오류 대신 명시적으로 에러를 던진다.
+ */
+export function useFeedback(): FeedbackContextValue {
+  const context = useContext(FeedbackContext);
+  if (!context) {
+    throw new Error('useFeedback은 FeedbackProvider 내부에서만 사용할 수 있습니다.');
+  }
+  return context;
+}
app/layout.tsx
--- app/layout.tsx
+++ app/layout.tsx
@@ -1,6 +1,8 @@
 import type { Metadata } from "next";
 import { Geist, Geist_Mono } from "next/font/google";
 import { ViewTransition } from "react";
+import { FeedbackHost } from "@/app/_components/feedback-host";
+import { FeedbackProvider } from "@/app/_components/feedback-provider";
 import "./globals.css";
 
 const geistSans = Geist({
@@ -44,10 +46,14 @@
       className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
     >
       <body className="min-h-full flex flex-col">
-        {/* 라우트 그룹 간 이동을 포함한 모든 전환에 크로스페이드를 적용한다. 클래스명(layout-transition)에
-            대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다 — 합류
-            전에는 브라우저 기본 크로스페이드로 동작한다. */}
-        <ViewTransition default="layout-transition">{children}</ViewTransition>
+        <FeedbackProvider>
+          {/* 라우트 그룹 간 이동을 포함한 모든 전환에 크로스페이드를 적용한다. 클래스명(layout-transition)에
+              대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다 — 합류
+              전에는 브라우저 기본 크로스페이드로 동작한다. */}
+          <ViewTransition default="layout-transition">{children}</ViewTransition>
+          {/* 피드백 오버레이는 전환 크로스페이드 대상이 아니므로 ViewTransition 바깥에 둔다. */}
+          <FeedbackHost />
+        </FeedbackProvider>
       </body>
     </html>
   );
Add a comment
List