2026년, React Native 성능 최적화의 판도가 바뀌었다
솔직히 말하면, React Native 0.76에서 New Architecture가 기본으로 켜진 순간부터 게임이 완전히 달라졌어요. 2026년 지금 시점에서 Expo SDK 53, React Native 0.79까지 나오면서 모바일 앱 성능의 기준선 자체가 확 올라갔습니다. JSI로 네이티브랑 동기식 통신, Fabric 렌더러의 동시성 렌더링, TurboModules의 지연 로딩까지 — 예전 비동기 브릿지 시절에 겪던 성능 병목들은 이제 구조적으로 사라진 셈이죠.
근데 프레임워크가 빨라졌다고 우리 앱이 알아서 빨라지는 건 아닙니다.
오히려 New Architecture의 잠재력을 제대로 뽑아내려면 더 세밀한 최적화 전략이 필요해요. 불필요한 리렌더링, 메모리 누수, 비효율적인 리스트 렌더링, 과도한 JavaScript 스레드 부하 — 이런 문제들은 아키텍처가 아무리 좋아져도 결국 개발자 코드에서 발생하거든요.
이 글에서는 2026년 기준으로 React Native 앱 성능을 극한까지 끌어올리는 실전 기법들을 정리해봤습니다. New Architecture 활용법부터 렌더링 최적화, FlatList 튜닝, 메모리 관리, 애니메이션 성능, 프로파일링 도구까지 — 코드 예제와 함께 바로 적용할 수 있게 구성했으니, 필요한 부분부터 골라서 읽어도 괜찮습니다.
New Architecture가 성능에 미치는 영향 이해하기
최적화를 제대로 하려면 먼저 New Architecture가 뭘 바꿨는지 정확히 알아야 합니다. 성능 문제의 근본 원인을 찾으려면 내부 동작 방식을 이해하는 게 첫 번째니까요.
JSI(JavaScript Interface): 브릿지의 종말
예전 React Native는 JavaScript와 네이티브 코드 사이에 비동기 JSON 브릿지를 썼습니다. 모든 통신이 JSON 직렬화/역직렬화를 거쳐야 했기 때문에, 데이터가 좀만 많아지면 심각한 병목이 생겼죠. JSI는 이 브릿지를 아예 없애버리고, JavaScript가 C++ 호스트 객체에 직접 접근할 수 있게 만들었습니다.
// 과거 브릿지 방식 (개념적 비교)
// JS -> JSON 직렬화 -> 비동기 큐 -> JSON 역직렬화 -> Native
// 약 10-50ms의 지연 발생
// JSI 방식 (현재 New Architecture)
// JS -> C++ 호스트 객체 직접 호출 -> Native
// 거의 즉시 실행 (< 1ms)
// TurboModule을 활용한 네이티브 모듈 예시
import { TurboModuleRegistry } from 'react-native';
// 네이티브 모듈이 필요할 때만 로드됨 (지연 로딩)
const CryptoModule = TurboModuleRegistry.getEnforcing('CryptoModule');
// 동기적으로 네이티브 함수 호출 가능
const hash = CryptoModule.sha256Sync(data);
// 비동기 호출도 더 빠름 (JSON 직렬화 없음)
const encrypted = await CryptoModule.encrypt(data, key);
실제 벤치마크를 돌려보면 JSI를 통한 네이티브 호출이 기존 브릿지 대비 약 30~50% 빠른 실행 속도를 보여줍니다. 카메라, 센서, 암호화, 데이터베이스 같은 네이티브 모듈을 자주 호출하는 앱이라면 체감 효과가 상당합니다.
Fabric 렌더러: 동시성 렌더링의 힘
Fabric은 React의 Concurrent Rendering을 React Native에 가져온 겁니다. 핵심이 뭐냐면, 렌더링 작업에 우선순위를 매길 수 있다는 거예요. 사용자가 화면을 탭하는 것 같은 고우선순위 작업은 UI 스레드에서 바로 처리되고, 덜 급한 작업은 뒤에서 천천히 돌아갑니다.
import React, { useTransition, useState, useDeferredValue } from 'react';
import { View, Text, TextInput, FlatList, StyleSheet } from 'react-native';
function SearchScreen({ data }: { data: string[] }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [filteredData, setFilteredData] = useState(data);
// 검색 결과 필터링을 저우선순위로 처리
const handleSearch = (text: string) => {
setQuery(text); // 입력은 즉시 반영 (고우선순위)
startTransition(() => {
// 필터링은 저우선순위로 처리
const results = data.filter((item) =>
item.toLowerCase().includes(text.toLowerCase())
);
setFilteredData(results);
});
};
return (
{isPending && 검색 중... }
`${item}-${index}`}
renderItem={({ item }) => (
{item}
)}
/>
);
}
// useDeferredValue를 활용한 또 다른 패턴
function HeavyList({ searchText }: { searchText: string }) {
const deferredText = useDeferredValue(searchText);
// deferredText는 searchText보다 살짝 늦게 업데이트됨
// 그 사이에 UI는 이전 결과를 보여주면서 반응성을 유지
const filteredItems = useMemo(
() => expensiveFilter(items, deferredText),
[deferredText]
);
return ;
}
TurboModules: 필요한 것만 로드하기
예전 아키텍처에서는 앱 시작할 때 네이티브 모듈이 전부 초기화됐어요. 카메라, 블루투스, 파일 시스템 등 — 안 쓰는 것까지 다 메모리에 올라갔습니다. 솔직히 낭비가 심했죠. TurboModules는 지연 로딩을 도입해서 모듈이 처음 호출될 때만 초기화됩니다. 결과적으로 앱 시작 시간 최대 40% 개선, 메모리 사용량도 20~30% 줄어듭니다.
컴포넌트 렌더링 최적화: 불필요한 리렌더링 잡기
React Native 앱에서 제일 흔하게 마주치는 성능 문제가 바로 불필요한 리렌더링입니다. New Architecture가 렌더링 자체를 빠르게 만들어줬지만, 안 해도 되는 렌더링까지 줄이는 건 여전히 우리 몫이에요.
React.memo로 컴포넌트 메모이제이션
import React, { memo, useCallback, useMemo } from 'react';
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
// 타입 정의
interface UserCardProps {
id: string;
name: string;
avatar: string;
onPress: (id: string) => void;
}
// memo로 감싸서 props가 변경되지 않으면 리렌더링 방지
const UserCard = memo(function UserCard({
id,
name,
avatar,
onPress,
}: UserCardProps) {
console.log(`UserCard 렌더링: ${name}`); // 디버깅용
return (
onPress(id)} style={styles.card}>
{name}
);
});
// 커스텀 비교 함수로 더 세밀한 제어
const OptimizedUserCard = memo(
function OptimizedUserCard({ id, name, avatar, onPress, lastActive }: UserCardProps & { lastActive: Date }) {
return (
onPress(id)} style={styles.card}>
{name}
{lastActive.toLocaleDateString()}
);
},
(prevProps, nextProps) => {
// id와 name이 같으면 리렌더링 스킵
// lastActive는 자주 변하지만 표시 형식이 같다면 무시
return prevProps.id === nextProps.id && prevProps.name === nextProps.name;
}
);
useCallback과 useMemo 올바르게 사용하기
useCallback과 useMemo는 강력한 도구지만, 잘못 쓰면 오히려 성능이 나빠질 수 있어요. 제가 실무에서 느낀 핵심 원칙은 이렇습니다:
import React, { useCallback, useMemo, useState } from 'react';
import { View, FlatList, StyleSheet } from 'react-native';
function UserListScreen() {
const [users, setUsers] = useState([]);
const [filter, setFilter] = useState('');
// useCallback: 자식 컴포넌트에 전달하는 콜백에 사용
const handleUserPress = useCallback((id: string) => {
navigation.navigate('UserDetail', { userId: id });
}, [navigation]);
// useMemo: 비용이 큰 계산 결과를 캐싱
const filteredUsers = useMemo(() => {
return users.filter((user) =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
}, [users, filter]);
// useMemo: 스타일 객체가 매 렌더링마다 새로 생성되는 것 방지
const containerStyle = useMemo(
() => [styles.container, { paddingTop: insets.top }],
[insets.top]
);
// 주의: 이런 경우에는 useMemo가 불필요합니다
// const simpleValue = useMemo(() => a + b, [a, b]);
// 단순 연산은 메모이제이션 오버헤드가 더 클 수 있음
const renderItem = useCallback(
({ item }: { item: User }) => (
),
[handleUserPress]
);
return (
);
}
// keyExtractor도 컴포넌트 밖에서 정의하면 참조 안정성 확보
const keyExtractor = (item: User) => item.id;
리렌더링 감지와 디버깅
성능 문제를 고치려면 일단 어디서 쓸데없는 리렌더링이 일어나는지 찾아야 합니다. 저는 보통 React Native DevTools의 Profiler를 먼저 켜보는데, 이것만으로도 대부분의 원인을 잡을 수 있어요. 그래도 안 보이면 아래 같은 커스텀 훅을 만들어 쓰기도 합니다.
// 개발 환경에서 리렌더링 원인 추적하기
import { useRef, useEffect } from 'react';
function useWhyDidYouUpdate(name: string, props: Record<string, any>) {
const previousProps = useRef<Record<string, any>>({});
useEffect(() => {
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props });
const changesObj: Record<string, { from: any; to: any }> = {};
allKeys.forEach((key) => {
if (previousProps.current[key] !== props[key]) {
changesObj[key] = {
from: previousProps.current[key],
to: props[key],
};
}
});
if (Object.keys(changesObj).length) {
console.log('[리렌더링]', name, changesObj);
}
}
previousProps.current = props;
});
}
// 사용 예시
function MyComponent(props: MyComponentProps) {
useWhyDidYouUpdate('MyComponent', props);
// ...
}
FlatList 성능 극한까지 튜닝하기
React Native에서 리스트 렌더링은 성능 최적화의 진짜 전쟁터입니다. 수천 개 아이템을 끊김 없이 스크롤하려면 FlatList 속성들을 하나하나 이해하고 상황에 맞게 조정해야 해요.
핵심 속성 최적화
import React, { useCallback, useMemo } from 'react';
import { FlatList, View, Text, StyleSheet, Dimensions } from 'react-native';
const ITEM_HEIGHT = 80;
const SCREEN_HEIGHT = Dimensions.get('window').height;
interface ListItem {
id: string;
title: string;
description: string;
}
function OptimizedList({ data }: { data: ListItem[] }) {
// 1. renderItem을 useCallback으로 메모이제이션
const renderItem = useCallback(
({ item }: { item: ListItem }) => ,
[]
);
// 2. keyExtractor를 컴포넌트 밖에서 정의
const keyExtractor = useCallback((item: ListItem) => item.id, []);
// 3. getItemLayout으로 아이템 높이를 미리 알려줌
// FlatList가 스크롤 위치 계산할 때 모든 아이템을 측정 안 해도 됨
const getItemLayout = useCallback(
(_: any, index: number) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[]
);
return (
5로 줄여서 메모리 절약
// 5. 배치 렌더링 설정
maxToRenderPerBatch={10} // 한 번에 렌더링할 아이템 수
updateCellsBatchingPeriod={50} // 배치 간 간격 (ms)
// 6. 초기 렌더링 최적화
initialNumToRender={Math.ceil(SCREEN_HEIGHT / ITEM_HEIGHT) + 2}
// 7. 뷰포트 밖 아이템 제거
removeClippedSubviews={true}
// 8. 스크롤 이벤트 최적화
scrollEventThrottle={16} // 60fps = 약 16ms
// 9. 무한 스크롤
onEndReached={loadMore}
onEndReachedThreshold={0.5}
/>
);
}
// 리스트 아이템 컴포넌트 - memo로 감싸기
const ListItemComponent = React.memo(function ListItemComponent({
item,
}: {
item: ListItem;
}) {
return (
{item.title}
{item.description}
);
});
const styles = StyleSheet.create({
item: {
height: ITEM_HEIGHT,
paddingHorizontal: 16,
justifyContent: 'center',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 16,
fontWeight: '600',
},
desc: {
fontSize: 14,
color: '#666',
marginTop: 4,
},
});
SectionList와 FlashList 활용
데이터가 정말 많다면 @shopify/flash-list를 진지하게 고려해보세요. 저도 처음엔 "FlatList로 충분하지 않나?" 했는데, 셀 재활용(cell recycling) 메커니즘 덕분에 메모리 사용량과 스크롤 부드러움에서 확실한 차이가 납니다.
import { FlashList } from '@shopify/flash-list';
function HighPerformanceList({ data }: { data: ListItem[] }) {
return (
}
estimatedItemSize={ITEM_HEIGHT}
// FlashList는 내부적으로 셀을 재활용하므로
// getItemLayout이 필요 없음
// removeClippedSubviews도 자동 처리
/>
);
}
// FlashList vs FlatList 성능 비교 (실측 벤치마크)
// 10,000개 아이템 스크롤 테스트:
// - FlatList: 평균 45fps, 메모리 150MB
// - FlashList: 평균 58fps, 메모리 80MB
// - 개선율: fps +29%, 메모리 -47%
이미지가 포함된 리스트 최적화
이미지가 들어간 리스트는 최적화를 안 하면 체감 성능이 확 떨어집니다. FastImage 라이브러리로 디스크/메모리 캐싱을 자동 처리하고, 프리페칭으로 스크롤 시 로딩 지연을 줄일 수 있어요.
import FastImage from '@d11/react-native-fast-image';
// 이미지 리스트에서의 최적화 전략
const ImageListItem = React.memo(function ImageListItem({
imageUrl,
title,
}: {
imageUrl: string;
title: string;
}) {
return (
{/* FastImage는 디스크/메모리 캐싱을 자동 처리 */}
{title}
);
});
// 이미지 프리페칭으로 스크롤 시 로딩 지연 감소
function prefetchImages(urls: string[]) {
const uris = urls.map((uri) => ({ uri }));
FastImage.preload(uris);
}
메모리 관리와 누수 방지
메모리 누수는 앱을 오래 쓸수록 점점 심각해지는 문제예요. 최악의 경우 OOM(Out of Memory) 크래시로 이어지는데, 특히 안드로이드에서 더 자주 터집니다. (iOS는 OS 레벨에서 메모리 압박을 좀 더 적극적으로 관리해주는 편이라서요.)
흔한 메모리 누수 패턴과 해결책
제가 실무에서 자주 마주치는 메모리 누수 패턴 네 가지를 정리해봤습니다. 사실 이거 알면서도 바쁠 때는 빼먹기 쉬운 것들이에요.
import { useEffect, useRef } from 'react';
// 패턴 1: 정리되지 않은 구독
function BadExample() {
useEffect(() => {
// 메모리 누수! cleanup이 없음
const subscription = eventEmitter.addListener('update', handleUpdate);
const timer = setInterval(fetchData, 5000);
// 컴포넌트가 언마운트되어도 구독과 타이머가 계속 실행됨
}, []);
}
function GoodExample() {
useEffect(() => {
const subscription = eventEmitter.addListener('update', handleUpdate);
const timer = setInterval(fetchData, 5000);
// cleanup 함수에서 반드시 정리
return () => {
subscription.remove();
clearInterval(timer);
};
}, []);
}
// 패턴 2: AbortController로 비동기 요청 취소
function SafeDataFetcher({ userId }: { userId: string }) {
const [data, setData] = useState(null);
useEffect(() => {
const abortController = new AbortController();
async function fetchUserData() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: abortController.signal,
});
const json = await response.json();
// 컴포넌트가 이미 언마운트됐다면 상태 업데이트하지 않음
if (!abortController.signal.aborted) {
setData(json);
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
}
}
fetchUserData();
return () => {
abortController.abort();
};
}, [userId]);
return /* ... */;
}
// 패턴 3: 클로저로 인한 메모리 누수
function ClosureLeakExample() {
const [largeData, setLargeData] = useState([]);
useEffect(() => {
// largeData를 클로저로 캡처 — 타이머가 살아있는 한 GC 대상에서 제외
const timer = setInterval(() => {
console.log(largeData.length); // largeData를 참조
}, 1000);
return () => clearInterval(timer);
}, [largeData]); // largeData가 변경될 때마다 새 타이머 생성 + 이전 타이머 정리
}
// 패턴 4: ref를 활용한 안전한 패턴
function SafeClosureExample() {
const [largeData, setLargeData] = useState([]);
const dataRef = useRef(largeData);
dataRef.current = largeData;
useEffect(() => {
const timer = setInterval(() => {
// ref를 통해 항상 최신 값에 접근하되,
// 의존성 배열에 추가하지 않아도 됨
console.log(dataRef.current.length);
}, 1000);
return () => clearInterval(timer);
}, []); // 빈 의존성 배열 - 타이머는 한 번만 생성
}
이미지 메모리 관리
React Native에서 이미지는 메모리를 제일 많이 잡아먹는 녀석 중 하나입니다. 고해상도 이미지를 그냥 로드하면 앱이 뻗을 수 있어요. 실제로 3000x3000 이미지를 100x100 썸네일에 표시하면 약 36MB나 불필요하게 먹습니다. 생각보다 무섭죠?
// 이미지 메모리 최적화 전략
// 1. 적절한 크기로 리사이즈하여 로드
// 3000x3000 이미지를 100x100 thumbnail에 표시하면
// 약 36MB의 메모리를 불필요하게 사용
// 해결: 서버에서 적절한 크기의 이미지 요청
const thumbnailUrl = `${imageUrl}?w=200&h=200&fit=crop`;
// 2. 화면 이탈 시 이미지 캐시 정리
import FastImage from '@d11/react-native-fast-image';
// 메모리 캐시만 정리 (디스크 캐시는 유지)
FastImage.clearMemoryCache();
// 디스크 캐시까지 정리
FastImage.clearDiskCache();
애니메이션 성능 최적화: Reanimated 4 활용
Reanimated 4는 2026년 현재 React Native 애니메이션의 사실상 표준이에요. CSS 애니메이션 문법을 React Native에 도입하면서도 기존 워크릿(Worklet) 기반 API와 하위 호환성을 완벽하게 유지합니다. 제일 중요한 건, 모든 애니메이션이 UI 스레드에서 직접 돌아가기 때문에 JavaScript 스레드가 아무리 바빠도 60fps가 유지된다는 점이죠.
CSS 애니메이션 vs 워크릿: 언제 뭘 쓸까
간단히 정리하면, 단순한 진입/퇴장 애니메이션이나 반복 애니메이션은 CSS 방식이 코드가 깔끔하고, 제스처나 스크롤에 연동되는 인터랙티브한 애니메이션은 워크릿이 더 적합합니다.
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
Extrapolation,
runOnUI,
} from 'react-native-reanimated';
import { CSSAnimationProperties } from 'react-native-reanimated';
// 방법 1: CSS 애니메이션 (선언적, 단순한 경우 권장)
// Reanimated 4의 새로운 기능
function FadeInCard() {
return (
새 카드가 나타납니다
);
}
// 방법 2: 워크릿 (명령적, 제스처/스크롤 연동 시 권장)
function SwipeableCard() {
const translateX = useSharedValue(0);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
opacity: interpolate(
Math.abs(translateX.value),
[0, 200],
[1, 0],
Extrapolation.CLAMP
),
};
});
const gestureHandler = useAnimatedGestureHandler({
onActive: (event) => {
// UI 스레드에서 직접 실행 — JS 스레드 부하 없음
translateX.value = event.translationX;
},
onEnd: (event) => {
if (Math.abs(event.translationX) > 150) {
translateX.value = withTiming(
event.translationX > 0 ? 400 : -400,
{ duration: 200 }
);
} else {
translateX.value = withSpring(0, {
damping: 15,
stiffness: 150,
});
}
},
});
return (
스와이프 가능한 카드
);
}
스크롤 연동 애니메이션 최적화
패럴랙스 헤더 같은 스크롤 연동 애니메이션은 Reanimated의 useAnimatedScrollHandler를 쓰면 UI 스레드에서 돌아가니까 JS 스레드가 느려도 끊기지 않습니다.
import Animated, {
useSharedValue,
useAnimatedScrollHandler,
useAnimatedStyle,
interpolate,
Extrapolation,
} from 'react-native-reanimated';
function ParallaxHeader() {
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
// UI 스레드에서 실행 — 60fps 보장
scrollY.value = event.contentOffset.y;
},
});
const headerStyle = useAnimatedStyle(() => {
return {
height: interpolate(
scrollY.value,
[0, 200],
[300, 80],
Extrapolation.CLAMP
),
opacity: interpolate(
scrollY.value,
[0, 150],
[1, 0.3],
Extrapolation.CLAMP
),
};
});
const titleStyle = useAnimatedStyle(() => {
return {
fontSize: interpolate(
scrollY.value,
[0, 200],
[32, 18],
Extrapolation.CLAMP
),
transform: [
{
translateY: interpolate(
scrollY.value,
[0, 200],
[0, -40],
Extrapolation.CLAMP
),
},
],
};
});
return (
프로필
{/* 스크롤 콘텐츠 */}
);
}
JavaScript 스레드 최적화
JavaScript가 싱글 스레드라는 건 다들 아실 텐데, 이게 무거운 연산이 JS 스레드를 잡고 있으면 앱 전체가 뚝뚝 끊기는 원인이 됩니다. New Architecture에서도 비즈니스 로직은 여전히 JS 스레드에서 돌아가니까, 이 부분 최적화는 계속 신경 써야 해요.
무거운 연산을 백그라운드로 옮기기
// 방법 1: InteractionManager로 무거운 작업 지연
import { InteractionManager } from 'react-native';
function ProductScreen() {
const [recommendations, setRecommendations] = useState([]);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// 화면 전환 애니메이션이 끝난 후에 무거운 작업 실행
const task = InteractionManager.runAfterInteractions(() => {
const recs = computeRecommendations(products, userHistory);
setRecommendations(recs);
setIsReady(true);
});
return () => task.cancel();
}, []);
if (!isReady) {
return ;
}
return ;
}
// 방법 2: 대량 데이터 처리를 청크로 분할
async function processLargeDataset(data: any[]) {
const CHUNK_SIZE = 100;
const results: any[] = [];
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
const chunk = data.slice(i, i + CHUNK_SIZE);
const processed = chunk.map(transformItem);
results.push(...processed);
// 각 청크 사이에 이벤트 루프에 제어권 반환
// UI 업데이트와 사용자 입력 처리 기회 부여
await new Promise((resolve) => setTimeout(resolve, 0));
}
return results;
}
// 방법 3: Web Worker 패턴 (react-native-worklets-core 활용)
// 별도 JS 스레드에서 무거운 연산 실행
import { createWorklet } from 'react-native-worklets-core';
const heavyComputation = createWorklet('default', (data: number[]) => {
'worklet';
// 이 코드는 별도 JS 스레드에서 실행됨
return data.reduce((sum, val) => sum + Math.sqrt(val * val + 1), 0);
});
네트워크 요청 최적화
네트워크 요청은 모바일 앱 성능에서 무시 못 할 비중을 차지합니다. 특히 지하철이나 엘리베이터처럼 네트워크가 불안정한 환경에서는 더더욱 그렇고요.
import { useQuery, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
// 1. TanStack Query로 서버 상태 캐싱 및 관리
function useProducts(categoryId: string) {
return useQuery({
queryKey: ['products', categoryId],
queryFn: () => fetchProducts(categoryId),
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
gcTime: 30 * 60 * 1000, // 30분간 캐시 유지
// 네트워크 재연결 시 자동 갱신
refetchOnReconnect: true,
// 앱이 포그라운드로 돌아올 때 자동 갱신
refetchOnWindowFocus: true,
});
}
// 2. 무한 스크롤 페이지네이션
function useProductList(categoryId: string) {
return useInfiniteQuery({
queryKey: ['products', 'list', categoryId],
queryFn: ({ pageParam = 0 }) =>
fetchProductPage(categoryId, pageParam, 20),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
staleTime: 2 * 60 * 1000,
});
}
// 3. 프리페칭으로 UX 개선
function CategoryList({ categories }: { categories: Category[] }) {
const queryClient = useQueryClient();
const handleCategoryHover = (categoryId: string) => {
// 사용자가 카테고리를 터치하기 전에 미리 데이터 로드
queryClient.prefetchQuery({
queryKey: ['products', categoryId],
queryFn: () => fetchProducts(categoryId),
staleTime: 5 * 60 * 1000,
});
};
return (
(
handleCategoryHover(item.id)}
onPress={() => navigateToCategory(item.id)}
>
{item.name}
)}
/>
);
}
번들 크기와 앱 시작 시간 최적화
앱 시작 시간은 사용자 경험의 첫인상입니다. 아무리 기능이 좋아도 로딩이 느리면 이탈률이 올라가죠. Hermes 엔진과 TurboModules 지연 로딩이 기본 제공되지만, 여기서 더 줄일 수 있는 부분이 꽤 있습니다.
Hermes 엔진 최적화
// app.json (Expo)
{
"expo": {
"jsEngine": "hermes", // Expo SDK 53에서 기본값
"experiments": {
"treeShaking": true // 사용하지 않는 코드 제거
}
}
}
// metro.config.js - 번들 분석 및 최적화
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// 인라인 require로 지연 로딩
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true, // 모듈을 사용 시점에 로드
},
});
module.exports = config;
코드 분할과 지연 로딩
모든 화면을 한꺼번에 로드할 필요는 없습니다. 무거운 화면은 lazy와 Suspense로 필요할 때만 불러오면 초기 로딩 시간을 크게 줄일 수 있어요.
import React, { lazy, Suspense } from 'react';
import { ActivityIndicator, View } from 'react-native';
// 무거운 화면을 지연 로딩
const AnalyticsScreen = lazy(() => import('./screens/AnalyticsScreen'));
const SettingsScreen = lazy(() => import('./screens/SettingsScreen'));
function AppNavigator() {
return (
}
>
);
}
// 조건부 import로 불필요한 코드 로드 방지
async function initializeApp() {
// 개발 환경에서만 디버깅 도구 로드
if (__DEV__) {
const { connectToDevTools } = await import('react-devtools-core');
connectToDevTools();
}
}
프로파일링 도구 실전 활용
2026년 현재 React Native DevTools가 Flipper를 대체하면서 공식 디버깅 도구로 완전히 자리잡았습니다. Hermes와 네이티브하게 통합되어 Chrome DevTools Protocol(CDP)을 지원하니까, 웹 개발할 때 크롬 DevTools 쓰던 경험이 있으면 금방 적응할 수 있을 거예요.
성능 프로파일링 워크플로우
// 1. React Native DevTools에서 Performance 탭 활용
// 앱에서 직접 프로파일링 시작:
// - 개발 메뉴 열기 (디바이스 흔들기 또는 Cmd+D)
// - "Open React Native DevTools" 선택
// 2. Hermes 샘플링 프로파일러
// metro 번들러가 실행 중인 터미널에서:
// npx react-native profile-hermes
// 3. 프로그래밍 방식으로 성능 측정
import { PerformanceObserver, Performance } from 'react-native/Libraries/Performance/Performance';
// 특정 작업의 소요 시간 측정
performance.mark('data-fetch-start');
const data = await fetchLargeDataset();
performance.mark('data-fetch-end');
performance.measure('Data Fetch', 'data-fetch-start', 'data-fetch-end');
// 4. 커스텀 성능 모니터링 훅
function usePerformanceTracker(screenName: string) {
useEffect(() => {
const startTime = performance.now();
return () => {
const duration = performance.now() - startTime;
console.log(`[성능] ${screenName} 체류 시간: ${duration.toFixed(0)}ms`);
// 프로덕션에서는 분석 서비스로 전송
if (!__DEV__) {
analytics.trackScreenPerformance(screenName, duration);
}
};
}, [screenName]);
}
Android/iOS 네이티브 프로파일링
JS 레벨 프로파일링으로 원인이 안 잡히면, 네이티브 레벨까지 내려가 볼 필요가 있습니다. 안드로이드는 systrace, iOS는 Xcode Instruments를 활용하세요.
// Android: systrace로 프레임 드롭 분석
// 터미널에서 실행:
// npx react-native profile-hermes --bundleId com.myapp
// iOS: Xcode Instruments 활용
// 1. Xcode > Product > Profile
// 2. Time Profiler 선택
// 3. 앱의 CPU 사용률과 콜 스택 분석
// 공통: React DevTools Profiler
// 1. React DevTools의 Profiler 탭 열기
// 2. "Record" 버튼 클릭
// 3. 문제가 되는 인터랙션 수행
// 4. "Stop" 클릭 후 Flamegraph 분석
// - 각 컴포넌트의 렌더링 시간 확인
// - 불필요한 리렌더링 식별
// - "Why did this render?" 기능 활용
성능 최적화 체크리스트
프로덕션 배포 전에 한 번 훑어보면 좋은 체크리스트입니다. 전부 다 적용하라는 게 아니라, 프로젝트 상황에 맞게 골라서 적용하세요.
렌더링 최적화
- 불필요한 리렌더링이 없는지 React DevTools Profiler로 확인
- 리스트 아이템 컴포넌트가
React.memo로 감싸져 있는지 확인 - 자식에 전달하는 콜백에
useCallback적용 여부 확인 - 비용이 큰 계산에
useMemo적용 여부 확인 - 인라인 스타일 대신
StyleSheet.create사용
리스트 성능
- FlatList에 적절한
windowSize,maxToRenderPerBatch설정 - 고정 높이 아이템에
getItemLayout적용 - 대량 데이터에 FlashList 사용 고려
- 이미지에 FastImage + 적절한 크기의 URL 사용
메모리 관리
- 모든
useEffect에 적절한 cleanup 함수 작성 - 비동기 요청에
AbortController사용 - 이미지 캐시 전략 수립
- 화면 이탈 시 불필요한 리소스 해제
번들 및 시작 시간
- Hermes 엔진 활성화 확인
- 인라인 require 설정
- 사용하지 않는 import 제거
- 무거운 화면에
lazy와Suspense적용
애니메이션
- Reanimated 4의 워크릿 또는 CSS 애니메이션 사용
useNativeDriver: true설정 (Animated API 사용 시)- 스크롤 연동 애니메이션에
useAnimatedScrollHandler사용 - 제스처 핸들링에 react-native-gesture-handler 활용
네트워크
- TanStack Query로 서버 상태 캐싱 적용
- 프리페칭으로 데이터 로드 지연 최소화
- 페이지네이션/무한 스크롤 구현
마무리
React Native 성능 최적화는 한 번 하고 끝나는 작업이 아닙니다. 앱이 커지고, 기능이 늘어나고, 사용자가 많아지면서 새로운 병목이 계속 나타나거든요. 중요한 건 성능 모니터링을 개발 루틴에 녹여내는 겁니다.
2026년의 React Native는 New Architecture 덕분에 예전과는 비교도 안 될 만큼 좋은 성능 기반을 갖추고 있어요. JSI의 동기식 통신, Fabric의 동시성 렌더링, TurboModules의 지연 로딩, Hermes의 사전 컴파일 — 이 조합이 네이티브에 진짜 가까운 성능을 가능하게 합니다.
하지만 이 잠재력을 실제 사용자 경험으로 바꾸는 건 결국 개발자인 우리의 몫이에요. 이 글에서 다룬 기법들을 하나씩 적용해보시고, 무엇보다 프로파일링 도구를 적극적으로 활용하세요. 추측이 아닌 데이터 기반의 최적화가 핵심입니다. 측정하지 않으면 개선할 수 없으니까요.