몸상태 선택 화면 렌더 최적화 에서 몸상태 관련 컴포넌트들을 1차적으로 렌더링 최적화를 시켰다.
useMemo
로 감싸서 사용하고 있고, 카테고리가 변경되는 경우에만 리렌더링이 되는 것을 Profiler
를 통해 확인하였기에 최적화가 잘 되었다고 판단했던 부분인데 놓쳤던 부분이 있었다.문제
렌더링된 이후에는 메모이징된 컴포넌트를 재사용함으로써 렌더링 최적화가 잘 되었음은 이전 성능최적화 단계에서 확인하였다.
하지만 초기 렌더링이 되는 시점에서도 렌더링 최적화가 된 상태인지 확인을 하지 않았던 것이 문제였다.
결론부터 말하자면, 카테고리 셀렉터 컴포넌트 내에 속한 카테고리 개수만큼 화면이 리렌더링 된다.
카테고리가 총 4개 있는데, 영상의 25초부터~40초까지 (
GroupedBodyContidionList
리렌더링 이후 UserMain
화면 전체 리렌더링)을 한세트로 하여서 총 4번 반복 렌더링 됨을 확인할 수 있다.카테고리 컴포넌트에서 특정 카테고리를 선택하면, 하단의 스크롤뷰를 해당 카테고리에 해당하는 위치로 스크롤 시켜주는 방식인데, 이걸 하기 위해서는 스크롤뷰 안의 컴포넌트들의 위치를 기록해 두어야 하고, 컴포넌트들의 위치를 얻기 위해서는 렌더링이 다 된 이후에
onLayout
콜백을 통해 얻게 된다.여기서 첫번째 핵심 포인트는 다음과 같다.
- 카테고리 선택 시 적절한 위치로 이동시키려면 위치 값을 알아야 한다.
- 컴포넌트의 위치는 렌더링이 진행된 이후에 얻을 수 있다.
// 스크린 컴포넌트 export const UserMain: VFC = () => { ... const { categories, isOnTop, scrollRef, categoryIndex, onLayoutScrollView, addGroupPosition, onPressCategory, handleCategoryWhenScroll, groupPositionList, changeCategoryIndex, handleCheckReachBottom, } = useCategories({ bodyConditions }); const { isDragging, handleScrollBeginDrag, handleScrollEndDrag } = useCheckIsDragging({ groupPositionList, changeCategoryIndex, handleCheckReachBottom, }); ... return( <> ... <AnimatedCategorySelector isOnTop={isOnTop} categories={categories} currentIndex={categoryIndex} onPressCategory={onPressCategory} isDragging={isDragging} /> {useMemo( () => ( <ScrollView ref={scrollRef as LegacyRef<ScrollView>} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingTop: verticalScale(84 + 68), // 카테고리 셀렉터 보다 아래에서 나타나도록 간격 추가 paddingBottom: verticalScale(380), // footer 높이 + 카테고리 간 간격 }} onScroll={handleCategoryWhenScroll} onMomentumScrollBegin={handleScrollBeginDrag} onMomentumScrollEnd={handleScrollEndDrag} onLayout={onLayoutScrollView} > <ContentWrapper> <GroupedBodyConditionList data={bodyConditions} selectedConditionList={selectedConditionList} onPressCell={handlePressCell} addGroupPosition={addGroupPosition} /> </ContentWrapper> </ScrollView> ), [...], )} </> ) }
코드를 보면 카테고리와 관련된 정보 수집 등을 다 담당하는 훅이
useCategories
훅인데, 이 훅이 스크린 컴포넌트에서 호출되고 있다.카테고리별 위치를
groupPositionList
라는 배열 상태를 가지고 있고, addGroupPosition
함수를 통해 카테고리별 포지션 정보를 추가하는 방식이다.즉, 카테고리 별로 위치를 기록하기 위해 addGroupPosition 함수를 호출하면 groupPositionList 상태의 setter가 호출하게 되고, 상태값이 변화했으니 해당 상태를 담고있는 컴포넌트가 리렌더링 되는데, 훅을 호출하는 위치가 스크린 컴포넌트에서 이므로 스크린 컴포넌트 자체가 리렌더링 되면서, 스크린 내에서 쓰이는 컴포넌트들도 모두 리렌더링 되는 이슈다.
export const useCategories = ({ bodyConditions }: Props): Return => { const scrollRef = useRef<ScrollView>(); const { onLayout: onLayoutScrollView, layout: scrollViewLayout } = useGetLayout(); const isOnScrolling = useRef(false); const [categoryIndex, changeCategoryIndex] = useState(0); const [groupPositionList, setGroupPositionList] = useState< (LayoutRectangle & { heightSum: number })[] >([]); const [isOnTop, setIsOnTop] = useState<boolean>(true); const [isOnBottom, setIsOnBottom] = useState<boolean>(false); const categories = useMemo(() => { return Object.keys(bodyConditions); }, [bodyConditions]); const addGroupPosition = useCallback((positionInfo: LayoutRectangle) => { setGroupPositionList((prev) => { return prev.concat({ ...positionInfo, heightSum: (prev[prev.length - 1]?.heightSum || 0) + positionInfo.height + (!prev.length ? verticalScale(68) : 0) + // 스크롤을 시작하면 카테고리 셀렉터 위치가 조정되므로, 첫번째 카테고리에서만 추가한다. verticalScale(72), // 그룹별 marginBottom 값 }); }); }, []); const onPressCategory = useCallback( // eslint-disable-next-line @typescript-eslint/no-shadow (categoryIndex: number) => { if (isOnScrolling.current) { return; } const positionToMove = (groupPositionList[categoryIndex - 1] || 0)?.heightSum + 1; isOnScrolling.current = true; changeCategoryIndex(categoryIndex); scrollRef?.current?.scrollTo({ y: positionToMove }); setTimeout(() => { isOnScrolling.current = false; }, 700); }, [groupPositionList, scrollRef], ); const handleCategoryWhenScroll = useCallback( ({ nativeEvent }: { nativeEvent: NativeScrollEvent }) => { // 스크롤 최상단에 있는지 판단 if (nativeEvent.contentOffset.y === 0) { setIsOnTop(true); return; } else { setIsOnTop(false); } // 스크롤 최하단에 있는지 판단 if ( nativeEvent.contentSize.height <= Math.ceil((scrollViewLayout?.height || 0) + nativeEvent.contentOffset.y) ) { setIsOnBottom(true); return; } else { setIsOnBottom(false); } // 카테고리 변화 감지 if ( groupPositionList[categoryIndex + 1] && nativeEvent.contentOffset.y > groupPositionList[categoryIndex].heightSum ) { if (!isOnScrolling.current) { changeCategoryIndex(categoryIndex + 1); } return; } if ( groupPositionList[categoryIndex - 1] && nativeEvent.contentOffset.y < groupPositionList[categoryIndex - 1].heightSum ) { if (!isOnScrolling.current) { changeCategoryIndex(categoryIndex - 1); } } }, [categoryIndex, groupPositionList, scrollViewLayout?.height], ); const handleCheckReachBottom = useCallback( ( nativeEvent: NativeSyntheticEvent<NativeScrollEvent>['nativeEvent'], ): boolean => { // 스크롤 최하단에 있는지 판단 if ( nativeEvent.contentSize.height <= Math.ceil((scrollViewLayout?.height || 0) + nativeEvent.contentOffset.y) ) { return true; } return false; }, [scrollViewLayout?.height], ); // 스크롤 끝에 도달 시, 카테고리 인덱스도 맨 마지막으로 이동시킴 useEffect(() => { if (isOnBottom) { changeCategoryIndex(categories.length - 1); } }, [categories.length, isOnBottom]); return { onLayoutScrollView, categories, isOnTop, scrollRef, categoryIndex, addGroupPosition, onPressCategory, handleCategoryWhenScroll, groupPositionList, changeCategoryIndex, handleCheckReachBottom, }; };
개선
1. 스크린 리렌더링 방지
- 우선 카테고리별 포지션 정보를 담는 groupPositionList 배열을 state로 쓰지 않도록 수정한다.
값이 기록될 때마다 화면이 리렌더링 될 필요는 없고, 카테고리를 선택했을 때 호출되는 함수에서 값을 잘 참조할 수 있기만 하면 된다.
따라서 상태로 쓸 이유가 없으므로 일반 배열로 선언하여 값이 바뀔 때마다 화면이 리렌더링 되는 현상을 막는다.
- useState → useRef 로 변경
기존에는
(GroupedBodyConditionList 렌더링 → 스크린 렌더링) * 카테고리 개수
형태로 렌더링이 진행되었다.지금은
GroupedBodyConditionList 렌더링 * 카테고리 개수, 이후 스크린 렌더링
으로 렌더링이 이루어진다. 불필요한 스크린 리렌더링 현상은 방지되었다.하지만 여전히 GroupedBodyConditionList 컴포넌트가 불필요하게 리렌더링 된다는 문제가 있다. 이어서 개선시켜 보자.
2. 몸상태 목록 리렌더링 방지
몸상태 목록은, 몸상태를 선택하기 전까지 한번만 렌더링 되면 되는데, 앞선 문제 현상을 보면 총 4번 몸상태 목록 전체가 리렌더링 되고 있었고, 4번은 카테고리 개수에 해당한다.
즉 카테고리 개수가 많아지면 많아질 수록 더 많은 불필요한 리렌더링이 일어나게 됨을 뜻하며, 4번의 리렌더링 만으로도 현재 알고케어에서 사용중인 태블릿으로 앱을 실행하면 버벅임이 눈에 확연히 보인다.

GroupedBodyConditionList가 리렌더링 되는 이유는 Hook 1이 바뀌었기 때문이라고 한다.
Hook 1은 GetLayout
import { GridCellList } from '@components/Common/GridCellList'; import styled from '@emotion/native'; import BodyConditionNotSelected from 'assets/images/BodyConditionNotSelected.png'; import React, { memo, ReactElement, useCallback } from 'react'; import { View } from 'react-native'; import { BodyConditionInfo, BodyConditionObject, GroupedBodyConditionInfo, } from 'types/schema'; import { verticalScalePx } from 'utils/functions/scale'; import { useGetLayout } from 'utils/hooks/useGetLayout'; import { VFC } from 'utils/types'; import { BodyCondition } from './BodyCondition'; import { Section } from './Section'; interface Props { data: GroupedBodyConditionInfo; selectedConditionList: BodyConditionObject; onPressCell: (bodyCondition: BodyConditionInfo) => void; addGroupPosition: (positionInfo: { height: number; width: number; x: number; y: number; }) => void; } export const GroupedBodyConditionList: VFC<Props> = ({ data, selectedConditionList, onPressCell, addGroupPosition, }) => { const { onLayout } = useGetLayout({ onFinish: addGroupPosition }); const renderItem = useCallback( (conditionInfo: BodyConditionInfo): ReactElement => { return ( <BodyCondition key={conditionInfo.name} info={conditionInfo} isSelected={selectedConditionList[conditionInfo.name] !== undefined} severity={ selectedConditionList[conditionInfo.name] !== undefined ? Object.keys(selectedConditionList[conditionInfo.name].id)[0] : undefined } onPressCell={onPressCell} /> ); }, [onPressCell, selectedConditionList], ); const bodyConditionListData = useCallback( (bodyConditionObject: BodyConditionObject) => Object.values(bodyConditionObject), [], ); return ( <> {Object.entries(data).map(([groupName, bodyConditionObject]) => ( <View key={groupName}> <Section title={groupName} onLayout={onLayout}> {groupName === '자주' && Object.values(bodyConditionObject).length === 0 ? ( <EmptyFrequentlyBodyCondition source={BodyConditionNotSelected} resizeMode='contain' /> ) : ( <GridCellList data={bodyConditionListData(bodyConditionObject)} renderItem={renderItem} numColumns={4} columnGap={24} rowGap={0} /> )} </Section> </View> ))} </> ); }; const EmptyFrequentlyBodyCondition = memo(styled.Image` margin-top: ${verticalScalePx(40)}; width: 100%; `);
카테고리 컨텐츠별 위치를 얻은 후에 결과값을 addGroupPosition을 호출하여 포지션 정보를 추가시키는 방식인데, 로직이 동일하고 이 컴포넌트에서는 얻어온 layout 값을 활용하지 않을 것이기 때문에 const { onLayout } = useGetLayout({ onFinish: addGroupPosition });를 호출하여 각 그룹 컴포넌트에게
onLayout
콜백을 전달하였다.문제는 useGetLayout 훅에서 layout 값을 state로 관리하고 있다는 점인데, 따라서 각 그룹이 렌더링이 완료되고 onLayout 콜백이 호출되면, 리스트 컴포넌트의 state가 갱신되는 것이고, 그러면 리스트 컴포넌트 자체가 리렌더링 되면서 모든 그룹 컴포넌트를 다시 그리게 된다.
가장 먼저 생각할 수 있는 해결방안은 그러면 각 그룹별 컴포넌트를 메모이징 시키면 되겠네?가 될텐데, 여기서 더 나아가서 layout state는 각 그룹 컴포넌트 내부에서 갖도록 하여서 본인의 레이아웃 값이 아니라면 리렌더링 되지 않도록 방지해야 한다.
export const GroupedBodyConditionList: VFC<Props> = ({ data, selectedConditionList, onPressCell, addGroupPosition, }) => { return ( <> {Object.entries(data).map(([groupName, bodyConditionObject]) => ( <GroupedBodyCondition groupName={groupName} bodyConditionObject={bodyConditionObject} addGroupPosition={addGroupPosition} selectedConditionList={selectedConditionList} onPressCell={onPressCell} /> ))} </> ); };
... interface Props { groupName: string; bodyConditionObject: BodyConditionObject; selectedConditionList: BodyConditionObject; addGroupPosition: (positionInfo: { height: number; width: number; x: number; y: number; }) => void; onPressCell: (bodyCondition: BodyConditionInfo) => void; } // 단일 그룹에 대한 몸상태 목록 export const GroupedBodyCondition: NamedExoticComponent<Props> = memo( ({ groupName, bodyConditionObject, addGroupPosition, selectedConditionList, onPressCell, }) => { const bodyConditionListData = useCallback( // eslint-disable-next-line @typescript-eslint/no-shadow (bodyConditionObject: BodyConditionObject) => Object.values(bodyConditionObject), [], ); // 그룹 컴포넌트의 layout 정보를 각 그룹별 컴포넌트 내에서 갖도록 수정함. const { onLayout } = useGetLayout({ onFinish: addGroupPosition }); const renderItem = useCallback( (conditionInfo: BodyConditionInfo): ReactElement => { return ( <BodyCondition key={conditionInfo.name} info={conditionInfo} isSelected={selectedConditionList[conditionInfo.name] !== undefined} severity={ selectedConditionList[conditionInfo.name] !== undefined ? Object.keys(selectedConditionList[conditionInfo.name].id)[0] : undefined } onPressCell={onPressCell} /> ); }, [onPressCell, selectedConditionList], ); return ( <View key={groupName}> <Section title={groupName} onLayout={onLayout}> {groupName === '자주' && Object.values(bodyConditionObject).length === 0 ? ( <EmptyFrequentlyBodyCondition source={BodyConditionNotSelected} resizeMode='contain' /> ) : ( <GridCellList data={bodyConditionListData(bodyConditionObject)} renderItem={renderItem} numColumns={4} columnGap={24} rowGap={0} /> )} </Section> </View> ); }, ); const EmptyFrequentlyBodyCondition = memo(styled.Image` margin-top: ${verticalScalePx(40)}; width: 100%; `);
그룹 컴포넌트의 layout 정보를 각 그룹별 컴포넌트 내에서 갖도록 수정하여서, 각 그룹의 레이아웃 값이 다른 그룹의 렌더링에 영향을 주지 않도록 개선되었다.
결론적으로 초기 렌더링은 한번씩만 된다.
기존에는 몸상태 그룹이 차례로 렌더링 되면서 그룹 하나가 렌더링이 완료될 때마다 그룹 리스트 전체가 리렌더링 되었었지만, 이제는 차례대로 렌더링이 완료되면서 그룹 하나하나 다른 그룹에 영향을 주지 않고 개별로 렌더링이 되는 것을 확인할 수 있다.
3. 몸상태 선택 시 렌더링 이슈
초기 렌더링 시점에서의 렌더링 이슈는 해결되었다.
이제는 몸상태를 하나씩 선택할 때에도 변화된 부분만 리렌더링 되도록 최적화 되어있는지 확인하고, 안되어 있다면 개선이 필요하다.
우선 살펴보기 전에 어디서부터, 몸상태를 선택하는 과정에서는 어떤 상황에, 어떤 부분을 최적화 시켜야 잘 된 최적화라고 할 수 있을지 생각을 먼저 해봤다.
- 몸상태 목록
- 몸상태를 선택 할 때는 선택된 몸상태만 렌더링 된다.
- 단일 몸상태 선택은 해당 몸상태 그룹을 리렌더링 시키지 않는다.
- 단일 몸상태 선택은 몸상태 전체 리스트를 리렌더링 시키지 않는다.
- 선택된 몸상태 리스트
- 추가로 선택한 몸상태만 리렌더링 된다.
- 심각도가 변경된 몸상태만 리렌더링 된다.
- 선택된 몸상태 state 관리는 어디서 할까?
- 글로벌 state
- 콘텍스트
- 몸상태 목록과, 선택된 몸상태를 포함하는 컴포넌트에서 내부 state로 관리