나는 이전에 Vue
로 제작되어있는 B2B 서비스를 React
로 마이그레이션 작업을 해야 했다.
⭐️
React
마이그레이션 작업의 추가 옵션 ⭐️
- 기획이나 정리된 문서 없음
- 디자이너 지원이 불가한 상황 (직접 디자인)
- 지금 제공해야할 고객사에만 필요한 특정 기능들을 핵심 기능에 포함시켜 개발해야 했음
- 추가 기능들로인해 핵심 기능이 포함되어 있는 페이지는 30개가 넘는 API 개수가 존재
- 해당 고객사와 계약 후, 얼마 지나지않아 새로운 고객사에도 제공을 해줘야 하는데 이 때, 이전 내용에 있던 고객사에만 필요한 특정 기능들은 제외시키고 제공을 해야했다.
- 투입된 인원은 프론트엔드(나) 1명, 백엔드 1명
- 작업 기간은 2달...🥹
위의 조건들을 제외하고도 자잘하게 생각해야할 부분들이 많았다.
일단 필요한 내용들을 전체적으로 정리하고, 우선순위를 나눴다.
그 중, 최적화와 관련된 부분은 후순위에 배치했었다. 하지만 데이터가 많아짐에 따라 렌더링이 많이 일어나는 부분이 느려지기 시작했다.
느려짐이 체감될 때도 추가 요청사항들이 많아 전체 최적화를 진행하기는 어려운 상황이었고, 우선 렌더링이 많이 일어나는 부분들을 리스트업 한 후, 최적화가 가장 필요한 부분을 먼저 개선 하기로 했다.
💡 참고 내용
- 렌더링되는 부분을 확인하기 위해선
React DevTool Profiler
를 사용했다.
현재 글 내용은 렌더링 최적화를 위한 코드 리팩토링에 관련된 내용이기에,React DevTool Profiler
사용과 관련된 내용은 생략하겠다.- 고객사와 관련되어 문제가 될 가능성이 있는 부분은 전부 난수로 표현되거나 가려지는 점 참고 바란다.
- MUI를 사용하여 UI를 구성하였다.
첫번째로 개선해야될 부분은 접수받은 특정 건들을 일자별로 조회하고, 조회된 리스트의 각 아이템들을 클릭했을 때, 아코디언 형태로 열리면서 클릭한 아이템 내의 이미지 리스트를 불러오는 부분이다.
조회된 리스트 중 한 아이템을 클릭했을 때, 위와 같이 조회된 리스트 전체가 렌더링이 일어나는 것을 볼 수 있다. 이 부분이 데이터가 많아지면 많아질수록 문제가 되고 있던 부분 중 하나다.
해당 부분은 리스트 중 한 아이템을 클릭했을 때, 화면에 있는 대부분의 컴포넌트가 변경되고, 팝업으로 열린 브라우저에서도 대부분 변경이 일어나게된다.
개발을 시작할 때, 기획이 확실하게 정의 되어있지 않았고 지속적으로 고객사와 협의를 통해 변경되는 내용들이 많았기 때문에 대체적으로 컴포넌트를 루즈하게 분리했었다.
우선 단일 책임에 맞춰 컴포넌트를 분리했다.
컴포넌트를 위와 같이 분리해도 렌더링은 거의 최적화 되지 않는다. 각 컴포넌트들이 참조하고 있는 상태들이 많고, 메모이제이션 작업을 따로 해줘야 하기 때문이다.
전역 상태 관리자는
Zustand
를 사용했으며, 팝업 브라우저에도 데이터를 동기화 시켜야해서Zustand
의Persist
를 사용하여localStorage
에서 상태를 관리했다.
해당 부분은 전역으로 관리되는 상태가 많았고, 전역 상태가 많은 가장 큰 이유로는 2가지가 있다.
각 컴포넌트들이 참조하고 있는 상태들이 많다보니, 서로 영향을 주지 않는 상태인데도 불구하고 같은 컴포넌트 안에 속해있는 경우가 있었다.
// 기존
const ReceiptAccordion = ({ receiptId }: Props) => {
const currentReceiptId = useWorkStore((state) => state.currentReceipt?.receiptId);
// 로직
return (
<ReceiptAccordion>
{/* 기타 컴포넌트 */}
{receiptId === currentReceiptId && <CopyModeController />}
</ReceiptAccordion>
);
};
위의 코드 중, currentReceiptId
값은 전역 상태에서 참조한 활성화 되어있는 receiptId
값이다.
해당 currentReceiptId
값과 props
로 내려받는 receiptId
값을 비교해서 해당 컴포넌트가 활성화 되어있는 지 판단 후, <CopyModeController />
를 보여준다.
하지만 코드를 위와 같이 작성하게 되면 문제가 있다.
Accordion
을 선택할 때마다 currentReceiptId
는 변경되고,
currentReceiptId
를 참조하고 있는 모든 Accordion
컴포넌트가 렌더링이 일어나게 된다.
리스트 형태의 컴포넌트를 만들다보면 이런 비슷한 경우를 맞이하는 경우가 생각보다 많은데 이때, 아래와 같은 형태로 코드를 작성하면 렌더링을 최소화 할 수 있다.
// 변경
const ReceiptAccordion = ({ receiptId }: Props) => {
const isActive = useWorkStore((state) => state.currentReceipt?.receiptId === receiptId);
// 로직
return (
<ReceiptAccordion>
{/* 기타 컴포넌트 */}
{isActive && <CopyModeController />}
</ReceiptAccordion>
);
};
이 코드에서 useWorkStore
훅은 콜백 함수를 사용하여 currentReceipt?.receiptId
와 receiptId
를 비교하고, 이 비교 결과(true
또는 false
)를 반환하게된다. 반환된 값에 따라 컴포넌트 내의 특정 요소(CopyModeController
)의 렌더링 여부가 결정된다.
isActive
값의 변화에 따른 렌더링 동작은 다음과 같다.
false
=> true
: 선택된 Accordion
이 활성화되며, CopyModeController
가 렌더링 된다.
true
=> false
: 닫힌 Accordion
이 비활성화되며, CopyModeController
가 렌더링되지 않는다.
이 로직을 통해 Accordion
컴포넌트는 선택된 경우와 닫힌 경우에만 상태 변화에 따라 리렌더링이 발생하고, 상태 값이 변하지 않은 경우(false
에서 false
로 또는 true
에서 true
로)에는 컴포넌트가 리렌더링되지 않는다.
이와 같은 방식으로 코드를 작성하면 불필요한 렌더링을 방지할 수 있다.
메모이제이션 처리는 위의 내용에서 언급했던 ReceiptAccordion
내부에 있는 컴포넌트들에 적용될 부분이 많았다.
// 기존
const ReceiptImageList = ({ receiptId, imageCount }: Props) => {
// 로직
return (
<StyledReceiptImageList withImage={withImage}>
{isLoading
? Array(imageCount)
.fill(0)
.map((_, i) => <ReceiptImageListItemSkeleton key={i} />)
: images.map((image) => <ReceiptImageListItem key={image.imageId} image={image} />)}
</StyledReceiptImageList>
)
};
export default ReceiptImageList;
첫번째로 Skeleton
컴포넌트들을 useMemo
처리하고, 컴포넌트를 memo
로 감싸줬다.
// 변경
const ReceiptImageList = ({ receiptId, imageCount }: Props) => {
// 로직
const skeletons = useMemo(
() => Array(imageCount).fill(0).map((_, i) => <ReceiptImageListItemSkeleton key={i} />),
[imageCount]
);
return (
<StyledReceiptImageList withImage={withImage}>
{isLoading
? skeletons
: images.map((image) => <ReceiptImageListItem key={image.imageId} image={image} />)}
</StyledReceiptImageList>
);
};
export default React.memo(ReceiptImageList);
위와 같은 처리 방식을 사용하면 imageCount
의존성이 변경되지 않는 한, Skeleton
컴포넌트는 불필요한 렌더링을 방지할 수 있고, memo
는 얕은 비교를 통해 props
가 변경되지 않았는지 확인하여, 컴포넌트의 불필요한 리렌더링을 방지할 수 있다.
이 외에도 추가로 useCallback
을 사용해서 컴포넌트의 렌더링을 최소화 하는 작업을했다.
// 기존
const ReceiptImageListItem = ({image}: Props) => {
// 로직
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const handleClickImageOpen: MouseEventHandler<HTMLElement> = (event) => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const handleClickImageClose: MouseEventHandler<HTMLElement> = (event) => {
event.stopPropagation();
setAnchorEl(null);
};
return (
<StyledReceiptImageListItem>
{/* 기타 컴포넌트 */}
<ImageController imageId={image.id} onOpen={handleClickImageOpen} onClose={handleClickImageClose} />
<ImageMenu anchorEl={anchorEl} onClose={handleClickImageClose} />
</StyledReceiptImageListItem>
)
}
export default ReceiptImageListItem;
ReceiptImageListItem
컴포넌트에서는 useCallback
을 사용해 핸들러를 메모이제이션하고, memo
를 사용해 불필요한 리렌더링을 방지했다.
// 변경
const ReceiptImageListItem = ({ image }: Props) => {
// 로직
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const handleClickImageOpen: MouseEventHandler<HTMLElement> = useCallback((event) => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
}, [setAnchorEl]);
const handleClickImageClose: MouseEventHandler<HTMLElement> = useCallback((event) => {
event.stopPropagation();
setAnchorEl(null);
}, [setAnchorEl]);
return (
<StyledReceiptImageListItem>
{/* 기타 컴포넌트 */}
<ImageController imageId={image.id} onOpen={handleClickImageOpen} onClose={handleClickImageClose} />
<ImageMenu anchorEl={anchorEl} onClose={handleClickImageClose} />
</StyledReceiptImageListItem>
);
}
export default memo(ReceiptImageListItem);
위의 메모이제이션 처리한 코드를 보면 handleClickImageOpen
과 handleClickImageClose
함수를 useCallback
으로 감싸줬는데, 이 부분은 함수를 하위 컴포넌트로 내려줄 때 보통 많이 사용한다.
(이 부분에선 의존성 배열에 setAnchorEl를 굳이 추가해주지 않아도 문제가 없다.)
여기까지가 첫번째로 최적화 작업을 진행한 내용이다.
위의 이미지를 보면 정확하게 변경되는 부분에만 렌더링 하이라이트가 적용되는 것을 볼 수 있다.
렌더링 속도도 비교해 보도록하겠다.
약 80개의 Accordion
을 조회하고 같은 상황에서 최적화 이전과 이후를 비교해봤다.
약 70 ~ 80배 차이가 난다. Accordion
의 개수에 비례해서 렌더링이 느려지고 있는 걸로 보인다. 이 부분은 Accordion
이 많아지면 많아질수록 더 많은 차이가 나게 될 걸로 보인다.
데이터양이 더 많아지기전에 리팩토링을 끝내서 참 다행이다..
렌더링 최적화 작업 끗.