8 minute read

들어가기전에 참고

렌더링이 일어나는 시점

1. 자신이 전달받은 props  변경될 .  
2. 자신의 state가 바뀔때
3. 부모 컴포넌트가 리렌더링될 
4. forcUpdate 함수가 실행될 

일반적인 업데이트 방법

  • 예제1
      // 예시
      setCount('계산한 새로운 상태');
    
    
      // 구현
      setCount(count + 100);
    
  • 예제2
      import React, { useState } from 'react';
    
      function ExampleComponent() {
        const [count, setCount] = useState(0);
    
        const handleIncrement = () => {
          setCount(count + 1); // 직접 접근하여 상태를 업데이트
        };
    
        return (
          <div>
            <p>Count: {count}</p>
            <button onClick={handleIncrement}>Increment</button>
          </div>
        );
      }
    
    

    💡set 으로 직접 접근하고 있다.

함수형 업데이트 방법

  • 예제1
      // 예시
      setCount(blang => {
        // 이전 상태 값을 기반으로 새로운 상태를 계산
        return '계산한 새로운 상태'
      });
    
    
      // 구현
      setCount(blang => blang + 100);
    
    

    💡 매개변수로 함수를 사용했고, 이 함수는 콜백함수이다.
    💡 콜백함수의 매개변수 이름은 blang 이고, 이전 상태 값을 나타내는 매개변수다.
    💡 useState 기능에서 업데이트시 콜백 함수의 매개변수로 이전 상태 값을 받아오도록 구현한 기능이다.

  • 예제2

      import React, { useState } from 'react';
    
      function ExampleComponent() {
        const [count, setCount] = useState(0);
    
        const handleIncrement = () => {
          setCount(prevCount => prevCount + 1); // 콜백 함수를 이용하여 상태를 업데이트
        };
    
        return (
          <div>
            <p>Count: {count}</p>
            <button onClick={handleIncrement}>Increment</button>
          </div>
        );
      }
    
    
    setCount(prevCount => prevCount + 1)
    

    💡setCount 함수를 호출할 때, 화살표 함수를 사용하여 최신 count 배열을 인자로 받아와서 새로운 배열을 생성했다.
    💡setCount 함수를 호출할 때, 함수형업데이트(콜백 함수)를 사용하여 이전의 count 상태를 받아와서 1을 더한 값으로 상태를 업데이트 했다.

업데이트 방법 비교

const [value, setValue] = useState(0);
  const onClick = () => {
    setValue(value + 1);
    setValue(value + 1);
    setValue(value + 1);
    console.log(value);
  }
  
  <button onClick={onClick} >테스트</button>

결과: 1
💡 비동기식으로 동작한다.

const [value, setValue] = useState(0);
  const onClick = () => {
    setValue(value => value + 1);
    setValue(value => value + 1);
    setValue(value => value + 1);
    console.log(value);
  }
  
  <button onClick={onClick} >테스트</button>

결과: 3
💡 동기식으로 동작한다.

비동기가 좋은거 아니야?

기본 매커니즘을 무시하면 성능이 떨어지는게 아닌가?
💡 사실 setState를 동기적으로 사용하는 것 자체가 최적화의 목적은 아니다.
💡 진짜 목적은 컴포넌트의 useCallback 함수로부터 state의 의존성을 제거함으로써(비 의존성 useCallback) 리렌더링을 방지한다.

최적화

[ 불변성 지키기 ] 개념

  • 기본적인 불변성 지키기

      const onToggle = useCallback((id) => {
          setTodos(todos => (
    
              todos.map((todo) =>
                  todo.id === id ? 
                  {
                      ...todo,
                      checked: !todo.checked
                  } : todo,
              )
          ));
      }, []);
    
    

    불변성 지키기
    💡 “불변성을 유지하면서”란, 객체나 배열의 내용이 변경되더라도 해당 객체나 배열의 메모리 주소가 변경되지 않는 것을 의미합니다. 즉, 기존의 데이터를 직접 수정하는 대신 새로운 객체나 배열을 생성하여 변경된 값을 반영하는 것을 말합니다.

    과정
    💡 …todos 전개연산자로 얕은복사를 수행.
    💡 기존 객체의 속성들을 그대로 복사한다. 즉, todo 와 동일한 객체를 바라본다.
    💡 이렇게 얕은 복사된 새로운 객체를 사용하여 변경이 필요한 checked 속성을 반전시킨다.

  • 기본적인 불변성 지키지 못한경우

      // 예시1) 
      const onToggle = useCallback((id) => {
          setTodos(todos => {
              todos.map((todo) => {
                  if (todo.id === id) {
                      todo.checked = !todo.checked; // 불변성을 위배하는 직접 수정
                  }
                  return todo;
              });
              return todos; // 업데이트된 배열을 반환하지 않음
          });
      }, []);
    
    
      // 예시2) 
      const onToggle = useCallback((id) => {
          for (let i = 0; i < todos.length; i++) {
              if (todos[i].id === id) {
                  todos[i].checked = !todos[i].checked;
                  break;
              }
          }
          setTodos(todos);
      }, []);
    
    

    불변성 지키기 실패
    💡 todos 배열을 직접 수정하여 업데이트하고 있다.

    문제점

    • 불변성 위반
      todos 배열을 직접 수정하면서 불변성을 위반합니다. 이전 상태의 todos 배열과 업데이트된 배열이 동일한 메모리를 공유하게 됩니다.
    • 리렌더링 최적화 어려움
      React에서 상태가 변경되었음을 감지하려면 얕은 비교(shallow comparison)를 통해 이루어집니다. 직접 수정하는 방식으로 상태를 업데이트하면 배열의 참조가 변경되지 않기 때문에 React가 변경을 감지하지 못하고 불필요한 리렌더링이 발생할 수 있습니다.
    • 예상치 못한 동작
      내부 객체가 중첩되어 있을 때, 변경된 내부 객체의 참조가 원본과 복사된 배열 두 곳에 모두 반영됩니다. 이로 인해 예상치 못한 동작이 발생할 수 있습니다.
  • 객체안에 있는 객체 불변성 키기

      // 초기 상태 설정
      const initialState = {
        user: {
          id: 1,
          name: 'John',
          address: {
            city: 'New York',
            zipcode: '12345',
          },
        },
      };
    
      // 중첩 객체를 복사하고 업데이트하는 함수 정의
      const updateAddress = (state, newAddress) => {
        return {
          ...state,  // 가장 먼저 원본값을 뿌리고
          user: {    
            ...state.user,        // 불변성을 지키면서 수정
            address: newAddress,   
          },
        };
      };
    
      // 초기 상태 출력
      console.log('Initial State:', initialState);
    
      // 주소 업데이트 후 상태 출력
      const newAddress = {
        city: 'Los Angeles',
        zipcode: '98765',
      };
      const updatedState = updateAddress(initialState, newAddress);
      console.log('Updated State:', updatedState);
    
      // 초기 상태는 변경되지 않았음을 확인
      console.log('Initial State (Unchanged):', initialState);
            
    

    객체안에 객체 불변성 지키기
    💡 위 코드에서 updateAddress 함수는 기존 상태를 변경하지 않으면서 중첩된 객체 user의 address를 업데이트 한다.
    💡 spread 연산자를 사용하여 중첩 객체들을 복사하고 변경된 값을 적용.
    💡 불변성을 유지하면서 상태를 업데이트가 구현되었다.

[ 최적화 ] 개념

화면 렌더링이 발생하면 함수는 계속 갱신된다. 하지만 아래 내용을 구현하면 동일한 컴포넌트중에 1개의 수정이 일어나도 각각의 컴포넌트별로 렌더링이 동작 하게된다. (비용을 아낀다.)

  1. 우선 useCallback( 원하는동작, []) 기능으로 함수 갱신을 막는다. (재사용하니까 좋네!)
  2. 이제 (불변성을 지키며) 함수형 업데이트 통한 접근으로 개별적으로 useState를 핸들링한다. (재사용하는 함수내에서 최신값을 받으니까 더 좋네!)

    “함수가 갱신되지 않도록 [재사용함수를 선언한 상태]에서 [함수형 업데이트]를 통해 최신 갱신된 useState 를 얻은 뒤 필터링된 새로운 배열로 리턴하는것이 핵심 ! “
    “기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는것을 “불변성을 지킨다” 라고 한다. “

방법1. 의존성 useCallback 최적화

STEP1. 의존성 useCallback 훅 생성

// AS-IS
const onRemove = (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
}

// TO-BE
const onRemove = useCallback( (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
}, [todos])

❗과정
💡 todos 상태를 의존하는 의존성 useCallback 함수를 생성.
💡 todos 배열에 변경분이 생길때만 함수가 새롭게 생성(렌더링) 된다.

❗장점
💡 변경분이 없어도 렌더링이 발생하면 계속 함수가 생성되는 문제를 해결했다.
💡 사용하는 컴포넌트가 많지 않다면 이렇게 사용해도 문제없이 성능이 좋다.

❗문제점
💡 이 함수를 사용하는 여러개의 동일한 컴포넌트가 존재하는 상태에서 어떤 1개의 컴포넌트가 todos를 수정하면 전체 컴포넌트가 렌더링 되어버린다. (매우 비효율적 렌더링이 발생).

방법2. 빈 의존성 useClaback & 함수형 업데이트 최적화

이 최적화 방법에서 필요한 준비물은 [빈 의존성 useCallback 함수], [함수형 업데이트 기법] 이다.

STEP1. 빈 의존성 useCallback 훅 생성

// AS-IS
const onRemove = useCallback( (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
}, [todos])

// TO-BE
const onRemove = useCallback( (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
}, []);  //우선 이렇게 빈 의존성으로 변경한다.

과정
💡 의존하지 않는 빈 의존성 useCallback 함수를 생성.
💡 렌더링 시점만 todos를 초기 값으로 가지는 새로운 함수가 생성 된다.
💡 렌더링 이후 todos가 변경되어도 최신 값으로 업데이트되지 않고 항상 최초의 메모리 (todos 배열)을 참조한다.

정리
💡 따라서 해당 함수는 렌더링되고 나서 외부에서 todos 값이 변경되었더라도 변경된 값이 반영되지 않고 이전의 todos 값을 기준으로 동작한다.

STEP2. 함수형 업데이트 문법 적용

// AS-IS
const onRemove = useCallback( (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
}, []);

// TO-BE
const onRemove = useCallback( (id) => {
    setTodos(todos => (todos.filter((todo) => todo.id !== id)));
}, []);

과정
💡 [함수형 업데이트]를 활용하여 이전 상태를 기반으로 새로운 상태를 업데이트
💡 useCallback 훅의 두 번째 인자로 빈 의존성 배열([])을 전달하더라도, 함수형 업데이트를 통해 항상 최신 todos 배열 값을 사용하게 된다.
💡 콜백함수기 때문에 더욱 안전함.

정리
💡 렌더링되어도 함수는 재사용되는데 참조하는 상태는 최신을 바라보는 기법이다.
💡 최적화된 업데이트를 위해 useCallback을 함께 사용하여 불필요한 함수 재생성을 방지하는 것이 권장되는 패턴이다.

장점

  1. 최신 데이터 사용
    콜백 함수를 통해 항상 최신의 todos 배열을 활용하여 새로운 배열을 생성. 때문에 todos 배열이 업데이트되기 전의 예전 데이터로 업데이트하는 오류를 방지할 수 있다.
  2. 의존성 배열의 필요성 제거
    useCallback의 의존성 배열을 비워두었기 때문에, todos를 의존성 배열로 추가할 필요가 없다. 따라서, todos가 업데이트되더라도 onRemove 함수가 재생성되지 않아도 된다.
  3. 안전한 업데이트(비동기니까!)
    setTodos 콜백 함수를 통해 업데이트하므로, 최신 todos 배열을 안전하게 참조할 수 있고, 불필요한 리렌더링을 방지할 수 있다.

방법3. useReducer 최적화

STEP1. 예제1

// 1. 초기값 정의
const initialState = {
  quantity: 0,
};

// 2. 액션 정의
const INCREASE = 'INCREASE';

// 3. Reducer 함수 생성
function reducer(state, action) {
  switch (action.type) {
    case INCREASE:
      return { ...state, quantity: state.quantity + 1 };
    default:
      return state;
  }
}

// 4. useReducer 훅 사용
function Product() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { quantity } = state;

  const handleIncrease = () => {
    dispatch({ type: INCREASE });
  };

  return (
    <div>
      <p>Quantity: {quantity}</p>
      <button onClick={handleIncrease}>Increase</button>
    </div>
  );
}

STEP2. 예제2

import { useReducer } from 'react';

// 2. 액션 정의
// 3. Reducer 함수 생성
function todosReducer(state, action) {
  switch (action.type) {
    case 'INSERT':
      //  { type: 'INSERT', todo: { id: i, text: '입력값', checked: false } }
      return todos.concat(action.todo);
      
    case 'REMOVE':
      //  { type: 'REMOVE', id: 1 }
      return state.filter((todo) => todo.id !== action.id);

    case 'TOGGLE':
      //  { type: 'REMOVE', id: 1 }
      return state.map((todo) =>
        todo.id === action.id ? { ...todo, checked: !todo.checked } : todo
      );
    default:
      return state;
  }
}


function ScheduleBoard() {
  // 1. 초기값 정의
  // 4. useReducer 훅 사용
  const [todos, dispatch] = useReducer(todoReducer, undefined , createBulkTodos);

  const nextId = useRef(todos.length);

  const onInsert = useCallback( (text) => {
      const todo = {
          id: nextId.current += 1
          , text: text
          , checked: false
      };
      dispatch({ type: 'INSERT', todo });
  }, []);

  const onRemove = useCallback((id) => {
    dispatch({ type: 'REMOVE', id });
  }, []);

  const onToggle = useCallback((id) => {
    dispatch({ type: 'TOGGLE', id });
  }, []);

  // 나머지 컴포넌트 로직과 JSX 반환
}

useReducer 패턴으로 작성했다.

초기 렌더링될 때만 함수를 호출

const [todos, dispatch] = useReducer(todoReducer, undefined, createBulkTodos);

💡 원래 2번째 인자에 초기값을 넣었는데 undefined 을 넣었다. 이는 only 초기렌더링을 의도한것이다.
💡 [함수형 업데이트를 활용한 상태 업데이트] 의 비의존성 useCallback 함수 기능을 구현한것이다.

정리
💡 useReducer를 사용하여 상태 관리하면 복잡한 상태 로직을 더 직관적이고 효율적으로 작성할 수 있다.
💡 useState 대신 useReducer를 사용하는 것은 상태가 복잡해지거나 상태 변경 로직이 복잡해질 때 더 유용하다.

정리

스텝별로 최적화 과정

과정
💡STEP1. 반복적인 [컴포넌트 리랜더링]을 방지한다.

  • 부모컴포넌트에서 호출하는 자식컴포넌트 반복 생성을 방지한다. (TodoList 컴포넌트 반복 생성을 방지)
  • React.memo 함수로 방지
    • // 부모컴포넌트 (반복적인 자식컴포넌트 호출)
      return (
              <TodoTemplate>
                   <TodoList todos={todos} onRemove={onRemove} onToggle={onToggle} />
               </TodoTemplate>
      )
      
    • // 자식컴포넌트 (React.memo 기능으로 불필요한 컴포넌트 랜더링을 방지)
      const TodoList = ({ todos, onRemove, onToggle }) => {
           retrun (/* 결과 리턴 */) 
      }
      // 
      export default React.memo(TodoList);
      
      

💡STEP2. [반복적인 컴포넌트에서 사용하는 props 리랜더링] 을 방지한다.

  • 부모컴포넌트에서 자식컴포넌트를 호출할때 사용하는 props 를 모두 최적화 한다. (todos, onRemove, onToggle 프롭스의 불필요한 리랜더링을 방지)
  • todos 배열이 업데이트되면 함수들도 새롭게 업데이트 되기 때문이다.
  • 빈 의존성 useCallback 함수 & 함수형업데이트 문법으로 방지
    • // 부모컴포넌트
      const onRemove = useCallback( (id) => {
          setTodos(todos => (todos.filter((todo) => todo.id !== id)));
      }, []);
      
      // onInsert, onToggle 모두 동일하게 처리하면 된다.
      // 불필요한 부모컴포넌트의 리랜더링이 발생하지 않아서
      // 자식컴포넌트는 자신의 컴포넌트 최적화만 신경쓰면 된다.  
          
      

최종 결과

사진1

💡v1 속도가 상대적으로 매우 느리다는것을 알 수 있다.
💡v2 와 v3 는 동일한 수준의 최적화를 보인다.
💡맨 마지막 최적화는 다음 포스팅에서 !

Tags:

Categories:

Updated:

Leave a comment