React startTransition을 활용하여 초기 렌더링 성능 개선하기

· 6 min read

시작하며

제스처나 애니메이션이 포함된 리스트를 렌더링하면 메인 스레드가 블로킹되면서 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>
  );
}
 

사용자 경험

  1. 화면 진입 → Light 카드들이 즉시 표시됨 (빠른 FCP)
  2. 사용자는 완성된 UI를 바로 볼 수 있음
  3. 사용자가 스와이프할 때쯤이면 이미 Heavy로 교체 완료
  4. 이질감 없음: 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 웹 환경에서도 동일하게 적용할 수 있습니다. 무거운 계산이나 복잡한 인터랙션이 포함된 컴포넌트라면 어디서든 적용해볼 수 있습니다.

profile
권순민
프론트엔드 개발자 권순민입니다.
Githube-Mail