한우의 개발일기

리액트 key알아보기 본문

카테고리 없음

리액트 key알아보기

한우코딩 2025. 3. 26. 22:45

React key 속성의 모든 것: 내부 작동 원리와 최적화 방법

React를 사용하다 보면 자주 마주치는 경고 중 하나가 있습니다:

Warning: Each child in a list should have a unique "key" prop.

리스트를 렌더링할 때 각 항목에 key를 추가하지 않으면 발생하는 이 경고는 왜 생기는 걸까요? React 공식 문서에서는 개념적인 설명을 제공하지만, 이번에는 실제로 key가 React 내부에서 어떻게 작동하는지 깊이 들여다보겠습니다.

내부 동작의 핵심: reconcileChildrenArray()

React는 컴포넌트를 업데이트할 때 '재조정(reconciliation)' 과정을 거칩니다. 리스트의 경우 이 과정은 reconcileChildrenArray() 함수에서 처리됩니다. React 소스 코드를 살펴보면, 이 함수의 주석에 흥미로운 내용이 있습니다:

// 우리는 파이버에 역참조가 없기 때문에 양쪽 끝에서 검색하는 최적화를 할 수 없습니다.
// 그 모델로 어디까지 갈 수 있는지 보려고 합니다. 만약 트레이드오프가 가치가 없다면,
// 나중에 추가할 수 있습니다.
// ...
// 이 첫 번째 반복에서는 모든 삽입/이동에 대해 나쁜 경우(모든 것을 Map에 추가)를 감수할 것입니다.

이 주석에서 알 수 있듯, React는 파이버(Fiber)에 역참조가 없기 때문에 양쪽 끝 최적화를 하지 않는 타협을 하고 있습니다. 자식 요소들은 배열이 아닌 'sibling'으로 연결된 파이버의 연결 리스트로 관리됩니다.

리스트에서 일어날 수 있는 변화들

리스트에서는 다양한 변화가 일어날 수 있습니다. 예를 들어, 다음과 같은 배열이 있다고 가정해봅시다:

const arrPrev = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

이 배열이 변경된 후, 인덱스 i의 요소가 다르다고 판단되면 여러 가지 수정 가능성이 있을 수 있습니다:

  1. 인덱스 i에 새 요소가 삽입됨
  2. 인덱스 i의 기존 요소가 새 요소로 대체됨
  3. 다른 인덱스에서 기존 요소가 i로 이동함
  4. 다른 인덱스에서 기존 요소가 이동해서 i의 기존 요소를 대체함
  5. i의 기존 요소가 제거되고, 다음 요소가 그 위치를 차지함

추가 분석 없이는 어떤 경우인지 파악하기 어렵습니다.

이제 다음과 같은 새 배열로 변경되었다고 가정해봅시다:

const arrNext = [11, 12, 9, 4, 7, 16, 1, 2, 3];

최소 비용으로 어떻게 변환해야 할까요? 최소 이동을 고려한다면 레벤슈타인 거리(Levenshtein distance)와 유사하지만, 변환 과정까지 포함하면 다음과 같은 비용이 발생합니다:

총 비용 = 분석(reconcile) 비용 + 변환(commit changes) 비용

극단적인 예: 배열 뒤집기

극단적인 예를 들어, 배열을 뒤집으면 어떻게 될까요?

const arrPrev = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];

각 위치가 다르기 때문에, 분석 단계에서 최적의 이동을 찾는 데 많은 시간이 소요될 수 있으며, 이 경우에는 모든 요소를 대체하는 것이 더 효율적일 수 있습니다.

여기서 양쪽 끝 최적화(two-ended optimization)의 필요성이 드러납니다. 다음 예시를 살펴봅시다:

const arrPrev = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const arrNext = [11, 12, 7, 8, 9, 10];

앞에서부터 비교하면 변화를 파악하기 어렵지만, 뒤에서부터 비교하면:

const arrPrev = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
const arrNext = [10, 9, 8, 7, 12, 11];

이제 패턴이 훨씬 명확해집니다. 뒤의 요소들은 변경되지 않았고, 앞의 요소들만 변경되었습니다.

React의 재조정 알고리즘

React의 재조정 알고리즘은 다음 단계로 진행됩니다:

  1. 첫 번째 시도: 낙관적 비교

    • 같은 인덱스의 요소를 비교하며 키가 같은지 확인 (headOld, headNew)
    • 키가 같으면 해당 요소를 재사용하고 넘어감
    • 키가 다르면 첫 번째 불일치 지점에서 멈춤
  2. 두 번째 시도: 맵 기반 비교

    • 불일치 지점 이후의 모든 이전 요소를 키 기반 맵으로 변환
    • 새 리스트의 각 요소에 대해:
      • 맵에서 같은 키를 가진 요소를 찾음
      • 발견되면 재사용, 아니면 새로 생성
    • 맵에 남은 요소는 모두 삭제

실제 소스 코드를 살펴보면 다음과 같은 요소들이 있습니다:

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 올드 파이버와 새 엘리먼트를 비교
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx],
    lanes
  );

  if (newFiber === null) {
    // 키가 일치하지 않으면 중단
    break;
  }

  // 기존 DOM 노드 삭제 여부 결정
  if (shouldTrackSideEffects) {
    if (oldFiber && newFiber.alternate === null) {
      deleteChild(returnFiber, oldFiber);
    }
  }

  // 요소 위치 업데이트
  lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

  // 새 파이버 리스트 구성
  if (previousNewFiber === null) {
    resultingFirstChild = newFiber;
  } else {
    previousNewFiber.sibling = newFiber;
  }
  previousNewFiber = newFiber;
  oldFiber = nextOldFiber;
}

최적화의 핵심: placeChild()

placeChild() 함수는 React 재조정 과정에서 핵심적인 역할을 합니다:

function placeChild(
  newFiber: Fiber,
  lastPlacedIndex: number,
  newIndex: number
): number {
  newFiber.index = newIndex;
  const current = newFiber.alternate;
  if (current !== null) {
    const oldIndex = current.index;
    if (oldIndex < lastPlacedIndex) {
      // 요소가 앞으로 이동하는 경우 - 이동 필요
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    } else {
      // 요소가 제자리에 있거나 뒤로 이동하는 경우 - 이동 불필요
      return oldIndex;
    }
  } else {
    // 새로 삽입되는 요소
    newFiber.flags |= Placement;
    return lastPlacedIndex;
  }
}

이 함수는 다음과 같이 작동합니다:

  1. 요소가 새로 삽입되는 경우: Placement 플래그 설정
  2. 요소가 이전보다 앞으로 이동하는 경우: Placement 플래그 설정
  3. 요소가 제자리에 있거나 뒤로 이동하는 경우: 이동 불필요

여기서 중요한 점은 요소가 뒤로 이동할 때는 실제 DOM 조작이 필요하지 않다는 것입니다. 왜냐하면 앞의 요소들이 이동하거나 제거되면, 자연스럽게 올바른 위치에 놓이기 때문입니다.

이미지로 보는 실제 예시

아래 이미지는 [1, 2, 3, 4, 5, 6]이 [1, 6, 2, 5, 4, 3]으로 변경되는 예시를 보여줍니다:


이 과정에서 lastPlacedIndex는:

  1. 1번 요소: 위치 변화 없음, 0 반환
  2. 6번 요소: 5에서 1로 이동 (앞으로 이동), Placement 설정, 5 반환
  3. 2번 요소: 1에서 2로 이동 (뒤로 이동), 이동 불필요, 1 반환

이 과정에서 6이 실제로 이동해야 하는 요소이고, 나머지 요소들은 상대적인 위치 조정으로 인해 자연스럽게 올바른 위치에 놓이게 됩니다.

커밋 단계에서의 DOM 변경

실제 DOM 변경은 커밋 단계에서 이루어집니다. insertOrAppendPlacementNode() 함수가 이 작업을 담당합니다:

function insertOrAppendPlacementNode(
  node: Fiber,
  before: ?Instance,
  parent: Instance
): void {
  const { tag } = node;
  const isHost = tag === HostComponent || tag === HostText;
  if (isHost) {
    const stateNode = node.stateNode;
    if (before) {
      insertBefore(parent, stateNode, before);
    } else {
      appendChild(parent, stateNode);
    }
  } else if (tag === HostPortal) {
    // 포털 처리
  } else {
    const child = node.child;
    if (child !== null) {
      // 재귀적으로 자식 노드 처리
      insertOrAppendPlacementNode(child, before, parent);
      let sibling = child.sibling;
      while (sibling !== null) {
        insertOrAppendPlacementNode(sibling, before, parent);
        sibling = sibling.sibling;
      }
    }
  }
}

이 함수는 Placement 플래그가 설정된 노드를 DOM에 삽입하거나 추가합니다.

왜 인덱스를 key로 사용하면 안 될까?

이제 인덱스를 key로 사용하면 안 되는 이유를 더 명확하게 이해할 수 있습니다:

const todoItems = todos.map((todo, index) =>
  <li key={index}>
    {todo.text}
  </li>
);
  1. 요소 식별 문제: 인덱스는 요소 자체의 고유 식별자가 아니라 위치를 나타냅니다.
  2. 불필요한 DOM 조작: 목록 중간에 요소가 삽입되면 모든 후속 요소의 인덱스가 바뀌어 불필요한 재렌더링이 발생합니다.
  3. 상태 관리 문제: 컴포넌트가 인덱스를 기준으로 상태를 유지하면, 인덱스 변경으로 상태가 예상치 못하게 변할 수 있습니다.

실전 팁: 효과적인 key 사용법

  1. 고유하고 안정적인 ID 사용하기

    todos.map(todo => <Todo key={todo.id} {...todo} />)
  2. 데이터에 고유 ID가 없는 경우 대안 찾기

    // 데이터 자체에서 고유한 값 생성
    items.map(item => <Item key={`${item.name}-${item.category}`} {...item} />)
  3. 최후의 수단으로만 인덱스 사용하기

    • 리스트가 정적이고 재정렬되지 않는 경우에만 사용
      staticItems.map((item, index) => <Item key={index} {...item} />)
  4. key는 형제 노드 사이에서만 고유하면 됨

    // 서로 다른 배열에서는 같은 key를 사용해도 됨
    <ul>
      {categories.map(category => <li key={category.id}>{category.name}</li>)}
    </ul>
    <ul>
      {products.map(product => <li key={product.id}>{product.name}</li>)}
    </ul>

결론

React의 key 속성은 단순한 경고 방지 도구가 아닌, 효율적인 DOM 업데이트를 위한 핵심 최적화 도구입니다. React의 재조정 알고리즘은 key를 통해 요소의 식별 및 변경 사항을 추적하고, 최소한의 DOM 조작으로 리스트를 업데이트합니다.

key를 이해하고 올바르게 사용함으로써:

  1. 불필요한 DOM 조작을 줄여 성능을 향상시킬 수 있습니다.
  2. 컴포넌트의 상태 관리가 예측 가능해집니다.
  3. 복잡한 리스트 변경도 효율적으로 처리할 수 있습니다.

React의 내부 알고리즘인 reconcileChildrenArray()placeChild()의 작동 방식을 이해함으로써, 개발자는 더 효율적인 코드를 작성하고 React의 성능을 최대한 활용할 수 있습니다.

참고 자료