junyeokk
Blog
react-internals·2024. 10. 02

Key 기반 리스트 Reconciliation

리스트를 렌더링하고 항목이 추가·삭제·재배치될 때, 프레임워크는 "이전 리스트와 새 리스트의 차이를 어떻게 최소한의 DOM 조작으로 반영할 것인가"라는 문제를 풀어야 한다. 이 문제가 단순해 보이지만, 리스트의 길이가 달라지거나 순서가 바뀌면 의외로 까다롭다.


문제 상황: key 없이 리스트를 비교하면

가장 단순한 접근은 인덱스 기반 비교다. 이전 리스트의 0번째 자식과 새 리스트의 0번째 자식을 비교하고, 1번째끼리 비교하고… 이런 식이다.

javascript
// 이전 리스트
["사과", "바나나", "체리"]

// 새 리스트 (맨 앞에 "망고" 추가)
["망고", "사과", "바나나", "체리"]

인덱스 기반으로 비교하면 이렇게 된다:

인덱스이전판단
0사과망고다름 → 교체
1바나나사과다름 → 교체
2체리바나나다름 → 교체
3없음체리새로 생성

실제로는 기존 세 항목이 그대로 있고 맨 앞에 하나만 추가된 건데, 인덱스 기반 비교는 네 번의 DOM 조작을 수행한다. 모든 요소를 교체하거나 다시 생성하는 셈이다. 항목이 100개인 리스트에서 맨 앞에 하나만 추가해도 101번의 DOM 조작이 발생한다.

더 심각한 문제도 있다. 각 항목이 내부 상태(input 값, 포커스, 애니메이션 상태 등)를 갖고 있으면, 인덱스가 밀리면서 상태가 엉뚱한 항목에 붙는다. 사용자가 두 번째 항목의 input에 타이핑하고 있었는데, 맨 앞에 항목이 추가되면서 그 input 값이 세 번째 항목으로 밀려버리는 식이다.


key가 해결하는 것

key는 "이 항목이 누구인지"를 식별하는 고유 값이다. 인덱스 대신 key를 기준으로 이전 리스트와 새 리스트를 매칭하면, 동일한 항목을 정확히 찾아서 재사용할 수 있다.

jsx
// key가 있으면
<ul>
  <li key="mango">망고</li>   {/* 새로 생성 */}
  <li key="apple">사과</li>   {/* 기존 것 재사용 */}
  <li key="banana">바나나</li> {/* 기존 것 재사용 */}
  <li key="cherry">체리</li>  {/* 기존 것 재사용 */}
</ul>

key 덕분에 "사과"는 인덱스가 0에서 1로 바뀌었더라도 동일한 DOM 노드를 유지한다. 실제 DOM 조작은 "망고" 노드를 새로 만들어 맨 앞에 삽입하는 것 하나뿐이다.


구현 원리: Map 기반 매칭

key 기반 reconciliation의 핵심 자료구조는 Map이다. 이전 자식 노드들을 순회하면서 key를 키로, 해당 노드 정보를 값으로 Map에 저장한다.

javascript
// 1단계: 이전 자식들을 Map에 저장
const oldChildrenMap = new Map();
oldChildren.forEach((child, index) => {
  if (child.key != null) {
    oldChildrenMap.set(child.key, { node: child, index });
  }
});

이렇게 하면 새 리스트를 순회할 때 O(1)로 이전 노드를 찾을 수 있다. key가 없는 경우(null 또는 undefined)에는 Map에 넣지 않고, 인덱스 기반 비교로 폴백한다.

javascript
// 2단계: 새 자식을 순회하면서 매칭
newChildren.forEach((newChild, newIndex) => {
  const key = newChild.key;
  const oldInfo = key != null ? oldChildrenMap.get(key) : null;

  if (oldInfo) {
    // 동일한 key를 가진 이전 노드 발견 → 업데이트(diff)
    diff(newChild, oldInfo.node, domNode);
    // 이동이 필요한지 판단
  } else {
    // 매칭되는 이전 노드 없음 → 새로 생성
    const newDom = createDomNode(newChild);
    container.insertBefore(newDom, container.childNodes[newIndex] || null);
  }
});
javascript
// 3단계: 새 리스트에 없는 이전 노드 제거
oldChildren.forEach((oldChild) => {
  if (oldChild.key != null) {
    const existsInNew = newChildren.some(
      (newChild) => newChild.key === oldChild.key
    );
    if (!existsInNew) {
      // DOM에서 제거
      container.removeChild(/* 해당 DOM 노드 */);
    }
  }
});

전체 흐름을 정리하면:

  1. 이전 자식들의 key → Map 구축
  2. 새 자식들을 순회하며 Map에서 매칭 → 있으면 재사용(diff), 없으면 새로 생성
  3. 이전 자식 중 새 리스트에 없는 것 → 삭제

이동 판단: lastIndex 알고리즘

매칭된 노드를 찾았다고 끝이 아니다. 이전 리스트에서의 위치와 새 리스트에서의 위치가 다르면 DOM 노드를 이동해야 한다. 하지만 모든 노드를 무조건 이동시키면 비효율적이다. 실제로 이동이 필요한 노드만 골라내는 데 lastIndex 알고리즘을 사용한다.

핵심 아이디어는 "이전 리스트에서의 순서가 유지되는 노드는 이동하지 않는다"는 것이다.

javascript
let lastIndex = 0;

newChildren.forEach((newChild, newIndex) => {
  const oldInfo = oldChildrenMap.get(newChild.key);

  if (oldInfo) {
    const oldIndex = oldInfo.index;

    if (oldIndex < lastIndex) {
      // 이전 리스트에서 더 앞에 있었는데 새 리스트에서 뒤로 갔다
      // → 이 노드를 현재 위치로 이동
      const domNode = container.childNodes[oldIndex];
      container.insertBefore(domNode, container.childNodes[newIndex]);
    } else {
      // 이전 리스트에서의 순서가 유지됨 → 이동 불필요
      lastIndex = oldIndex;
    }

    // 속성 변경이 있으면 업데이트
    diff(newChild, oldInfo.node, domNode);
  }
});

구체적으로 어떻게 동작하는지 예시를 보자.

예시: 순서 변경

이전: [A(0), B(1), C(2), D(3)] 새로: [B, D, A, C]
단계새 노드이전 인덱스lastIndex이동?
1B10→1아니오 (1 ≥ 0)
2D31→3아니오 (3 ≥ 1)
3A03 (0 < 3)
4C23 (2 < 3)

B와 D는 이전 리스트에서의 상대적 순서가 유지되므로 이동하지 않는다. A와 C만 이동하면 된다. 총 4개 노드 중 2개만 이동하는 것이다.

lastIndex의 한계

이 알고리즘은 간단하고 대부분의 경우 잘 동작하지만, 최악의 경우가 있다.

이전: [A(0), B(1), C(2), D(3)] 새로: [D, A, B, C]
단계새 노드이전 인덱스lastIndex이동?
1D30→3아니오
2A03
3B13
4C23

실제로는 D 하나만 맨 앞으로 이동하면 되는데, A·B·C 세 개를 이동한다. 마지막 요소를 맨 앞으로 보내는 패턴에서 비효율이 발생하는 것이다. 이것이 React가 사용하는 방식의 알려진 한계점이다.

Vue 3는 이 문제를 최장 증가 부분 수열(LIS, Longest Increasing Subsequence) 알고리즘으로 해결한다. LIS를 구하면 "이동하지 않아도 되는 최대 노드 집합"을 찾을 수 있어서, 최소한의 이동만 수행할 수 있다. 다만 LIS 계산 자체에 O(n log n) 비용이 들기 때문에, 리스트가 작으면 오히려 오버헤드가 될 수 있다.


key 선택 가이드

좋은 key

jsx
이전: [A(0), B(1), C(2), D(3)]
새로: [B, D, A, C]

key는 형제 노드 사이에서만 고유하면 된다. 전역적으로 유일할 필요는 없다. 서로 다른 리스트에서 같은 key를 사용해도 문제없다.

나쁜 key: 인덱스

jsx
이전: [A(0), B(1), C(2), D(3)]
새로: [D, A, B, C]

인덱스를 key로 사용하면 key가 없는 것과 사실상 같다. 항목이 추가·삭제·재정렬될 때 인덱스가 밀리면서, reconciliation이 "같은 항목"이라고 잘못 판단한다.

단, 리스트가 정적이고 순서가 절대 바뀌지 않는 경우에는 인덱스를 key로 써도 괜찮다. 하지만 "나중에 정렬 기능이 추가될 수도 있으니까" 처음부터 고유한 key를 사용하는 것이 안전하다.

나쁜 key: 랜덤 값

jsx
// DB에서 온 고유 ID — 가장 이상적
items.map(item => <ListItem key={item.id} {...item} />)

// 고유한 문자열 조합
users.map(user => <UserCard key={user.email} {...user} />)

렌더링할 때마다 key가 바뀌면 reconciliation이 모든 항목을 "이전에 없던 새 항목"으로 판단한다. 매번 전체 DOM을 파괴하고 새로 생성하게 되어 key를 쓰는 의미가 완전히 사라진다. 내부 상태도 매번 초기화된다.


key가 상태 초기화에 쓰이는 패턴

key의 본래 목적은 리스트 reconciliation이지만, "컴포넌트를 강제로 다시 마운트하고 싶을 때" 활용할 수도 있다.

jsx
// ❌ 인덱스를 key로 사용
items.map((item, index) => <ListItem key={index} {...item} />)

key가 바뀌면 reconciliation은 이전 컴포넌트와 새 컴포넌트를 다른 것으로 판단한다. 이전 컴포넌트를 언마운트하고 새 컴포넌트를 마운트하므로, 내부 상태가 완전히 초기화된다. useEffect로 상태를 리셋하는 것보다 깔끔한 경우가 있다.


정리

비교 기준인덱스 기반key 기반
항목 식별위치(순서)고유 값
삽입/삭제 시뒤쪽 모든 항목 재처리변경된 항목만 처리
순서 변경 시전체 항목 교체이동 필요한 것만 이동
내부 상태인덱스 밀리면 엉킴정확히 유지
시간 복잡도O(n) 비교, O(n) DOM 조작O(n) 비교, 최소 DOM 조작

key 기반 reconciliation의 핵심을 한 문장으로 요약하면: Map으로 이전 노드를 빠르게 찾고, lastIndex로 이동이 필요한 노드만 골라내서, 최소한의 DOM 조작으로 리스트를 업데이트한다.