정의
앱 첫 진입에 네이티브 앱처럼 로고 스플래시를 첫 페인트부터 보여 주되, JavaScript를 실행하지 않는 검색 크롤러의 색인에는 그 스플래시가 들어가지 않게 하고 싶을 때가 있다. 이를 위해 서버 렌더(SSR) 마크업이나 User-Agent 분기가 아니라, HTML <body> 최상단에 인라인 블로킹 스크립트를 넣어 스플래시 노드를 DOM에 직접 만든다. 하이드레이션이 끝나면 클라이언트가 opacity로 걷어낸다.
인라인 블로킹 스크립트는 HTML에 직접 박혀 파서가 만나는 즉시 실행되는 <script>다. JS를 안 돌리는 크롤러는 이걸 실행하지 않으므로 스플래시가 봇이 보는 HTML엔 남지 않는다.
왜 필요한가
세 가지 대안이 각각 막혀서 이 패턴이 필요하다.
- SSR 마크업을 쓰면 스플래시가 첫 HTML에 그대로 실려 크롤러 색인에 샌다(원치 않는 로딩 텍스트가 검색결과에 노출).
- UA 게이트(서버에서 요청 헤더를 읽어 "봇이면 스플래시 스킵")는 공유 레이아웃에서
headers()를 읽는 순간, 그 아래 라우트의 정적 생성/ISR을 강제로 dynamic으로 바꿔 정적 캐시를 통째로 무력화한다. - mount 이후에만 뜨는 클라이언트 스플래시는 서버에서 초기 상태가 비어 첫 HTML이 텅 비므로, "로딩 첫 화면"으로는 첫 페인트 갭이 생겨 부적합하다.
인라인 스크립트 주입은 정적 렌더/ISR을 유지하면서 JS 실행 시점에만 스플래시를 그려 세 문제를 동시에 피한다. 여기서 ISR(Incremental Static Regeneration) 은 페이지를 미리 정적으로 구워 두고 주기적으로만 갱신하는 캐시 방식이고, dynamic 강제란 매 요청마다 서버가 새로 렌더하게 되는 것을 뜻한다.
동작 원리
서버: 정적/ISR HTML 생성 (스플래시는 SSR로 안 넣음)
→ HTML 응답에 <body> 최상단 인라인 <script> 포함
브라우저 파서:
1. <script> 즉시 실행 → 스플래시 노드를 body 최상단 prepend (첫 페인트 전)
2. 나머지 HTML 파싱 + React 하이드레이션
→ React 19가 body/head 상단의 예상 밖 노드를 tolerant하게 skip
3. 하이드레이션 완료 → 클라이언트가 스플래시를 opacity로 제거
크롤러(JS 미실행): <script>를 안 돌림 → 스플래시 노드 없음
- 최신 React(19)는 body/head 상단에 제3자(브라우저 확장·스크립트)가 끼워 넣은 예상 밖 노드를 하이드레이션에서 너그럽게 건너뛴다. 이 덕에 SSR 없이 주입해도 hydration mismatch가 나지 않는다.
- 스플래시 노드는 React 트리 밖(스크립트가 만든 것)이라, 클라이언트가 DOM에서 직접 제거해도 재조정 충돌이 없다.
실무 적용
// 서버 레이아웃: 문자열 빌더가 만든 인라인 스크립트를 주입
function buildBootSplashScript(brand: string) {
// '<' 유니코드 이스케이프로 </script> 조기 종료 차단
const safe = JSON.stringify(brand).replace(/</g, "\\u003c");
return `(function(){var n=document.createElement('div');
n.className='boot-splash';n.textContent=${safe};
document.body.prepend(n);})();`;
}
export default function AppLayout({ children }) {
return (
<>
<script
dangerouslySetInnerHTML={{ __html: buildBootSplashScript(BRAND) }}
/>
{children}
</>
);
}
.boot-splash {
position: fixed;
inset: 0;
z-index: 110;
background: var(
--bg
); /* body/themeColor와 같은 토큰 → 걷힐 때 색 점프 없음 */
opacity: 1;
transition: opacity 200ms;
}
@media (prefers-reduced-motion: reduce) {
.boot-splash {
transition: none;
}
}
하이드레이션 뒤 클라이언트가 .boot-splash에 opacity 0을 주고 transition 종료 시 제거한다.
트레이드오프
- 인라인 스크립트 주입: 정적 캐시/ISR을 지키고 봇 은닉·첫 페인트 노출을 모두 얻는다. 대신 문자열로 스크립트를 만들어 이스케이프·테스트에 신경 써야 한다.
- SSR 마크업: 구현이 가장 쉽지만 봇 색인 누수.
- UA 게이트: 봇 은닉은 되지만 정적 캐시를 깬다.
사용하면 안 되는 경우
- 봇 은닉이 필요 없는 경우 — 그냥 SSR/CSS 스플래시가 더 단순하다.
- 하위 라우트에 정적/ISR 캐시가 전혀 없는 순수 동적 앱 — UA 게이트를 써도 잃을 캐시가 없으므로 이 패턴의 이점이 줄어든다.
흔한 실수
- 공유 레이아웃에서
headers()/cookies()를 무심코 써서 하위 ISR 라우트를 dynamic으로 깨뜨림. - 인라인 스크립트에 넣는 문자열에
</script>가 섞여 스크립트가 조기 종료(이스케이프·textContent로 방지, 경계값 테스트로 검증). - 스플래시 배경색을 body와 다르게 둬 걷히는 순간 색 점프 발생.
관련 개념
- ssr-hydration-mismatch — 주입 노드를 하이드레이션이 어떻게 다루는지의 전제
- usesyncexternalstore-snapshot-identity — 서버에서 비는 mount-gated 클라이언트 상태
- critical-rendering-path — 블로킹 스크립트가 파싱·페인트에 미치는 영향