⚡ ScrollView 버벅임 현상
외주 업체에서 상품소개 페이지를 만들었다고 해서 확인을 해봤습니다.
아이폰에서는 잘 작동하지만 안드로이드에서는 스크롤이 불가능할 정도로 버벅거리는 현상이 발생했습니다.
react-native의 스스로 화면 밖 요소를 최적화해주는 FlatList
를 사용해 볼까 싶었지만
일정한 포맷이 반복되는 요소들이 아니어서 결국엔 ScrollView
를 사용할 수 밖에 없었습니다.
처음에는 상태 관련된 최적화가 되어 있지 않아서 발생하는 문제인 줄 알았지만,
이리저리 헤매다 결국엔 ScrollView
내부에 너무 많은 요소가 존재해 발생하는 문제라는 것을 알아냈습니다.
ScrollView
내부엔 40장 이상, 총합 7mb 정도 되는 이미지들과 가벼운 컴포넌트들이 있었는데
최신 기기에서 고작 이런 걸로 버벅거리나 싶었습니다.
이 문제를 해결하기 위한 방법은 너무나 당연하게도 “스크롤 하며 보이는 부분만 렌더링 하기”라고 생각해,
웹에서 스크롤을 통해 요소를 감지하는 IntersectionObserver
를 사용하려 했습니다.
그런데 알고 보니 IntersectionObserver
는 웹에서만 지원하는 Web API기 때문에
react-native에서는 사용이 불가능했습니다.
react-native 정보를 아무리 뒤져봐도 스크롤 감지를 위한 기능을 제공하지 않는다는 것을 알고 난 후,
결국 바닥부터 만들어야 된다는 것을 깨닫고 눈물을 머금었습니다.
그럼에도 방법은 있다고, 어떻게든 만들어낸 기능을 소개드립니다.
expo로 생성한 프로젝트 예제는 github에 올려놓았습니다.
📌 스크롤 위치 얻기, ScrollView의 onScroll 이벤트
우선 스크롤 최적화를 하려면 무엇보다도 현재 스크롤 위치 정보가 필요하다고 생각했습니다.
이러한 정보는 다음과 같이 ScrollView의 onScroll
이벤트에서 제공받을 수 있었습니다.
<ScrollView
onScroll={(event) => {
// event.nativeEvent 객체 사용
const { contentOffset, contentSize, layoutMeasurement, zoomScale } = event.nativeEvent;
// contentOffset: 스크롤된 컨텐츠의 좌표
const { x, y } = contentOffset;
console.log(`X: ${x}, Y: ${y}`);
// contentSize: 스크롤뷰의 내용 컨텐츠 크기
const { width, height } = contentSize;
console.log(`Content Width: ${width}, Height: ${height}`);
// layoutMeasurement: 현재 스크롤뷰의 레이아웃 측정값
const { width, height } = layoutMeasurement;
console.log(`Layout Width: ${width}, Height: ${height}`);
// zoomScale: 줌(확대/축소) 레벨
console.log(`Zoom Scale: ${zoomScale}`);
}}
>
{/* 스크롤뷰의 내용 */}
</ScrollView>
onScroll이 제공하는 event의 nativeEvent 객체는 스크롤뷰와 관련된 이벤트 정보를 포함하고 있습니다.
이중에서도 contentOffset.y
를 통해 1단계, 현재 스크롤 위치 정보를 얻을 수 있습니다.
📌 요소의 위치 얻기, ref.measureLayout
무거운 이미지 요소를 렌더 트리에 추가, 제외하려면 그 요소의 위치 또한 알아야 합니다.
measureLayout
은 React Native에서 사용되는 함수 중 하나로,
컴포넌트의 레이아웃 정보를 측정하고 다른 컴포넌트와의 상대적인 위치를 파악하는 데 사용됩니다.
이 함수를 사용하면 컴포넌트의 위치와 크기를 파악할 수 있어서, 레이아웃 기반의 작업을 수행할 때 유용합니다.
measureLayout 함수는 다음과 같이 사용됩니다.
viewRef.measureLayout(
relativeToNativeNode,
successCallback,
failureCallback
);
relativeToNativeNode
(Native Handle 또는 React Ref)- 측정된 레이아웃 정보를 비교할 대상 컴포넌트의 네이티브 핸들(node handle) 또는 React 레퍼런스입니다.
이 대상 컴포넌트와의 상대적인 위치를 계산합니다.
- 측정된 레이아웃 정보를 비교할 대상 컴포넌트의 네이티브 핸들(node handle) 또는 React 레퍼런스입니다.
successCallback
- 레이아웃 정보 측정이 성공한 경우 호출되는 콜백 함수입니다.
이 콜백 함수는 측정된 컴포넌트의 위치와 크기를 인수로 받습니다.
- 레이아웃 정보 측정이 성공한 경우 호출되는 콜백 함수입니다.
failureCallback
- 레이아웃 정보 측정이 실패한 경우 호출되는 콜백 함수입니다.
실패할 때 오류를 처리하거나 에러 처리를 수행하는 데 사용됩니다.
- 레이아웃 정보 측정이 실패한 경우 호출되는 콜백 함수입니다.
이를 이용해서 스크롤마다 현재 스크롤 위치와 요소의 위치를 얻는 코드는 다음과 같습니다.
export default function App() {
const scrollViewRef = useRef(null);
const targetRef = useRef(null); // 비교할 대상 컴포넌트의 ref
const handleOnScroll = (event) => {
const offsetY = event.nativeEvent.contentOffset.y;
console.log(`스크롤뷰 y 위치: ${offsetY}`);
if (!offsetY || !targetRef?.current) return;
// viewRef의 레이아웃을 relativeRef와 비교하여 위치 정보를 측정
targetRef.current.measureLayout(
scrollViewRef.current, // relativeToNativeNode에 대상 컴포넌트의 ref 전달
(x, y, width, height) => {
console.log(`이미지 y 시작: ${y}`);
console.log(`이미지 y 끝: ${y + height}`);
},
(error) => console.log(error),
);
};
return (
<View style={{ flex: 1, backgroundColor: 'black' }}>
<ScrollView ref={scrollViewRef} onScroll={handleOnScroll}>
<Image
style={{ width: 300, height: 2000 }}
ref={targetRef}
src={'https://cdn.pixabay.com/photo/2014/04/05/11/39/biology-316571_1280.jpg'}
/>
</ScrollView>
</View>
);
};
measureLayout의 y
가 이미지의 최상단 지점, y + height
는 최하단 지점입니다.
이를 통해 스크롤 뷰 내부 이미지 요소의 정확한 위치를 얻을 수 있습니다.
📌 범위에 따른 조건부 렌더링
자 그럼 모든 위치 정보를 알아냈으니 조건부 렌더링을 통해
스크롤뷰 내부의 요소가 일정 범위 밖으로 나가면 렌더 트리에서 제외,
일정 범위 내로 진입하면 렌더링 하면 됩니다.
그러기 위해서는 다음과 같은 3가지 조건이 필요합니다.
- 스크롤 위치가 요소 시작 부분 혹은 마지막 부분에서부터 일정 범위 내로 들어오면 렌더링
- 스크롤 위치가 요소 시작 부분 혹은 마지막 부분에서 부터 일정 범위 밖으로 나가면 렌더 해제
- 스크롤 위치가 요소 내부에 있을 경우 렌더링
이와 같은 조건을 달기 위해서는 다음과 같은 값들이 필요합니다.
- 시작 부분에서부터 범위 밖으로 벗어났는지 여부
- 마지막 부분부터 범위 밖으로 벗어 났는지 여부
- 스크롤 위치가 요소 내부에 위치하는지 여부
위와 같은 조건식을 코드로 변환하면 다음과 같은 모습이 됩니다.
export default function App() {
const scrollViewRef = useRef(null);
const targetRef = useRef(null);
const [isRender, setIsRender] = useState(false);
const handleOnScroll = (event) => {
const offsetY = event.nativeEvent.contentOffset.y;
if (!offsetY || !targetRef?.current) return;
targetRef.current.measureLayout(
scrollViewRef.current,
(x, y, width, height) => {
const range = 700; // 범위
// Math.abs()는 절대 값을 반환하는 함수
const isOutsideUpDiff = Math.abs(y - offsetY) > range; // 시작 부분에서 벗어났는지
const isOutSideDownDiff = Math.abs(y + height - offsetY) > range; // 마지막 부분에서 벗어났는지
const isInContent = offsetY >= y && offsetY <= y + height; // 스크롤 위치가 요소 내부에 위치하는지
// range 픽셀 이내로 스크롤하면 렌더링
// 시작 부분에서 벗어나지 않았거나(or) 마지막 부분에서 벗어나지 않았을 경우
if ((!isOutsideUpDiff || !isOutSideDownDiff)) setIsRender(true);
// range 픽셀 밖으로 벗어나면 렌더 해제
// 요소 내부에 위치하지 않고 시작, 마지막 부분에서 벗어났을 경우
if (!isInContent && isOutsideUpDiff && isOutSideDownDiff) setIsRender(false);
});
};
return (
<View style={{ flex: 1, backgroundColor: 'black' }}>
<ScrollView ref={scrollViewRef} onScroll={handleOnScroll}>
{isRender ? (
<Image
style={{ width: 300, height: 2000 }}
ref={targetRef}
src={'https://cdn.pixabay.com/photo/2014/04/05/11/39/biology-316571_1280.jpg'}
/>
): <></> }
</ScrollView>
</View>
);
};
📌 여러 요소를 범위에 따라 조건부 렌더링
자, 그럼 스크롤 위치에 따른 하나의 요소에 대한 조건부 렌더링 기능을 완성시켰습니다.
하지만 주제는 스크롤뷰 내부에 여러 이미지 요소를 최적화시키는 것이었습니다.
그러기 위해선 위 코드에서 조금만 손을 봐주면 됩니다.
- ref를 배열로 관리
- 조건부 렌더링을 위한 boolean 값을 배열로 관리
- 스크롤 계산 로직을 반복문으로 돌리기
const dataList = [
{url: 'https://cdn.pixabay.com/photo/2014/04/05/11/39/biology-316571_1280.jpg'},
{url: 'https://cdn.pixabay.com/photo/2014/04/05/11/39/biology-316571_1280.jpg'},
{url: 'https://cdn.pixabay.com/photo/2014/04/05/11/39/biology-316571_1280.jpg'},
]
export default function App() {
const scrollViewRef = useRef(null);
const targetRefList = useRef([]);
const [isRenderList, setIsRenderList] = useState(dataList.map(() => true));
const handleOnScroll = (event) => {
const offsetY = event.nativeEvent.contentOffset.y;
if (!offsetY || !scrollViewRef?.current) return;
// ref list 반복
targetRefList.current.forEach((targetRef, index) => {
targetRef?.measureLayout(scrollViewRef.current, (x, y, width, height) => {
const range = 700;
const isOutsideUpDiff = Math.abs(y - offsetY) > range;
const isOutSideDownDiff = Math.abs(y + height - offsetY) > range;
const isInContent = offsetY >= y && offsetY <= y + height;
// isRenderList에서 해당 index로 set
if (!isOutsideUpDiff || !isOutSideDownDiff) {
setIsRenderList((prev) => {
const temp = [...prev];
temp[index] = true;
return temp;
});
}
if (!isInContent && isOutsideUpDiff && isOutSideDownDiff) {
setIsRenderList((prev) => {
const temp = [...prev];
temp[index] = false;
return temp;
});
}
});
});
};
return (
<View style={{ flex: 1, backgroundColor: 'black' }}>
<ScrollView ref={scrollViewRef} onScroll={handleOnScroll}>
{isRenderList[0] ? (
<Image
ref={(element) => (targetRefList.current[0] = element)}
style={{ width: 300, height: 2000 }}
src={dataList[0].url}
/>
) : <></> }
{isRenderList[1] ? (
<Image
ref={(element) => (targetRefList.current[1] = element)}
style={{ width: 300, height: 2000 }}
src={dataList[1].url}
/>
) : <></> }
{isRenderList[2] ? (
<Image
ref={(element) => (targetRefList.current[2] = element)}
style={{ width: 300, height: 2000 }}
src={dataList[2].url}
/>
) : <></> }
</ScrollView>
</View>
);
};
배열 형태의 ref
를 요소에 넣을 때는ref={(element) ⇒ (targetRefList.current[index] = element)}
과 같은 방식을 사용하면 됩니다.
위 코드를 직접 실행시켜 보면 잘 되나 싶지만 문제점이 하나 있습니다.
실행 즉시 바로 렌더가 해제되며 요소가 자리 잡고 있던 레이아웃이 통째로 날아가서 감지조차 안된다는 문제입니다.
그럼 이 문제를 어떻게 해결할 수 있을까요?
📌 (완성) 이미지 크기만큼 초기 레이아웃 잡아주기
위와 같은 문제를 해결하기 위해 이미지를 렌더링 하는 별도의 컴포넌트를 만들어 주도록 하겠습니다.
export const MyImage = ({ url, width, height, isRender }) => {
return (
<View style={{ width, height }}>
{isRender && <Image src={url} style={{ width, height }} />}
</View>
);
};
실제로 사용했던 컴포넌트에서는 Image.resolveAssetSource()
를 이용해
자동으로 이미지 크기를 조정해 주는 계산식이 포함되어 있으나,
이 글에선 생략하고 간단히 고정된 너비와 높이를 외부에서 받아오는 방식으로 설명하겠습니다.
이 컴포넌트는 View를 통해 이미지와 같은 크기의 레이아웃을 잡아주는 역할을 하고
Prop으로 받은 isRender
를 이용해 이미지를 조건부 렌더링 해주고 있습니다.
이 컴포넌트를 사용한 전체 코드는 다음과 같습니다.
const dataList = [
{
url: 'https://cdn.pixabay.com/photo/2014/04/05/11/39/biology-316571_1280.jpg',
},
{
url: 'https://cdn.pixabay.com/photo/2014/04/05/11/39/biology-316571_1280.jpg',
},
{
url: 'https://cdn.pixabay.com/photo/2014/04/05/11/39/biology-316571_1280.jpg',
},
];
export default function App() {
const scrollViewRef = useRef(null);
const targetRefList = useRef([]);
const [isRenderList, setIsRenderList] = useState(dataList.map(() => true));
const handleOnScroll = (event) => {
const offsetY = event.nativeEvent.contentOffset.y;
if (!offsetY || !scrollViewRef?.current) return;
// ref list 반복
targetRefList.current.forEach((targetRef, index) => {
targetRef?.measureLayout(scrollViewRef.current, (x, y, width, height) => {
const range = 700;
const isOutsideUpDiff = Math.abs(y - offsetY) > range;
const isOutSideDownDiff = Math.abs(y + height - offsetY) > range;
const isInContent = offsetY >= y && offsetY <= y + height;
// isRenderList에서 해당 index로 set
if (!isOutsideUpDiff || !isOutSideDownDiff) {
setIsRenderList((prev) => {
const temp = [...prev];
temp[index] = true;
return temp;
});
}
if (!isInContent && isOutsideUpDiff && isOutSideDownDiff) {
setIsRenderList((prev) => {
const temp = [...prev];
temp[index] = false;
return temp;
});
}
});
});
};
return (
<View style={{ flex: 1, backgroundColor: 'black' }}>
<ScrollView ref={scrollViewRef} onScroll={handleOnScroll}>
<View ref={(element) => (targetRefList.current[0] = element)}>
<MyImage
width={300}
height={1000}
url={dataList[0].url}
isRender={isRenderList[0]}
/>
</View>
<View ref={(element) => (targetRefList.current[1] = element)}>
<MyImage
width={300}
height={1000}
url={dataList[1].url}
isRender={isRenderList[1]}
/>
</View>
<View ref={(element) => (targetRefList.current[2] = element)}>
<MyImage
width={300}
height={1000}
url={dataList[2].url}
isRender={isRenderList[2]}
/>
</View>
</ScrollView>
</View>
);
}
이미지 컴포넌트를 View로 한번 감싸서 ref를 넣어주어 컴포넌트의 크기를 감지하도록 합니다.
다음은 조건부 렌더링을 극적으로 표현하기 위해 range 값을 극도로 축소시켜 실행한 모습입니다.
사용자가 눈치채지 못할 만큼 range 값을 부여해 화면 밖에서 조건부 렌더링을 한다면 모든 최적화가 끝납니다.
너무 단편적인 예시를 들어 코드가 길어졌지만 버벅거리지 않을 정도로
적절히 데이터 셋을 배열로 만들어 반복문으로 렌더 한다면 더욱 효율적으로 사용이 가능합니다.
또한 주의할 점으론 onScroll 함수는 기기의 주사율만큼 실행되는 이벤트기 때문에 굉장히 자주 실행됩니다.
그렇기에 이벤트 핸들러 함수 내부에 너무 복잡하고 비용을 많이 소모하는 로직을 실행시킨다면
최적화하느니 못하기 때문에 간단하고 최적화된 코드가 필수입니다.
RN에 깊은 이해가 없는 상태로 어려웠던 내용을 글로 풀어내니 많이 부족했던 것 같습니다.
글을 다 쓰고 나니 예제가 적절했나 싶기도 하고 방법 자체도 뭔가 완벽하지 않은 것 같아서 한편으로 찝찝하네요.
혹시나 댓글이나 오픈채팅으로 궁금한 점 물어보시면 최대한 정성스럽게 답변해 드리겠습니다!
'React-Native' 카테고리의 다른 글
2025년 React Native 현황과 CLI vs Expo 비교분석 (0) | 2025.01.15 |
---|---|
[React Native] Android Native-Module Event Listener 만들기 (0) | 2025.01.10 |
[React-Native] BackHandler, 뒤로 가기 시 앱 종료 묻기 (0) | 2023.07.21 |
[React-Native + TypeScript] Stack Navigation, 스택 네비게이션 사용법 (1) | 2023.07.18 |
React Native 장단점과 CLI, Expo 비교와 후기 (0) | 2023.07.13 |
댓글