TL;DR
- 문제: 500개 아이템 리스트 렌더링 시 JS 스레드 블로킹으로 인한 버벅임
- 해결: 애니메이션이 없는 Light 컴포넌트 → press 애니메이션이 있는 Heavy 컴포넌트로 점진적 전환
왜 애니메이션이 포함된 요소의 초기 렌더링이 느릴까?
press 애니메이션이 포함된 리스트를 렌더링할 때, 각 아이템마다 애니메이션 상태를 관리하기 위한 객체들이 생성됩니다.
이 과정이 왜 성능 병목이 되는지 코드로 살펴보겠습니다.
import { useCallback, useRef } from 'react';
import { Animated } from 'react-native';
import { spring } from '../constants/easings';
interface PressAnimConfig {
pressIn: {
scale: number;
opacity: number;
};
pressOut: {
scale: number;
opacity: number;
};
}
interface PressAnimHandlers {
scaleAnim: Animated.Value;
opacityAnim: Animated.Value;
startPressInAnim: () => void;
startPressOutAnim: () => void;
}
export function usePressAnimation({
pressIn,
pressOut,
}: PressAnimConfig): PressAnimHandlers {
const scaleAnim = useRef(new Animated.Value(pressOut.scale)).current;
const opacityAnim = useRef(new Animated.Value(pressOut.opacity)).current;
const startPressInAnim = useCallback(() => {
Animated.parallel([
Animated.spring(scaleAnim, {
toValue: pressIn.scale,
useNativeDriver: true,
...spring.rapid,
}),
Animated.spring(opacityAnim, {
toValue: pressIn.opacity,
useNativeDriver: true,
...spring.rapid,
}),
]).start();
}, [scaleAnim, opacityAnim, pressIn]);
const startPressOutAnim = useCallback(() => {
Animated.parallel([
Animated.spring(scaleAnim, {
toValue: pressOut.scale,
useNativeDriver: true,
...spring.quick,
}),
Animated.spring(opacityAnim, {
toValue: pressOut.opacity,
useNativeDriver: true,
...spring.quick,
}),
]).start();
}, [scaleAnim, opacityAnim, pressOut]);
return {
scaleAnim,
opacityAnim,
startPressInAnim,
startPressOutAnim,
};
}
import { useCallback, useRef } from 'react';
import { Animated } from 'react-native';
import { spring } from '../constants/easings';
interface PressAnimConfig {
pressIn: {
scale: number;
opacity: number;
};
pressOut: {
scale: number;
opacity: number;
};
}
interface PressAnimHandlers {
scaleAnim: Animated.Value;
opacityAnim: Animated.Value;
startPressInAnim: () => void;
startPressOutAnim: () => void;
}
export function usePressAnimation({
pressIn,
pressOut,
}: PressAnimConfig): PressAnimHandlers {
const scaleAnim = useRef(new Animated.Value(pressOut.scale)).current;
const opacityAnim = useRef(new Animated.Value(pressOut.opacity)).current;
const startPressInAnim = useCallback(() => {
Animated.parallel([
Animated.spring(scaleAnim, {
toValue: pressIn.scale,
useNativeDriver: true,
...spring.rapid,
}),
Animated.spring(opacityAnim, {
toValue: pressIn.opacity,
useNativeDriver: true,
...spring.rapid,
}),
]).start();
}, [scaleAnim, opacityAnim, pressIn]);
const startPressOutAnim = useCallback(() => {
Animated.parallel([
Animated.spring(scaleAnim, {
toValue: pressOut.scale,
useNativeDriver: true,
...spring.quick,
}),
Animated.spring(opacityAnim, {
toValue: pressOut.opacity,
useNativeDriver: true,
...spring.quick,
}),
]).start();
}, [scaleAnim, opacityAnim, pressOut]);
return {
scaleAnim,
opacityAnim,
startPressInAnim,
startPressOutAnim,
};
}
import {
cloneElement,
forwardRef,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
} from 'react';
import {
Animated,
Pressable,
StyleSheet,
type ViewStyle,
type GestureResponderEvent,
} from 'react-native';
import { usePressAnimation } from '../../hooks/usePressAnimation';
import { PressableUnderlay } from './PressableUnderlay';
interface HeavyPressableEffectProps {
onPress?: (event: GestureResponderEvent) => void;
onPressIn?: (event: GestureResponderEvent) => void;
onPressOut?: (event: GestureResponderEvent) => void;
children: ReactNode;
style?: ViewStyle;
disabled?: boolean;
underlay?: ReactElement;
}
export const HeavyPressableEffect = forwardRef<any, HeavyPressableEffectProps>(
(
{
onPress,
onPressIn: _onPressIn,
onPressOut: _onPressOut,
children,
style,
disabled = false,
underlay = <PressableUnderlay style={styles.underlay} />,
},
ref: Ref<any>,
) => {
const { scaleAnim, opacityAnim, startPressInAnim, startPressOutAnim } =
usePressAnimation({
pressIn: { scale: 0.96, opacity: 1 },
pressOut: { scale: 1, opacity: 0 },
});
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
_onPressIn?.(event);
if (onPress == null) {
return;
}
startPressInAnim();
},
[_onPressIn, onPress, startPressInAnim],
);
const handlePressOut = useCallback(
(event: GestureResponderEvent) => {
_onPressOut?.(event);
if (onPress == null) {
return;
}
startPressOutAnim();
},
[_onPressOut, onPress, startPressOutAnim],
);
return (
<Pressable
role="button"
ref={ref}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={style}
>
{underlay != null
? cloneElement(underlay, {
...underlay.props,
style: [underlay.props.style, { opacity: opacityAnim }],
})
: null}
<Animated.View
style={{
transform: [{ scale: scaleAnim }],
}}
>
{children}
</Animated.View>
</Pressable>
);
},
);
HeavyPressableEffect.displayName = 'HeavyPressableEffect';
const styles = StyleSheet.create({
underlay: {
borderRadius: 12,
},
});
import {
cloneElement,
forwardRef,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
} from 'react';
import {
Animated,
Pressable,
StyleSheet,
type ViewStyle,
type GestureResponderEvent,
} from 'react-native';
import { usePressAnimation } from '../../hooks/usePressAnimation';
import { PressableUnderlay } from './PressableUnderlay';
interface HeavyPressableEffectProps {
onPress?: (event: GestureResponderEvent) => void;
onPressIn?: (event: GestureResponderEvent) => void;
onPressOut?: (event: GestureResponderEvent) => void;
children: ReactNode;
style?: ViewStyle;
disabled?: boolean;
underlay?: ReactElement;
}
export const HeavyPressableEffect = forwardRef<any, HeavyPressableEffectProps>(
(
{
onPress,
onPressIn: _onPressIn,
onPressOut: _onPressOut,
children,
style,
disabled = false,
underlay = <PressableUnderlay style={styles.underlay} />,
},
ref: Ref<any>,
) => {
const { scaleAnim, opacityAnim, startPressInAnim, startPressOutAnim } =
usePressAnimation({
pressIn: { scale: 0.96, opacity: 1 },
pressOut: { scale: 1, opacity: 0 },
});
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
_onPressIn?.(event);
if (onPress == null) {
return;
}
startPressInAnim();
},
[_onPressIn, onPress, startPressInAnim],
);
const handlePressOut = useCallback(
(event: GestureResponderEvent) => {
_onPressOut?.(event);
if (onPress == null) {
return;
}
startPressOutAnim();
},
[_onPressOut, onPress, startPressOutAnim],
);
return (
<Pressable
role="button"
ref={ref}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={style}
>
{underlay != null
? cloneElement(underlay, {
...underlay.props,
style: [underlay.props.style, { opacity: opacityAnim }],
})
: null}
<Animated.View
style={{
transform: [{ scale: scaleAnim }],
}}
>
{children}
</Animated.View>
</Pressable>
);
},
);
HeavyPressableEffect.displayName = 'HeavyPressableEffect';
const styles = StyleSheet.create({
underlay: {
borderRadius: 12,
},
});
문제점:
- press animation을 위한 HeavyPressableEffect 컴포넌트를 500개 렌더링 할 경우 500개 × 2개 Animated.Value = 1000개 객체 생성
- 모든 컴포넌트가 마운트될 때까지 JS 스레드 블로킹
- 사용자가 화면을 보기까지 수백 ms 대기
해법: Progressive Rendering
핵심 아이디어
1단계 (즉시): 가벼운 컴포넌트 렌더링 → 빠른 TTI (Time to Interactive)
2단계 (지연): 무거운 컴포넌트로 렌더링 → press animation이 포함된 완전한 기능
React 19 + Fabric 기준:
import { startTransition } from 'react';
function Prerender({ initial, children }) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
startTransition (() => {
setIsReady(true); // 비긴급 업데이트
});
}, []);
return isReady ? children : initial;
}
import { startTransition } from 'react';
function Prerender({ initial, children }) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
startTransition (() => {
setIsReady(true); // 비긴급 업데이트
});
}, []);
return isReady ? children : initial;
}
startTransition
을 사용하여 Prerender 컴포넌트를 구현해줍니다.
구현 방법
Light Component (즉시 렌더링)
// LightPressableEffect.tsx
function LightPressableEffect({ onPress, style, children }) {
return (
<TouchableOpacity onPress={onPress} style={style} activeOpacity={0.7}>
{children}
</TouchableOpacity>
);
}
// LightPressableEffect.tsx
function LightPressableEffect({ onPress, style, children }) {
return (
<TouchableOpacity onPress={onPress} style={style} activeOpacity={0.7}>
{children}
</TouchableOpacity>
);
}
- 즉시 인터랙션 가능
- 애니메이션 관련 코드가 없는 가벼운 컴포넌트 → 메모리 효율적
- 기본 opacity 애니메이션만 제공
Heavy Component (완전한 애니메이션)
// HeavyPressableEffect.tsx
function HeavyPressableEffect({ onPress, style, children, underlay }) {
const { scaleAnim, opacityAnim, startPressInAnim, startPressOutAnim } = usePressAnim({
pressIn: { scale: 0.96, opacity: 1 },
pressOut: { scale: 1, opacity: 0 },
});
return (
<Pressable onPress={onPress} style={[{ position: 'relative' }, style]}>
{/* Underlay: absolute positioned background */}
{underlay != null ? cloneElement(underlay, {
style: [underlay.props.style, { opacity: opacityAnim }],
}) : null}
{/* Content: scale animation */}
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
{children}
</Animated.View>
</Pressable>
);
}
// HeavyPressableEffect.tsx
function HeavyPressableEffect({ onPress, style, children, underlay }) {
const { scaleAnim, opacityAnim, startPressInAnim, startPressOutAnim } = usePressAnim({
pressIn: { scale: 0.96, opacity: 1 },
pressOut: { scale: 1, opacity: 0 },
});
return (
<Pressable onPress={onPress} style={[{ position: 'relative' }, style]}>
{/* Underlay: absolute positioned background */}
{underlay != null ? cloneElement(underlay, {
style: [underlay.props.style, { opacity: opacityAnim }],
}) : null}
{/* Content: scale animation */}
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
{children}
</Animated.View>
</Pressable>
);
}
- press시 Underlay는 고정, Content만 scale
- Spring 애니메이션
Prerender로 점진적 전환
// PressableEffect/index.tsx
const _PressableEffect = forwardRef((props, ref) => {
if (props.onPress == null) {
return <View ref={ref} style={props.style}>{props.children}</View>;
}
return (
<Prerender
initial={<LightPressableEffect {...props} />}
children={<HeavyPressableEffect ref={ref} {...props} />}
/>
);
});
export const PressableEffect = Object.assign(_PressableEffect, {
Underlay: PressableUnderlay,
});
// PressableEffect/index.tsx
const _PressableEffect = forwardRef((props, ref) => {
if (props.onPress == null) {
return <View ref={ref} style={props.style}>{props.children}</View>;
}
return (
<Prerender
initial={<LightPressableEffect {...props} />}
children={<HeavyPressableEffect ref={ref} {...props} />}
/>
);
});
export const PressableEffect = Object.assign(_PressableEffect, {
Underlay: PressableUnderlay,
});
전략:
onPress
없으면 → 정적 View (최소 비용)onPress
있으면 → Light로 시작, Heavy로 전환
성능 측정: Flashlight로 검증하기
테스트 환경
- Device: Android
- Tool: Flashlight (Perf Profiler)
- Scenario: 500개 아이템 ScrollView 렌더링
- Build: Release 모드
# 테스트 실행
yarn android --mode release
flashlight test --bundleId "com.awesomeproject" \
--testCommand "adb shell monkey -p com.awesomeproject -c android.intent.category.LAUNCHER 1" \
--duration 10000
# 테스트 실행
yarn android --mode release
flashlight test --bundleId "com.awesomeproject" \
--testCommand "adb shell monkey -p com.awesomeproject -c android.intent.category.LAUNCHER 1" \
--duration 10000
측정 결과
왼 : 미최적화, 오 : 최적화
지표 | 미최적화 (Heavy 직접)92점 | 최적화 (Light→Heavy)100점 | 개선율 |
---|---|---|---|
Performance Score | 92 | 100 | +8.7% |
Average FPS | 58 FPS | 59.4 FPS | +2.4% |
Average CPU | 29.8% | 11.5% | -61.4% |
High CPU Usage | 0.7초 | None | 100% 제거 |
Average RAM | 251.8 MB | 304.2 MB | +20.8% |
핵심 개선 지표:
- CPU 사용량 61.4% 감소 (29.8% → 11.5%)
- CPU 스파이크 완전 제거 (0.7초 → None)
- FPS 안정화 (58 → 59.4 FPS)
실제 동작 비교

최적화가 적용된 탭은 초기 렌더링이 약 200ms 빠릅니다. startTransition
으로 Heavy 컴포넌트 전환을 비긴급 업데이트로 처리하기 때문에, 성능 비교 데모앱의 동작처럼 애니메이션이 즉시 동작하지 않고 Light 컴포넌트가 먼저 표시됩니다.
마치며
이 글에서는 500개 아이템을 ScrollView로 렌더링한 극단적인 케이스를 다뤘습니다. 실무에서 FlatList를 사용하면 이런 문제는 대부분 해결되지만, virtualization을 적용하기 어려운 복잡한 레이아웃이나 수십 개의 애니메이션 요소가 동시에 렌더링되는 상황에서는 글에서 소개한 Progressive Rendering이 빛을 발휘한다고 생각합니다.
React Native에서 60 FPS를 달성하려면 프레임당 16.67ms 이내에 모든 작업을 완료해야 합니다. 개별 최적화는 1ms 단위로 작아 보이지만, 이런 미세한 개선들이 모여 부드러운 React Native앱을 만들 수 있다고 생각합니다.
데모 앱의 전체 소스코드는 GitHub 저장소에서 확인할 수 있습니다.
참고 자료
- Toss Design System