React 재조정: 컴포넌트 뒤에 숨겨진 엔진

post-thumbnail

개인 공부용으로 정리한 글입니다.
참고한 글 :
https://cekrem.github.io/posts/react-reconciliation-deep-dive/
https://roy-jung.github.io/250414-react-reconciliation-deep-dive/

컴포넌트 정체성(Identity)과 상태 유지(State Persistence)

리액트는 요소의 타입(type)트리 내 위치(position)를 기준으로 컴포넌트의 정체성을 판단한다

타입과 위치가 같으면 상태를 유지하고, 타입이 다르면 기존 요소를 언마운트하고 새로 마운트한다.

다음 예제를 살펴본다.


const UserInfoForm = () => {
  const [isEditing, setIsEditing] = useState(false);

  return (
    <div>
      <button onClick={() => setIsEditing(!isEditing)}>
        {isEditing ? "Cancel" : "Edit"}
      </button>
      {isEditing ? (
        <input type="text" placeholder="Enter your name" />
      ) : (
        <input type="text" placeholder="Enter your name" disabled />
      )}
    </div>
  );
};

Edit 버튼을 누르고 입력한 텍스트는 Cancel 후 다시 Edit해도 사라지지 않는다.

이는 리액트가 input 타입이 같고 위치가 같음을 인식하여 DOM을 재사용하기 때문이다.

타입을 바꿔서 다음처럼 작성하면 다르게 동작한다

{isEditing ? (
  <input type="text" placeholder="Enter your name" />
) : (
  <div>Name will appear here</div>
)}

이 경우, inputdiv는 타입이 다르므로 리액트는 기존 노드를 제거하고 새로 생성한다. 따라서 입력한 값은 사라진다.


가상 DOM이 아닌 "요소(Element) 트리"

리액트는 "가상 DOM"이라고 부르기보다는 "요소 트리"를 관리한다.

JSX는 다음과 같은 객체 트리로 변환된다.

{
  type: 'div',
  props: {
    children: [
      { type: 'h1', props: { children: 'Hello' } },
      { type: 'p', props: { children: 'World' } }
    ]
  }
}
  • 기본 태그(div, input)는 문자열 타입
  • 커스텀 컴포넌트(Input)는 함수 참조 타입
    {
      type: Input, // Input 함수 자체에 대한 참조
      props: {
        id: "company-tax-id",
        placeholder: "Enter company Tax ID"
      }
    }

리액트 조정(Reconciliation) 과정

  1. 컴포넌트를 호출하여 새 요소 트리를 만든다.
  2. 이전 트리와 비교한다.
  3. 필요한 DOM 변경 작업을 결정한다.
  4. DOM을 업데이트한다.

비교 시 따르는 주요 규칙은 다음과 같다

1. 요소 타입이 정체성을 결정

타입이 바뀌면 하위 트리를 통째로 다시 만든다.

// 첫 번째 렌더링
<div>
  <Counter />
</div>

// 두 번째 렌더링
<span>
  <Counter />
</span>

divspan으로 바뀌었으므로 전체 하위 트리가 다시 생성된다.

2. 트리 내 위치가 중요

같은 위치에 다른 타입의 컴포넌트가 오면 기존 컴포넌트를 언마운트하고 새 컴포넌트를 마운트한다.

<>
  {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

showDetailstruefalse로 바뀌면, 위치는 같지만 타입이 다르므로 UserProfile은 언마운트되고 LoginPrompt가 마운트된다.

같은 타입이라면 상태를 유지한다.

<>
  {isPrimary ? (
    <UserProfile userId={123} role="primary" />
  ) : (
    <UserProfile userId={456} role="secondary" />
  )}
</>

isPrimary 값에 관계없이 타입(UserProfile)이 같기 때문에 상태가 유지된다.

3. key는 위치 비교보다 우선

key를 사용하면 위치가 바뀌더라도 컴포넌트 정체성을 유지할 수 있다.

const TabContent = ({ activeTab, tabs }) => {
  return (
    <div>
      {tabs.map((tab) => (
        <div key={tab.id}>
          {activeTab === tab.id ? (
            <UserProfile key="active-profile" userId={tab.userId} role={tab.role} />
          ) : (
            <div key="placeholder">Select this tab to view {tab.userId}'s profile</div>
          )}
        </div>
      ))}
    </div>
  );
};

active-profile이라는 key를 부여했기 때문에 어떤 탭이 활성화되더라도 UserProfile의 상태를 유지할 수 있다.

렌더링 구조 예시는 다음과 같다.

tsx
복사편집
// activeTab === "1"
<div>
  <div key="1">
    <UserProfile key="active-profile" userId="a" role="aa" />
  </div>
  <div key="2">
    <div key="placeholder">Select this tab to view b's profile</div>
  </div>
</div>

// activeTab === "2"
<div>
  <div key="1">
    <div key="placeholder">Select this tab to view a's profile</div>
  </div>
  <div key="2">
    <UserProfile key="active-profile" userId="b" role="bb" />
  </div>
</div>

UserProfile 컴포넌트의 타입과 key가 같기 때문에 리액트는 상태를 유지한다.


key의 확장적인 활용

1. 리스트가 아닌 경우에도 key 활용 가능

const Component = () => {
  const [isReverse, setIsReverse] = useState(false);

  return (
    <>
      <Input key={isReverse ? "some-key" : null} />
      <Input key={!isReverse ? "some-key" : null} />
    </>
  );
};

isReverse가 변경되면 some-key를 가진 인풋의 위치가 이동하면서 상태도 함께 이동한다.

💡

*isReverse = true*일 때 6번 줄의 *Input*과, *isReverse = false*일 때 7번 줄의 *Input*은 모두 동일한 key(*'some-key'*)를 가지므로, 리액트는 이를 동일한 컴포넌트가 6번 줄에서 7번 줄로 이동한 것으로 파악합니다.

2. 동적 리스트와 정적 요소 혼합

<>
  {items.map((item) => (
    <ListItem key={item.id} />
  ))}
  <StaticElement />
</>

동적 리스트와 정적 요소가 함께 있을 때, 리액트는 동적 리스트를 하나의 덩어리로 처리한다. StaticElement는 항상 고정된 위치를 유지하므로 리스트가 변경되어도 재마운트되지 않는다.


상태의 지역화(State Colocation)

상태의 지역화는 상태를 사용하는 곳에 최대한 가깝게 유지하는 패턴. 상태를 사용하는 컴포넌트 내부로 옮기면 불필요한 렌더링을 줄일 수 있다.

성능이 낮은 구조

const App = () => {
  const [filterText, setFilterText] = useState("");
  const filteredUsers = users.filter((user) => user.name.includes(filterText));

  return (
    <>
	    <SearchBox filterText={filterText} onChange={setFilterText} />
      <UserList users={filteredUsers} />
      <ExpensiveComponent />
    </>
  );
};

filterText 변경 시 ExpensiveComponent까지 재렌더링된다.

성능이 좋은 구조

const UserSection = () => {
  const [filterText, setFilterText] = useState("");
  const filteredUsers = users.filter((user) => user.name.includes(filterText));

  return (
    <><SearchBox filterText={filterText} onChange={setFilterText} />
      <UserList users={filteredUsers} />
    </>
  );
};

const App = () => (
  <><UserSection />
    <ExpensiveComponent />
  </>
);

UserSection만 재렌더링되어 성능이 향상된다.


컴포넌트 설계와 성능 최적화

React.memo 사용 이전에 고려해야 할 점:

  • 하나의 컴포넌트가 여러 책임을 지고 있지 않은지
  • 상태가 트리 너무 위에 있지 않은지

문제 있는 설계 예시

const ProductPage = ({ productId }) => {
  const [selectedSize, setSelectedSize] = useState("medium");
  const [quantity, setQuantity] = useState(1);
  const [shipping, setShipping] = useState("express");
  const [reviews, setReviews] = useState([]);

  useEffect(() => {
    fetchProductDetails(productId);
    fetchReviews(productId).then(setReviews);
  }, [productId]);

  return (
    <div>
      <ProductInfo selectedSize={selectedSize} onSizeChange={setSelectedSize} quantity={quantity} onQuantityChange={setQuantity} />
      <ShippingOptions shipping={shipping} onShippingChange={setShipping} />
      <Reviews reviews={reviews} />
    </div>
  );
};

모든 상태가 하나의 컴포넌트에 집중되어 있어, 불필요한 렌더링이 발생한다.

개선된 설계 예시

const ProductPage = ({ productId }) => {
  return (
    <div>
      <ProductConfig productId={productId} />
      <ReviewsSection productId={productId} />
    </div>
  );
};

const ProductConfig = ({ productId }) => {
  const [selectedSize, setSelectedSize] = useState("medium");
  const [quantity, setQuantity] = useState(1);
  const [shipping, setShipping] = useState("express");

  return (
    <>
	    <ProductInfo selectedSize={selectedSize} onSizeChange={setSelectedSize} quantity={quantity} onQuantityChange={setQuantity} />
      <ShippingOptions shipping={shipping} onShippingChange={setShipping} />
    </>
  );
};

const ReviewsSection = ({ productId }) => {
  const [reviews, setReviews] = useState([]);

  useEffect(() => {
    fetchReviews(productId).then(setReviews);
  }, [productId]);

  return <Reviews reviews={reviews} />;
};

컴포넌트 경계를 나누어 필요한 부분만 재렌더링되도록 최적화한다.

조정(Reconciliation)과 클린 아키텍처

리액트의 조정 알고리즘은 클린 아키텍처 원칙과 완벽하게 일치합니다.

  1. 단일 책임 원칙(Single Responsibility Principle)각 컴포넌트는 변경 이유가 하나여야 합니다. 컴포넌트가 단일 책임에 집중하면 불필요한 재렌더링이 줄어듭니다.
  2. 의존성 역전 원칙(Dependency Inversion)컴포넌트는 구체적인 구현이 아닌 추상화에 의존해야 합니다. 이를 통해 컴포지션을 통해 성능을 최적화하기가 더 쉬워집니다.
  3. 인터페이스 분리 원칙(Interface Segregation)컴포넌트는 최소한의 집중된 인터페이스를 가져야 합니다. 이는 props 변경으로 인해 불필요한 재렌더링이 발생할 가능성을 줄여줍니다.

실용적인 가이드라인

조정에 대한 심층 분석을 바탕으로 다음과 같은 실용적인 가이드라인을 제안합니다.

  1. 컴포넌트 정의를 부모 컴포넌트 외부로 이동하여 재마운트를 방지하세요.
  2. 상태를 하위로 이동하여 재렌더링 경계를 분리하세요.
  3. 동일한 위치에서 일관된 컴포넌트 타입을 유지하여 언마운트를 방지하세요.
  4. key를 전략적으로 사용하세요. 리스트뿐만 아니라 컴포넌트 정체성을 제어하고 싶을 때도 유용합니다.
  5. 재렌더링 문제를 디버깅할 때, 요소 트리와 컴포넌트 정체성을 기준으로 생각하세요.
  6. React.memo는 단지 도구일 뿐입니다. 조정 알고리즘의 제약 내에서 작동하며, 근본적인 알고리즘을 변경하지는 않습니다.

요약

  • 리액트는 요소의 타입과 위치를 기준으로 컴포넌트를 식별한다.
  • key를 사용하면 위치에 상관없이 컴포넌트 정체성을 제어할 수 있다.
  • 상태는 최대한 사용하는 컴포넌트에 가까운 곳에 위치시킨다.
  • 컴포넌트를 책임 단위로 분리하여 재렌더링 범위를 최소화한다.
  • React.memo는 구조 최적화 후 필요한 경우에만 사용한다.

0개의 댓글