key={Math.random()} 즉석 생성이 부르는 대참사"리액트의 재조정(Reconciliation)에서
Key의 생명은 '고유성(Uniqueness)'만이 아닙니다. 더 중요한 본질은 바로 '렌더링 간의 불변성(Stability)'입니다."
key={index}가 안티 패턴이라는 사실을 접한 뒤, "그렇다면 겹치지 않는 고유값을 주면 되겠네!"라며 JSX 렌더링 함수 내부에서 key={Math.random()}이나 key={crypto.randomUUID()}를 호출하는 실수를 종종 범하곤 합니다.
하지만 이는 index를 Key로 쓰는 것보다 훨씬 파괴적인 버그와 렌더링 참사를 불러옵니다. 리액트 Fiber 아키텍처 관점에서 왜 인라인 동적 키 생성이 최악의 패턴인지 공학적으로 파헤쳐 봅니다.
React 공식 문서와 가상 DOM 비교 알고리즘이 전제하는 Key의 조건은 두 가지입니다.
1번째 렌더링: key="0.491823..."
2번째 렌더링: key="0.884192..." (부모가 리렌더링될 때마다 새로운 난수/UUID 발급)
➔ 리액트의 판단: "완전히 다른 컴포넌트가 들어왔구나! 이전 인스턴스를 통째로 파괴(Unmount)하고 새로 생성(Mount)하자."
리액트의 Diffing 알고리즘은 이전 Fiber 트리와 새 JSX 요소를 비교할 때 key가 다르면 기존 컴포넌트를 즉시 파괴(Unmount)합니다.
[이전 Fiber Tree][새 렌더링 트리 (state 1개 변경)]
Fiber A (Key: "0.123") <-- 비교 --> JSX A (Key: "0.987")
➔ ⚠️ Key 불일치 판정!
➔ 1) Fiber A 및 실제 DOM 노드 강제 삭제 (Unmount)
➔ 2) 새로운 DOM 노드 생성 및 브라우저 DOM Tree에 재삽입 (Mount)
❖ 인라인 난수 Key 주입 시 발생하는 연쇄 반응:
document.createElement 수준의 비용이 발생합니다.useState, useRef, 타이머, 네트워크 요청 등이 리렌더링마다 초기화됩니다.AnimatePresence는 매번 마운트 애니메이션을 다시 실행하거나, DOM 노드가 즉시 파괴되어 Exit 효과가 씹히는 현상이 발생합니다.이 패턴이 실제 UI에서 일으키는 가장 대표적인 치명적 버그는 "글자 하나 칠 때마다 포커스가 풀리는 현상"입니다.
// ❌ 최악의 안티 패턴: 렌더링할 때마다 새로운 Key를 즉석 생성
function SearchFilterList({ items }) {
const [query, setQuery] = useState('');
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색어 입력..."
/>
<ul>
{items.map((item) => (
// ⚠️ 렌더링 함수 실행 시마다 매번 새로운 UUID 발급!
<li key={crypto.randomUUID()}>
<input defaultValue={item.name} />
</li>
))}
</ul>
</div>
);
}
버그 발생 및 사용자 경험 파괴 흐름
1. 사용자가 리스트 내부의 인풋창을 클릭하여 포커스(Focus)를 맞추고 텍스트를 한 글자 입력함.
부모 컴포넌트의 State(query)가 변경되며 자식 리스트 전체가 리렌더링됨.
crypto.randomUUID()가 새로운 값을 반환 ➔ 리액트는 기존 <li>와 <input> DOM 노드를 브라우저에서 완전히 삭제(Unmount) 후 새로 생성.
[참사 발생]: 브라우저에 존재하던 포커스 대상 DOM이 사라졌으므로 인풋의 포커스가 즉시 풀리고 키보드 입력이 끊어짐 (사용자는 한 글자 치고 마우스로 다시 클릭해야 하는 최악의 UX를 겪음).
고유 ID는 "렌더링하는 시점"이 아니라, "데이터가 생성되거나 가공되는 시점"에 단 한 번만 생성하여 데이터 객체 내부에 고정(Persist)시켜야 합니다.
// ✅ 올바른 패턴: 데이터 생성 시점에 ID 부여
const addItem = (text) => {
const newItem = {
id: crypto.randomUUID(), // 데이터가 생성되는 이벤트 시점에 딱 1번만 발급!
text: text
};
setItems((prev) => [...prev, newItem]);
};
// 렌더링 시에는 불변하는 데이터의 고유 ID를 참조
return (
<ul>
{items.map((item) => (
<li key={item.id}>
<input defaultValue={item.text} />
</li>
))}
</ul>
);
완벽한 DOM 인스턴스 재사용: 컴포넌트가 언마운트되지 않고 Props만 부드럽게 갱신.
포커스 및 제어 상태 유지: 입력 중인 포커스, 스크롤 위치, 셀렉션 상태가 완벽히 보존됨.
불필요한 브라우저 Layout/Paint 연산 차단.
| 구분 | key={index} | key={Math.random()} | key={item.id} (불변 고유값) |
|---|---|---|---|
| 고유성 (Unique) | ⚠️ 순서에 의존 | ✅ 고유함 | ✅ 완벽히 고유함 |
| 불변성 (Stable) | ❌ 삽입/삭제 시 변경 | ❌ 매 렌더링마다 변경 | ✅ 렌더링 내내 불변 |
| 주요 발생 버그 | 이전 상태가 엉뚱한 위치로 전이 | DOM 강제 파괴, 포커스 풀림, 깜빡임 | 없음 (버그 방어) |
| DOM 라이프사이클 | 기존 DOM 강제 재사용 | 매번 Unmount ➔ Mount 반복 | 정상 Update / 필요한 노드만 Mount |