React Native 앱의 느린 UI 반응과 프레임 드롭 문제를 해결하기 위한 UI 스레드 최적화 기법을 심층 분석합니다. 효율적인 리렌더링 방지 및 부드러운 애니메이션 튜닝 전략을 통해 사용자 경험을 개선하는 방법을 단계별로 학습합니다.
사용자가 애플리케이션을 사용할 때, 터치에 즉각 반응하고 화면 전환이 부드럽게 이루어지는 경험은 매우 중요합니다. 하지만 React Native 앱을 개발하다 보면 예상치 못하게 UI가 버벅거리거나 애니메이션이 끊기는 현상에 직면할 수 있습니다. 이러한 문제는 대부분 UI 스레드의 과부하로 인해 발생하며, 사용자가 앱을 떠나게 만드는 주요 원인 중 하나로 작용합니다.
여러분은 혹시 "앱이 느리다고 사용자 불만이 접수되었어요"라는 피드백을 받아본 적이 있으신가요? 혹은 직접 개발한 앱의 스크롤이 부드럽지 않고 애니메이션이 뚝뚝 끊기는 경험을 해보셨을 수도 있습니다. React Native는 웹 기술로 네이티브 앱을 만들 수 있는 강력한 도구이지만, 그 특성상 성능 최적화에 대한 깊이 있는 이해가 없다면 이러한 문제에 쉽게 봉착하게 됩니다.
이 글에서는 React Native 앱의 렌더링 성능 저하를 초래하는 UI 스레드 문제의 근본 원인을 파악하고, 이를 해결하기 위한 구체적인 리렌더링 방지 기법과 애니메이션 튜닝 전략을 단계별로 심층 분석합니다. 프로그래밍을 이제 막 배우기 시작한 입문자도 쉽게 이해할 수 있도록 전문 용어를 풀어서 설명하며, 실용적인 예시와 함께 최적화 기법을 적용하는 방법을 안내할 예정입니다. 앱의 사용자 경험을 한 단계 끌어올리고자 하는 모든 개발자에게 이 글이 유용한 지침서가 될 것으로 판단됩니다.
📑 목차
- React Native UI 스레드, 왜 중요한가요? (기본 개념 이해)
- 네이티브 모듈과 자바스크립트 스레드의 역할
- UI 스레드와 렌더링 파이프라인
- 렌더링 성능 저하의 주범: 불필요한 리렌더링 진단
- 리렌더링 발생 원리
- 개발자 도구를 활용한 성능 측정
- 리렌더링 방지 마스터하기: 효율적인 컴포넌트 관리
- `React.memo`를 활용한 컴포넌트 메모이제이션
- `useCallback`과 `useMemo`로 함수 및 값 최적화
- 불변성(Immutable) 데이터 구조의 중요성
- 애니메이션 튜닝의 정석: 부드러운 사용자 경험 제공
- 네이티브 드라이버(Native Driver) 활용
- `useNativeDriver` 옵션 이해
- LayoutAnimation과 Reanimated 라이브러리
- UI 스레드 부하 줄이기: 비동기 작업 및 백그라운드 처리
- 대규모 리스트 최적화 (`FlatList`, `SectionList`)
- 비동기 작업 분리 (Web Workers 개념)
- 실제 프로젝트에 적용하는 UI 스레드 최적화 전략
- 성능 예산 설정 및 지속적인 모니터링
- 코드 리뷰를 통한 최적화 습관화
- 테스트: 스트레스 및 사용자 테스트
- 결론
Image by Lenzatic on Pixabay
React Native UI 스레드, 왜 중요한가요? (기본 개념 이해)
React Native의 성능을 이해하기 위해서는 먼저 그 아키텍처와 UI 스레드의 역할을 이해하는 것이 필수적입니다. React Native는 웹 기술(JavaScript)을 사용하여 네이티브 모바일 앱을 만듭니다. 이 과정에서 여러 '스레드'가 서로 협력하며 앱을 구동합니다.
네이티브 모듈과 자바스크립트 스레드의 역할
스레드(Thread)는 간단히 말해 컴퓨터 프로그램이 작업을 처리하는 "일꾼"이라고 생각할 수 있습니다. React Native 앱에는 크게 두 가지 주요 스레드가 존재합니다.
- 자바스크립트 스레드 (JavaScript Thread): 여러분이 작성하는 React 컴포넌트, 로직, 상태 관리 등 대부분의 비즈니스 로직이 이 스레드에서 실행됩니다. 마치 앱의 "두뇌" 역할을 하며, 다음에 무엇을 할지 결정하고 데이터를 처리합니다.
- UI 스레드 (User Interface Thread): 이 스레드는 실제 화면에 보이는 모든 요소를 그리는 역할을 담당합니다. 사용자의 터치, 스크롤, 애니메이션 등 모든 시각적인 반응을 처리합니다. 안드로이드에서는 '메인 스레드', iOS에서는 '메인 스레드' 또는 '메인 큐'라고도 불립니다. 이 스레드는 앱의 "얼굴" 역할을 하며, 사용자에게 보이는 모든 것을 담당합니다.
이 두 스레드는 '브릿지(Bridge)'라는 통신 채널을 통해 서로 메시지를 주고받으며 협력합니다. 자바스크립트 스레드가 "버튼을 눌렀으니 색깔을 빨간색으로 바꿔줘!"라고 명령하면, 브릿지를 통해 UI 스레드에게 이 메시지를 전달하고, UI 스레드가 실제 화면의 버튼 색깔을 변경하는 방식입니다.
UI 스레드와 렌더링 파이프라인
렌더링 파이프라인(Rendering Pipeline)은 화면에 무언가를 그리는 일련의 과정을 의미합니다. React Native에서 UI 스레드는 이 렌더링 파이프라인의 최종 단계를 담당합니다.
- 자바스크립트 스레드에서 컴포넌트의 상태(state)나 속성(props)이 변경되면, React는 변경된 부분을 감지하고 가상 DOM(Virtual DOM)을 업데이트합니다.
- 가상 DOM의 변경 사항을 실제 네이티브 UI 요소에 적용하기 위한 명령들을 생성합니다.
- 이 명령들은 브릿지를 통해 UI 스레드로 전달됩니다.
- UI 스레드는 전달받은 명령들을 해석하여 실제 화면에 UI 요소를 그리고 배치하며, 사용자 인터랙션을 처리합니다.
만약 자바스크립트 스레드에서 너무 많은 작업이 동시에 발생하거나, 브릿지를 통해 너무 많은 데이터가 오고 가면, UI 스레드는 제때 명령을 받지 못하거나 처리할 여유가 없어지게 됩니다. 이렇게 되면 UI 스레드가 멈추거나 느려져서 애니메이션이 끊기거나 터치 반응이 늦어지는 현상, 즉 프레임 드롭(Frame Drop)이 발생하게 됩니다. 모바일 앱에서는 일반적으로 1초에 60프레임(60 FPS)을 유지해야 부드러운 사용자 경험을 제공할 수 있으며, 이를 위해 UI 스레드는 항상 여유롭고 빠르게 동작해야 합니다.
렌더링 성능 저하의 주범: 불필요한 리렌더링 진단
React Native 앱에서 UI 스레드의 성능을 저하시키는 가장 흔한 원인 중 하나는 바로 불필요한 리렌더링(Re-rendering)입니다. 리렌더링은 React 컴포넌트가 화면에 다시 그려지는 과정을 의미하며, 이 과정이 너무 자주 또는 비효율적으로 발생하면 앱의 성능에 심각한 영향을 미치게 됩니다.
리렌더링 발생 원리
React 컴포넌트는 다음과 같은 경우에 리렌더링됩니다.
- 상태(State) 변경: 컴포넌트 내부의 상태(
useState훅으로 관리되는 데이터)가 변경될 때마다 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다. - 속성(Props) 변경: 부모 컴포넌트로부터 전달받은 속성(props)이 변경될 때마다 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.
- 부모 컴포넌트 리렌더링: 부모 컴포넌트가 리렌더링되면, 특별한 최적화가 없는 한 모든 자식 컴포넌트들도 함께 리렌더링됩니다. 이는 React의 기본 동작 방식이며, 성능 문제의 주범이 되곤 합니다.
- Context 변경:
React.Context를 사용하는 컴포넌트의 컨텍스트 값이 변경되면, 해당 컨텍스트를 구독하는 모든 컴포넌트들이 리렌더링됩니다.
문제는 이러한 리렌더링이 실제 화면에 변경이 필요 없는데도 발생할 수 있다는 점입니다. 예를 들어, 부모 컴포넌트의 상태가 변경되었지만 자식 컴포넌트에 전달되는 속성에는 아무런 변화가 없어도 자식 컴포넌트는 기본적으로 리렌더링됩니다. 이 불필요한 리렌더링은 자바스크립트 스레드에서 더 많은 계산을 요구하고, 브릿지를 통해 UI 스레드에 불필요한 업데이트 명령을 보내게 되어 결국 앱의 전반적인 성능을 저하시키는 원인이 됩니다.
개발자 도구를 활용한 성능 측정
어떤 컴포넌트가 불필요하게 리렌더링되는지, 그리고 이로 인해 어떤 성능 문제가 발생하는지 진단하는 것은 최적화의 첫걸음입니다. React Native 개발자 도구는 이러한 진단을 돕는 유용한 기능을 제공합니다.
- React Native Debugger: 이 도구는 크롬 개발자 도구와 유사한 인터페이스를 제공하며, React 컴포넌트 트리를 시각적으로 확인하고 각 컴포넌트의 렌더링 시간을 측정할 수 있습니다. 특히 'Performance' 탭을 통해 컴포넌트 렌더링 주기를 상세하게 분석할 수 있습니다.
- Flipper: Facebook에서 개발한 모바일 앱 디버깅 플랫폼으로, React Native 앱의 네트워크 요청, 로그, 그리고 UI 성능을 통합적으로 모니터링할 수 있습니다. Flipper의 'Layout Animation' 또는 'Performance' 플러그인을 활용하면 렌더링되는 컴포넌트와 발생 시간을 시각적으로 파악하는 데 큰 도움이 됩니다.
- Why Did You Render: 이 라이브러리는 개발 모드에서만 사용되며, 컴포넌트가 왜 리렌더링되었는지 콘솔에 자세히 출력해주는 강력한 도구입니다. 어떤 props나 state가 변경되어 리렌더링을 유발했는지 명확하게 파악하여 최적화 포인트를 찾는 데 매우 효과적입니다.
이러한 도구들을 사용하여 앱의 특정 화면에서 발생하는 리렌더링 이벤트를 관찰하고, 예상치 못한 리렌더링이 발생하는 지점을 찾아내는 것이 중요합니다. 예를 들어, 스크롤 시 특정 영역의 컴포넌트들이 계속 리렌더링되거나, 입력 필드에 텍스트를 입력할 때 관련 없는 컴포넌트들이 함께 리렌더링되는 경우를 발견할 수 있습니다. 이러한 지점들이 바로 성능 최적화가 필요한 부분으로 판단됩니다.
리렌더링 방지 마스터하기: 효율적인 컴포넌트 관리
불필요한 리렌더링을 방지하는 것은 React Native 앱의 UI 스레드를 여유롭게 유지하고 렌더링 성능을 크게 향상시키는 핵심 전략입니다. React는 개발자가 이러한 최적화를 수행할 수 있도록 여러 기능을 제공합니다.
`React.memo`를 활용한 컴포넌트 메모이제이션
메모이제이션(Memoization)은 이전에 계산한 값을 저장해두었다가 동일한 입력이 들어오면 다시 계산하지 않고 저장된 값을 반환하는 최적화 기법입니다. React에서 컴포넌트 레벨의 메모이제이션을 위해 React.memo를 사용합니다.
React.memo는 고차 컴포넌트(Higher-Order Component, HOC)로, 컴포넌트를 감싸서 사용합니다. React.memo로 감싸진 컴포넌트는 자신의 속성(props)이 변경되지 않는 한, 부모 컴포넌트가 리렌더링되어도 자신은 리렌더링되지 않습니다. React.memo는 기본적으로 얕은 비교(shallow comparison)를 수행하여 props의 변경 여부를 판단합니다.
import React from 'react';
import { Text, View, StyleSheet } from 'react-native';
const MyPureComponent = ({ title, count }) => {
console.log('MyPureComponent 렌더링!');
return (
<View style={styles.container}>
<Text>제목: {title}</Text>
<Text>카운트: {count}</Text>
</View>
);
};
// React.memo로 컴포넌트를 감싸 메모이제이션 적용
const MemoizedMyPureComponent = React.memo(MyPureComponent);
const styles = StyleSheet.create({
container: {
padding: 10,
marginVertical: 5,
backgroundColor: '#e0f7fa',
borderRadius: 5,
},
});
export default MemoizedMyPureComponent;
위 예시에서 MemoizedMyPureComponent는 title이나 count props가 실제로 변경될 때만 리렌더링됩니다. 만약 부모 컴포넌트의 다른 상태가 변경되어 부모가 리렌더링되더라도, MemoizedMyPureComponent에게 전달되는 title과 count 값이 이전과 동일하다면 해당 컴포넌트는 렌더링을 건너뛰게 됩니다.
`useCallback`과 `useMemo`로 함수 및 값 최적화
React.memo는 컴포넌트 자체의 리렌더링을 막지만, 컴포넌트에 전달되는 속성(props) 중 함수나 객체가 매번 새로 생성된다면 React.memo의 얕은 비교가 실패하여 컴포넌트가 불필요하게 리렌더링될 수 있습니다. 이때 useCallback과 useMemo 훅이 유용하게 사용됩니다.
- `useCallback`: 함수를 메모이제이션하는 훅입니다. 의존성 배열(dependency array)에 있는 값이 변경되지 않는 한, 동일한 함수 인스턴스를 반환합니다. 이는 자식 컴포넌트에 함수를 props로 전달할 때 특히 유용합니다.
import React, { useState, useCallback } from 'react';
import { Button, Text, View } from 'react-native';
const ChildComponent = React.memo(({ onPress }) => {
console.log('ChildComponent 렌더링!');
return <Button title="자식 버튼" onPress={onPress} />;
});
const ParentComponent = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('초기 텍스트');
// count가 변경될 때만 새로운 함수 인스턴스 생성
const handlePress = useCallback(() => {
setCount(prevCount => prevCount + 1);
}, []); // 의존성 배열이 비어있으므로, 컴포넌트 마운트 시 한 번만 생성
return (
<View>
<Text>부모 카운트: {count}</Text>
<Button title="부모 카운트 증가" onPress={() => setCount(count + 1)} />
<Button title="텍스트 변경" onPress={() => setText('새로운 텍스트')} />
{/* handlePress 함수가 변하지 않으므로 ChildComponent는 text 변경 시 리렌더링되지 않음 */}
<ChildComponent onPress={handlePress} />
</View>
);
};
export default ParentComponent;
위 예시에서 handlePress 함수는 useCallback으로 감싸져 의존성 배열([])에 따라 한 번만 생성됩니다. 따라서 ParentComponent의 text 상태가 변경되어 ParentComponent가 리렌더링되더라도 handlePress 함수는 동일한 참조를 유지하므로, React.memo로 감싸진 ChildComponent는 리렌더링되지 않습니다.
- `useMemo`: 특정 값을 메모이제이션하는 훅입니다. 의존성 배열에 있는 값이 변경되지 않는 한, 이전에 계산된 값을 반환합니다. 복잡한 계산 결과나 객체를 자식 컴포넌트에 props로 전달할 때 유용합니다.
import React, { useState, useMemo } from 'react';
import { Text, View } from 'react-native';
const MemoizedDisplayComponent = React.memo(({ computedValue }) => {
console.log('MemoizedDisplayComponent 렌더링!');
return <Text>계산된 값: {computedValue}</Text>;
});
const CalculationComponent = () => {
const [num1, setNum1] = useState(10);
const [num2, setNum2] = useState(20);
const [otherState, setOtherState] = useState(false);
// num1 또는 num2가 변경될 때만 계산을 다시 수행
const complexCalculation = useMemo(() => {
console.log('복잡한 계산 실행!');
return num1 * num2 + 100; // 예시로 복잡한 계산
}, [num1, num2]); // num1 또는 num2가 변경될 때만 재계산
return (
<View>
<Text>Num1: {num1}</Text>
<Text>Num2: {num2}</Text>
<Text>Other State: {String(otherState)}</Text>
<Button title="Num1 증가" onPress={() => setNum1(num1 + 1)} />
<Button title="Other State 토글" onPress={() => setOtherState(!otherState)} />
<MemoizedDisplayComponent computedValue={complexCalculation} />
</View>
);
};
export default CalculationComponent;
useMemo를 사용하여 complexCalculation 값을 메모이제이션하면, otherState가 변경되어 CalculationComponent가 리렌더링되더라도 num1과 num2가 변경되지 않는 한 complexCalculation은 다시 계산되지 않습니다. 또한, MemoizedDisplayComponent에 전달되는 computedValue도 동일한 참조를 유지하므로 MemoizedDisplayComponent 역시 불필요하게 리렌더링되지 않습니다.
불변성(Immutable) 데이터 구조의 중요성
React.memo, useCallback, useMemo는 모두 얕은 비교를 기반으로 동작합니다. 이는 객체나 배열의 참조(reference)가 변경되었는지를 확인한다는 의미입니다. 만약 객체나 배열 내부의 값만 변경되고 참조는 그대로 유지된다면, React는 변경을 감지하지 못하고 최적화가 무력화될 수 있습니다.
예를 들어, 다음과 같이 객체를 직접 수정하는 코드는 문제가 될 수 있습니다.
const [user, setUser] = useState({ name: '홍길동', age: 30 });
const updateAge = () => {
user.age = 31; // 객체를 직접 수정!
setUser(user); // user 객체의 참조는 변경되지 않음
};
위 코드에서 user 객체의 age 속성만 변경되었고, user 객체 자체의 메모리 주소(참조)는 변경되지 않았습니다. setUser(user)를 호출해도 React는 이전 user 객체와 현재 user 객체가 동일한 것으로 판단하여 리렌더링을 건너뛸 수 있습니다 (또는 불필요한 비교 작업을 수행할 수 있습니다). 이는 React의 상태 업데이트 방식과 메모이제이션 기법을 무력화시킬 수 있습니다.
올바른 방법은 불변성(Immutable)을 유지하며 새로운 객체를 생성하는 것입니다. 즉, 기존 객체를 수정하는 대신 새로운 객체를 만들고 변경된 부분만 반영하여 상태를 업데이트해야 합니다.
const [user, setUser] = useState({ name: '홍길동', age: 30 });
const updateAge = () => {
setUser({ ...user, age: 31 }); // 새로운 객체를 생성하여 업데이트
};
이렇게 하면 user 객체의 참조가 변경되므로 React는 상태가 변경되었음을 정확히 감지하고 필요한 리렌더링을 수행하게 됩니다. React.memo, useCallback, useMemo를 사용할 때 객체나 배열을 props로 전달한다면, 반드시 불변성을 유지하는 방식으로 데이터를 관리해야 최적화 효과를 온전히 누릴 수 있습니다.
Image by jatocreate on Pixabay
애니메이션 튜닝의 정석: 부드러운 사용자 경험 제공
사용자 경험에서 애니메이션은 매우 중요한 요소입니다. 부드러운 애니메이션은 앱의 반응성을 높이고 사용자의 몰입감을 향상시킵니다. React Native에서 애니메이션을 구현할 때 UI 스레드의 부하를 최소화하여 끊김 없는 움직임을 만드는 것이 중요합니다.
네이티브 드라이버(Native Driver) 활용
React Native의 기본 Animated API는 자바스크립트 스레드에서 애니메이션 로직을 실행합니다. 이 과정에서 애니메이션의 각 프레임마다 자바스크립트 스레드에서 계산된 값이 브릿지를 통해 UI 스레드로 전달되어 화면에 그려지게 됩니다. 만약 자바스크립트 스레드에 다른 무거운 작업이 있다면, 애니메이션 데이터가 UI 스레드에 제때 전달되지 못하여 애니메이션이 끊기는 현상이 발생할 수 있습니다.
이러한 문제를 해결하기 위해 네이티브 드라이버(Native Driver)를 활용할 수 있습니다. Animated API를 사용할 때 useNativeDriver: true 옵션을 설정하면, 애니메이션 시작 시 모든 애니메이션 동작에 대한 정보를 브릿지를 통해 UI 스레드로 한 번에 전달합니다. 이후 애니메이션은 자바스크립트 스레드의 개입 없이 UI 스레드에서 직접 실행됩니다.
import React, { useRef, useEffect } from 'react';
import { Animated, View, StyleSheet, Button } from 'react-native';
const FadeInView = (props) => {
const fadeAnim = useRef(new Animated.Value(0)).current; // 초기 투명도 0
const fadeIn = () => {
Animated.timing(fadeAnim, {
toValue: 1, // 최종 투명도 1
duration: 2000, // 2초
useNativeDriver: true, // <--- 네이티브 드라이버 사용
}).start();
};
const fadeOut = () => {
Animated.timing(fadeAnim, {
toValue: 0,
duration: 2000,
useNativeDriver: true, // <--- 네이티브 드라이버 사용
}).start();
};
return (
<View style={styles.container}>
<Animated.View // 투명도를 애니메이션할 View
style={{
...props.style,
opacity: fadeAnim, // 애니메이션 값과 투명도 연결
}}
>
{props.children}
</Animated.View>
<View style={styles.buttonContainer}>
<Button title="Fade In" onPress={fadeIn} />
<Button title="Fade Out" onPress={fadeOut} />
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
buttonContainer: {
flexDirection: 'row',
marginTop: 20,
}
});
export default FadeInView;
useNativeDriver: true 옵션을 사용하면 UI 스레드에서 직접 애니메이션을 처리하므로, 자바스크립트 스레드가 바쁘더라도 애니메이션은 끊김 없이 부드럽게 실행됩니다. 이는 프레임 드롭을 줄이고 사용자 경험을 크게 향상시키는 효과적인 방법으로 판단됩니다.
`useNativeDriver` 옵션 이해
useNativeDriver 옵션은 모든 애니메이션 속성에 적용될 수 있는 것은 아닙니다. 주로 변환(transform) 속성(예: translateX, translateY, scale, rotate)과 불투명도(opacity) 속성에 적용 가능합니다. 이러한 속성들은 UI 스레드에서 직접 조작하기 쉽기 때문입니다.
하지만 layout 속성(예: width, height, top, left, margin, padding)을 애니메이션할 때는 useNativeDriver: true를 사용할 수 없습니다. 이러한 속성들은 UI 요소의 크기나 위치를 변경하므로, 레이아웃 계산을 다시 수행해야 하며 이는 자바스크립트 스레드의 관여가 필요하기 때문입니다. layout 속성을 애니메이션할 경우 useNativeDriver를 false로 설정하거나, 다른 애니메이션 라이브러리를 고려해야 합니다.
LayoutAnimation과 Reanimated 라이브러리
React Native는 Animated API 외에도 두 가지 주요 애니메이션 옵션을 제공합니다.
- `LayoutAnimation`: 이 API는 컴포넌트의 레이아웃이 변경될 때 자동으로 애니메이션을 적용해주는 편리한 방법입니다. 상태 변경으로 인해 컴포넌트가 추가되거나 제거되거나 크기가 변경될 때, 명시적인 애니메이션 로직 없이도 부드러운 전환 효과를 줄 수 있습니다.
LayoutAnimation은 완전히 네이티브 스레드에서 작동하며, 복잡한 로직 없이 간편하게 사용할 수 있다는 장점이 있습니다. 다만, 애니메이션의 세밀한 제어가 어렵다는 단점이 있습니다.import React, { useState } from 'react'; import { View, Text, Button, LayoutAnimation, StyleSheet, Platform, UIManager } from 'react-native'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); } const LayoutAnimationExample = () => { const [showBox, setShowBox] = useState(false); const toggleBox = () => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); // 애니메이션 설정 setShowBox(!showBox); }; return ( <View style={styles.container}> <Button title="박스 토글" onPress={toggleBox} /> {showBox && ( <View style={styles.box}> <Text>나는 박스!</Text> </View> )} </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, box: { width: 100, height: 100, backgroundColor: 'dodgerblue', marginTop: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', }, }); export default LayoutAnimationExample; - `React Native Reanimated`: 이 라이브러리는
AnimatedAPI의 한계를 극복하기 위해 설계된 강력한 애니메이션 라이브러리입니다. UI 스레드에서 애니메이션을 선언적으로 작성하고 실행할 수 있도록 하여, 자바스크립트 스레드와 브릿지의 오버헤드를 완전히 제거합니다. 이는 복잡한 제스처, 인터랙티브 애니메이션, 고성능의 시각 효과를 구현할 때 매우 유용합니다.Reanimated는 학습 곡선이 다소 높지만, 최고의 성능과 유연성을 제공하므로 고도화된 애니메이션이 필요한 경우 적극적으로 고려할 만한 솔루션으로 판단됩니다.useSharedValue,useAnimatedStyle,withTiming,withSpring등 다양한 훅과 함수를 제공합니다.
애니메이션의 복잡도와 필요한 제어 수준에 따라 적절한 방법을 선택하는 것이 중요합니다. 간단한 전환에는 LayoutAnimation, 변환/불투명도 애니메이션에는 Animated + useNativeDriver, 그리고 복잡하고 고성능의 인터랙티브 애니메이션에는 Reanimated를 사용하는 것이 일반적인 권장 사항입니다.
UI 스레드 부하 줄이기: 비동기 작업 및 백그라운드 처리
UI 스레드가 부드럽게 작동하려면, 자바스크립트 스레드도 과부하 없이 효율적으로 동작해야 합니다. 자바스크립트 스레드에서 너무 많은 계산이나 동기적인 작업이 발생하면, UI 업데이트 명령을 UI 스레드에 제때 전달하지 못하게 되어 결국 앱의 응답성이 저하됩니다. 따라서 무거운 작업을 분리하고 비동기적으로 처리하는 것이 중요합니다.
대규모 리스트 최적화 (`FlatList`, `SectionList`)
모바일 앱에서는 수많은 아이템을 포함하는 리스트를 표시하는 경우가 많습니다. 이러한 대규모 리스트를 처리할 때 ScrollView를 사용하면 심각한 성능 문제가 발생할 수 있습니다.
ScrollView는 기본적으로 내부의 모든 컴포넌트를 한 번에 렌더링합니다. 리스트 아이템이 1000개라면, 화면에 보이는 몇 개뿐만 아니라 1000개의 모든 컴포넌트를 미리 생성하고 렌더링하므로, 초기 로딩 시간이 길어지고 메모리 사용량이 급증하며, 스크롤 성능이 매우 저하될 수 있습니다.
반면 FlatList와 SectionList는 가상화(Virtualization)라는 기법을 사용하여 이러한 문제를 해결합니다. 가상화는 화면에 실제로 보이는 아이템과 그 주변의 일부 아이템만 렌더링하고, 화면 밖으로 벗어난 아이템들은 메모리에서 해제하여 재활용하는 방식입니다. 이를 통해 렌더링될 컴포넌트의 수를 최소화하고 메모리 사용량을 줄여 스크롤 성능을 크게 향상시킵니다.
| 특징 | ScrollView | FlatList / SectionList |
|---|---|---|
| 렌더링 방식 | 모든 자식 컴포넌트 한 번에 렌더링 | 화면에 보이는 부분만 렌더링 (가상화) |
| 초기 로딩 | 아이템 수에 비례하여 느려질 수 있음 | 빠름 (initialNumToRender 설정 가능) |
| 메모리 사용 | 모든 아이템의 메모리 유지 | 가시 영역 아이템만 유지 (효율적) |
| 스크롤 성능 | 아이템 수가 많아지면 저하 | 매우 부드러움 |
| 적합한 상황 | 아이템 수가 적고 정적인 콘텐츠 | 대규모 동적 리스트, 무한 스크롤 |
FlatList의 주요 최적화 속성:
data: 리스트에 표시할 데이터 배열.renderItem: 각 아이템을 렌더링할 함수.keyExtractor: 각 아이템의 고유 키를 추출하는 함수. 리스트 성능과 재활용에 매우 중요합니다.initialNumToRender: 초기 렌더링 시 몇 개의 아이템을 렌더링할지 지정.windowSize: 화면에 보이는 아이템과 그 주변 몇 개의 아이템을 유지할지 지정. 기본값은 21 (뷰포트 10개 위, 뷰포트 10개 아래, 뷰포트 1개).removeClippedSubviews: 화면 밖으로 벗어난 뷰를 제거하여 메모리를 절약하는 옵션. (주의: 특정 상황에서 깜빡임 발생 가능성 있음)
대규모 리스트를 다룰 때는 항상 FlatList 또는 SectionList를 사용하는 것이 강력히 권장됩니다.
비동기 작업 분리 (Web Workers 개념)
자바스크립트 스레드에서 수행되는 무거운 계산 작업(예: 대량의 데이터 처리, 복잡한 필터링, 이미지 처리)은 UI 스레드의 응답성을 저해할 수 있습니다. 이러한 작업들은 비동기적으로 처리하거나, 가능하면 백그라운드 스레드로 분리하여 실행하는 것이 이상적입니다.
- `setTimeout` / `setImmediate`: 자바스크립트의 이벤트 루프(Event Loop) 특성을 활용하여 긴 작업을 작은 조각으로 나누어 비동기적으로 실행할 수 있습니다. 예를 들어, 1000개의 아이템을 처리해야 한다면, 한 번에 100개씩 처리하고
setTimeout(..., 0)을 사용하여 다음 100개 처리를 다음 이벤트 루프 틱으로 미루는 방식입니다. 이렇게 하면 자바스크립트 스레드가 중간중간 UI 업데이트와 같은 다른 작업을 처리할 여유를 가질 수 있습니다. - Web Workers (개념적 적용): 웹 개발에서는 Web Workers를 사용하여 무거운 계산을 메인 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있습니다. React Native는 직접적인 Web Workers API를 제공하지 않지만, 유사한 개념으로 백그라운드 작업을 처리할 수 있는 라이브러리나 패턴을 사용할 수 있습니다. 예를 들어,
react-native-background-task와 같은 라이브러리는 앱이 백그라운드에 있을 때도 작업을 수행할 수 있도록 돕습니다. - 네이티브 모듈 활용: 매우 복잡하고 계산 집약적인 작업은 자바스크립트 스레드에서 처리하기보다, C++ 또는 Swift/Kotlin과 같은 네이티브 언어로 구현된 네이티브 모듈에서 처리하는 것이 가장 효율적입니다. 네이티브 모듈은 자바스크립트 스레드를 거치지 않고 직접 UI 스레드나 다른 네이티브 스레드에서 작업을 수행할 수 있으므로, React Native 앱의 성능 병목을 크게 해소할 수 있습니다.
핵심은 자바스크립트 스레드가 항상 반응성을 유지할 수 있도록 무거운 작업은 가능한 한 짧게 만들거나, 비동기적으로 분리하거나, 아예 다른 스레드로 위임하는 전략을 취하는 것입니다. 이는 UI 스레드에 부하를 주지 않고 앱의 전반적인 반응성과 부드러움을 유지하는 데 필수적인 요소로 판단됩니다.
Image by pen_ash on Pixabay
실제 프로젝트에 적용하는 UI 스레드 최적화 전략
지금까지 배운 React Native UI 스레드 최적화 기법들을 실제 프로젝트에 효과적으로 적용하기 위해서는 체계적인 접근 방식이 필요합니다. 단순히 코드를 수정하는 것을 넘어, 개발 프로세스 전반에 걸쳐 성능을 고려하는 문화와 시스템을 구축하는 것이 중요합니다.
성능 예산 설정 및 지속적인 모니터링
성능 예산(Performance Budget)은 앱의 성능 목표를 명확하게 정의하고, 이 목표를 달성하기 위한 구체적인 기준을 설정하는 것을 의미합니다. 예를 들어:
- "모든 스크롤 애니메이션은 60 FPS를 유지해야 한다."
- "초기 로딩 시간은 2초를 넘지 않아야 한다."
- "버튼 클릭 후 UI 반응까지의 지연 시간은 100ms 이내여야 한다."
- "번들(bundle) 크기는 5MB를 넘지 않아야 한다."
이러한 성능 예산을 설정하면, 개발 과정에서 성능에 대한 명확한 기준을 가지고 의사결정을 내릴 수 있습니다. 예를 들어, 새로운 라이브러리 도입이나 복잡한 UI 컴포넌트 개발 시 성능 예산에 미치는 영향을 미리 평가하고 필요하다면 다른 대안을 모색할 수 있습니다.
설정한 성능 예산을 지속적으로 모니터링하는 것도 중요합니다. React Native Debugger의 Performance Monitor, Flipper의 플러그인, 그리고 실제 기기에서의 프로파일링 도구(예: Xcode Instruments, Android Studio Profiler)를 활용하여 앱의 프레임 속도(FPS), 메모리 사용량, CPU 사용량 등을 주기적으로 측정해야 합니다. CI/CD 파이프라인에 성능 테스트를 통합하여, 새로운 코드가 병합될 때마다 자동으로 성능 지표를 측정하고 예산 초과 시 경고를 발생시키는 시스템을 구축하는 것도 좋은 방법입니다.
코드 리뷰를 통한 최적화 습관화
성능 최적화는 특정 단계에서만 수행되는 것이 아니라, 개발 과정 전반에 걸쳐 이루어져야 합니다. 특히 코드 리뷰(Code Review)는 동료 개발자들이 서로의 코드를 검토하며 잠재적인 성능 문제를 조기에 발견하고 개선하는 데 매우 효과적인 방법입니다.
코드 리뷰 시 다음과 같은 사항들을 중점적으로 확인하는 습관을 들여야 합니다:
- 불필요한 리렌더링 유발 요소:
React.memo,useCallback,useMemo가 적절히 사용되었는지, 또는 불변성(Immutable) 데이터 구조가 잘 지켜지고 있는지 확인합니다. 특히 객체나 배열을 props로 넘길 때 새로운 참조가 매번 생성되는지 주의 깊게 살펴봐야 합니다. - 대규모 리스트 처리:
ScrollView대신FlatList나SectionList가 사용되었는지, 그리고keyExtractor와 같은 필수 속성들이 올바르게 구현되었는지 검토합니다. - 애니메이션 구현:
AnimatedAPI 사용 시useNativeDriver: true옵션이 적용되었는지, 또는Reanimated와 같은 고성능 라이브러리가 적절하게 활용되었는지 확인합니다.LayoutAnimation의 적용 가능성도 함께 고려할 수 있습니다. - 복잡한 계산 또는 동기적 작업: 자바스크립트 스레드에서 오랜 시간을 소모할 수 있는 계산 작업이 있는지 파악하고, 비동기 처리나 네이티브 모듈 위임 등의 최적화 방안을 논의합니다.
이러한 코드 리뷰를 통해 팀 전체의 성능 최적화 역량을 강화하고, 성능을 고려하는 개발 습관을 정착시킬 수 있습니다. 지속적인 학습과 공유를 통해 새로운 최적화 기법들을 도입하고 적용하는 것도 중요합니다.
테스트: 스트레스 및 사용자 테스트
성능 최적화는 이론만으로는 부족하며, 실제 사용 환경에서의 테스트가 필수적입니다.
- 스트레스 테스트 (Stress Test): 앱이 최대 부하 상황에서 어떻게 동작하는지 테스트합니다. 예를 들어, 매우 많은 데이터를 한 번에 로드하거나, 수많은 애니메이션을 동시에 실행하거나, 네트워크 연결이 불안정한 환경에서 앱을 사용해보는 것입니다. 이러한 테스트를 통해 잠재적인 병목 현상과 안정성 문제를 발견할 수 있습니다.
- 사용자 테스트 (User Test): 실제 사용자들이 앱을 사용하는 과정을 관찰하고 피드백을 수집합니다. 개발자가 예상치 못한 방식으로 앱을 사용하거나, 특정 상황에서 성능 저하를 경험할 수 있습니다. 사용자들의 솔직한 피드백은 개발자가 놓칠 수 있는 성능 문제를 발견하는 데 큰 도움이 됩니다.
테스트 과정을 통해 발견된 성능 문제는 다시 개발자 도구를 활용하여 진단하고, 앞서 설명한 최적화 기법들을 적용하여 해결해나가야 합니다. 이 과정은 반복적인 사이클로 이루어지며, 지속적인 개선을 통해 앱의 성능과 사용자 경험을 꾸준히 향상시킬 수 있습니다.
결론
React Native 앱 개발에서 UI 스레드 최적화는 단순한 선택 사항이 아닌, 사용자의 만족도를 높이고 앱의 성공을 좌우하는 필수적인 요소로 판단됩니다. 불필요한 리렌더링을 방지하고 애니메이션을 부드럽게 튜닝하는 것은 앱의 반응성을 극대화하고 프레임 드롭을 최소화하는 핵심 전략입니다.
우리는 이 글을 통해 UI 스레드의 중요성부터 시작하여, React.memo, useCallback, useMemo를 활용한 리렌더링 방지, 네이티브 드라이버 및 Reanimated를 이용한 애니메이션 튜닝, 그리고 FlatList를 통한 대규모 리스트 최적화까지 다양한 기법들을 심층적으로 살펴보았습니다. 또한, 실제 프로젝트에 이러한 최적화 전략을 적용하기 위한 성능 예산 설정, 지속적인 모니터링, 코드 리뷰, 그리고 테스트의 중요성도 강조하였습니다.
이 글에서 다룬 최적화 기법들을 여러분의 React Native 프로젝트에 적극적으로 적용해보시길 권장합니다. 처음에는 다소 어렵게 느껴질 수 있지만, 꾸준히 연습하고 성능 측정 도구를 활용하여 결과를 확인하며 개선해나간다면, 분명 사용자들에게 더욱 빠르고 매끄러운 경험을 제공하는 고품질의 애플리케이션을 만들 수 있을 것입니다.
이 글에 대한 질문이나 여러분의 React Native 성능 최적화 경험, 또는 새롭게 알게 된 유용한 팁이 있다면 언제든지 댓글로 공유해주세요! 함께 논의하며 더 나은 개발자가 되기를 바랍니다.
📌 함께 읽으면 좋은 글
- [모바일 앱 개발] 모바일 앱 접근성 UI, 필수 점검인가 선택 사항인가: 설계부터 구현까지 완벽 가이드
- [모바일 앱 개발] 모바일 앱 메모리 지옥에서 탈출한 나의 경험: 이미지 캐싱과 객체 풀링 실전 적용기
- [임베디드 IoT] IoT 디바이스 섀도우, 오프라인 환경 데이터 일관성 유지를 위한 동기화 전략 설계
이 글이 도움이 되셨다면 공감(♥)과 댓글로 응원해 주세요!
궁금한 점이나 다루었으면 하는 주제가 있다면 댓글로 남겨주세요.
'모바일 앱 개발' 카테고리의 다른 글
| 모바일 앱 푸시 알림, 그 동작 원리를 완벽하게 이해하는 방법 (0) | 2026.07.17 |
|---|---|
| 모바일 앱 접근성 UI, 필수 점검인가 선택 사항인가: 설계부터 구현까지 완벽 가이드 (0) | 2026.07.15 |
| 리워드 광고, 함부로 도입하면 개발팀 리소스 낭비됩니다: 성공적인 SDK 연동과 수익화 전략 (0) | 2026.07.14 |
| Native 앱 vs WebView: 모바일 앱에 웹 콘텐츠를 통합하는 전략적 선택 (0) | 2026.07.12 |
| 모바일 앱 메모리 지옥에서 탈출한 나의 경험: 이미지 캐싱과 객체 풀링 실전 적용기 (0) | 2026.07.10 |