지난 글에서 중복 검사 함수를 이중 루프에서 Set으로 바꾸며 O(n²)를 O(n)으로 줄였다. 요점은 O(1)에 조회할 수 있는 자료구조를 곁에 두면 반복 탐색을 없앨 수 있다는 것이었다.
React가 리스트를 렌더링할 때 key를 요구하는 이유도 정확히 같은 자리에 있다. 흔히 "성능 때문"이라고만 알고 넘어가는데, 무엇이 어떻게 느려지는지 따라가 보면 생각보다 깊은 곳까지 이어진다.
key가 없으면 무엇이 느려지는가
먼저 흔한 오해를 하나 걷어내야 한다. "key가 없으면 React가 리스트 전체를 훑어야 해서 느리다"는 설명인데, 이건 사실과 다르다.
key가 있든 없든 React는 어차피 새 리스트의 모든 항목을 한 번씩은 본다. 항목이 n개면 최소 n번 봐야 하고, 이건 어떤 방법으로도 피할 수 없는 비용이다. 그러니 문제는 몇 번 훑느냐가 아니다.
진짜 문제는 다른 데 있다. 이전 항목과 새 항목을 올바르게 짝지을 수 있는가, 즉 식별(identity)의 문제다. 이 둘은 완전히 다른 작업이다.
- 훑기: 새 리스트의 항목을 하나씩 본다 → 항상 O(n), 피할 수 없음
- 짝짓기: 지금 보고 있는 이 항목이 이전에 있던 그 항목인지 판단한다 → key가 좌우하는 부분
리스트 [A, B, C]에 D가 추가되어 [A, B, C, D]가 됐다고 하자. React는 네 항목을 모두 봐야 하지만, 실제로 DOM에 새로 만들어야 할 건 D 하나뿐이다. A, B, C를 "이전의 그 A, B, C"로 알아볼 수 있어야 재사용할 수 있고, 그 판단의 근거가 key다.
짝짓기는 어떻게 이뤄지나
React는 이전 트리와 새 트리를 비교할 때, 같은 부모 아래의 자식들을 순서대로 하나씩 맞춰본다. 앞에서부터 이전 항목과 새 항목의 key를 비교해 가는데, key가 일치하는 동안은 그대로 짝을 지어 나간다.
문제는 중간에 key가 어긋나는 순간이다. 여기서부터는 순서대로 맞춰봐야 소용이 없다. 항목이 삽입됐을 수도, 삭제됐을 수도, 순서가 통째로 뒤바뀌었을 수도 있다.
이때 React는 남아 있는 이전 항목들을 key를 기준으로 하는 Map에 담는다. 그리고 새 항목을 순회하면서 자기 key로 그 Map에 물어본다.
// 개념적으로는 이런 모양이다
const existingChildren = new Map(); // key → 이전 노드
// ...남은 이전 항목들을 담아둔 뒤
for (const newChild of newChildren) {
const matched = existingChildren.get(newChild.key);
if (matched) {
// 이전 노드를 재사용
} else {
// 이전에 없던 key → 새로 만든다
}
}
지난 글의 seen.has(value)와 정확히 같은 구조다. 이전 목록 전체를 매번 뒤지면 O(n²)가 되지만, Map으로 한 번 인덱싱해두면 각 조회가 O(1)이라 전체가 O(n)으로 끝난다. key는 이 Map의 키가 되는 값이고, 그래서 React가 개발자에게 key를 요구하는 것이다.
index를 key로 쓰면 벌어지는 일
key를 아예 주지 않으면 React는 배열의 순서(index)를 기준으로 짝을 짓고, 콘솔에 경고를 낸다. 그래서 경고를 없애려고 key={index}를 넣는 경우가 많은데, 이건 경고만 사라질 뿐 동작은 그대로다. 두 경우를 따로 볼 필요가 없다는 뜻이다.
리스트 맨 앞에 항목을 하나 추가해 보면 무슨 일이 일어나는지 드러난다.
// 이전
[A(key=0), B(key=1), C(key=2)]
// 이후: 맨 앞에 X 삽입
[X(key=0), A(key=1), B(key=2), C(key=3)]
이전 항목들은 { 0: A, 1: B, 2: C }로 인덱싱되어 있다. 이제 새 리스트를 순회한다.
key=0을 물어보면 "A였다"고 답한다. 하지만 그 자리에 들어와야 할 건 X다.key=1은 "B였다"고 답한다. 실제로는 A가 와야 한다.key=2는 "C였다"고 답한다. 실제로는 B가 와야 한다.key=3은 이전에 없던 key다. 그래서 C가 새 항목으로 취급되어 새로 만들어진다.
React는 같은 key = 같은 항목이라고 믿는다. 그래서 A였던 노드를 재사용하되 내용물만 X로 덮어쓰고, B였던 노드에 A의 내용을 덮어쓰고... 이런 식으로 밀려 나간다. 실제로는 A, B, C가 그 자리에 그대로 있는데도 전부 "내용이 바뀐 항목"으로 처리된다.
한 개만 추가됐을 뿐인데 사실상 리스트 전체가 업데이트되는 셈이다.
더 성가신 건 성능이 아니라 버그 쪽이다. 각 항목에 <input>처럼 자기 상태를 가진 요소가 들어 있다면, 그 상태는 재사용된 DOM 노드에 그대로 남는다. 맨 앞에 항목을 추가했더니 방금 입력하던 값이 엉뚱한 줄로 옮겨 가 있는 현상이 이래서 생긴다. 체크박스 선택이 밀리는 것도 같은 원인이다.
item.id처럼 항목마다 고유하고 순서가 바뀌어도 따라다니는 값을 key로 주면, 삽입이 일어나도 짝짓기가 어긋나지 않는다.
짝지은 다음에는 무엇을 하나
짝을 지었다고 끝이 아니다. 같은 key로 매칭된 항목이라도 내용이 바뀌었을 수 있다. 이때 React는 이전 props와 새 props를 속성 하나하나 단위로 비교한다.
// 이전
{ text: "hello", style: { color: "red", fontSize: 14 }, onClick: fn1 }
// 이후
{ text: "world", style: { color: "red", fontSize: 16 }, onClick: fn1 }
네 가지 중 실제로 달라진 건 text와 style.fontSize 두 개다. color와 onClick은 그대로이니 건너뛴다. 결과적으로 실제 DOM에 가해지는 작업은 텍스트 변경과 fontSize 변경, 딱 두 개다.
굳이 이렇게 잘게 나눠 비교하는 이유는 두 작업의 비용이 완전히 비대칭이기 때문이다.
oldProps.style.color === newProps.style.color는 메모리에 있는 값 두 개를 비교하는 연산이다. 아주 싸다. 반면 실제 DOM의 스타일을 건드리면 브라우저는 그 요소의 크기와 위치를 다시 계산하고(리플로우), 다시 그려야 한다(리페인트). 주변 요소까지 영향을 받을 수 있어 비교 연산과는 비교가 안 되게 비싸다.
그래서 전략이 이렇게 된다. 싼 연산을 여러 번 해서, 비싼 연산의 횟수를 줄인다.
여기서 오해하기 쉬운 지점이 하나 있다. 이 전략의 목표는 리플로우와 리페인트를 없애는 게 아니다. fontSize가 진짜로 바뀌었다면 결국 브라우저는 다시 그려야 하고, 그 비용은 피할 수 없다. 목표는 다시 그려야 할 것의 개수를 실제로 바뀐 것만큼으로 줄이는 것이다. 네 개를 무조건 다시 쓰는 대신 두 개만 쓰는 것, 리스트가 1,000개일 때 이 차이가 벌어진다.
그리고 이제 앞의 이야기와 연결된다. index를 key로 써서 짝짓기가 밀리면, 이 비교 단계에서 안 바뀐 항목의 props까지 "바뀐 것"으로 판정된다. 정교한 속성 비교를 해봐야 애초에 잘못된 짝을 비교하고 있으니 소용이 없다.
트리 비교는 원래 O(n³)이었다
여기까지 보면 자연스럽게 의문이 생긴다. 트리 두 개를 통째로 비교하는 일이 그렇게 값싸게 될 수 있나?
일반적인 트리 비교 알고리즘, 그러니까 두 트리의 차이를 최소 변경 횟수로 정확히 계산하는 알고리즘은 O(n³)이다. 노드가 1,000개면 10억 번 규모의 연산이다. 화면을 그릴 때마다 이걸 할 수는 없다.
React는 정확한 최소 diff를 포기하는 대신, 실무에서 거의 항상 들어맞는 가정 두 개를 세워 이 비용을 O(n)까지 낮췄다.
가정 1. 타입이 다르면 완전히 다른 트리다.
// 이전
<div><Counter /></div>
// 이후
<span><Counter /></span>
div가 span으로 바뀌면 React는 내부에 무엇이 있든 이 서브트리를 통째로 버리고 새로 만든다. 안에 있던 Counter의 상태도 함께 사라진다. 내부를 하나씩 비교해서 "Counter는 살릴 수 있지 않을까?"를 따지지 않는다. 실제로 태그 타입이 바뀌는 경우는 대개 정말 다른 것을 그리려는 상황이므로, 이 가정 하나로 비교 범위가 크게 줄어든다.
가정 2. 같은 레벨의 형제끼리만 비교한다.
트리 깊이를 넘나들며 "혹시 이 노드가 저 아래로 옮겨 간 건 아닐까?"를 추적하지 않는다. 부모가 같은 형제들끼리만 맞춰본다. 그래서 노드를 다른 부모 아래로 옮기면 React 입장에서는 이동이 아니라 삭제와 생성이 된다.
이 두 가정 위에서, 같은 레벨의 형제들을 어떻게 짝지을지에 대한 힌트가 바로 key다. 지금까지 본 이야기가 전부 여기에 들어가 있다.
Fiber: 무엇을 비교하나에서 언제 비교하나로
마지막으로 하나가 더 있다. 비교를 아무리 효율적으로 해도, 트리가 크면 그 작업 자체가 오래 걸린다는 문제다.
React 15까지 이 비교 작업은 재귀 함수 호출이었다. 한 번 시작하면 끝까지 내려갔다 올라와야 했고, 그동안 브라우저의 메인 스레드는 다른 일을 하지 못했다. 사용자가 타이핑을 하거나 스크롤을 해도 화면이 반응하지 않는 구간이 생긴다는 뜻이다.
재귀는 중간에 멈추기가 어렵다. 진행 상태가 콜 스택에 쌓여 있어서, 멈추려면 그 스택을 통째로 보관했다가 되살려야 하기 때문이다.
React 16에서 도입된 Fiber는 이 문제를 자료구조로 푼다. 가상 DOM의 각 노드를 "중간에 멈췄다 이어서 할 수 있는 작업 단위"로 표현한 객체다.
{
type: 'div',
child: fiberA, // 첫 번째 자식
sibling: fiberB, // 다음 형제
return: parentFiber, // 부모로 돌아가는 링크
// ...
}
부모, 자식, 형제를 각각 가리키는 포인터가 있다는 게 핵심이다. 트리를 재귀로 내려가는 대신, 이 포인터를 따라 child → sibling → return 순으로 이동하는 반복문으로 순회할 수 있다.
반복문이 되면 이런 일이 가능해진다.
- Fiber 노드 하나를 처리한다
- 지금 양보해야 할 급한 일이 있는지 확인한다 (사용자 입력 등)
- 있으면 어느 노드까지 처리했는지 포인터만 저장해두고 빠져나온다
- 급한 일이 끝나면 저장해둔 포인터부터 이어서 계속한다
진행 상태가 콜 스택이 아니라 그냥 객체 하나에 들어 있으니, 멈추고 재개하는 데 특별한 장치가 필요 없다.
여기서 구분해 둘 게 있다. Fiber는 무엇을 비교할지를 바꾼 게 아니다. 지금까지 본 diffing 규칙(타입 비교, 형제끼리 비교, key 매칭)은 그대로다. Fiber가 바꾼 건 그 비교 작업을 언제, 어떤 순서로 실행할지다. 같은 로직 위에 얹힌 스케줄링 레이어라고 보는 편이 정확하다.
마무리
"리스트에 key를 넣어라"는 경고 하나를 따라갔을 뿐인데 여기까지 왔다. 정리하면 이렇다.
- React가 리스트를 훑는 비용은 key와 무관하게 항상 O(n)이다
- 진짜 문제는 이전 항목과 새 항목을 올바르게 짝짓는 식별이고, key가 그 근거가 된다
- 짝짓기가 어긋나면 안 바뀐 항목까지 바뀐 것으로 판정되어, 불필요한 DOM 조작과 상태가 밀리는 버그가 생긴다
- 짝지은 항목은 props를 속성 단위로 비교해 실제로 바뀐 것만 DOM에 반영한다. 비교는 싸고 리플로우·리페인트는 비싸기 때문이다
- 원래 O(n³)인 트리 비교를, 타입과 형제 범위에 대한 두 가정으로 O(n)까지 낮춘 것이 React의 diffing이다
- Fiber는 그 비교 작업을 중단하고 재개할 수 있게 만든 자료구조다
key={index}가 왜 위험한지는 이제 한 문장으로 말할 수 있다. index는 항목을 따라다니는 값이 아니라 자리를 가리키는 값이라, 자리가 밀리는 순간 짝짓기가 통째로 어긋나기 때문이다.
- 1.중복 검사 함수를 O(n²)에서 O(n)으로 바꾸기
- 2.React는 왜 key를 요구할까 — diffing부터 Fiber까지