[Front-end] 개발자 공부

[개발 공부 80일차] React 공식 문서 | Escape Hatches (4)

MOLLY_ 2024. 7. 11. 07:00
728x90

< 목차 >
0. TL;DR
1. 불필요한 Effect를 제거하는 방법
2. 비용이 많이 드는 계산 ⇒ 캐싱 처리

3. prop 변경 시 모든 state 초기화
4. prop이 변경될 때 일부 state 조정
5. 이벤트 핸들러 간 로직 공유
6. 데이터 Fetching할 때 고려할 점

 

 

이번 파트 스터디 요약 소감

 

Effect가 필요하지 않을 수도 있다 (You Might Not Need an Effect)

외부 시스템이 관여하지 않는 경우 (예를 들어 일부 props 또는 state가 변경될 때 컴포넌트의 state를 업데이트하려는 경우), Effect가 필요하지 않다.

 

 

0. TL;DR

  1. 비용이 많이 드는 계산을 캐시하려면 useEffect 대신 useMemo를 추가하자
  2. 체 컴포넌트 트리의 state를 초기화하려면 다른 key를 전달하자
  3. 컴포넌트가 표시되어 실행되는 코드는 Effect에 있어야 하고 나머지는 이벤트에 있어야 함

 

 

1. 불필요한 Effect를 제거하는 방법

Effect가 필요하지 않은 두 가지 일반적인 경우

  1. 렌더링을 위해 데이터를 변환할 때
    : 불필요한 렌더링 패스를 피하려면, 컴포넌트의 최상위 레벨에서 모든 데이터를 변환하자. 그러면 props나 state가 변경될 때마다 해당 코드가 자동으로 다시 실행된다.

  2. 사용자 이벤트를 핸들링할 때

 

 

2. 비용이 많이 드는 계산 ⇒ 캐싱 처리

  // 🚨 아래와 같은 중복된 state 및 불필요한 효과는 피하자 ⇒ 비효율적
  const [visibleTodos, setVisibleTodos] = useState([]);
  
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);
  
  
  // --------------------- 옳은 예시 구분선 ---------------------
  
  
  function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  
  // (1) state와 Effect 제거
  // ✅ getFilteredTodos()가 느리지 않다면 괜찮음
  const visibleTodos = getFilteredTodos(todos, filter);
}


	// (2) getFilteredTodos()가 느리거나 todos가 많을 경우, newTodo처럼 관련 없는 state 변수가
	// 변경될 때 getFilteredTodos()를 다시 계산하고 싶지 않을 수 있음
	// ⇒ 아래처럼 useMemo Hook으로 래핑해서 값비싼 계산을 캐시(또는 “메모이제이션”)
	function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  
  const visibleTodos = useMemo(() => {
    // ✅ todos 또는 filter가 변경되지 않는 한 다시 실행되지 않음
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
}
	
	// 이렇게 하면 todos나 filter가 변경되지 않는 한 내부 함수가 다시 실행되지 않기를 원한다는 것을
	// React에게 알리게 됨. React는 초기 렌더링 중에 getFilteredTodos()의 반환값을 기억함

 

 

💡 계산이 비싼지 어떻게 알 수 있을까?

수천 개의 객체를 만들거나 반복하는 경우가 아니라면 비용이 많이 들지 않을 것이다. 좀 더 확신을 얻고 싶다면 console.log를 추가하여 코드에 소요된 시간을 측정할 수 있다.

 

// (1) console.log를 추가하여 코드에 소요된 시간 측정
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

// filter array: 0.15ms와 같은 로그가 console에 표시됨
// 전체적으로 기록된 시간이 상당한 양(예: 1ms 이상)으로 합산되면 해당 계산을 메모이제이션하는 것이 좋음


// (2) 해당 계산을 useMemo로 감싸서 해당 상호작용에 대해 총 로깅 시간이 감소했는지를 확인
// useMemo는 첫 번째 렌더링을 더 빠르게 만들지 않음
// 업데이트 시 불필요한 작업을 건너뛰는 데만 도움됨
console.time('filter array');
const visibleTodos = useMemo(() => {
  return getFilteredTodos(todos, filter); // todos와 filter가 변경되지 않은 경우 건너뜀
}, [todos, filter]);
console.timeEnd('filter array');

 

 

⚠️ 주의 ⚠️

  1. 개발자의 컴퓨터가 사용자의 컴퓨터보다 빠를 수 있으므로 인위적인 속도 저하로 성능을 테스트하는 것이 좋다. 예를 들어, Chrome은 이를 위해 CPU Throttling 옵션을 제공한다.
  2. 개발 중에 성능을 측정하는 것은 가장 정확한 결과를 제공하지 않는다는 점에 유의하자. 가장 정확한 시간을 얻으려면 프로덕션용 앱을 빌드하고 사용자가 사용하는 것과 같은 기기에서 테스트할 것!

 

 

3. prop 변경 시 모든 state 초기화

🐈 예제 설명

  1. 한 프로필에서 다른 프로필로 이동할 때 comment state가 재설정되지 않는 문제를 발견
  2. 그 결과, 실수로 잘못된 사용자의 프로필에 댓글을 게시하기 쉽게 됨
  3. 이 문제를 해결하기 위해 userId가 변경될 때마다 comment state 변수를 비우고자 함

 

 

해결 과정

// ProfilePage와 그 자식이 오래된 값으로 처음 렌더링한 다음 다시 렌더링해서 비효율적
export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🚨 Effect에서 prop 변경 시, state 초기화를 피하자
  useEffect(() => {
    setComment('');
  }, [userId]);
}


// --------------------- 옳은 예시 구분선 ---------------------


// 명시적인 key를 전달하여 각 사용자의 프로필이 개념적으로 다른 프로필임을 React에 알리자
// 컴포넌트를 둘로 나눔 → 외부 컴포넌트에서 내부 컴포넌트로 key 어트리뷰트를 전달
export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

// Profile 컴포넌트에 userId를 key로 전달하면 React가 userId가 다른 두 개의 Profile 컴포넌트를
// ✅ ‘state를 공유해서는 안 되는’ 2개의 다른 컴포넌트로 취급하도록 요청하는 것
function Profile({ userId }) {

  // ✅ 이 state와 아래의 다른 state는 key 변경 시, 자동으로 재설정
  const [comment, setComment] = useState('');
}

// 하지만 외부 ProfilePage 컴포넌트만 내보내, 프로젝트의 다른 파일에 표시된다는 점에는 유의할 것

 

 

4. prop이 변경될 때 일부 state 조정

prop이 변경될 때 전체가 아닌 일부 state만 재설정하거나 조정하고 싶을 때가 있다.

 

// 이 List 컴포넌트는 items 목록을 prop으로 받고 selection state 변수에 선택된 item을 유지
// items prop이 다른 배열을 받을 때마다 selection을 null로 재설정하고자 함
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🚨 Effect에서 prop 변경 할 때, state 조정하지 말자
  // items가 변경될 때마다 List와 그 자식 컴포넌트들은 처음의 오래된 selection 값으로 렌더링
  useEffect(() => {
    setSelection(null);
  }, [items]);
}


// --------------------- 옳은 예시 구분선 ---------------------


// Effect를 삭제하는 것으로 시작하자
// ✅ 렌더링 중에 state를 조정하는 게 더 좋음
  const [prevItems, setPrevItems] = useState(items);
  
  // 렌더링 도중 다른 컴포넌트의 state를 업데이트하면 에러가 발생함
  // 반복을 피하려면 items !== prevItems와 같은 조건이 필요
  if (items !== prevItems) {
  
	  // 이전 렌더링의 정보를 저장하는 것은 이해하기 어려울 수 있지만,
	  // Effect에서 동일한 state를 업데이트하는 것보다 나음
    setPrevItems(items);
    
    // React는 아직 List 자식을 렌더링 하거나 DOM을 업데이트하지 않았기 때문에
    // 오래된 selection 값의 렌더링을 훌쩍 건너뛸 수 있음
    setSelection(null);
  }

 

 

이 패턴이 Effect보다 더 효율적이지만, 대부분의 컴포넌트는 이 패턴이 필요하지 않다.

어떻게 하든 props나 다른 state에 따라 state를 조정하면, 데이터 흐름을 이해하고 디버깅하기가 더 어려워져버린다.

 

대신 위 예제처럼 key를 사용하여 모든 state를 초기화하거나 렌더링 중에 모든 state를 계산할 수 있는지 항상 확인하자.

 

// 예를 들어, 선택한 item을 저장(및 초기화)하는 대신 선택한 item ID를 저장할 수 있음
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  
  // ✅ 렌더링 중에 모든 것을 계산..! 최고다! 그에게 주어지는 합격 목걸이
  // 이제 state를 “조정”할 필요가 전혀 없음
  // 선택한 ID를 가진 item이 목록에 있으면 선택된 state로 유지
  const selection = items.find(item => item.id === selectedId) ?? null;
}

 

 

5. 이벤트 핸들러 간 로직 공유

🐈 예제 설명

  1. 제품을 구매할 수 있는 두 개의 버튼(구매 및 결제)이 있는 제품 페이지가 있음
  2. 사용자가 제품을 장바구니에 넣을 때마다 알림을 표시하고 싶음
  3. 두 버튼의 클릭 핸들러에서 모두 showNotification()을 호출하는 건 반복적이니까
  4. 이 로직을 Effect에 배치하고 싶음

 

 

해결 과정

function ProductPage({ product, addToCart }) {

  // 🚨 Effect 내부의 이벤트별 로직은 피하자
  // 이 Effect는 불필요하고, 버그를 유발할 가능성이 높음
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
}


// --------------------- 옳은 예시 구분선 ---------------------


// 어떤 코드가 Effect에 있어야 하는지 이벤트 핸들러에 있어야 하는지 확실하지 않은 경우,
// 이 코드가 실행되어야 하는 이유를 자문해 보자
// 컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드에만 Effect를 사용할 것

// 페이지가 표시되었기 때문이 아니라 사용자가 버튼을 눌렀기 때문에 알림이 표시되어야 함
// Effect를 삭제하고, 공유 로직을 두 이벤트 핸들러에서 호출되는 함수에 넣자

// ✅ 이벤트 핸들러에서 이벤트별 로직이 호출됨
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }

 

 

6. 데이터 Fetching할 때 고려할 점

  1. 응답 캐싱 (사용자가 뒤로가기 버튼을 클릭하여 이전 화면을 즉시 볼 수 있도록)
  2. 서버에서 데이터를 가져오는 방법 (초기 서버 렌더링 HTML에 스피너 대신 가져온 콘텐츠가 포함되도록)
  3. 네트워크 워터폴을 피하는 방법 (자식이 모든 부모를 기다리지 않고 데이터를 가져올 수 있도록)

⇒ 이러한 문제는 React뿐만 아니라 모든 UI 라이브러리에 적용된다.

문제를 해결하는 것은 간단하지 않기 때문에 모던 프레임워크는 Effect에서 데이터를 가져오는 것보다 더 효율적인 내장 데이터 가져오기 메커니즘을 제공한다.

 

 

프레임워크를 사용하지 않고(그리고 직접 빌드하고 싶지 않고) Effect에서 데이터를 가져오고 싶다면, 아래 예시처럼 가져오기 로직을 사용자 정의 Hook으로 추출하는 것을 고려하자. 데이터 가져오기 로직을 사용자 정의 Hook으로 옮기면 나중에 효율적인 데이터 가져오기 전략을 취하기가 더 쉬워진다.

 

function SearchResults({ query }) {
  const [page, setPage] = useState(1);
  const params = new URLSearchParams({ query, page });
  
  // 사용자 정의 Hook으로 추출
  const results = useData(`/api/search?${params}`);

  function handleNextPageClick() {
    setPage(page + 1);
  }
}

 

 

728x90