임동욱 임동욱 07-27
feat: 스플래시 표시 게이팅 로직 추가
Co-Authored-By: Claude Opus 5 
@fb938db3b9f97d3892d6121139f6f16de7dcc81c
 
app/_components/splash-gate.tsx (added)
+++ app/_components/splash-gate.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { SplashScreen } from '@/components/ui/splash-screen';
+import { SPLASH_SHOWN_STORAGE_KEY } from '@/lib/constants/splash';
+
+/** 로딩 자체가 즉시 끝나도 스플래시가 깜빡이지 않도록 유지하는 최소 표시 시간(ms). */
+const SPLASH_MIN_DISPLAY_MS = 700;
+
+function hasShownSplashThisSession(): boolean {
+  try {
+    return sessionStorage.getItem(SPLASH_SHOWN_STORAGE_KEY) === '1';
+  } catch {
+    return false;
+  }
+}
+
+function markSplashShown(): void {
+  try {
+    sessionStorage.setItem(SPLASH_SHOWN_STORAGE_KEY, '1');
+  } catch {
+    // sessionStorage 접근 불가 환경(시크릿 모드 제한 등) — 매 로드마다 재표시되는 것으로 완화한다.
+  }
+}
+
+/**
+ * 스플래시 표시 게이팅 로직 — 비주얼(SplashScreen, design 소유)은 항상 서버 렌더와 동일하게
+ * 마운트해 hydration 불일치를 만들지 않는다. "이미 이번 세션에 표시했음"으로 인한 깜빡임 방지는
+ * `app/layout.tsx`의 `<head>` 동기 inline script + CSS가 첫 페인트 전에 `<html>`에 부여한
+ * data attribute가 담당한다(useEffect는 첫 페인트 이후에 실행되어 깜빡임을 막을 수 없다 —
+ * preventing-flash-before-hydration.md). 이 컴포넌트는 같은 판정을 렌더 중 ref에 1회 계산해두고,
+ * 이미 표시한 세션이면 effect에서 타이머 자체를 걸지 않는다(= state를 effect 본문에서 동기
+ * 호출하지 않아 cascading render를 만들지 않는다).
+ */
+export function SplashGate() {
+  const [isVisible, setIsVisible] = useState(true);
+  const [isExiting, setIsExiting] = useState(false);
+  const skipIntroRef = useRef<boolean | null>(null);
+
+  if (skipIntroRef.current === null) {
+    skipIntroRef.current =
+      typeof window !== 'undefined' && hasShownSplashThisSession();
+  }
+
+  useEffect(() => {
+    if (skipIntroRef.current) {
+      // 이미 <head> inline script + CSS로 숨겨진 상태 — 타이머/퇴장 애니메이션을 돌릴 필요가 없다.
+      return;
+    }
+
+    const timer = setTimeout(() => {
+      setIsExiting(true);
+    }, SPLASH_MIN_DISPLAY_MS);
+
+    return () => clearTimeout(timer);
+  }, []);
+
+  function handleExitAnimationEnd() {
+    markSplashShown();
+    setIsVisible(false);
+  }
+
+  if (!isVisible) {
+    return null;
+  }
+
+  return (
+    <div
+      data-splash-gate
+      className={isExiting ? 'animate-fade-out' : undefined}
+      onAnimationEnd={isExiting ? handleExitAnimationEnd : undefined}
+    >
+      <SplashScreen />
+    </div>
+  );
+}
app/layout.tsx
--- app/layout.tsx
+++ app/layout.tsx
@@ -3,7 +3,23 @@
 import { ViewTransition } from "react";
 import { FeedbackHost } from "@/app/_components/feedback-host";
 import { FeedbackProvider } from "@/app/_components/feedback-provider";
+import { SplashGate } from "@/app/_components/splash-gate";
+import { SPLASH_SHOWN_STORAGE_KEY } from "@/lib/constants/splash";
 import "./globals.css";
+
+// 스플래시 재표시 방지 — <head>는 <body>보다 먼저 파싱되므로 body 하위 요소는 아직 존재하지
+// 않는다(preventing-flash-before-hydration.md의 Themes 패턴과 동일하게 <html>만 조작 가능).
+// 이번 세션에 이미 표시했다면 첫 페인트 전에 <html>에 data attribute를 부여하고, 아래 스타일
+// 규칙이 SplashGate 루트를 즉시 숨긴다 — useEffect보다 먼저 실행되어 깜빡임이 없다.
+//
+// SPLASH_SHOWN_STORAGE_KEY는 반드시 'use client' 파일이 아닌 lib/constants/splash에서
+// import한다 — splash-gate.tsx('use client')의 일반 값(문자열) export를 Server Component가
+// 직접 import하면 클라이언트 레퍼런스 프록시 특성상 undefined로 평가된다(컴포넌트 export만
+// 정상적으로 프록시된다).
+const SPLASH_HIDE_STYLE = `html[data-splash-shown] [data-splash-gate]{display:none}`;
+const SPLASH_GATE_SCRIPT = `(function(){try{if(sessionStorage.getItem(${JSON.stringify(
+  SPLASH_SHOWN_STORAGE_KEY
+)}))document.documentElement.setAttribute("data-splash-shown","")}catch(e){}})()`;
 
 const geistSans = Geist({
   variable: "--font-geist-sans",
@@ -43,13 +59,18 @@
   return (
     <html
       lang="ko"
+      suppressHydrationWarning
       className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
     >
+      <head>
+        <style dangerouslySetInnerHTML={{ __html: SPLASH_HIDE_STYLE }} />
+        <script dangerouslySetInnerHTML={{ __html: SPLASH_GATE_SCRIPT }} />
+      </head>
       <body className="min-h-full flex flex-col">
+        <SplashGate />
         <FeedbackProvider>
           {/* 라우트 그룹 간 이동을 포함한 모든 전환에 크로스페이드를 적용한다. 클래스명(layout-transition)에
-              대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다 — 합류
-              전에는 브라우저 기본 크로스페이드로 동작한다. */}
+              대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다. */}
           <ViewTransition default="layout-transition">{children}</ViewTransition>
           {/* 피드백 오버레이는 전환 크로스페이드 대상이 아니므로 ViewTransition 바깥에 둔다. */}
           <FeedbackHost />
 
lib/constants/splash.ts (added)
+++ lib/constants/splash.ts
@@ -0,0 +1,10 @@
+/**
+ * 스플래시를 세션당 1회만 표시했음을 기록하는 sessionStorage 키.
+ *
+ * `app/layout.tsx`(Server Component — `<head>` inline script 문자열 생성)와
+ * `app/_components/splash-gate.tsx`('use client') 양쪽이 같은 값을 참조해야 한다. 'use client'
+ * 모듈의 export는 컴포넌트가 아닌 일반 값(문자열 등)일 경우 Server Component에서 import 시
+ * `undefined`로 평가된다 — Next.js의 클라이언트 레퍼런스 프록시가 컴포넌트 형태만 가정하기
+ * 때문이다. 그래서 이 상수는 'use client'가 없는 순수 모듈로 분리한다.
+ */
+export const SPLASH_SHOWN_STORAGE_KEY = "edupay-admin-splash-shown";
Add a comment
List