본문 바로가기
항해99/실전 WIL | TIL

[TIL-008] 리액트 Hooks, 최적화 과정, VDOM 이해

by junvely 2023. 4. 19.

Today목표 : 4/18일 리액트 Hooks, 최적화 과정, VDOM 이해


알게된 점,

 

리액트 Hooks(useContext, useCallback, useMemo, )

1. useContext => react context API로 전역 데이터를 관리한다. 

부모 -> 자식으로 데이터를 전달할 때. props으로 내려주면서, 드릴링 현상이 일어난다.

- props drilling의 문제점

1. 너무 깊어지면(만약 단계가 100..이렇게) 어디에서 props를 내려주고 있는지 파악이 어려움

2. 어떤 컴포넌트에서 에러 발생 시 디버깅이 힘들어짐

useContexnt => 데이터를 전역 데이터로 관리함으로서 하위 컴포넌트에서도 접근 가능하게 하여 props 드릴링을 하지 않아도 된다.

 

context의 필수 개념

1. createContext : context 생성

export const FamilyContext = createContext(null);

하위 컴포넌트에서도 접근 가능한 전역 context가 생성된다.

2. .provider : context -> 객체로 하위컴포넌트로 전달

하위 컴포넌트를 context.provider로 감싸주고, 하위 컴포넌트에 value로 객체를 전달한다.

function GrandFather() {
  const houseName = "스파르타";
  const pocketMoney = 10000;

  return (
    <FamilyContext.Provider value={{ houseName, pocketMoney }}>
      <Father />
    </FamilyContext.Provider>
  );
}

하위 컴포넌트에서 data 변수에 useContext(context이름)를 담으면 해당 데이터를 사용 가능하다.

function Child() {

  const data = useContext(FamilyContext);

  return (
    <div>
      나는 이 집안의 막내에요.
      <br />
      할아버지가 우리 집 이름은 <span style={stressedWord}>{data.houseName}</span>
      라고 하셨어요.
      <br />
      게다가 용돈도 <span style={stressedWord}>{data.pocketMoney}</span>원만큼이나
      주셨답니다.
    </div>
  );
}

3. consumer : context의 변화 감지

 

이때 주의할 점! : useContext를 사용할 때, Provider에서 제공한 value가 달라진다면 useContext를 사용하고 있는 모든 컴포넌트가 리렌더링 되어 엄청나게 비효율적, 따라서 value 부분을 항상 신경써줘야 하며, 이후에 배우게 될 메모리제이션이 그 키가 된다.

 

 

 

2. 최적화 ( React.memo, useMemo, useCallback) 🟡

- 최적화 : 불필요한 렌더링 발생을 막는다. => *캐싱 : 메모리에 저장한다,

1. memo => 컴포넌트를 캐싱
2. useMemo => 값을 캐싱
3. useCallback => 함수를 캐싱

- 리렌더링 발생 조건 :

1. state 변경
2. props 변경
3. 부모 컴포넌트 리렌더링 시 모든 자식컴포넌트 리렌더링

 

1. React.memo = memo

3번, 부모컴포넌트 리렌더링시 모든 자식컴포넌트 리렌더링 최적화의 문제점

=> 부모 컴포넌트 내부 state 변경으로 인한 리렌더링 시 -> 자식 컴포넌트는 props을 받지 않고 관계가 없더라도, 부모 컴포넌트가 리렌더링 되면 무조건 모든 자식 컴포넌트는 리렌더링 된다. 

React.memo로 컴포넌트를 메모리에 저장해 두고 부모 컴포넌트 state 변경으로 인해 자식 컴포넌트의 props를 비교하여 변경사항이 없는 이상, 자식 컴포넌트는 리렌더링 되지 않는다. => 'component memoization"

=> memo로 감싼 컴포넌트를 렌더링할 때 -> 이전에 기억하고 있던 (메모이제이션) 결과물을 메모리에 기억해두고 있다가 props이 변경되었는지 비교 -> 변경이 없으면 재사용하고, 변경될 경우 렌더링 하는 것(렌더링 여부를 prop의 변경여부를 검사하여 결정)

 

- 사용방법 : React.memo로 감싸준다.

export default React.memo(Box1);

=> ❗그렇다면, 모든 컴포넌트에 사용하여 부모 컴포넌트로 부터 받는 props이 변경되는 것이 아닌 이상, 불필요한 렌더링을 모두 막으면 좋지 않은가? => 생각해 보니 memo 자체가 어쨋든 메모리를 차지하는 것이기 때문에 많이 사용할 수록 오히려 성능에 좋지는 않을 것 같다.

1. 순수 함수 컴포넌트 2. 크고 무거운 컴포넌트  3. props을 받지 않는데 자주 렌더링되는 컴포넌트 등에 적절하게 사용하자. 컴포넌트가 무겁지 않고 자주 다른 props로 계속 렌더되어야 한다면 굳이 memo가 필요하지 않기 때문에 불필요한 memoization을 하지 않도록 한다.

=> ❗memo를 사용할 것을 고려하면, export를 함수 아래쪽에서 해주는 것이 좋은 것 같다.

function Box1() {
  console.log("BOX1");
  return <div style={{ border: "1px solid black" }}>Box1</div>;
}

export default React.memo(Box1);

 

 

2. useCallback : 인자로 들어오는 함수 자체를 메모이제이션 한다.

1. 예를 들어, count를 초기화하는 initCount를 하위 컴포넌트의 props으로 전달 할 경우,

// count를 초기화해주는 함수
  const initCount = () => {
    setCount(0);
  };
  // 하위 컴포넌트 props로 전달
  <Box1 initCount={initCount} />

하위 컴포넌트는 React.memo를 사용하여 부모로 부터 받는 props에 변경사항이 없을 경우, 리렌더링 되지 않아야 한다.

export default React.memo(Box1);

하지만 함수의 변경사항이 없는데도 불구하고(=props의 변경사항이 없음) 계속해서 부모 컴포넌트가 리렌더링될 때, 같이 리렌더링이 일어난다. => 왜?

= (함수 = 객체) 이고, 객체를 props으로 전달할 경우, 불변성의 여부로 주소값을 통해 React는 리렌더링 할지를 결정한다. 

즉 부모 컴포넌트가 리렌더링이 되면 => 내부의 함수는 다시 그려져 새 주소값을 가지게 되고, 리렌더링에 의해 새 주소값이 하위 컴포넌트에 전달되면,  props에 전달되었던 이전 함수의 주소값과 비교하여 다르기 때문에 props이 변경되었다고 인식하여 리렌더링을 발생시킨다. 

때문에 useCallback을 사용해 부모 App.js가 처음 렌더링 될 때  함수를 메모리에 저장하여, 함수가 리렌더링 되어 새 주소값으로 갱신하지 않고 항상 메모이제이션된 처음 주소값만 내려보내도록 하는 것이다. => 이렇게 하면 props으로 전달된 함수의 주소값이 같아 리렌더링 되지 않는다.

 const initCount = useCallback(() => {
    setCount(0);
  }, []);

 

2. 하지만 여기서 문제는, 함수 내부에서 어떤 state를 사용해야 할 경우 useCallback은 app 컴포넌트가 처음 그려지는 시점에 메모이제이션 하기 때문에, state의 상태도 초기값으로 기억된다. 즉 state의 초기값을 스냅샷으로 가지고 있어 함수 내부에서 state를 사용 시 항상 초기값을 반환한다는 점이다. 

const initCount = useCallback(() => {
  setCount(0);
}, [count]);

=> count가 변경될 때 만큼은 변경값을 반영해야 하기 때문에, useCallback에 의해 다시 메모리에 저장되어야 한다. 

=> 이때 의존성 배열에 [count]를 전달하면, count가 변경될 때 마다 다시 메모리가 변경되어 반영 할 수 있다.

 

❗따라서, 그래서 useCallback은 언제 사용하면 좋을까? 에 대한 나의 생각은, 최적화의 목적은 불필요한 렌더링을 줄이는 데에 있고, useCallback의 목적은 memo를 사용하더라도, 하위로 전달되는 함수에 의해 props에서 값이 변경되었다고 인식하여 불필요하게 리렌더링되는 것을 방지하는 것이기 때문에 => props으로 하위 컴포넌트에 계속 내려주어야 하는 함수에 사용해 주어야 하위 컴포넌트의 불필요한 렌더링을 줄일 수 있을 것 같다.함수 내에서 참조해야 하는 satate가 있을 경우, 계속해서 값을 관찰하여 변경사항이 있을 경우 바로 반영해줄 수 있도록 의존성 배열[ ]에 추가하여 관리해 주어야 한다.

 

 

 

3. useMemo : 값을 메모이제이션 한다.

1. 값 자체를 저장할 때, 2. 함수의 리턴 값을 저장할 때 사용

함수가 내부적으로 매우 복잡한 연산을 수행하기 때문에 결과값을 리턴하는데 시간이 몇초 이상 오래 걸린다면 어떻게 될까? 컴포넌트의 재 렌더링이 필요할 때 마다 이 함수가 호출이 되므로 사용자는 지속적으로 UI에서 지연이 발생하는 경험을 하게 된다.

1) 엄청 무거운 로직을 수행하는 함수의 return값을 저장하는 경우. => 무거운 로직을 자주 렌더링하는 컴포넌트에서 사용할 경우

// 무거운 로직 함수 => 리렌더링 시마다 다시 실행해 return값 저장
const heavyWork = () => {
    for (let i = 0; i < 1000000000; i++) {}
    return 100;
  };

함수는 어차피 100을 계속해서 return하는데, 컴포넌트가 state 변경 등에 의해 리렌더링 될 때마다 함수를 다시 실행시켜 return값을 저장하는 것은 굉장히 비효율적이다. 따라서 처음 return 값을 메모리에 저장하여, 함수의 변화가 없을 경우 함수를 실행시키지 않고 필요할 때마다 저장된 값을 꺼내 쓰도록 한다.

	// CASE 1 : useMemo를 사용하지 않았을 때
  const value = heavyWork();

	// CASE 2 : useMemo를 사용했을 때
  const value = useMemo(() => heavyWork(), []);

 

2) 컴포넌트가 리렌더링 되면, 리렌더링 시 마다 무거운 로직의 함수를 다시 실행해 return값을 저장해야 한다.

의존성 배열에 [ value ] 값을 추가하여, value 값이 변경되지 않을 때에는 컴포넌트가 리렌더링 되어도 다시 값을 할당하지 않고, 메모리에 기억해 두고 사용한다.

 

3) 해당 컴포넌트가 리렌더링 시 => 내부 객체도 새로 그려져 새로운 주소값을 가지게 되는 경우

=> useEffect를 사용해 의존성 배열에 me를 추가하여 컴포넌트가 리렌더링 되는 것과 무관하게 me함수가 변경될 때만 실행되도록 하였는데, 계속해서 다른 state 등으로 리렌더링될 때 마다 실행되는 이유

  const me = {
    name: "Ted Chang",
    age: 21,
    isAlive: isAlive ? "생존" : "사망",
  };

  useEffect(() => {
    console.log("생존여부가 바뀔 때만 호출해주세요!");
  }, [me]);

원인 : 전체 컴포넌트가 리렌더링 될 때, 객체가 다시 그려지면서 다시 새로운 주소값을 가지기 때문에 useEffect에서는 불변성에 여부에 의해 me객체가 변경되었다고 인식하여 계속해서 리렌더링 시마다 함수를 실행시킨다.

=> 이 때, useMemo에서 me객체를 return시키면, 메모리에 기존 주소값으로 저장해 놓기 때문에 리렌더링 시에도 me가 변경되지 않았다고 인식하여 함수를 실행시키지 않는다.

const me = useMemo(() => {
  return {
    name: "Ted Chang",
    age: 21,
    isAlive: isAlive ? "생존" : "사망",
  };
}, [isAlive]);

=> ❗하지만 1. useMemo도 역시 별도 메모리 공간을 차지하기 때문에 남발하게 되면 오히려 성능이 악화될 수 있으므로 필요시에만 쓰도록 한다. 2. 컴포넌트의 복잡도가 올라가기 때문에 코드를 읽기도 어려워지고 유지보수성도 떨어지게 된다. 또한 3. useMemo가 적용된 레퍼런스는 재활용을 위해서 가바지 컬렉션(garbage collection)에서 제외되기 때문에 메모리를 더 쓰게 된다.

 

[React] 똑똑하게 useMemo 사용하기

참조(이미지 포함): When to useCallback & useMemo hooks ? 이 글은 [React] 똑똑하게 useCallback 사용하기에서 이어지는 글이다. 리액트의 성능 최적화에 대한 이야기와 useCallback 훅을 사용해 최적화 한 예시를

db2dev.tistory.com

=> ❗정리 하자면 useMemo는 값을 저장할 때 사용하는데, 1. 컴포넌트가 리렌더링이 되더라도 함수의 return에서 변경사항이 없을 경우, useMemo의 내부 함수를 굳이 다시 실행하지 않고 return값을 저장해 두었다가 필요할 때 사용한다. 의존성 배열에 값을 추가하면, 해당 값이 변경될 때 마다 다시 함수를 실행해 변경값을 저장한다. => 즉 불필요한 컴포넌트 렌더링으로 인해 함수가 매번 렌더링 될 때마다 무거운 로직을 실행하여 성능이 느려지거나 하는 것을 방지해 준다. 2. useEffect 등을 사용해 mount + 해당 value가 변경될 때에만 업데이트 하고싶은데, 함수나 객체의 경우 불변성 여부 때문에 리렌더링 할 때마다 새 주소값으로 value가 변경되었다고 인식하여 계속해서 리렌더링 하는 것을, useMemo를 사용해 내부에서 객체를 return 하면, 객체의 기존 참조값을 메모리에 저장하고 있어 객체가 변경되지 않는 이상 메모리에 저장해 두고 다시 객체나 함수를 실행하여 값을 return하지 않는다.

 

 

3. 지금까지 내가 이해한 React Hooks와 최적화 과정

1. React Hook

- useState, useRef 차이 = 둘다 값을 저장하지만,

=> state는 값이 변경될 때 마다 리렌더링 + 값 초기화(일반 변수와의 차이점 : 리렌더링 되면 변수도 값이 초기화 된다는 점에서 같지만, 변수는 값이 변경되어도 렌더링을 일으키지 않음)

=> ref는 Lifecycle과 관련없어 리렌더링 되어도 값이 유지됨 + 값이 변경되어도 렌더링x, Ref는 주로DOM 조작에 사용된다.

- useContext => 전역 데이터로 관리하여 props드릴링을 거치지 않아도, 모든 컴포넌트에서 props을 전달받을 수 있도록 함 => redux와 비슷, 차이점 공부

- useEffect => mount시 실행, mount시 + 특정 값 변경 시에만 실행, unMount 시 실행되게 => 앱 첫 시작 시 데이터를 받아오거나, 특정값이 변경되면 업데이트 할 때

 

2. 최적화 과정

1) React.memo를 이용해 부모가 리렌더링 시-> 무조건 자식 컴포넌트도 리렌더링 되는 것을 방지(부모의 state가 변경되어 props이 변경되지 않을 경우, 부모와 관계가 없지만 자주 렌더링 될 경우, 크고 무거운 로직을 수행하는 경우)  => 즉 자주 props에 변경 사항이 있어 계속 렌더링이 일어 나야만 하는 컴포넌트를 제외하고는(memo사용 의미없음)  memo를 사용하면 불필요한 렌더링을 줄여 최적화 한다.

2) memo를 사용하였음에도 불구하고 계속해서 리렌더링이 일어나는 경우 => useCallback =>  부모에서 전달받는 props이 객체 또는 함수일 경우 = 불변성 여부 때문에 부모 컴포넌트가 리렌더링 될 때 마다 새로운 주소값으로 다시 생성되어 props에서 변경되었다고 판단하여 리렌더링이 일어남 => useCallback으로 메모리에 저장해 두어, 렌더링 시에도 함수에 변경사항이 없는 이상 기존 함수를 스냅샷으로 저장해 기존 주소값 유지하여 props으로 전달하면 => 더 이상 하위 컴포넌트에서 부모 컴포넌트의 리렌더링으로 인해 리렌더링 일어나지 않음, 함수에서 사용해야 하는 값만 의존성 배열에 추가하여 업데이트하여 반영할 수 있도록 한다. 

3) useMemo 

1. 컴포넌트 내부에서 state 값 변경 등에 의한 빈번한 리렌더링이 일어날 때, 무거운 로직을 수행하거나 하는 함수의 return값을 저장해야 할 경우, 리렌더링 시마다 매번 무거운 로직을 실행하여 저장하지 않도록 return값을 메모리에 저장해 놓고 변경사항이 없을 경우 리렌더링 시마다 함수를 실행하지 않고 기존 값을 사용하도록 함

2. useEffect를 사용해 해당 값이 변경될 때에만 실행되도록 해야 하는데, 해당 값이 함수 또는 객체일 경우, 컴포넌트가 리렌더링이 될 때 마다 함수 또는 객체가 새로 생성되어 주소값이 변경 되므로 의존성 배열에서 함수가 변경되었다고 판단해 리렌더링 시 마다 실행한다. => 해당 함수나 객체를 useMemo를 사용해 return 함으로써 메모리에 처음 주소값을 저장해 불변했다고 판단하여 렌더링이 일어나지 않도록 한다. 의존성 배열에는 함수나 객체가 참조해야 할 값을 추가해 해당 값이 변경될 때 마다 return값을 새로 메모리에 저장하도록 업데이트 한다.

 

 

 

4. 리액트의 돔(DOM)과 가상돔(VDOM)

1. 가상돔 = VDOM 이란?

- 실제 DOM을 완벽하게 복사한 객체 형태이다.

- VDOM은 객체 형태로 메모리에 저장되어 실제 DOM보다 훨씬 빠르고 가볍다. => 리액트에서 VDOM을 사용하는 이유

 

2. 가상돔은 실제 DOM이 아닌데 어떻게 화면에 그려질까?

1) 가상돔은 2가지 버전이 있다. 

- 화면 갱신 전 DOM 객체, 화면 갱신 후 DOM 객체 => State가 변경 시, 화면 갱신 후 VDOM을 생성한다.

2) 이 2가지 전, 후 VDOM을 비교해 어느 부분이 변경 되었는지 파악하고

3) 그 부분만 DOM에 적용하여 엘리먼트를 변경한다.

- 이 때, 한 건씩 적용하지X, 모아서 한번에 적용한다 = State의 배치 업데이트 => 리액트가 화면을 변경하는 방법

- 예를들어 클릭 시 총 5개의 엘리먼트가 변경되어야 한다면, 실제 DOM에서는 5번 갱신을 하지만, VDOM에서는 모두 모아서 1번 갱신한다 => DOM에서 가장 비싼, 오래걸리는 작업이 바로 Painting 작업인데, 리액느는 이런 Painting 작업을 최소화 하여 엄청나게 빠르고 성능이 우수한 것이다.

 

 

 

배운 점, 아쉬운 점

1. React Hooks들을 한 번 훑어보고 이해할 수 있어서 좋았지만, 실제로 프로젝트에서 사용해 보지 않아 현실적으로 어떤 상황에서 어떤 Hooks이 더 효과적으로 사용할 수 있을지 확신은 가지 않는다.(예를들어 state와 ref와 event를 이용한 DOM 조작이라던지..) 하지만 계속 시도 하면서 여러가지 차이점에 대해 공부하다 보면 습득에 도움이 될 것 같다.

2. 최적화에 대해 memo, useCallback, usememo를 사용한다는 것을 듣긴 했지만, 실제로 연습해 보니 계속해서 불필요한 렌더링이 일어났어서 이해가 잘 되지 않았는데, 이 문제점이 객체의 불변성 여부와 관련이 있다는 것을 깨닫게 되었다. 이부분을 유의하여 다음 번 사용에서는 객체의 불변성 여부를 고려해 useCallback이나 useMemo를 효율적으로 사용해 보아야 겠다.

3. VDOM과 Life cycle의 알고리즘을 완벽히 이해하는 데에는 꽤 많은 시간이 소요될 것 같다. 차근차근 개념에 익숙해진 뒤, 기술면접을 준비하며 다시 자세히 보면 훨씬 이해가 쉬울 것 같다. 오늘의 목표까지는 모두 완수하여 뿌듯하다.