React Fiber 아키텍처와 Reconciliation 심화 이해

들어가며

안녕하세요, 여러분. 오늘은 React의 핵심 엔진인 Fiber 아키텍처에 대해 깊이 있게 살펴보려고 합니다. 특히 Fiber가 어떻게 Reconciliation을 수행하는지, 그리고 이를 이해하는 것이 왜 성능 최적화에 중요한지 함께 알아보겠습니다.

Fiber 아키텍처의 핵심: Reconciliation Engine

Fiber는 React 16에서 도입된 새로운 reconciliation 엔진입니다. 쉽게 말해, 우리가 작성한 컴포넌트 트리를 실제 DOM에 효율적으로 반영하는 역할을 담당합니다.

🎯 Reconciliation이란?

브라우저에 현재 렌더링된 DOM 트리와 Virtual DOM을 나란히 놓은 그림. 오른쪽 Virtual DOM은 Root Fiber 아래로 Current Tree와 Work in Progress Tree 두 갈래가 뻗어 있고, Work in Progress 쪽 노드 세 개만 붉게 칠해져 이번에 바뀐 부분을 나타낸다

Reconciliation은 React가 DOM을 업데이트하는 과정입니다. 우리가 선언적으로 “이렇게 보여줘”라고 작성하면, React가 “어떻게 만들지” 알아서 처리해주는 마법 같은 과정이죠.

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

먼저 흥미로운 예제를 하나 보겠습니다:

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

  return (
    <div className="form-container">
      <button onClick={() => setIsEditing(!isEditing)}>
        {isEditing ? "취소" : "편집"}
      </button>

      {isEditing ? (
        <input
          type="text"
          placeholder="이름을 입력하세요"
          className="edit-input"
        />
      ) : (
        <input
          type="text"
          placeholder="이름을 입력하세요"
          disabled
          className="view-input"
        />
      )}
    </div>
  );
};

💡 놀라운 발견

편집 모드에서 무언가를 입력하고 “취소” 버튼을 눌렀다가 다시 “편집”을 누르면… 입력했던 텍스트가 그대로 남아있습니다!

왜 그럴까요? 두 input 엘리먼트가 다른 props를 가지고 있는데도 말이죠.

핵심 원리: React는 같은 타입의 엘리먼트가 같은 위치에 있으면, DOM 엘리먼트를 재사용하고 props만 업데이트합니다.

하지만 만약 이렇게 바꾸면 어떻게 될까요?

javascript
{isEditing ? (
  <input type="text" placeholder="이름을 입력하세요" />
) : (
  <div className="view-only-display">여기에 이름이 표시됩니다</div>
)}

이제는 토글할 때마다 입력값이 사라집니다. 엘리먼트 타입이 다르기 때문이죠.

컴포넌트 정체성과 상태 유지를 두 열로 비교한 그림. 왼쪽은 같은 타입·같은 위치라 input이 input으로 바뀌어도 DOM을 재사용하고 props만 업데이트해 상태가 유지되고, 오른쪽은 input이 div로 바뀌어 전체 서브트리가 재생성되고 상태가 손실된다

Element Tree: Virtual DOM을 넘어서

많은 분들이 “React는 Virtual DOM을 사용한다”고 알고 계실 텐데, 더 정확히는 Element Tree라는 표현이 맞습니다.

🌳 JSX에서 Element Tree로

JSX 코드가 Element Tree 객체로 바뀌는 과정을 좌우로 놓은 그림. 왼쪽의 div·h1·p로 이루어진 JSX가 오른쪽에서는 type과 props.children이 중첩된 자바스크립트 객체 트리로 표현된다

📌 중요한 차이점

  • DOM 엘리먼트: type이 문자열 ('div', 'input')
  • React 컴포넌트: type이 함수 참조 (실제 컴포넌트 함수)
javascript
// 커스텀 컴포넌트의 경우
{
  type: Input,  // Input 함수 자체가 참조됨
  props: {
    id: "company-tax-id",
    placeholder: "사업자 등록번호 입력"
  }
}

Fiber의 Reconciliation 작동 원리

Fiber가 UI를 업데이트할 때 거치는 단계:

Fiber Reconciliation의 작동 원리를 네 단계로 늘어놓은 그림. 새 Element Tree 생성, 이전 Tree와 비교, DOM 작업 계산, 효율적으로 적용이 번호가 붙은 원과 화살표로 차례차례 이어진다

  1. 새로운 Element Tree 생성 - 컴포넌트 함수들을 호출
  2. 이전 Tree와 비교 - 효율적인 diff 알고리즘 적용
  3. 필요한 DOM 작업 계산 - 최소한의 변경사항 도출
  4. 효율적으로 적용 - 배치 처리 및 우선순위 고려

1️⃣ 엘리먼트 타입이 정체성을 결정한다

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

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

divspan으로 바뀌었으므로, React는 전체 서브트리를 다시 만듭니다. Counter의 상태도 사라집니다!

2️⃣ 트리에서의 위치가 중요하다

트리에서의 위치가 정체성을 결정하는 것을 보여주는 그림. showDetails가 true일 때 위치 1에 있던 UserProfile은 false가 되면 LoginPrompt로 바뀌어 언마운트·마운트되고, 위치 2의 Footer는 동일한 컴포넌트라 그대로 유지된다

javascript
// showDetails가 true일 때
<>
  {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

// showDetails가 false일 때
<>
  {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

Fragment의 첫 번째 자식 위치를 하나의 “슬롯”으로 봅니다. showDetails가 바뀌면 같은 위치에 다른 컴포넌트 타입이 오므로, 이전 컴포넌트는 언마운트되고 새 컴포넌트가 마운트됩니다.

하지만 이런 경우는 어떨까요?

javascript
// isPrimary에 관계없이 UserProfile은 유지됨
<>
  {isPrimary ? (
    <UserProfile userId={123} role="primary" />
  ) : (
    <UserProfile userId={456} role="secondary" />
  )}
</>

같은 위치에 같은 타입(UserProfile)이므로 컴포넌트 인스턴스는 유지되고 props만 업데이트됩니다.

3️⃣ Key로 위치 기반 비교를 오버라이드

key 속성은 개발자가 컴포넌트 정체성을 명시적으로 제어할 수 있게 해줍니다:

Key로 위치 기반 비교를 오버라이드하는 것을 좌우로 비교한 그림. Key가 없으면 순서를 바꿨을 때 B 컴포넌트가 위치 1의 A로, A 컴포넌트가 위치 2의 B로 잘못 매칭되고, key를 붙이면 순서가 바뀌어도 각자 올바르게 매칭된다

javascript
const TabContent = ({ activeTab, tabs }) => {
  return (
    <div className="tab-container">
      {tabs.map((tab) => (
        <div key={tab.id} className="tab-content">
          {activeTab === tab.id ? (
            <UserProfile
              key="active-profile"  // 고정된 key 사용
              userId={tab.userId}
              role={tab.role}
            />
          ) : (
            <div key="placeholder" className="placeholder">
              {tab.userId}의 프로필을 보려면 탭을 선택하세요
            </div>
          )}
        </div>
      ))}
    </div>
  );
};

“active-profile”이라는 key를 사용함으로써, 다른 탭으로 전환해도 UserProfile 컴포넌트의 상태가 유지됩니다.

Key의 숨겨진 힘

🔑 리스트뿐만이 아니다

Key는 리스트에서만 사용하는 것이 아닙니다. 전략적인 DOM 제어에도 활용할 수 있습니다:

javascript
const UserForm = ({ userId }) => {
  // Uncontrolled 컴포넌트 사용
  return (
    <form>
      <input
        key={userId}  // userId가 바뀌면 DOM 엘리먼트 재생성
        name="username"
        defaultValue=""  // value 대신 defaultValue 사용
      />
    </form>
  );
};

userId가 바뀔 때마다 React는 완전히 새로운 DOM 엘리먼트를 생성합니다. Uncontrolled input의 상태는 DOM 자체에 있으므로, 이렇게 하면 사용자를 전환할 때 입력값이 자동으로 초기화됩니다.

🎯 동적 리스트와 정적 엘리먼트의 조합

javascript
<>
  {items.map((item) => (
    <ListItem key={item.id} />
  ))}
  <StaticElement />  {/* items가 변해도 재마운트되지 않음 */}
</>

React는 내부적으로 이렇게 표현합니다:

javascript
[
  // 전체 동적 배열이 하나의 자식으로 취급됨
  [
    { type: ListItem, key: "1" },
    { type: ListItem, key: "2" },
  ],
  { type: StaticElement },  // 항상 두 번째 위치 유지
]

리스트가 변경되어도 StaticElement는 항상 위치 2를 유지하므로 재마운트되지 않습니다.

성능 패턴: State Colocation

State Colocation은 상태를 사용하는 곳과 최대한 가깝게 두는 패턴입니다.

State Colocation을 좌우로 비교한 그림. 나쁜 예는 App이 filterText 상태를 들고 있어 필터가 바뀌면 SearchBox·UserList·ExpensiveComponent가 모두 리렌더링되고, 좋은 예는 상태를 UserSection으로 내려 ExpensiveComponent가 리렌더링에서 빠진다

❌ 나쁜 예: 전체 앱이 리렌더링

javascript
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 />  {/* 필터와 무관한데 리렌더링됨 */}
    </>
  );
};

✅ 좋은 예: 관련 컴포넌트만 리렌더링

javascript
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 = () => {
  return (
    <>
      <UserSection />
      <ExpensiveComponent />  {/* 필터 변경 시 리렌더링 안 됨 */}
    </>
  );
};

컴포넌트 설계: 변화에 최적화하기

🏗️ 단일 책임 원칙 적용

컴포넌트 설계의 단일 책임 원칙을 좌우로 비교한 그림. 나쁜 예의 ProductPage는 사이즈·수량·배송·리뷰 상태를 혼자 들고 있어 사이즈를 바꾸면 전체가 리렌더링되고, 좋은 예는 ProductConfig와 ReviewsSection으로 갈라 사이즈 변경이 ProductConfig에만 미친다

javascript
// ❌ 너무 많은 책임을 가진 컴포넌트
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 /* ... */ />
      <ShippingOptions /* ... */ />
      <Reviews reviews={reviews} />
    </div>
  );
};

✅ 관심사별로 분리된 설계

javascript
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 (/* ... */);
};

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

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

  // 리뷰 관련 로직만
  return <Reviews reviews={reviews} />;
};

실무 가이드라인

📋 Fiber와 함께 작업하기 위한 체크리스트

  1. 컴포넌트 정의는 부모 컴포넌트 밖에 - 리마운트 방지
  2. 상태는 아래로 내리기 - 리렌더 경계 격리
  3. 같은 위치의 컴포넌트 타입 일관성 유지 - 불필요한 언마운트 방지
  4. Key 전략적 활용 - 리스트뿐만 아니라 정체성 제어에도
  5. 리렌더 이슈 디버깅 시 Element Tree로 생각하기
  6. React.memo는 도구일 뿐 - 근본적인 알고리즘은 바뀌지 않음

마무리

Fiber 아키텍처의 Reconciliation을 이해하면, React 성능 패턴의 “왜”를 알 수 있습니다. Composition이 왜 효과적인지, Key가 왜 필요한지, 컴포넌트 안에 컴포넌트를 정의하면 왜 문제가 되는지 모두 설명됩니다.

이 지식으로 우리는 과도한 메모이제이션으로 Reconciliation 알고리즘과 싸우는 대신, React가 컴포넌트를 식별하고 업데이트하는 방식에 맞춰 자연스럽게 성능이 좋은 애플리케이션을 만들 수 있습니다.

여러분은 어떤 패턴을 사용하고 계신가요? 함께 논의해보면 좋겠습니다. 감사합니다! 🚀