React) key에 대한 고찰 (feat. 소스코드 레벨에서 딥다이브)

2ast·2024년 6월 12일
3

고찰

목록 보기
1/1
post-thumbnail

Intro

"component의 key는 유니크해야한다"라거나 "순서가 바뀔 수 있다면 index를 key로 쓰는 것은 지양해야한다"와 같은 말은 react에 이제 막 입문한 사람이 아니라면 모두 한 번쯤은 들어봤을 법한 얘기다. 하지만 왜 그래야 하는지 알고 있는 사람은 생각보다 드물다. '그냥 그래야 한다고 하니까', 'react에서 경고를 띄워주니까', '실제로 해봤더니 문제가 있길래' 정도로 인지하고 있을 확률이 높다. 그도 그럴게, react 공식문서를 봐도 "key를 적절히 부여하지 않으면 성능상 문제가 발생하거나 state관련 문제를 겪게 될거야" 정도로 두루뭉실하게 언급하고만 있기도하고, 대개 그냥 시키는대로 유니크한 id를 key로 넘기기만 하면 문제가 발생하지 않기 때문에 딥다이브할 동기 자체가 많지 않은 주제이기도 하다.
.
.
.
그래서 내가 딥다이브해봤다. 아무리 검색을 해봐도 'key는 렌더링 전 후 컴포넌트를 찾는데 쓰인다' 정도로만 언급할 뿐 구체적으로 key가 왜 필요하고, 어디서 어떻게 쓰이는지를 설명하는 자료는 찾지 못했기 때문이다. 그래서 실제로 react 내부에서 children 렌더링은 어떻게 이루어지고, 여기서 key가 어떻게 쓰이는지는 react 소스코드를 직접 보면서 알아내야했다. 다만 처음부터 react 소스코드를 깡으로 보는 것은 꽤나 어려웠기 때문에 React 톺아보기 - 05. Reconciler의 도움을 많이 받았다. 블로그에서 react에서 재조정이 어떻게 동작하는지 구체적으로 설명해주셔서 react 코드를 파악하는데 아주 큰 도움이 되었다.

이번 글은 key와 관련된 아주 미스테리한 동작을 먼저 살펴보고, 왜 이렇게 동작하는지 알아내는 것을 목적으로 할 예정이다. 이렇게 목적이 있는 쪽이 더 재밌기도하고, 실제로 내가 react key 딥다이브를 마음먹은 배경이 된 현상이기 때문이다.

// 로직과 무관한 style prop은 제거
const KeySample = () => {
  const [items, setItems] = useState(["A", "B", "B"]);
  const deleteItem = (index) =>
    setItems((prev) => prev.filter((_, idx) => idx !== index));
  return (
    <div>
      <div>
        current items: {JSON.stringify(items)}
      </div>
      {items.map((item, index) => {
        return (
          <div
            key={item}
          >
            key: {item} / index: {index}
            <button
              onClick={() => deleteItem(index)}
            >
              삭제
            </button>
          </div>
        );
      })}
    </div>
  );
};

코드 자체는 특별한 내용 없이 아주 간단하다. ['A','B','B'] items가 있고, 각 item이 key로 부여되기 때문에 'B'가 중복 키로 렌더링된다. 이때 'A'를 key로 갖고 있는 0번 index 아이템이 제거되면 어떻게 될까? 당연히 items는 ['B','B']가 되므로 총 2개의 아이템이 렌더링 될 것을 예상하기 쉽다. 하지만 실제로는 ['B'.'B','B'] 가 렌더링 된다.

'A' 삭제 후 실제 data는 ['B','B']지만, 'A'가 사라진 자리에 'B' 아이템이 갑자기 생겨난다. 심지어 index는 왜 또 1인지 모르겠다. items가 빈 array가 되어도 사라지지 않는, 말 그대로 유령 컴포넌트가 생겨난 것이다. 이번 글에서는 react 소스코드 레벨에서 재조정 프로세스를 파악하고, 최종적으로는 이 현상의 원인을 밝혀내려고 한다.

얕은 다이빙

딥다이브 전에, 개략적인 수준에서 왜 react에서 key를 적절하게 설정해야하는지 짚고 넘어가겠다.

  • 왜 react에서는 key로 uuid나 Math.random() 같은 난수 기반 값을 직접 넘기지 말라고 하는걸까?
  • 왜 순서가 바뀔 가능성이 있을 때 index를 key로 쓰면 안되는 걸까?
  • 왜 key를 잘못 넘겼을 때 성능 이슈랑 state관련 이슈가 발생할 수 있다고 경고하는 걸까?

그 이유를 알려면 render와 mount에 대한 개념부터 잡고가야한다. react에서 컴포넌트가 처음 만들어지는 것을 mount라고 표현하고, 컴포넌트가 해석되어 화면에 보여지는 과정을 render라고 표현하면 얼추 의미는 통할 것이다. 여기서 이미 mount된 컴포넌트가 state 변경 등의 이유로 새로운 props를 받아 뷰가 최신화되는 과정을 re-render라고 표현한다. 그런데 과연 react는 렌더링 전후에 어떤 컴포넌트가 새롭게 추가됐고, 어떤 컴포넌트가 이미 마운트되어서 업데이트만 하면 되고, 어떤 컴포넌트가 삭제되었는지 어떻게 아는 걸까? 정답부터 말하자면 react는 type과 key로 같은 컴포넌트인지 판단하고 있다.

// 변경 전
<div>
  <div key={'1'}/>
  <div key={'2'}/>
  <div key={'3'}/>
</div>

// 변경 후_1
<div>
  <div key={'1'}/>
  <div key={'2'}/>
  <div key={'4'}/>
</div>

// 변경 후_2
<div>
  <div key={'1'}/>
  <div key={'2'}/>
  <span key={'3'}/>
</div>

위와 같이 '변경 전' 상태에서 '변경 후_1' 상태로 바뀌었다고 해보자. 모두 동일하지만 3번 째 div의 key가 달라졌다. react는 재조정 시점에 VDOM을 구성하면서 변경 전후 컴포넌트를 하나씩 비교한다. 이때 1,2번 째 item은 div라는 tag과 key가 모두 바뀌지 않았기 때문에 동일한 컴포넌트가 update만 발생했다고 판단하게 된다. 하지만 3번 째는 key가 달라졌으므로 다른 컴포넌트라고 판단하고, key '3'에 해당하는 컴포넌트를 unmount처리 후 key '4'에 해당하는 컴포넌트를 새롭게 mount한다. '변경 후_2'로 바뀌었을 때도 동일하다. 이때는 3번 째 아이템까지 key는 동일하지만 tag가 div에서 span으로 바뀌었다. 역시나 react는 별개의 item이라고 인식하고 기존 3번 째 아이템을 제거 후 <span key={'3'}/>을 mount한다. 즉 결과적으로 1,2 번째 아이템에서는 re-render, 3번 째 아이템에서는 re-mount가 발생했다고 해석할 수 있다.

이제 이 케이스를 확장해보자. 만약 index를 key로 썼다면 어땠을까?

// 변경 전
<div>
  <div key={0}>호랑이</div>
  <div key={1}>사자</div>
  <div key={2}>고양이</div>
</div>

// 뒤에 아이템 추가
<div>
  <div key={0}>호랑이</div>
  <div key={1}>사자</div>
  <div key={2}>고양이</div>
  <div key={3}>치타</div>
</div>

// 뒤에 아이템 제거
<div>
  <div key={0}>호랑이</div>
  <div key={1}>사자</div>
</div>

index를 key로 사용했더라도 순서는 유지한채로 list의 뒤쪽부터 새로운 item이 추가되었거나, 기존 아이템이 삭제되었다면 큰 문제는 없다. 왜냐하면 앞쪽 index부터 순차적으로 변경 전후를 비교할 때, 기존 아이템의 key가 변함이 없기 때문이다. react는 적절한 컴포넌트를 찾아 re-render를 수행하고, 치타가 추가되었다면 새롭게 mount, 고양이가 삭제되었다면 고양이 컴포넌트가 unmount 될 것이다.

문제는 list의 순서가 바뀌었을 때다.

// 변경 전
<div>
  <div key={0}>호랑이</div>
  <div key={1}>사자</div>
</div>

// 앞에 아이템 추가
<div>
  <div key={0}>치타</div>
  <div key={1}>호랑이</div>
  <div key={2}>사자</div>
</div>

이렇게 index를 key로 사용했을 때 앞쪽에 새로운 아이템이 추가되면 모든 list의 key가 바뀌게 된다. 이걸 react의 관점에서 해석해보면 다음과 같다.

key 0 에 해당하는 아이템에 children으로 넘겨지는 값이 "호랑이"에서 "치타"로 바뀌었군! 새로운 props를 넘겨서 re-render 해야겠다.
key 1 에 해당하는 아이템에 children으로 넘겨지는 값이 "사자"에서 "호랑이"로 바뀌었군! 새로운 props를 넘겨서 re-render 해야겠다.
key 2 에 해당하는 컴포넌트가 새롭게 추가되었군! "사자"를 children으로 갖는 div를 새롭게 mount해야겠어!

주목해야하는 점은, 리스트 앞쪽에 "치타"가 추가된게 아니라, 최초 "호랑이"를 넘겨받고 있던 컴포넌트가 "치타"로 update되었다는 점이다. 여기서 바로 state관련 이슈가 발생할 여지가 생긴다. react의 state와 같은 값들은 component instance에 귀속되어 있기 때문에, 리스트 맨 앞에 있는 "치타" 컴포넌트는 기존 "호랑이" 컴포넌트의 상태를 그대로 갖고 있게 되고, 반대로 2번 index의 "사자"는 새롭게 추가되었기 때문에 state 또한 새롭게 초기화된다. 이를 시각적으로 확인하기 위해 간단한 예시 코드로 실제 테스트해보겠다.

const features = {
  치타: "치토스",
  호랑이: "형님",
  사자: "킹",
};

const Animal = ({ animal }) => {
  const [feature] = useState(features[animal]);
  return (
    <div>
      {animal} {feature}
    </div>
  );
};

const KeyAnimal = () => {
  const [animals, setAnimals] = useState(["호랑이", "사자"]);

  return (
    <div>
      {animals.map((animal, index) => (
        <Animal key={index} animal={animal} />
      ))}
      <button onClick={() => setAnimals((prev) => ["치타", ...prev])}>
        치타 투입!
      </button>
    </div>
  );
};

동물이 '치타'라면 '치타 치토스', '호랑이'라면 '호랑이 형님', '사자'라면 '사자 킹'이 렌더링 되도록 짜여진 간단한 코드다. ['호랑이','사자']가 렌더링된 상태에서 0번 index에 '치타'를 추가하면 의도한대로 '치타 치토스'가 렌더링 될까?

우리의 기대와는 다르게 animal과 feature가 이상하게 조합되어 나온다. react가 최초 '호랑이'에 해당하는 0번 index 아이템과 새롭게 추가된 '치타' 0번 index 아이템을 매칭하면서 re-render가 발생했고, re-render 될 때 이전 state는 유지하므로 원래 0번 index feature였던 '형님'이 남아있게 되면서 발생한 현상이다. 결과적으로 새롭게 추가된 아이템은 0번 index 치타가 아니라, 2번 index 사자였다.(그래서 2번 index만 정확한 animal feature 조합이 가능했다)

같은 이치로 만약 Math.random()같은 난수를 key로 넘기면 어떻게 될까

const Animal = ({ animal }) => {
  const [random] = useState(Math.random);
  return (
    <div>
      {animal} {random}
    </div>
  );
};

const KeyAnimal = () => {
  const [animals] = useState(["치타", "호랑이", "사자"]);
  const [_, toggle] = useReducer((state) => !state, false);

  return (
    <div>
      {animals.map((animal) => (
        <Animal key={Math.random()} />
      ))}
      <button onClick={toggle}>re-render</button>
    </div>
  );
};


re-render를 유발했을 뿐인데 모든 컴포넌트에서 re-mount가 발생하면서 state가 소실되고 있다. 렌더링 전후로 key가 달라진 것을 react는 '기존 컴포넌트는 모두 제거되었고, 새로운 컴포넌트가 새롭게 추가되었구나'라고 해석한 결과다. 즉, 난수 기반 key는 성능상 이슈는 물론 각 컴포넌트의 state가 매번 초기화된다거나, useEffect가 매번 실행되는 등 state/effect 관련 이슈를 일으키기 때문에 주의해야 한다.

깊은 다이빙

지금까지 개념적인 수준에서 왜 유니크하면서 유지되는 값을 key로 넘겨야하는지에 대해서 알아봤다. 하지만 이정도로는 Intro에서 봤던 이상한 렌더링 이슈의 원인을 밝혀낼 수 없다. 심지어 위에서 살펴본 내용으로만 따지면 '왜 key가 유니크해야하는가'에 대한 답변도 조금은 애매해진다. 그래서 이번에는 react 소스코드를 직접 보면서 실제 children 재조정 단계에서 key가 어떻게 활용되는지 살펴보려고 한다.

react에서는reconcileChildrenArray라는 함수가 children rendering 재조정을 수행한다. 원본 코드의 경로는 여기에 있지만, 전체적인 코드 흐름을 파악하기 쉽도록 다루지 않을 코드와 주석을 지워서 가져와봤다.

function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<any>
): Fiber | null {
  let resultingFirstChild: Fiber | null = null;
  let previousNewFiber: Fiber | null = null;

  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  let nextOldFiber = null;
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);
    if (newFiber === null) {
      break;
    }

    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }

  if (newIdx === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx]);
      if (newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        // TODO: Move out of the loop. This only happens for the first run.
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    if (getIsHydrating()) {
      const numberOfForks = newIdx;
      pushTreeFork(returnFiber, numberOfForks);
    }
    return resultingFirstChild;
  }

  const existingChildren = mapRemainingChildren(oldFiber);

  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx]
    );
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        existingChildren.delete(
          newFiber.key === null ? newIdx : newFiber.key
        );
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  if (shouldTrackSideEffects) {
    existingChildren.forEach((child) => deleteChild(returnFiber, child));
  }

  return resultingFirstChild;
}

코드를 보다보면 fiber라는 변수가 자주 보인다. fiber는 react가 채택한 알고리즘을 지칭하고, 실제로 Fiber라는 객체를 이용해 DOM을 제어한다. 간단하게 react component tree에서 특정 node를 가리키는 값이라고 이해하면 쉽다. props와 state 등의 데이터 뿐만 아니라 렌더링 전후 대응되는 컴포넌트를 의미하는 alternate, 형제 fiber를 가리키는 sibling 등 다양한 값을 갖고 있기 때문에 fiber를 이용해 효과적으로 컴포넌트 트리를 구성하고, 제어할 수 있다.

전체 코드만 봐서는 무슨말을 하는지 전혀 알 수 없으니 커다란 작업단위별로 쪼개서 자세히 살펴보겠다.

Part.1 children의 순서가 바뀌지 않는 케이스

렌더링 전후로 children을 비교하여 적절하게 업데이트해주는 과정은 마냥 단순하지만은 않다. 정석적으로 이 문제를 해결하려면 렌더링 전후 각각의 array를 2중으로 반복하여 각각의 아이템을 하나씩 비교하고, key와 type이 일치하는 item을 매칭해야한다. 그래야만 어떤 child가 새롭게 추가되었고, 어떤 child가 업데이트 되었고, 어떤 child가 삭제되었는지 확인할 수 있기 때문이다.
하지만 react는 연산 과정을 줄이기 위해 재조정 프로세스를 2가지 케이스로 상정해 분리했다. 가장 이상적인 케이스는 렌더링 전후 children의 순서가 바뀌지 않았을 경우다. 이때는 key와 type이 일치하는 node를 굳이 찾아서 매칭할 필요 없이, index를 기준으로 newFiber와 oldFiber를 동시에 순회하면서 index에 해당하는 fiber를 비교해주기만 하면 단 한번의 반복문으로 재조정이 가능하기 때문이다.
실제로 아래 코드를 보면 reconcileChildrenArray내부에서 newIdx를 기준으로 실행되는 for 반복문을 볼 수 있다. 이 반복문이 바로 순서가 바뀌지 않는 케이스에서 재조정을 수행하는 코드다.

function reconcileChildrenArray(
  ...
){
  ...
  
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    // oldFiber를 업데이트한 newFiber를 반환.
    const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);
    // 만약 newChild에 매칭되는 oldFiber가 없다면 null을 반환하고 반복문 종료
    if (newFiber === null) {
      break;
    }
    
    // 순서가 바뀌지 않고 유지되었다면 fiber를 업데이트하고 반복문 진행
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;

  }
}

반복문 내부를 살펴보면 가장먼저 반복문을 종료하는 escape 조건이 명시되어 있다. updateSlot을 수행하고, 그 결과 반환된 newFiber가 null이라면 반복문을 빠져나오고 있는데, updateSlot은 newChild에 매칭되는 oldFiber를 찾아서 최신 데이터로 업데이트 된 newFiber를 반환해준다.

function updateSlot(returnFiber: Fiber, oldFiber: Fiber | null, newChild: any) {
  const key = oldFiber !== null ? oldFiber.key : null;
  ...
  if (typeof newChild === "object" && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE: {
        if (newChild.key === key) {
          const updated = updateElement(returnFiber, oldFiber, newChild);
          return updated;
        } else {
          return null;
        }
      }
      ...
    }
    ...
  }
  ...
}

당연하게도, newChild에 매칭되는 oldFiber가 없으면 null을 반환한다. 이때 newFiber가 null로 리턴되는 수 많은 조건 중의 하나가 바로 key가 일치하지 않는 케이스다. 즉, 렌더링 전후 children을 비교할 때 같은 index에 위치한 component의 key가 달라지면, react는 '순서가 변경되었다'고 판단해서 for문을 종료해버린다.

반복문 종료 이후에는 children 뒤쪽에 아이템을 추가해야하는지, 제거해야하는지 판단해야 한다. updateSlot이 null을 반환하지 않고 for문이 종료되는 조건은, newChildren을 모두 순회했거나, oldFiber를 모두 순회했을 경우다. 이때 newChildren에 남아있는 item이 있다면 남아있는 item을 일괄 추가해주어야하고, oldFiber가 남아있다면 해당 fiber는 제거대상이라는 의미이므로 일괄 제거해주어야한다.

// newChildren을 모두 순회했다면
if (newIdx === newChildren.length) {
    // 남아있는 oldFiber를 제거해준다.
    // deleteRemainingChildren는 oldFiber의 sibling을 순회하며 모두 제거한다.
    // sibling은 fiber의 다음 index fiber를 의미
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

// oldFiber를 모두 순회했다면
if (oldFiber === null) {
    // 남은 newChildren을 돌면서 newFiber를 추가해준다.
    for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(returnFiber, newChildren[newIdx]);       if (newFiber === null) {
     continue;
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
 
  return resultingFirstChild;
}

여기까지가 children의 순서가 바뀌지 않는 이상적인 케이스의 재조정 프로세스다. 여기서 재밌는 인사이트 하나를 얻을 수 있는데, 순서가 바뀌지만 않는다면 키가 중복되어도 전혀 문제 없다는 점이다. 즉, '순서가 바뀌지 않는다면 index를 key로 사용해도 무방하다' 보다는 '순서가 바뀌지 않는다면 키는 중복을 허용한다'가 더 맞는 표현이다.
'key를 고유한 값으로 사용해야한다'는 다른 말로 'key를 다른 아이템과 비교해야하는 상황이 생긴다'로 풀어쓸 수 있다. 하지만 react 소스코드를 보면 알 수 있듯이, 순서가 바뀌지 않는다면 child의 key를 다른 child의 key와 비교하는 로직은 어디에도 없다. 렌더링 전후 같은 index에 위치한 fiber와 key를 비교하는데 쓰일 뿐이다. 렌더링을 기점으로 key가 바뀌지만 않는다면, 형제 component와 key는 얼마든지 중복되어도 좋다. 실제로 순서가 바뀌지 않고 array의 뒤쪽에서 삭제, 추가가 발생하는 케이스 한정으로 children에 동일한 key를 주고 테스트를 해봐도 전혀 문제 없음을 알 수 있다.

Part.2 children의 순서가 바뀌는 케이스

이번에는 children의 순서가 바뀌었을 때 재조정 프로세스를 알아보겠다.

// 남아있는 oldFiber를 Map으로 만들어준다. 
const existingChildren = mapRemainingChildren(oldFiber);

// 중단되었던 newIdx부터 newChildren을 다시 반복한다.
for (; newIdx < newChildren.length; newIdx++) {
  // index에 해당하는 newChild와 매칭되는 oldFiber를 existingChildren에서 찾는다
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx]
  );
  // 매칭되는 fiber를 찾았다면
  if (newFiber !== null) {
    // re-render 상황이라면
    if (shouldTrackSideEffects) {
      // Map에서 매칭된 fiber를 제거해준다.
      existingChildren.delete(
        newFiber.key === null ? newIdx : newFiber.key
      );
    }
    // newFiber를 추가해준다.
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
  }
}

// re-render 상황이라면
if (shouldTrackSideEffects) {
  // 마지막까지 매칭되지 않고 남아있는 oldFiber를 제거해준다.
  existingChildren.forEach((child) => deleteChild(returnFiber, child));
}

가장 먼저 수행하는 작업은 남아있는 oldFiber를 Map으로 변환하는 것이다. 그래야 newChildren을 순회하면서 oldFiber에 매칭되는 아이템을 찾는 작업을 빠르게 수행할 수 있기 때문이다. 이때mapRemainingChildren을 자세히 볼 필요가 있는데, 여기서 key의 중요성이 드러난다.

  function mapRemainingChildren(
    currentFirstChild: Fiber,
  ): Map<string | number, Fiber> {
    
    // Map을 만든다.
    const existingChildren: Map<string | number, Fiber> = new Map();

    let existingChild: null | Fiber = currentFirstChild;
    while (existingChild !== null) {
      // child의 key가 있다면 existingChild.key를 key로 Map에 item set
      if (existingChild.key !== null) {
        existingChildren.set(existingChild.key, existingChild);
      } else {
        // child의 key가 없다면 index를 key로 Map에 item set
        existingChildren.set(existingChild.index, existingChild);
      }
      existingChild = existingChild.sibling;
    }
    return existingChildren;
  }

existingChild.key가 있다면 이 값을 key로, 없다면 index를 key로 Map에 item을 셋팅하고 있다. 여기서 두 가지 사실을 알 수 있다. react component에 key를 지정하지 않으면 내부적으로 index를 key로 사용하고 있다는 점과, key가 중복된다면 Map으로 변환하는 과정에서 fiber가 소실된다는 점이다.
만약 3개의 child 모두 'A'를 key로 갖고 있다고 해보자, 반복문이 도는 동안 'A'라는 동일한 키로 각각 다른 child를 Map에 등록하게 되기 때문에 결국 key 'A'에 해당하는 value는 마지막에 등록된 child만 남게 된다. 바로 이 지점으로부터 중복 key의 모든 문제가 발생한다.

이어서 다음 코드를 살펴보자

for (; newIdx < newChildren.length; newIdx++) {
  // index에 해당하는 newChild와 매칭되는 oldFiber를 existingChildren에서 찾는다
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx]
  );
  // 매칭되는 fiber를 찾았다면
  if (newFiber !== null) {
    // re-render 상황이라면
    if (shouldTrackSideEffects) {
      // Map에서 매칭된 fiber를 제거해준다.
      existingChildren.delete(
        newFiber.key === null ? newIdx : newFiber.key
      );
    }
    // newFiber를 추가해준다.
    ...
  }
}

updateFromMap은 Map으로 변환한 existingCildren으로부터 newChild와 매칭되는 fiber를 찾아 업데이트하고, 만약 매칭되는 fiber가 없다면 새롭게 만들어서 newFiber로 반환해준다. 내부 구현체는 아까 살펴봤던 updateSlot과 유사하기 때문에 스킵하겠다.
매칭되는 fiber를 찾았다면, existingChildren Map에서 매칭된 아이템을 제거한다. 이때 shouldTrackSideEffectstrue라는 조건이 붙는데, shouldTrackSideEffects는 지금 작업이 re-render에 해당하는지를 나타내는 필드라고 생각하면 쉽다. 만약 최초 마운트 로직이라면 어차피 existingChildren은 비어있을 것이다.

마지막으로 모든 반복문이 종료된 후 existingChildren에 남아있는 아이템들을 대상으로 deleteChild를 호출해준다. newChildren을 모두 순회하고도 남아있는 existingChildren은 새로운 트리에서 제거된 아이템이라는 뜻이기 때문이다.

if (shouldTrackSideEffects) {
  // 마지막까지 매칭되지 않고 남아있는 oldFiber를 제거해준다.
  existingChildren.forEach((child) => deleteChild(returnFiber, child));
}

그리고 deleteChild 내부를 살펴보면 재밌는 사실을 알 수 있다.

function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
  if (!shouldTrackSideEffects) {
    // Noop.
    return;
  }
  const deletions = returnFiber.deletions;
  if (deletions === null) {
    returnFiber.deletions = [childToDelete];
    returnFiber.flags |= ChildDeletion;
  } else {
    deletions.push(childToDelete);
  }
}

deleteChild는 그 자체로 fiber를 제거하는 역할을 수행하지 않는다. 다만 returnFiber에 child가 제거되어야한다는 ChildDeletion Flag를 부여하고, returnFiber.deletions에 fiber를 array로 명시할 뿐이다. react는 DOM을 최신화하는 프로세스를 명확하게 구분해두고 있는데, 지금 하고 있는 작업은 어디까지나 재조정 프로세스로, 현시점에서 DOM을 제어하지 않기 때문이다. 이렇게 returnFiber에 제거해야할 fiber는 알려주는 것으로 역할을 마치고 있다.

이제 원인을 밝혀볼 시간

지금까지 react에서 children 재조정을 어떻게 수행하고, 여기서 key가 어떻게 쓰이는지 알아봤다. 기억하고 있을지 모르겠지만 글 초입에서 중복 key를 사용하면 data에는 없지만 DOM에는 남아있는, 말 그대로 유령 노드가 생기는 미스테리한 현상을 공유했었다. 이제 이 현상의 원인을 밝힐 준비가 끝났다.

순서대로 논리의 흐름을 따라가보자. 렌더링 전후 oldFiber와 newFiber를 이렇게 시각화할 수 있을 것 같다.

// oldFiber
[
  {key:'A', index:0},
  {key:'B', index:1},
  {key:'B', index:2}
]

// newFiber
[
  {key:'B', index:0},
  {key:'B', index:1}
]

가장먼저 index를 기준으로 oldFiber와 newFiber를 동시에 순회할 것이다.

// oldFiber의 0번 째 아이템
{key:'A', index:0},
// newFiber의 0번 째 아이템
{key:'B', index:0},

newFiber와 oldFiber의 0번 째 아이템은 key가 다르기 때문에, react는 순서가 바뀌었다고 판단해서 'children의 순서가 바뀌었을 때' 로직으로 넘어간다. 이때 가장 먼저 oldFiber를 Map으로 만든다.

// existingChildren
[
  {key:'A', index:0},
  {key:'B', index:2}
]

Map으로 변환하는 과정에서 1번 index와 2번 index key가 중복되므로, 1번 index는 소실되고, 2번 index만이 남아서 결과적으로 ['A','B']로 구성된 Map이 만들어 진다.

이제 newChildren을 기준으로 for문을 수행하면서, existingChildren에서 매칭되는 fiber를 찾는다.

// newChildren 0번 째 아이템
{key:'B', index:0} 
// newChildren 0번 째 아이템과 key가 일치하는 existingChild
{key:'B', index:2}

0번째 인덱스에서 매칭되는 아이템을 찾았으므로, fiber를 업데이트하고, existingChildren에서 {key:'B', index:2}를 제거한다.
이어서 반복문을 수행한다.

// newChildren 1번 째 아이템
{key:'B', index:1} 
// newChildren 0번 째 아이템과 key가 일치하는 existingChild는 없다.

이제 existingChildren에 'B'를 key로 갖는 fiber는 없기 때문에 react는 {key:'B', index:1}가 새롭게 추가된 child라고 판단해서 mount하게 된다.

이제 반복문이 모두 끝났으므로, 남아있는 existingChildren을 대상으로 deleteChild를 수행한다.

// 현재 남아있는 existingChild
[{key:'A', index:0}]

결과적으로 key 'A'만이 남아있기 때문에 'A'는 제거 대상이라고 판단하여 returnFiber에 'A'가 deletions로 부여된다.

바로 이 지점에서 문제가 생긴다. 아까 Map으로 변환할 때 소실되었던 {key:'B', index:1}는 existingChildren에 존재하지 않기때문에, deltions에 포함되지 않는다. react는 이후 DOM 조정 단계에서 deletions를 기반으로 제거될 child를 판단하기 때문에, deletions에 없는 {key:'B', index:1}는 DOM에 잔류하게 되는 것이다.

// 화면에 렌더링되는 3개의 'B'
{key:'B', index:1} // 제거되지 못하고 남아있는 유령 노드
{key:'B', index:0} // 첫 번째로 매칭된 'B'. oldFiber에서 index 2였음
{key:'B', index:1} // 새롭게 마운트 된 'B'

마치며

react에서 children 재조정을 어떻게 수행하는지 소스코드 레벨에서 살펴보니 원인을 알 수 없었던 미스테리한 현상을 밝혀낼 수 있었다. 동일 선상에서 로직의 흐름을 따라간다면 이렇게 새로운 아이템이 추가되는 케이스에서 발생하는 현상도 원인을 추적할 수 있다.

사실 react key라는게 시키는대로 unique한 id로 잘 넣어주기만하면 큰 문제가 발생할 여지가 없는 개념이다보니 이렇게 딥다이브하는게 얼마나 유익한 정보인지는 잘 모르겠다. 하지만 나름 react로 개발해오면서 key를 다룰때마다 조금씩 애매한 지점이 생겨서 좀 찝찝했는데, 이렇게 해소할 수 있어서 개인적으로는 굉장히 만족스럽다.

profile
React-Native 개발블로그

2개의 댓글

comment-user-thumbnail
2024년 7월 28일

우연히 보게 되었는데 좋은 글들이 너무 많아서 글을 정주행 하고 있습니다

많은 인사이트들 얻어갑니다 감사합니다

1개의 답글