시작하며
제스처나 애니메이션이 포함된 리스트를 렌더링하면 메인 스레드가 블로킹되면서 FCP가 느려지는 문제가 있습니다. 이 글에서는 시각적으로 동일하지만 가벼운 Light 컴포넌트를 먼저 보여주고, startTransition으로 Heavy 컴포넌트를 나중에 교체하는 방식을 다뤄보겠습니다.
예시 상황
이메일 앱처럼 스와이프로 삭제/아카이브할 수 있는 카드 리스트를 생각해봅시다.
function EmailList({ emails }: { emails: Email[] }) {
return (
<View style={styles.list}>
{emails.map(email => (
<SwipeableCard
key={email.id}
onSwipeLeft={() => archive(email.id)}
onSwipeRight={() => deleteEmail(email.id)}
>
<EmailCard email={email} />
</SwipeableCard>
))}
</View>
);
}
function EmailList({ emails }: { emails: Email[] }) {
return (
<View style={styles.list}>
{emails.map(email => (
<SwipeableCard
key={email.id}
onSwipeLeft={() => archive(email.id)}
onSwipeRight={() => deleteEmail(email.id)}
>
<EmailCard email={email} />
</SwipeableCard>
))}
</View>
);
}
각 SwipeableCard가 마운트될 때 다음과 같은 작업들이 발생합니다
- 제스처 핸들러 초기화 (PanResponder, GestureHandler 등)
- 여러 개의 애니메이션 값 생성 (translateX, opacity, scale 등)
- 스프링 물리 계산을 위한 설정
- 스와이프 임계값, 속도 계산 로직
문제점
- 100개 이메일 × (제스처 핸들러 + 애니메이션 값들) = 수백 ms의 메인 스레드 블로킹
- 모든 카드가 마운트될 때까지 사용자는 빈 화면을 보게 됨
- FCP(First Contentful Paint)가 크게 지연됨
해법: Progressive Rendering
핵심 아이디어
1단계 (즉시): Light 컴포넌트 렌더링 → 빠른 FCP
2단계 (지연): Heavy 컴포넌트로 교체 → 스와이프 기능 활성화
핵심은 startTransition입니다.
Light 컴포넌트를 먼저 커밋하고, Heavy 컴포넌트로의 교체는 비긴급 업데이트로 처리합니다.
사용자가 스와이프를 시도하기 전까지는 어떤 컴포넌트가 렌더링되어 있는지 알 수 없습니다.
구현 방법
ProgressiveRender 컴포넌트
startTransition을 활용하여 Light → Heavy 전환을 구현합니다.
import { useState, useEffect, startTransition, type ReactNode } from 'react';
interface ProgressiveRenderProps {
light: ReactNode;
heavy: ReactNode;
}
function ProgressiveRender({ light, heavy }: ProgressiveRenderProps) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
startTransition(() => {
setIsReady(true);
});
}, []);
return isReady ? heavy : light;
}
import { useState, useEffect, startTransition, type ReactNode } from 'react';
interface ProgressiveRenderProps {
light: ReactNode;
heavy: ReactNode;
}
function ProgressiveRender({ light, heavy }: ProgressiveRenderProps) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
startTransition(() => {
setIsReady(true);
});
}, []);
return isReady ? heavy : light;
}
Light 컴포넌트 (즉시 렌더링)
Light 컴포넌트는 Heavy와 시각적으로 완전히 동일하지만, 제스처/애니메이션 로직이 없습니다.
function LightCard({ email, onPress }: CardProps) {
return (
<Pressable style={styles.card} onPress={onPress}>
<Text style={styles.sender}>{email.sender}</Text>
<Text style={styles.subject}>{email.subject}</Text>
<Text style={styles.preview}>{email.preview}</Text>
</Pressable>
);
}
function LightCard({ email, onPress }: CardProps) {
return (
<Pressable style={styles.card} onPress={onPress}>
<Text style={styles.sender}>{email.sender}</Text>
<Text style={styles.subject}>{email.subject}</Text>
<Text style={styles.preview}>{email.preview}</Text>
</Pressable>
);
}
- 제스처 핸들러 없음
- 애니메이션 값 생성 없음
- 단순 클릭만 처리
- 시각적으로는 Heavy와 동일
Heavy 컴포넌트 (완전한 기능)
function SwipeableCard({ email, onPress, onSwipeLeft, onSwipeRight }: CardProps) {
// 여러 개의 애니메이션 값 생성
const translateX = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
// 제스처 핸들러 초기화
const panResponder = useMemo(() => PanResponder.create({
// 스와이프 감지, 이동 처리, 임계값 계산 등...
}), []);
return (
<Animated.View
style={{ transform: [{ translateX }], opacity }}
{...panResponder.panHandlers}
>
<CardContent email={email} onPress={onPress} />
</Animated.View>
);
}
function SwipeableCard({ email, onPress, onSwipeLeft, onSwipeRight }: CardProps) {
// 여러 개의 애니메이션 값 생성
const translateX = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
// 제스처 핸들러 초기화
const panResponder = useMemo(() => PanResponder.create({
// 스와이프 감지, 이동 처리, 임계값 계산 등...
}), []);
return (
<Animated.View
style={{ transform: [{ translateX }], opacity }}
{...panResponder.panHandlers}
>
<CardContent email={email} onPress={onPress} />
</Animated.View>
);
}
- 제스처 핸들러 초기화 (PanResponder)
- 애니메이션 값 생성 및 바인딩
- 스와이프 임계값, 속도 계산 로직
- 시각적으로는 Light와 동일
ProgressiveRender로 점진적 전환
function EmailList({ emails }: { emails: Email[] }) {
return (
<View style={styles.list}>
{emails.map(email => (
<ProgressiveRender
key={email.id}
light={
<LightCard
email={email}
onPress={() => openEmail(email.id)}
/>
}
heavy={
<SwipeableCard
email={email}
onPress={() => openEmail(email.id)}
onSwipeLeft={() => archive(email.id)}
onSwipeRight={() => deleteEmail(email.id)}
/>
}
/>
))}
</View>
);
}
function EmailList({ emails }: { emails: Email[] }) {
return (
<View style={styles.list}>
{emails.map(email => (
<ProgressiveRender
key={email.id}
light={
<LightCard
email={email}
onPress={() => openEmail(email.id)}
/>
}
heavy={
<SwipeableCard
email={email}
onPress={() => openEmail(email.id)}
onSwipeLeft={() => archive(email.id)}
onSwipeRight={() => deleteEmail(email.id)}
/>
}
/>
))}
</View>
);
}
사용자 경험
- 화면 진입 → Light 카드들이 즉시 표시됨 (빠른 FCP)
- 사용자는 완성된 UI를 바로 볼 수 있음
- 사용자가 스와이프할 때쯤이면 이미 Heavy로 교체 완료
- 이질감 없음: Light → Heavy 전환이 시각적으로 보이지 않음
적용 시 고려사항
언제 효과적일까?
Light → Heavy 전환이 시각적으로 동일한 경우
- 스와이프 가능한 리스트: 정적 카드 → 스와이프 제스처
- 드래그 앤 드롭: 정적 리스트 → 드래그로 순서 변경
- 호버/프레스 애니메이션: 정적 버튼 → 애니메이션 효과
- 핀치 줌 이미지: 정적 이미지 → 줌/패닝 가능
주의사항
당연한 얘기지만, Light와 Heavy 컴포넌트의 레이아웃이 다르면 CLS가 발생합니다.
// 좋지 않은 예시
<ProgressiveRender
light={<div style={{ height: 50 }} />}
heavy={<div style={{ height: 80 }} />}
/>
// 좋은 예시
<ProgressiveRender
light={<div style={{ height: 80 }} />}
heavy={<div style={{ height: 80 }} />}
/>
// 좋지 않은 예시
<ProgressiveRender
light={<div style={{ height: 50 }} />}
heavy={<div style={{ height: 80 }} />}
/>
// 좋은 예시
<ProgressiveRender
light={<div style={{ height: 80 }} />}
heavy={<div style={{ height: 80 }} />}
/>
마치며
참고로 이 글에서는 React Native 스타일로 예시를 작성했지만, startTransition은 React의 코어 API이기 때문에 React 웹 환경에서도 동일하게 적용할 수 있습니다. 무거운 계산이나 복잡한 인터랙션이 포함된 컴포넌트라면 어디서든 적용해볼 수 있습니다.
