깊은 비교와 얕은 비교 맛보기 下

김인태·2025년 2월 10일
0
post-thumbnail

들어가며

전편에서는 리액트는 비교를 통해서 성능 최적화를 하기 때문에, 자바스크립트에서는 어떤 비교가 있고, 비교의 개념과 그 코드는 어떻게 구성되는지 알아보았습니다.

그렇다면 이제는 실제로 어떻게 적용되어 성능 최적화에 기여하는지 알아보도록 하겠습니다.

혹시나 잊으셨을까봐 전편글 링크도 함께 첨부합니다.

[전편 글][https://velog.io/@carloskim/깊은-비교와-얕은-비교-맛보기-上](https://velog.io/@carloskim/%EA%B9%8A%EC%9D%80-%EB%B9%84%EA%B5%90%EC%99%80-%EC%96%95%EC%9D%80-%EB%B9%84%EA%B5%90-%EB%A7%9B%EB%B3%B4%EA%B8%B0-%E4%B8%8A)

얕은 비교 코드

[리액트에서 얕은 비교 코드]

https://github.com/facebook/react/blob/main/packages/shared/shallowEqual.js

//hasOwnProperty.js


//객체가 특정 프로퍼티를 자신의 직접적인 프로퍼티로 가지고 있는지 확인하는 메서드입니다.
const hasOwnProperty = Object.prototype.hasOwnProperty;

export default hasOwnProperty;

//objectIs.js

//동등 비교에서 발생하는 특수한 경우들을 올바르게 처리하기 위한 함수입니다.
// +0 === -0 (false)의 처리 등..
// NaN === NaN (false) 에 대한 처리
function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

const objectIs: (x: any, y: any) => boolean =
  // $FlowFixMe[method-unbinding]
  typeof Object.is === 'function' ? Object.is : is;

export default objectIs;

//shallowEqual.js

import is from './objectIs';  // 정확한 값 비교를 위한 유틸리티
import hasOwnProperty from './hasOwnProperty';  // 안전한 프로퍼티 존재 확인

/**
 * 두 객체 간의 얕은 비교를 수행하는 함수
 * @param {mixed} objA 비교할 첫 번째 객체
 * @param {mixed} objB 비교할 두 번째 객체
 * @returns {boolean} 두 객체가 얕은 수준에서 동일한지 여부
 */
function shallowEqual(objA: mixed, objB: mixed): boolean {
  // 1. 완전히 동일한 객체인지 먼저 확인
  // Object.is를 사용하여 +0/-0, NaN 등의 엣지케이스 처리
  if (is(objA, objB)) {
    return true;
  }

  // 2. null 체크 및 타입 체크
  // 둘 중 하나라도 객체가 아니거나 null이면 false 반환
  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  // 3. 객체의 키 배열 생성
  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  // 4. 키의 개수가 다르면 두 객체는 다름
  if (keysA.length !== keysB.length) {
    return false;
  }

  // 5. objA의 모든 키에 대해 검사
  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    
    // 두 가지 조건 체크:
    if (
      // 5-1. objB에 해당 키가 존재하는지 확인
      // hasOwnProperty.call을 사용하여 프로토타입 체인 오염 방지
      !hasOwnProperty.call(objB, currentKey) ||
      
      // 5-2. 해당 키의 값이 양쪽 객체에서 동일한지 확인
      // Object.is를 사용하여 정확한 값 비교
      // $FlowFixMe[incompatible-use] - Flow 타입 체커 경고 무시
      !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }

  // 모든 검사를 통과하면 두 객체는 얕은 수준에서 동일
  return true;
}

export default shallowEqual;

리액트의 shallow compare 함수 요약!

  • 두 객체의 각 속성(키)을 하나씩 비교하면서
  • === 연산자를 사용해 엄격한 동등성 검사를 수행하고
  • 하나라도 다른 값이 있으면 바로 false를 반환하며
  • 모든 키의 값이 완전히 일치할 때만 true를 반환합니다.

하지만 찾아보다 보니 shallowCompare add-on은 제거되고, react.memo와 PureComponent가 표준이 되었습니다. 얕은 비교하는 방식 자체가 사라진 것은 아니지만 다른 방식을 채택하고 있습니다.

또한 PureComponent도 마찬가지로 class형 컴포넌트에서만 사용되기 때문에 제외하고 React에서 얕은 비교가 사용되는 사례들을 한 번 알아보도록 하겠습니다.

리액트에서 얕은 비교가 사용되는 예시

1. React.memo

// 부모 컴포넌트
function ExpensiveListContainer() {
  const [searchTerm, setSearchTerm] = useState("");
  const [selectedFilter, setSelectedFilter] = useState("all");

  return (
    <div>
      <input 
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)} 
      />
      <ExpensiveItemList // 이 컴포넌트를 메모이제이션
        filter={selectedFilter}
        items={someExpensiveItems}
        onItemClick={handleItemClick}
      />
      <SimpleFooter /> // 이 컴포넌트는 메모이제이션 불필요
    </div>
  );
}

// 최적화가 필요한 복잡한 컴포넌트
const ExpensiveItemList = React.memo(function ExpensiveItemList({ 
  filter, 
  items, 
  onItemClick 
}) {
  // 복잡한 필터링 로직
  const filteredItems = useMemo(() => {
    return items.filter(item => /* 복잡한 필터링 */);
  }, [items, filter]);

  return (
    <div>
      {filteredItems.map(item => (
        <div key={item.id}>
          {/* 복잡한 렌더링 로직 */}
        </div>
      ))}
    </div>
  );
});

React.memo로 컴포넌트를 래핑하면 해당 컴포넌트는 렌더링하고, 결과를 메모이징합니다. 그리고 다음 렌더링이 일어날 때 props가 같다면 메모이징된 내용을 재사용합니다!

언제 사용할까?

  • 부모 컴포넌트가 자주 리렌더링되지만 자식 컴포넌트의 props는 자주 변경되지 않을 때
  • 컴포넌트가 같은 props로 자주 렌더링될 때
  • 컴포넌트가 큰 리스트나 복잡한 데이터를 처리할 때

만약 위의 조건들에 부합하지 않다면, 사용하지 않는게 좋습니다! 만약 Props가 자주 달라지는 컴포넌트에

memo를 래핑했다고 했을 때, 리액트는 두 가지 작업을 수행하는데

  1. 변경되기 전 props와 다음 props 의 동등한지 비교를 위해서 비교함수를 수행
  2. 비교함수는 false를 반환하고, 리액트는 이전 렌더링 내용과 다음 렌더링 내용비교

비교 함수의 결과는 대부분 false 를 반환하여 props비교는 불필요하게 되고, 결국에는 불필요한 memoization이 되겠죠?

useMemo

function ProductList({ products, category, searchTerm }) {
  // 1. 기본적인 사용
  const filteredProducts = useMemo(() => {
    return products.filter(product => 
      product.category === category &&
      product.name.includes(searchTerm)
    );
  }, [products, category, searchTerm]); // 이 값들이 변경될 때만 필터링 실행

  // 2. 객체 메모이제이션
  const productStats = useMemo(() => ({
    total: products.length,
    inStock: products.filter(p => p.stock > 0).length,
    averagePrice: products.reduce((acc, p) => acc + p.price, 0) / products.length
  }), [products]); // products가 변경될 때만 통계 재계산

  return (
    <div>
      <div>총 상품 수: {productStats.total}</div>
      <div>재고 있는 상품: {productStats.inStock}</div>
      <ProductGrid products={filteredProducts} />
      <CategoryStats data={sortedAndGrouped} />
    </div>
  );
}

useMemo hook은 리렌더링 사이에 계산 결과를 캐싱할 수 있게 해주는 React Hook인데요,

첫 번째 인자는 메모이제이션 하고싶은 값을 계산하는 순수함수.

두 번째 인자는 콜백함수가 사용하는 의존성들의 배열입니다. 이 값들이 변경될 때 콜백함수가 실행됩니다.

useMemo는 리렌더링이 발생하면 → 리액트가 의존성 배열의 각 값을 이전 렌더링 값과 얕은 비교를 하고 →
모든 의존성이 같다면 → 이전의 메모이제이션된 값을 재사용!

하나라도 다르다면 → 콜백 함수를 실행하여 새 값을 계산하고 메모이제이션합니다.

3. useEffect

function Cart({ items, user }) {
  // 🚫 잘못된 예시: 객체를 직접 의존성으로 사용
  useEffect(() => {
    const total = calculateTotal(items);
    saveToDatabase({ user, total });
  }, [{ user }]); // 매 렌더링마다 새로운 객체가 생성되어 항상 실행됨

  // ✅ 올바른 예시: 필요한 속성만 의존성으로 사용
  useEffect(() => {
    const total = calculateTotal(items);
    saveToDatabase({ user, total });
  }, [user.id, items]); // 실제로 변경된 경우에만 실행

  // 🚫 잘못된 예시: 불필요한 의존성
  useEffect(() => {
    console.log('User logged in');
  }, [user]); // user 객체의 모든 변경에 반응

  // ✅ 올바른 예시: 필요한 값만 의존성으로 사용
  useEffect(() => {
    console.log('User logged in');
  }, [user.isLoggedIn]);
}

useEffect는 너무 유명하기 때문에, 어떤 기능을 하는지는 따로 말씀 안드리겠습니다 ㅎㅎㅎ..

혹시나 모르신다면.. 여기 를 클릭해주세요

useEffect가 얕은 비교를 사용하는 동작순서는 이러합니다.

컴포넌트 리렌더링 발생 → 리액트가 의존성 배열의 각 값과 이전 렌더링의 값을 얕은 비교(===)

비교 결과에 따라

  • 하나라도 다르다면 effect
  • 같다면 실행안함

useCallback

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // 1. 기본적인 useCallback 사용
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []); // 의존성이 없으므로 항상 같은 함수 참조 유지

  // 2. 의존성이 있는 useCallback
  const handleSearch = useCallback((searchTerm) => {
    console.log(`Searching for ${searchTerm} in ${text}`);
  }, [text]); // text가 변경될 때만 새로운 함수 생성

  // 3. 자식 컴포넌트에 전달되는 콜백
  const handleItemSelect = useCallback((itemId) => {
    console.log(`Selected item: ${itemId}`);
    setCount(c => c + 1);
  }, []); // count는 setState 함수 형태로 사용되므로 의존성 불필요

  return (
    <div>
      <ExpensiveChild onItemSelect={handleItemSelect} />
      <SearchComponent onSearch={handleSearch} />
      <button onClick={handleClick}>Count: {count}</button>
    </div>
  );
}

컴포넌트 리렌더링 발생 → React는 의존성 배열의 각 값을 이전 렌더링의 값과 얕은 비교(===)

  • 비교 결과:
    • 하나라도 다르다면 → 새로운 함수 생성
    • 모두 같다면 → 이전에 메모이제이션된 함수 재사용

결론

React의 성능 최적화 전략의 핵심에는 얕은 비교(shallow comparison)가 자리 잡고 있습니다. 이를 통해 React는 효율적으로 컴포넌트의 리렌더링 여부를 결정합니다. 우리는 이번 탐구를 통해:

  1. React가 사용하는 얕은 비교 메커니즘을 이해했습니다.
  2. 얕은 비교가 어떻게 React의 성능 최적화에 기여하는지 살펴보았습니다.

얕은 비교와 깊은 비교의 차이점을 이해함으로써, 우리는 React의 내부 작동 방식에 대한 더 깊은 통찰을 얻었습니다. 이는 우리가 더 효율적이고 성능이 뛰어난 React 애플리케이션을 개발하는 데 도움이 될 것 같아요! .추가적으로 React의 Reconciliation 과정에 대해 더 자세히 탐구하여, 얕은 비교가 이 과정에서 어떻게 활용되는지 더욱 깊이 이해하고자 합니다. 이를 통해 React의 성능 최적화 전략을 더욱 효과적으로 활용할 수 있을 것입니다.이번 학습을 통해 React의 성능 최적화 전략을 더 깊이 이해할 수 있었던 것 같습니다!

출처

[React.memo] https://ui.toast.com/weekly-pick/ko_20190731

profile
새로운 걸 배우는 것을 좋아하는 프론트엔드 개발자입니다!

0개의 댓글