React 19.2 Suspense Batching × ViewTransition: 스켈레톤 연속 깜빡임을 단일 page transition animation으로 통합하는 방법
SSR 대시보드를 만들다 보면 한 번쯤은 이런 상황을 맞닥뜨리게 됩니다. 세 군데서 병렬로 데이터를 불러오는데, 각 영역의 스켈레톤이 200ms, 210ms, 220ms 간격으로 뚝뚝 콘텐츠로 교체되면서 화면이 세 번 "팝" 하고 깜빡이는 것입니다. ViewTransition으로 크로스페이드를 걸어봤더니 이번엔 애니메이션이 세 번 연속 재생되면서 오히려 더 눈에 거슬렸던 적도 있습니다. 저도 처음엔 왜 이렇게 되는지 한참 헤맸습니다.
React 19.2는 이 문제를 정면으로 파고듭니다. Suspense Batching과 <ViewTransition>이 연동되면, 거의 동시에 완료되는 여러 Suspense 경계가 하나의 단일 page transition animation으로 묶여 처리됩니다. 연속 깜빡임이 한 번의 매끄러운 전환으로 바뀌는 것입니다. 라이브러리 없이, Core Web Vitals에 영향 없이, 선언적 컴포넌트 하나로 네이티브 앱 수준의 전환을 얻을 수 있다는 점에서 충분히 주목할 만한 변화입니다.
이 글에서는 그 원리가 무엇인지, 실제 코드에서 어떻게 쓰는지, 그리고 어떤 함정이 있는지를 살펴봅니다. React Suspense의 기본 동작 방식을 알고 있다면 충분합니다. View Transitions API를 처음 접하더라도 브라우저 API 레벨부터 차근차근 짚어갈 테니 걱정 없습니다.
목차
- 핵심 개념
- SSR 스트리밍과 Suspense Batching
<ViewTransition>: React Canary 채널이란?- 두 기능이 만나면 무슨 일이 일어나는가
- 실전 적용
- 예시 1: SSR 대시보드 스켈레톤 배치 전환
- 예시 2:
nameprop으로 히어로 애니메이션 - 예시 3: 방향성 있는 페이지 전환
- 예시 4: 애니메이션 충돌 처리
- 장단점 분석
- 마치며
핵심 개념
SSR 스트리밍과 Suspense Batching: 경계들을 묶어서 한 번에 내보내기
먼저 배경을 조금 짚고 넘어가면 좋습니다. React의 SSR 스트리밍은 서버에서 데이터가 준비되는 순서대로 각 Suspense 경계의 fallback을 실제 콘텐츠로 교체해 클라이언트에 전달하는 방식입니다. 전체 페이지를 기다리지 않고 준비된 부분부터 화면에 표시할 수 있다는 장점이 있죠. 각 Suspense 경계가 완료되는 순간 서버가 그 청크를 클라이언트로 흘려보내고(stream), 클라이언트에서 그 자리의 fallback이 실제 콘텐츠로 교체되는 구조입니다.
문제는 "거의 동시에" 준비되는 경계들입니다. 200ms, 210ms, 220ms — 이 세 응답은 사람 눈으로 보면 동시에 완료된 거나 마찬가지지만, 기존 React는 각각을 별도의 DOM 업데이트로 처리했습니다. 결과는 스켈레톤이 세 번 따로따로 교체되는 "팝" 현상이었습니다.
React 19.2의 Suspense Batching은 거의 동시에 완료되는 여러 Suspense 경계의 reveal을 단일 플러시로 묶어 클라이언트에 전달합니다. "거의 동시에"의 임계값이 몇 ms냐고 물어보신다면 — 솔직히 React 팀이 공개한 수치는 없습니다. 내부 휴리스틱으로 결정되기 때문에, "내 200ms와 300ms 차이도 배칭이 되나?" 같은 질문에는 DevTools로 직접 확인하는 것이 가장 정확한 답입니다.
LCP 보호 휴리스틱: React는 LCP(Largest Contentful Paint) 2.5초 임계값을 기준으로 배칭을 자동 해제합니다. 배칭 때문에 Core Web Vitals가 나빠지는 상황을 방지하는 안전장치가 내장되어 있습니다.
한 가지 중요한 점 — 이 배칭은 SSR 스트리밍 환경에서의 이야기입니다. 순수 클라이언트 렌더링이 주라면 다른 메커니즘(startTransition 기반 배칭)이 적용됩니다.
<ViewTransition>: React Canary 채널이란?
<ViewTransition> 얘기를 꺼내기 전에 "Canary 채널"이 뭔지 잠깐 짚고 가겠습니다. React 팀은 안정화 전 기능을 먼저 프레임워크들이 채택할 수 있도록 Canary 채널을 운영합니다. stable 릴리스에는 아직 포함되지 않지만, Next.js App Router 같은 주요 프레임워크가 직접 채택하면서 사실상 프로덕션 수준의 안정성을 확보해가는 방식입니다.
<ViewTransition>은 현재 Canary 상태입니다. 그러니까 react@latest를 단독으로 설치해서 쓰기보다는, Next.js App Router를 통해 쓰는 것이 현재 가장 안정적인 경로입니다. App Router는 React Canary를 직접 채택하므로 별도 패키지 설치 없이 사용할 수 있습니다.
그렇다면 <ViewTransition>은 무엇을 하는 컴포넌트일까요? 브라우저의 View Transitions API(document.startViewTransition(callback))를 직접 다루면 DOM 변경 전후 스냅샷 캡처와 React 렌더 사이클 타이밍을 수동으로 맞춰야 해서 꽤 까다롭습니다. <ViewTransition> 컴포넌트는 이 조율을 자동으로 처리합니다. startTransition으로 상태를 변경하거나, useDeferredValue로 렌더를 미루거나, Suspense의 fallback → 콘텐츠 전환이 일어나는 시점에 자동으로 활성화됩니다.
// 기본 사용 패턴
import { ViewTransition, Suspense } from 'react';
function Dashboard() {
return (
<ViewTransition>
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
</ViewTransition>
);
}지원 prop은 enter, exit, update, name이며, 각각 진입·이탈·업데이트 시 적용할 CSS 클래스와 공유 요소 전환을 제어합니다.
브라우저 지원 현황 (2026년 5월 기준): 글로벌 지원율 약 78%. Chrome 111+, Edge 111+, Safari 18+(iOS 18 포함)에서 동일 문서 View Transitions를 지원합니다. Firefox는 아직 부분 지원 단계입니다.
두 기능이 만나면 무슨 일이 일어나는가
이제 핵심입니다. 배칭 이전에는 Suspense 경계가 하나씩 완료될 때마다 ViewTransition이 연속으로 실행됐습니다. 배칭 이후에는 여러 경계가 한 번에 완료되므로 ViewTransition 하나가 더 넓은 콘텐츠 블록 전체를 감싸 단일 애니메이션으로 처리합니다. React 팀은 공식 릴리스 노트에서 이를 "avoid chaining animations of content that stream in close together"라고 명시했습니다.
// 배칭 전후 동작 비교
<ViewTransition>
<Suspense fallback={<SkeletonHeader />}>
<Header /> {/* ~200ms */}
</Suspense>
</ViewTransition>
<ViewTransition>
<Suspense fallback={<SkeletonFeed />}>
<Feed /> {/* ~220ms */}
</Suspense>
</ViewTransition>
// 배칭 이전: Header 완료 → 애니메이션 1회 → Feed 완료 → 애니메이션 1회
// 배칭 이후: Header + Feed 동시 완료 → 애니메이션 1회200ms와 220ms면 20ms 차이입니다. 사람이 인지하는 "동시성" 임계값 안이라 배칭이 자동으로 묶어줍니다.
실전 적용
원리를 확인했으니, 이제 실제 코드에서 어떻게 쓰는지 살펴보겠습니다. 예시는 가장 흔한 시나리오인 SSR 대시보드부터 시작해서, 히어로 애니메이션, 방향성 전환, 그리고 애니메이션 충돌 상황 순서로 조금씩 심화됩니다.
예시 1: SSR 대시보드에서 스켈레톤 배치 전환
Next.js App Router 기반 대시보드에서 가장 자주 마주치는 패턴입니다. 여러 패널이 각각 다른 데이터 소스에 의존하는 상황입니다.
먼저 MetricsPanel이 어떻게 생겼는지 보면, 이 컴포넌트가 왜 Suspense로 감싸지는지 바로 이해가 됩니다.
// app/dashboard/_components/MetricsPanel.tsx
async function MetricsPanel() {
const metrics = await fetchMetrics(); // DB 쿼리 ~200ms — async Server Component
return <MetricsDisplay data={metrics} />;
}async 서버 컴포넌트이기 때문에 fetch가 완료될 때까지 React가 이 컴포넌트를 대기시키고, 그 사이에 Suspense fallback(스켈레톤)을 보여주는 구조입니다.
// app/dashboard/page.tsx (Next.js App Router)
import { Suspense } from 'react';
import { ViewTransition } from 'react'; // Canary — Next.js App Router에서 별도 설치 없이 사용 가능
export default function DashboardPage() {
return (
<>
<ViewTransition>
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel /> {/* DB 쿼리 ~200ms */}
</Suspense>
</ViewTransition>
<ViewTransition>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* 외부 API ~220ms */}
</Suspense>
</ViewTransition>
<ViewTransition>
<Suspense fallback={<TableSkeleton />}>
<TransactionTable /> {/* DB 쿼리 ~210ms */}
</Suspense>
</ViewTransition>
</>
);
}| 시나리오 | 동작 | 사용자 경험 |
|---|---|---|
| 배칭 없음 | 200ms, 210ms, 220ms에 3회 개별 전환 | 스켈레톤이 뚝뚝 교체되는 "팝" 3회 |
| 배칭 적용 | 세 경계가 묶여 단일 전환 | 화면 전체가 한 번에 교체 |
Next.js 16을 쓰고 있다면 next.config.js에 viewTransition: true 하나 추가하는 것만으로 App Router 라우트 전환 전체에 ViewTransition이 자동 연동됩니다.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
viewTransition: true,
},
};
export default nextConfig;예시 2: name prop으로 공유 요소 전환 (히어로 애니메이션)
솔직히 이 기능을 처음 봤을 때 "이게 진짜 JS 없이 되는 거야?"라고 의심했습니다. 동일한 name prop을 가진 ViewTransition이 삭제 트리와 삽입 트리에 동시에 존재하면, React가 두 요소 간의 공유 전환(Shared Element Transition)으로 자동 처리합니다.
// 목록 페이지 — 썸네일
function ProductCard({ id, thumbnail }: { id: string; thumbnail: string }) {
return (
<ViewTransition name={`product-image-${id}`}>
<img src={thumbnail} className="thumbnail" alt="product" />
</ViewTransition>
);
}
// 상세 페이지 — 히어로 이미지
function ProductDetail({ id, hero }: { id: string; hero: string }) {
return (
<ViewTransition name={`product-image-${id}`}>
<img src={hero} className="hero-image" alt="product" />
</ViewTransition>
);
}name이 같으면 React가 목록 → 상세 전환 시 썸네일이 히어로 이미지로 자연스럽게 확장되는 애니메이션을 자동 생성합니다. FLIP 애니메이션을 직접 구현하거나 Motion 같은 라이브러리 없이 네이티브 앱 수준의 전환을 얻을 수 있습니다.
FLIP 애니메이션: First(시작 위치) → Last(끝 위치) → Invert(역방향 변환 적용) → Play(애니메이션 재생) 순서로 요소 이동을 표현하는 기법. 브라우저 리페인트를 최소화하면서 부드러운 레이아웃 전환을 구현합니다.
<ViewTransition>의nameprop이 이 FLIP을 자동으로 처리합니다.
예시 3: 방향성 있는 페이지 전환
앱처럼 느껴지는 슬라이드 전환이 필요할 때는 enter와 exit prop을 활용할 수 있습니다. 아래 코드는 React Router v7 기준입니다.
// React Router v7 기준
import { useNavigate } from 'react-router-dom';
import { startTransition } from 'react';
function ForwardButton() {
const navigate = useNavigate();
return (
<button
onClick={() => {
startTransition(() => {
navigate('/next-page');
});
}}
>
다음 페이지
</button>
);
}
// 페이지 컴포넌트에 방향성 전환 적용
<ViewTransition
enter="slide-in-from-right"
exit="slide-out-to-left"
>
<Suspense fallback={<PageSkeleton />}>
<PageContent />
</Suspense>
</ViewTransition>/* slide-in-from-right / slide-out-to-left 클래스 정의 */
.slide-in-from-right::view-transition-new(*) {
animation: slide-from-right 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-out-to-left::view-transition-old(*) {
animation: slide-to-left 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slide-from-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slide-to-left {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}React Router v7에서는 <Link viewTransition> prop과 useViewTransitionState() 훅으로 전환 중 요소별 애니메이션을 더 세밀하게 제어할 수 있습니다. Next.js App Router라면 앞서 소개한 viewTransition: true 설정으로 라우트 전환 자체에 ViewTransition이 이미 붙어있으므로 별도 처리가 불필요합니다.
예시 4: 애니메이션 충돌 처리 — 큐잉이 아니라 스킵
배칭과 ViewTransition을 함께 쓸 때 알아두면 좋은 동작이 있습니다. ViewTransition이 실행 중일 때 추가 업데이트가 들어오면 React는 첫 번째 애니메이션이 끝난 뒤 중간 상태를 모두 건너뛰고 최종 상태로 한 번만 전환합니다.
// 빠른 데이터 업데이트 상황에서의 동작 재현
function LiveStatusPanel() {
const [status, setStatus] = useState('idle');
useEffect(() => {
setStatus('loading'); // A → B 애니메이션 시작
setTimeout(() => setStatus('processing'), 50); // 무시됨 (C)
setTimeout(() => setStatus('done'), 100); // 최종 상태 (D)
// 결과: B 애니메이션 종료 후 B → done 한 번만 실행
}, []);
return (
<ViewTransition>
<StatusBadge status={status} />
</ViewTransition>
);
}A → B 애니메이션 실행 중
C 업데이트 도착 → 큐에 쌓임
D 업데이트 도착 → 큐에 쌓임
B 애니메이션 종료 → B → D 한 번만 실행 (C는 건너뜀)이 동작은 빠른 사용자 인터랙션이나 잦은 데이터 업데이트 상황에서 애니메이션이 무한정 쌓이는 것을 방지합니다. 의도된 설계이니 "C가 빠졌다"고 버그를 의심하지 않아도 됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 연속 팝핑 제거 | 거의 동시에 완료되는 Suspense 경계들이 개별 애니메이션 대신 하나의 통합 전환으로 처리됨 |
| 선언적 API | view-transition-name CSS 속성이나 document.startViewTransition() 직접 호출 없이 컴포넌트 선언만으로 동작 |
| 렌더 사이클 통합 | React의 Suspense, Transition, 동시성 기능과 타이밍을 자동 조율 — 스냅샷 타이밍을 직접 관리할 필요 없음 |
| LCP 보호 내장 | 2.5초 휴리스틱으로 배칭이 Core Web Vitals를 훼손하지 않도록 자동 조정 |
| 히어로 애니메이션 간소화 | name prop 하나로 FLIP 애니메이션 구현 코드 대체 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Canary 상태 API | <ViewTransition>은 React stable 미포함. Next.js App Router 경유가 현재 가장 안정적인 사용 경로 |
Next.js App Router 사용, 또는 @shuding/next-view-transitions 대안 활용 |
| 의도적 지연 | 배칭을 위해 콘텐츠 표시를 약간 지연 | 지연 허용 범위가 좁은 스트리밍 UX에서는 trade-off 검토 필요 |
| 브라우저 미지원 폴백 | Firefox 등 미지원 브라우저에서는 애니메이션 없이 즉시 전환 | @supports (view-transition-name: none)으로 graceful degradation 설계 |
| CSS 특이성 복잡도 | ::view-transition-old, ::view-transition-new 의사 선택자가 기존 CSS와 충돌 가능. 같은 name 두 개면 브라우저가 예외를 던짐 |
목록 아이템엔 반드시 name={item-${id}} 형태로 고유 name 보장 |
| 배칭 범위 파악 어려움 | 깊이 중첩된 Suspense 트리에서 어떤 경계가 함께 배칭되는지 파악하기 어려움. 임계값은 공개된 수치 없이 내부 휴리스틱으로 결정 | Chrome DevTools Animations 패널로 실제 타이밍 확인 |
| SSR 전용 배칭 | Suspense Batching은 SSR 스트리밍 환경에 초점 | 클라이언트 렌더링 중심이라면 startTransition 기반 전환 패턴 검토 |
Graceful Degradation: 최신 기능을 지원하는 환경에서는 풍부한 경험을 제공하되, 미지원 환경에서는 기능이 빠진 기본 동작으로 우아하게 대체되도록 설계하는 원칙입니다. ViewTransition의 경우 애니메이션 없는 즉시 전환이 폴백이 됩니다.
실무에서 가장 흔한 실수
-
view-transition-name을 중복 할당하는 경우 — 같은 name이 두 개 이상 존재하면 브라우저가 예외를 발생시킵니다. 목록 아이템처럼 반복되는 요소에는 반드시name={item-${id}}형태로 고유 name을 보장해야 합니다. -
"애니메이션이 한 번밖에 안 된다"고 이상하게 여기는 경우 — 이건 버그가 아닌 의도된 동작입니다. 오히려 세 번 나온다면 배칭이 안 되고 있는 상황이니, DevTools Animations 패널에서 타이밍을 먼저 확인해보는 것이 좋습니다.
-
모든 Suspense 경계에 무조건 ViewTransition을 감싸는 경우 — 작은 컴포넌트까지 모두 감싸면
view-transition-name네임스페이스 관리가 복잡해집니다. 사용자가 실제로 인지하는 전환 단위(패널, 페이지, 히어로 이미지 등)에만 적용하는 것을 권장합니다.
마치며
React 19.2의 Suspense Batching과 <ViewTransition>의 결합은, SSR 스트리밍 환경에서 발생하던 연속 깜빡임 문제를 선언적 API 하나로 해결하는 실질적인 개선입니다. 복잡한 애니메이션 라이브러리 없이 네이티브 앱 수준의 page transition animation을 얻을 수 있다는 점에서 충분히 시도해볼 가치가 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
viewTransition: true플래그를 켜기next.config.js에experimental: { viewTransition: true }추가 후, 기존 라우트 전환이 어떻게 달라지는지 Chrome DevTools Animations 패널에서 확인해보시면 됩니다. -
스켈레톤이 있는 대시보드 패널을
<ViewTransition>으로 감싸기 응답 시간이 비슷한 여러 패널(200~300ms 범위)에 적용했을 때 배칭이 실제로 묶이는지 DevTools Network 탭의 타이밍과 함께 비교해보시면 됩니다. -
반복 목록 아이템에
name={item-${id}}적용해 히어로 애니메이션 경험하기 목록 → 상세 페이지 전환에서 히어로 애니메이션이 JS 한 줄 없이 동작하는 걸 확인하시면 이 API의 잠재력이 더 명확하게 느껴질 것입니다.
<ViewTransition>이 아직 Canary 상태이긴 하지만, Next.js App Router를 통한 경로는 이미 프로덕션 수준으로 안정화되어 있습니다. 연속 깜빡임으로 고민 중이셨다면 지금이 적용해볼 좋은 시점입니다.
참고 자료
- React 19.2 공식 릴리스 노트 | react.dev
- <ViewTransition> 공식 API 레퍼런스 | react.dev
- React Labs: View Transitions, Activity, and more (2025.04) | react.dev
- React 19.2 Brings Suspense Batching to Server Rendering | Medium
- React 19.2 Release Guide: Activity, useEffectEvent, SSR Batching | Code With Seb
- React 19.2 is here: Activity API, useEffectEvent, and more | LogRocket
- React View Transitions and Activity API tutorial | LogRocket
- Revealed: React's experimental animations API | Motion Blog
- Next.js View Transitions 공식 가이드 | nextjs.org
- The Complete Guide to React ViewTransition | DEV Community
- Meta Ships React 19.2 | InfoQ