렌더링 최적화의 필요성
React 애플리케이션에서는 상태(state)나 props가 변경될 때 컴포넌트가 다시 렌더링됩니다. 특히 어떤 컴포넌트가 리렌더링되면 그 하위의 모든 자식 컴포넌트들도 함께 리렌더링되는 특징이 있습니다.
이는 UI 업데이트를 일관성 있게 해주지만, 규모가 큰 프로젝트에서는 불필요한 리렌더링이 성능 저하로 이어질 수 있습니다. 예를 들어 변경되지도 않은 컴포넌트까지 반복 렌더링되면 대량의 리스트를 렌더링하는 경우 프레임 드랍이나 인터랙션 지연을 유발할 수 있습니다.
최적화 방법 / 코드 예시
이번 문서에서는 크롬의 React Developer Tools 확장 프로그램을 활용해 상품 구매 페이지에 렌더링 최적화를 적용하는 과정을 정리합니다.
먼저 상품 구매 페이지는 크게 두 섹션으로 나뉩니다.
배송 정보 입력 : 구매자의 정보를 입력받는 섹션
장바구니 정보 : 장바구니에 담긴 상품의 수량을 조정하고, 총 가격 확인과 함께 구매를 트리거할 수 있는 섹션
각각의 섹션에 대해 순서대로 최적화 방법을 알아보겠습니다.
1. 배송 정보 입력
초기의 코드에서는 입력란에 사용한 input 컴포넌트의 onChange 핸들러를 통해 배송 정보의 state 를 업데이트 했습니다. 구매자 항목의 예시 코드를 먼저 보겠습니다.
// 로직 부분
const [buyerName, setBuyerName] = useState<string>('');
const buyerNameErrorMessage = useMemo<string>(()=>{
const trimmedName = buyerName.trim();
if (trimmedName) {
if (/^[가-힣\x20]*$/.test(trimmedName)) return '';
else return '한글만 사용해주세요.';
} else {
return '공백이 아닌 내용을 입력해주세요.';
}
}, [buyerName]);
// ui 부분
<S.PurchaseStateInput
title='구매자'
onChange={(e) => setBuyerName(e.target.value)}
errorMessage={buyerNameErrorMessage}
/>
이 경우 사용자가 값을 입력할 때 마다 state 가 변화하기 때문에, 매 입력마다 Form 컴포넌트 전체가 리렌더링됩니다.
리렌더링을 방지하기 위해 입력값을 useRef 로 사용할 수 있지만 이 경우 유효성 검사를 실시간으로 확인하기 어렵다는 문제점이 있습니다.
react-hook-form 의 동작 원리는 다음과 같습니다.
submit 이 발생한 후로는 유효성 검사를 통해 에러 상태가 변할 때에만 리렌더링 됩니다.
이러한 원리를 통해 렌더링을 최적화할 수 있으며, 유효성 검사 관련 코드의 단순화와 통일성을 챙길 수 있습니다. 그 외에도 다양한 내부 속성이나 메서드들을 지원합니다.
react-hook-form 을 적용하는 기본적인 로직 코드만 살펴보고, 적용 이후의 렌더링 변화를 살펴보겠습니다.
// 로직 부분
const { register, formState: { errors } } = useForm<PurchaseFormData>();
const registerObject = register('buyerName', {
required: '구매자는 필수 입력입니다.',
onBlur: (event) => {
event.target.value = event.target.value.trim();
},
validate: (value: string) =>
!!value.trim() || '공백이 아닌 내용을 입력해주세요.',
pattern: {
value: /^[가-힣\x20]*$/,
message: '한글만 사용해주세요.',
},
minLength: {
value: 2,
message: '이름은 2자 이상 10자 이하여야 합니다.',
},
maxLength: {
value: 10,
message: '이름은 2자 이상 10자 이하여야 합니다.',
},
})
// ui 부분
<S.PurchaseInput
title='구매자'
{ ...registerObject }
aria-errormessage={errors.buyerName && errors.buyerName.message}
/>
이제 처음에는 입력값이 변해도 렌더링 되지 않다가, Submit 이 실행된 이후 유효성 검사 결과에 따라 errors 상태가 변할때만 리렌더링 되는것을 확인할 수 있습니다.
2. 장바구니 정보
장바구니 정보는 결제 페이지 뿐 아니라 모든 구매자 페이지에서 확인할 수 있어야 하기 때문에, React Context 를 활용해 구현되어 있습니다.
문제점은 Context 내부에 정의된 장바구니 목록 state 가 하위 컴포넌트 뿐 아니라 최상위 컴포넌트에서도 사용되기 때문에, 장바구니 목록 내 상품의 수량을 수정할 경우 전체 컴포넌트가 반복적으로 렌더링된다는 점입니다.
이 현상을 해결하기 위해서는 2가지 작업이 선행되어야 합니다.
장바구니 목록 state 와 동일한 정보를 저장하는 ref 를 만들어서 사용합니다.
전체 컨텍스트를 2개의 컨텍스트로 분리합니다.
State 컨텍스트(UI 전용) : 장바구니 목록, 총 가격, etc...
Actions 컨텍스트(로직 전용) : 장바구니 목록 Ref Getter, 장바구니 상품 추가, 장바구니 상품 업데이트, etc...
위에 설명된 내용 중 Actions 컨텍스트에노란색으로 강조된 부분을 보시면 장바구니 목록 Ref Getter 라는 항목이 있습니다.
이 항목은 UI 가 아닌 로직에서 장바구니 정보가 필요할 때, 리렌더링을 유발시킬 수 있는 state 대신 동일한 정보를 담고있는 ref 를 사용하기 위한 메서드입니다. 코드를 요약해 살펴보겠습니다.
// State Context 및 Actions Context 의 타입
type CartItemsStateContextType = {
items: CartItem[];
checkItemIsInCart: (product: ProductSchema | undefined) => boolean;
cartSize: number;
totalPrice: number;
};
type CartItemsActionsContextType = {
addItem: (product: ProductSchema, quantity: number) => void;
updateItem: (product: ProductSchema, quantity: number, max?: number) => void;
removeItem: (product: ProductSchema) => void;
clearCart: () => void;
getItemsRef: () => React.MutableRefObject<CartItem[]>;
};
/**
* 장바구니 내 데이터를 저장할 Context 의 Provider
* LocalStorage 를 체크해서 장바구니 상태를 초기화합니다.
*/
export const CartItemsProvider = ({ children }: ReactNode) => {
// ...생략
// 장바구니 목록의 state, ref 생성
const [items, setItems, itemsRef] = useStateWithRef<CartItem[]>([]);
// 장바구니 목록 ref getter
const getItemsRef = useCallback(() => itemsRef, [itemsRef]);
// State Context Provider 의 value
const stateValue = useMemo(() => {
return { items, cartSize, totalPrice, checkItemIsInCart };
}, [items, cartSize, totalPrice, checkItemIsInCart]);
// Actions Context Provider 의 value
const actionsValue = useMemo(() => {
return {
addItem,
updateItem,
removeItem,
clearCart,
getItemsRef,
};
}, [addItem, updateItem, removeItem, clearCart, getItemsRef]);
return (
<CartItemsStateContext.Provider value={stateValue}>
<CartItemsActionsContext.Provider value={actionsValue}>
{children}
</CartItemsActionsContext.Provider>
</CartItemsStateContext.Provider>
);
};
위 코드에서 state 와 ref 를 병용하기 위해 사용한 useStateWithRef 커스텀 훅은 아래와 같이 구현했습니다.
// useState와 useRef를 동기화해서 같이 쓸 수 있는 커스텀 훅
export function useStateWithRef<T>(
initialValue: T,
): [T, (value: T | ((prev: T) => T)) => void, React.MutableRefObject<T>] {
const [state, setState] = useState<T>(initialValue);
const ref = useRef<T>(state);
// state 의 setter 와 ref 수정을 묶어서 사용합니다.
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setState((prev) => {
let next;
if (typeof value === 'function') next = (value as (prev: T) => T)(prev);
else next = value;
ref.current = next;
return next;
});
}, []);
return [state, setValue, ref];
}
그 외에도 상품 구매 페이지 내부의 하위 컴포넌트들(배송 정보 입력, 장바구니 정보, 구매 확인 체크박스, etc...)에 React.memo 메서드를 적용하여 상위 컴포넌트 state 변화 시 필요없는 리렌더링을 줄였습니다.
그럼 이제 코드 수정 후 렌더링의 변화를 살펴보겠습니다.
이제 장바구니 목록 state 가 변할 때, State Context 를 사용중인 컴포넌트(장바구니 정보, 총 가격)만 리렌더링이 발생하는 것을 확인할 수 있습니다.
후기
React 프로젝트에서 렌더링을 최적화하기 위한 방법이 여러가지가 있지만, 기본적으로 컴포넌트의 구조를 잘 만들지 않으면 최적화하기가 어렵다는 것을 느꼈습니다.
프로젝트를 개발에 있어서 React 라이브러리 이해도 뿐 아니라 컴포넌트 구조의 중요성을 느끼게 해준 과정이었습니다.