일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- lazy()
- 클래스
- 리액트
- CSR
- 리액트훅
- react-hook
- 모던자바스크립트
- useEffect
- reactquery
- express
- msw
- Database
- ErrorBoundary
- NextJs
- 초기마운트
- Firebase
- 리액트 훅
- SSR
- useLayoutEffect
- key
- react-hook-form
- next-cookies
- react
- docker
- Today
- Total
한우의 개발일기
리액트 key알아보기 본문
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의 요소가 다르다고 판단되면 여러 가지 수정 가능성이 있을 수 있습니다:
- 인덱스 i에 새 요소가 삽입됨
- 인덱스 i의 기존 요소가 새 요소로 대체됨
- 다른 인덱스에서 기존 요소가 i로 이동함
- 다른 인덱스에서 기존 요소가 이동해서 i의 기존 요소를 대체함
- 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의 재조정 알고리즘은 다음 단계로 진행됩니다:
첫 번째 시도: 낙관적 비교
- 같은 인덱스의 요소를 비교하며 키가 같은지 확인 (headOld, headNew)
- 키가 같으면 해당 요소를 재사용하고 넘어감
- 키가 다르면 첫 번째 불일치 지점에서 멈춤
두 번째 시도: 맵 기반 비교
- 불일치 지점 이후의 모든 이전 요소를 키 기반 맵으로 변환
- 새 리스트의 각 요소에 대해:
- 맵에서 같은 키를 가진 요소를 찾음
- 발견되면 재사용, 아니면 새로 생성
- 맵에 남은 요소는 모두 삭제
실제 소스 코드를 살펴보면 다음과 같은 요소들이 있습니다:
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;
}
}
이 함수는 다음과 같이 작동합니다:
- 요소가 새로 삽입되는 경우: Placement 플래그 설정
- 요소가 이전보다 앞으로 이동하는 경우: Placement 플래그 설정
- 요소가 제자리에 있거나 뒤로 이동하는 경우: 이동 불필요
여기서 중요한 점은 요소가 뒤로 이동할 때는 실제 DOM 조작이 필요하지 않다는 것입니다. 왜냐하면 앞의 요소들이 이동하거나 제거되면, 자연스럽게 올바른 위치에 놓이기 때문입니다.
이미지로 보는 실제 예시
아래 이미지는 [1, 2, 3, 4, 5, 6]이 [1, 6, 2, 5, 4, 3]으로 변경되는 예시를 보여줍니다:
이 과정에서 lastPlacedIndex
는:
- 1번 요소: 위치 변화 없음, 0 반환
- 6번 요소: 5에서 1로 이동 (앞으로 이동), Placement 설정, 5 반환
- 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>
);
- 요소 식별 문제: 인덱스는 요소 자체의 고유 식별자가 아니라 위치를 나타냅니다.
- 불필요한 DOM 조작: 목록 중간에 요소가 삽입되면 모든 후속 요소의 인덱스가 바뀌어 불필요한 재렌더링이 발생합니다.
- 상태 관리 문제: 컴포넌트가 인덱스를 기준으로 상태를 유지하면, 인덱스 변경으로 상태가 예상치 못하게 변할 수 있습니다.
실전 팁: 효과적인 key 사용법
고유하고 안정적인 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
를 이해하고 올바르게 사용함으로써:
- 불필요한 DOM 조작을 줄여 성능을 향상시킬 수 있습니다.
- 컴포넌트의 상태 관리가 예측 가능해집니다.
- 복잡한 리스트 변경도 효율적으로 처리할 수 있습니다.
React의 내부 알고리즘인 reconcileChildrenArray()
와 placeChild()
의 작동 방식을 이해함으로써, 개발자는 더 효율적인 코드를 작성하고 React의 성능을 최대한 활용할 수 있습니다.
참고 자료
- React 공식 문서: Lists and Keys
- React 소스 코드: reconcileChildrenArray()