React의 동작에 대한 고찰
포스트
취소

React의 동작에 대한 고찰

해당 포스트는 overreacted - UI 런타임으로서의 React를 읽고 정리한 포스트입니다.
주관적으로 해석한 내용이 들어가 있어서 잘못된 내용이 포함될 수 있습니다.

📌 용어 정리

0️⃣ 호스트 트리

UI를 표현하는데 사용하는 자료구조를 의미합니다.
( DOM과 같은 것을 의미하는 것 같음 )

1️⃣ 호스트 객체

UI를 표현하는 하나의 노드를 의미합니다.
( document.querySelector("#root")같은 것을 의미하는 것 같음 )

🧐 React의 동작에 대한 고찰

대부분의 내용은 overreacted - UI 런타임으로서의 React를 읽으면서 이해한대로 정리한 내용이므로 해당 게시글을 읽어보시는 것을 추천드립니다.

0️⃣ 진입점

overreacted - 진입점을 참고했습니다.

ReactDOM.render()React의 진입접입니다.
React가 실행되는 시작지점이죠.

create-react-app으로 초기 세팅을 하면 아래와 같이 코드가 구성됩니다.

1
2
3
4
5
6
// index.js
import ReactDOM from 'react-dom';
import App from './App';

// `document.getElementById("root")`는 일반적인 엘리먼트고, `public/index.html`에 보시면 찾을 수 있습니다.
ReactDOM.render(<App />, document.getElementById("root"));

위에서 <App />React 엘리먼트 트리, document.getElementById("root")이 호스트 트리입니다.

ReactDOM.render(reactElement, domContainer)domContainerreactElement와 똑같이 만드는 코드입니다.
아래 내용들을 읽어보면 알게 되겠지만 React의 특수한 동작으로 인해 반복을 최소화하고 효율적으로 동작합니다.

1️⃣ 재조정

overreacted - 재조정를 참고했습니다.

React 엘리먼트 트리와 호스트 트리를 일치시키는 것을 의미합니다.

1
2
3
4
5
// (1) ( 진입점 )
ReactDOM.render(<img className="thumbnail" src="a.png" />, document.getElementById("root"));

// (2) ( 특정 버튼 클릭으로 인해 실행된다고 가정 )
ReactDOM.render(<img className="thumbnail" src="b.png" />, document.getElementById("root"));

만약 위와 같은 상황이 있다고 가정해보겠습니다.
(1)이 실행되고 특정 버튼이 클릭된 경우 (2)가 실행될 때 React에서는 (1)에서 렌더링했던 것을 버리고 아예 새로 (2)를 렌더링하지 않습니다.
(2)를 실행하면 (1)(2)Diffing Algorithm을 통해 효율적으로 비교한 뒤 변경된 부분(위 예시에서는 src)만 변경합니다.

DOM 트리를 이용해서 엘리먼트를 분석하는데 리렌더링할 때 DOM 트리에서 해당 위치의 노드 타입을 비교해서 같으면 해당 노드를 호스트 객체에서 가져와 그대로 사용합니다.
단, 노드는 그대로 사용하지만 내부 속성이 다른지는 Diffing Algorithm으로 비교하고 다르다면 혹은 추가했다면 해당 속성만 변경해줍니다.
그리고 재귀적으로 과정을 반복해서 모든 자식 노드들을 비교하는 과정을 통해서 변경된 부분만 수정하고 렌더링하게 됩니다.

  • 요약
    1. 엘리먼트 트리와 호스트 트리의 각 위치의 노드 타입을 비교
    2. 만약 같다면 재사용 다르다면 새로 생성
    3. 같은 경우 노드는 재사용하고 속성과 컨텐츠 등을 비교
    4. 속성과 컨텐츠 등이 같다면 재사용 다르다면 새로 생성

2️⃣ key를 사용해야하는 이유

아래 코드를 예시로 설명하겠습니다.
(1)의 코드가 렌더링되다가 특정 이벤트로 인해 (2)로 바뀌는 중인 상황이라고 가정하겠습니다.

저희의 시각으로는 순서가 역순으로 바뀌었다고 보이겠지만 React의 입장에서는 그것을 알 수 없습니다.
따라서 <li>는 그대로 사용하되 컨텐츠를 새로 생성해서 변경하게 됩니다.
지금은 간단한 텍스트지만 이 내용이 복잡하고 많은 연산을 소모하는 작업이라면 필요없는 작업의 반복이 될 것 입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// (1)
const Tweets = () => {
  return (
    <ul>
      <li>apple</li>
      <li>banana</li>
      <li>melon</li>
    </ul>
  )
}

// (2)
const Tweets = () => {
  return (
    <ul>
      <li>melon</li>
      <li>banana</li>
      <li>apple</li>
    </ul>
  )
}

위와 같은 문제를 해결하기 위해서 key를 사용합니다.
아래와 같이 사용한다면 React에서 key를 보고 “아! 이 엘리먼트는 이전에 만들었던 엘리먼트네! 그러면 이전에 썼던 엘리먼트를 재사용하면 되겠네!”라고 이해하고 재사용하게 됩니다.
훨씬 합리적이고 효율적으로 동작하도록 도와주는 유용한 속성입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const Tweets = () => {
  return (
    <ul>
      <li key="1">apple</li>
      <li key="2">banana</li>
      <li key="3">melon</li>
    </ul>
  )
}

const Tweets = () => {
  return (
    <ul>
      <li key="3">melon</li>
      <li key="2">banana</li>
      <li key="1">apple</li>
    </ul>
  )
}

사용 이유를 이해했다면, 배열 메서드의 index를 사용해도 되는 경우와 어느 범위에서 유일한 값을 작성해야하는지에 대한 의문에 대한 답을 얻을 수 있을겁니다.

3️⃣ 재귀적으로 렌더링

overreacted - 재귀를 참고했습니다.

재귀적으로 렌더링할 엘리먼트를 찾아봅니다.
자식이 엘리먼트를 렌더링한다면 다시 자식의 자식을 찾습니다.
그리고 이 과정을 엘리먼트가 아닐 때까지 반복합니다.
그리고 엘리먼트가 아니라면 렌더링을 시작합니다.

위 과정을 통해서 React진입점을 통해서 하나의 엘리먼트에만 접근한다면 모든 엘리먼트를 렌더링할 수 있게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
const Component = () => {
  return (
    <main>
      <section>
        <p>Hello</p>
      </section>
    </main>
  )
}

ReactDOM.render(<Component />, document.getElementById("root"));
  • 흐름 ( 위 예시 기준 )
    1. <Component />를 보고 내부의 <main></main>을 찾습니다.
    2. <main></main>이 엘리먼트이기 때문에 자식인 <section></section>을 찾습니다.
    3. <section></section>이 엘리먼트이기 때문에 자식인 <p></p>를 찾습니다.
    4. <p></p>가 엘리먼트이기 때문에 자식인 "Hello"를 찾습니다.
    5. "Hello"는 엘리먼트가 아니기 때문에 렌더링하는 과정을 수행합니다.

4️⃣ 지연 평가

overreacted - 지연 평가를 참고했습니다.

React에서 컴포넌트를 호출하는 방법중에서 <Page />{Page()}에 대한 차이에 대해 설명하면서 지연 평가에 대해 알아보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 특정 조건에 의해서 children이 렌더링 됨
const Page = ({ user, children }) => {
  // "children" 사용 X
  if (!user) return <div>로그인후에 접근해주세요!</div>;

  // "children" 사용 O
  return (
    <>
      <div>{children}</div>
    </>
  );
};

const Comments = () => {
  return (
    <div>
      <span>대충 댓글</span>
    </div>
  );
};

const App = () => {
  return (
    <article>
      {/* (1) "React"에게 "Page"라는 컴포넌트를 호출하는 권한을 위임 */}
      <section><Page /></section>

      {/* (2) 개발자가 명시적으로 컴포넌트 호출 */}
      <section>{Page()}</section>
    </article>
  );
};

위의 예시처럼 부모 컴포넌트에서 조건부로 props children으로 호출하는 경우가 있다면 두 방식의 차이가 발생합니다.
(1)의 방식은 리액트에서 호출의 권한을 맡기고 필요하다면 호출하도록 하는 방식입니다.
(2)의 방식은 개발자가 명시적으로 컴포넌트를 호출하는 방식입니다.

(2)를 쓰면 Page라는 컴포넌트를 사용하지 않는 경우에도 반드시 호출하게 됩니다.
그러면 불필요한 렌더링이 발생할 수 있는 가능성이 있죠.
따라서 관습에 맞게 컴포넌트를 사용할 때는 <Page />와 같은 방식으로 사용하는 것이 좋습니다.

5️⃣ 메모이제이션

overreacted - 메모이제이션을 참고했습니다.

React에서는 일반적으로 부모 컴포넌트가 리렌더링 되면 자식 컴포넌트도 리렌더링 됩니다.

불가피하게 부모 컴포넌트에서 자식에 영향을 미치지 않는 값이 변화해도 자식 컴포넌트가 리렌더링 되는 상황이 발생하게 됩니다.
이 상황을 해결하는 방법으로 메모이제이션을 사용합니다.

메모이제이션이란 이전에 계산했던 값은 캐싱했다가 다시 그대로 사용하는 것을 말합니다.
그렇다면 React에서 어떻게 사용해야 할까요?

1. 컴포넌트 메모이제이션

React.memo(component)를 사용하면 부모 컴포넌트에서 자식 컴포넌트에게 준 props가 바뀌는 경우에만 자식 컴포넌트가 리렌더링 됩니다.

2. 함수 메모이제이션

React.useCallback(func, deps)을 사용하면 두 번째 인자로 준 deps의 값이 변하는 경우에만 다시 함수를 생성합니다.

3. 변수 메모이제이션

React.useMemo(value, deps)를 사용하면 두 번째 인자로 준 deps의 값이 변하는 경우에만 다시 변수를 계산합니다.

컴포넌트 트리 위치에 지역 상태로 저장되고, 지역 상태가 파괴되는 경우 같이 삭제, 마지막으로 렌더링 된 항목만 저장합니다.

여기서 주의할 점은 메모이제이션을 무조건 하는 게 좋다가 아니라는 것입니다.
메모이제이션 자체가 비용이기 때문에 A 컴포넌트는 했을 경우 더 효율적이고 B 컴포넌트는 안 했을 경우 더 효율적으로 동작할 수 있기 때문에 개발자의 판단대로 적절하게 사용하는 것이 중요합니다.
( 아마도 컴포넌트의 경우 props가 변하는 상황이 일반적이기 때문에 메모이제이션을 적용할 수 있도록 React.memo()를 따로 만든 것 같습니다. )

6️⃣ 일괄 작업

overreacted - 일괄 작업을 참고했습니다.

만약 React에서 리렌더링을 해야한다면 즉시 실행할까요?
특정 statesetState()로 변경했다면 하던 작업을 멈추고 즉시 리렌더링을 할까요?
정답은 아니요입니다.

React를 쓰면서 아래와 같은 상황을 직면한 적이 있을겁니다.
분명히 setCount()로 값을 증가시켜줬는데 실제로 콘솔에 찍히는 값은 변화가 없습니다.
그리고 다시 눌러보면 그때 변화가 있는데 한타임씩 느린 결과를 보게 됩니다.

원인은 바로 setCount()가 비동기로 동작하고 그 이유는 일괄 작업을 위해서 입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Button = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);

    console.log(count); // 0
  }

  return (
    <button type="button" onClick={handleClick}>
      click me
    </button>
  );
};

( FIXME: 이벤트 버블링 포스트 작성 후 링크 추가하기 )

만약 아래의 코드에서 <Child />의 버튼을 누르는 경우 이벤트 버블링에 의해서 <Parent />onClick 이벤트도 동작합니다.
그렇다면 실행 순서가 <Child /> -> <Parent /> 순서인데 <Child />setCount()를 실행하고 즉시 리렌더링 되지는 않습니다.

React에서는 부모가 리렌더링되면 자식도 리렌더링 되는 것이 기본 동작입니다.( React.memo()를 사용한다면 props가 바뀌는 경우에 자식의 리렌더링 실행 )
그렇다면 <Parent />가 리렌더링되면 <Child />도 리렌더링된다는 의미입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const Parent = () => {
  const [count, setCount] = useState(0);

  return (
    <div onClick={() => setCount(count + 1)}>
      <Child />
    </div>
  );
};

const Child = () => {
  const [count, setCount] = useState(0);

  return (
    <button type="button" onClick={() => setCount(count + 1)}>
      click me
    </button>
  );
};

만약 setCount() 호출 즉시 리렌더링이 된다면

  1. <Child /> 리렌더링
  2. <Parent /> 리렌더링
  3. <Child /> 리렌더링

위처럼 비효율적인 리렌더링을 수행하게 될 것입니다.
따라서 React에서는 setState()에 의해 즉시 리렌더링을 하지 않고 모든 이벤트 핸들러를 실행시킨후에 일괄적으로 리렌더링을 수행합니다.

  1. <Parent /> 리렌더링
  2. <Child /> 리렌더링

결과적으로 위처럼 두 번만 리렌더링되게 됩니다.

📮 레퍼런스

  1. overreacted - UI 런타임으로서의 React
  2. React 공식 문서 - 재조정
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.