RN 의 라이프 사이클은, React 와 동일하게 운영될까? 마운트, 언마운트, 리마운트 말이다.
이러한 라이프 사이클은 React 든 RN이든 동일하게 운영된다. useState, useEffect 같은 훅들이 돌아가는 시점이나 마운트, 업데이트, 언마운트의 개념은 완전히 같다. RN 도 결국 React 라이브러리를 그대로 쓰기 때문이다.
하지만 모바일 앱(App) 환경이기 때문에 추가되는 "앱 라이프사이클(App Lifecycle)"과 "화면 네비게이션 라이프사이클"이 존재한다. 이 부분이 웹과 다르다.
웹에서 하던 거랑 똑같다.
웹 브라우저는 탭을 닫거나 새로고침하면 끝이지만, 모바일 앱은 "백그라운드(Background)" 상태가 있다. 사용자가 홈 버튼을 눌러서 나갔다가 다시 돌아올 수 있기 때문이다.
React Native에서는 AppState API를 통해 이를 감지한다.
import React, { useEffect, useState } from 'react';
import { AppState, Text } from 'react-native';
const AppLifecycleExample = () => {
useEffect(() => {
// 앱 상태 변경 감지 리스너 등록
const subscription = AppState.addEventListener('change', nextAppState => {
if (nextAppState === 'active') {
console.log('앱이 다시 활성화되었습니다! (Foreground)');
} else if (nextAppState.match(/inactive|background/)) {
console.log('앱이 백그라운드로 갔습니다.');
}
});
return () => {
subscription.remove();
};
}, []);
return <Text>앱 상태 모니터링 중...</Text>;
};
웹(React Router)과 앱(React Navigation)은 화면 이동 방식이 다르다. 이게 실무에서 가장 많이 헷갈리는 부분이다.
웹 (React Router): 페이지를 이동(a -> b)하면, a 컴포넌트는 Unmount(파괴)된다. useEffect의 cleanup 함수가 실행된다.
앱 (Stack Navigation) : 화면을 이동(a -> b)하면, a 컴포넌트는 Unmount 되지 않고 그대로 메모리에 살아있다. (카드 덮기 방식).
따라서 b에서 뒤로 가기를 눌러 a로 돌아와도 a는 재마운트(Mount) 되지 않는다.
그냥 가려져 있던 게 다시 보이는 것뿐이다.
그래서 React Native에서는 화면이 다시 포커스(Focus) 되었을 때를 감지하기 위해 useFocusEffect 라는 전용 훅을 쓰며, 다음과 같은 상황이 있다.
import { useFocusEffect } from '@react-navigation/native';
import { useCallback } from 'react';
// 화면이 포커스 될 때마다 실행됨 (매번 실행)
useFocusEffect(
useCallback(() => {
console.log('이 화면이 보입니다!');
return () => {
console.log('이 화면이 사라졌습니다(다른 화면으로 덮힘 or 뒤로가기)');
};
}, [])
);
가장 중요한 점은 useCallback과 함께 사용해야 한다는 것이다. 그렇지 않으면 무한 루프에 빠질 수 있다.
스택에서 화면이 언마운트되지 않고 포커스만 잃는다는 점이 웹과 큰 차이점이다.
이제 성능과 직결된 라이프라이클 2가지를 더 보자.
현재 화면이 포커스 상태인지 boolean 값으로 반환한다. 조건부 렌더링이나 특정 로직 분기에 사용한다.
import { useIsFocused } from '@react-navigation/native';
const MyScreen = () => {
const isFocused = useIsFocused();
return <Text>{isFocused ? '현재 이 화면 보는 중' : '다른 화면 보는 중'}</Text>;
};
다만 useIsFocused는 포커스 상태가 바뀔 때마다 리렌더링을 유발하기 때문에, 단순히 진입/이탈 시점에 뭔가를 실행하고 싶다면 useFocusEffect를 쓰는 게 더 적합하다.
웹에서는 페이지가 뜨자마자 데이터를 불러와도(useEffect) 상관없지만, 앱은 화면 전환 애니메이션이 있어서 문제가 된다.
import React, { useEffect, useState } from 'react';
import { InteractionManager, Text, View } from 'react-native';
const ExpensiveScreen = () => {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// 1. 화면 전환 애니메이션이 완전히 끝날 때까지 기다린다.
const task = InteractionManager.runAfterInteractions(() => {
// 2. 애니메이션 끝! 이제 무거운 작업을 시작한다.
console.log('애니메이션 종료, 데이터 로딩 시작');
setIsReady(true);
});
return () => task.cancel(); // 청소(Cleanup)
}, []);
if (!isReady) {
return <View><Text>로딩 중... (애니메이션은 부드럽게 돌아감)</Text></View>;
}
return <View><Text>엄청 무거운 데이터 화면!</Text></View>;
};
웹의 CSS는 브라우저가 렌더링 하기 전에 크기를 대충 알 수 있지만, React Native는 비동기 레이아웃 시스템(Yoga Engine)을 쓴다.
import React, { useState } from 'react';
import { View, Text } from 'react-native';
const LayoutExample = () => {
const [boxHeight, setBoxHeight] = useState(0);
return (
<View
// 1. 렌더링이 끝나면 이 함수가 실행된다.
onLayout={(event) => {
const { height } = event.nativeEvent.layout;
console.log('내 실제 높이:', height);
setBoxHeight(height);
}}
style={{ padding: 20, backgroundColor: 'red' }}
>
<Text>
글자 양에 따라 높이가 변하는 박스.
React Native는 이 박스가 다 그려지기 전까진 높이를 모름
</Text>
</View>
);
};
하지만, onLayout은 렌더링 후에 실행되므로, 여기서 setState를 하면 필수로 리렌더링(Re-render)이 일어난다. 즉, 화면이 한 번 깜빡일 수 있으니 꼭 필요할 때만 써야 한다.
모바일 앱에서는 화면이 "사라진다"고 해서 항상 언마운트되는 게 아니다. Stack Navigator 기준으로는 스택에 쌓인 채 대기하는 경우가 대부분이다. 그래서 데이터 새로고침, 구독 재등록 같이 "화면에 돌아올 때마다 실행해야 하는 작업"은 useEffect 대신 useFocusEffect를 써야 한다는 걸 기억해두자.