React를 사용하다 보면 자주 마주치는 경고 중 하나가 있습니다:
Warning: Each child in a list should have a unique "key" prop.
리스트를 렌더링할 때 각 항목에 key를 추가하지 않으면 발생하는 이 경고는 왜 생기는 걸까요? React 공식 문서에서는 개념적인 설명을 제공하지만, 이번에는 실제로 key가 React 내부에서 어떻게 작동하는지 깊이 들여다보겠습니다.
React는 컴포넌트를 업데이트할 때 '재조정(reconciliation)' 과정을 거칩니다. 리스트의 경우 이 과정은 reconcileChildrenArray() 함수에서 처리됩니다. React 소스 코드를 살펴보면, 이 함수의 주석에 흥미로운 내용이 있습니다:
// 우리는 파이버에 역참조가 없기 때문에 양쪽 끝에서 검색하는 최적화를 할 수 없습니다.
// 그 모델로 어디까지 갈 수 있는지 보려고 합니다. 만약 트레이드오프가 가치가 없다면,
// 나중에 추가할 수 있습니다.
// ...
// 이 첫 번째 반복에서는 모든 삽입/이동에 대해 나쁜 경우(모든 것을 Map에 추가)를 감수할 것입니다.
이 주석에서 알 수 있듯, React는 파이버(Fiber)에 역참조가 없기 때문에 양쪽 끝 최적화를 하지 않는 타협을 하고 있습니다. 자식 요소들은 배열이 아닌 'sibling'으로 연결된 파이버의 연결 리스트로 관리됩니다.
리스트에서는 다양한 변화가 일어날 수 있습니다. 예를 들어, 다음과 같은 배열이 있다고 가정해봅시다:
const arrPrev = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
이 배열이 변경된 후, 인덱스 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의 재조정 알고리즘은 다음 단계로 진행됩니다:
첫 번째 시도: 낙관적 비교
두 번째 시도: 맵 기반 비교
실제 소스 코드를 살펴보면 다음과 같은 요소들이 있습니다:
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() 함수는 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;
}
}
이 함수는 다음과 같이 작동합니다:
여기서 중요한 점은 요소가 뒤로 이동할 때는 실제 DOM 조작이 필요하지 않다는 것입니다. 왜냐하면 앞의 요소들이 이동하거나 제거되면, 자연스럽게 올바른 위치에 놓이기 때문입니다.
아래 이미지는 [1, 2, 3, 4, 5, 6]이 [1, 6, 2, 5, 4, 3]으로 변경되는 예시를 보여줍니다:


이 과정에서 lastPlacedIndex는:
이 과정에서 6이 실제로 이동해야 하는 요소이고, 나머지 요소들은 상대적인 위치 조정으로 인해 자연스럽게 올바른 위치에 놓이게 됩니다.
실제 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로 사용하면 안 되는 이유를 더 명확하게 이해할 수 있습니다:
const todoItems = todos.map((todo, index) =>
<li key={index}>
{todo.text}
</li>
);
고유하고 안정적인 ID 사용하기
todos.map(todo => <Todo key={todo.id} {...todo} />)
데이터에 고유 ID가 없는 경우 대안 찾기
// 데이터 자체에서 고유한 값 생성
items.map(item => <Item key={`${item.name}-${item.category}`} {...item} />)
최후의 수단으로만 인덱스 사용하기
staticItems.map((item, index) => <Item key={index} {...item} />)
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를 이해하고 올바르게 사용함으로써:
React의 내부 알고리즘인 reconcileChildrenArray()와 placeChild()의 작동 방식을 이해함으로써, 개발자는 더 효율적인 코드를 작성하고 React의 성능을 최대한 활용할 수 있습니다.