코드 리뷰에서 이런 질문을 받았습니다.
"React에서 key는 어떤 역할일까요?"
면접에서도 자주 나오는 주제라 이참에 제대로 정리했습니다. key만 따로 떼어 보면 이해가 잘 안 되고, Virtual DOM과 Reconciliation이라는 배경까지 같이 봐야 "아, 그래서 key가 필요하구나"가 됩니다. 그래서 배경부터 짚고 갑니다.
"반복문에서 key를 안 쓰면 에러가 난다"고 알고 있는 경우가 많은데, 정확히는 에러가 아니라 경고(warning)입니다.
Warning: Each child in a list should have a unique "key" prop.
경고가 떠도 화면은 일단 동작합니다. 하지만 key를 제대로 주지 않으면 성능 저하와 상태(state) 관련 버그가 따라옵니다. key는 "경고를 끄는 용도"가 아니라, React가 리스트의 각 요소를 식별하는 핵심 장치입니다.
key를 이해하려면 이 세 개념이 먼저 필요합니다.
Virtual DOM은 실제 DOM을 흉내 낸 메모리 상의 가벼운 JS 객체 트리입니다. 화면에 직접 그려지지는 않습니다. 실제 DOM을 조작하는 건 웹에서 가장 느린 작업이기 때문에, 일단 메모리에서 먼저 계산해 보려는 것입니다.
Reconciliation(재조정)은 state나 props가 바뀌었을 때, React가 새 Virtual DOM 트리를 만들고 이전 트리와 비교해서, 실제 DOM에 적용할 최소한의 변경만 계산해 내는 과정입니다.
Diffing 알고리즘은 그 두 트리를 비교하는 방법입니다. 원래 트리를 통째로 비교하면 O(n³)이지만, React는 몇 가지 가정을 두고 이걸 O(n)까지 낮췄습니다.
<div>가 <span>으로 바뀌면 옛 트리는 버리고 새로 만든다)세 번째가 바로 key가 등장하는 지점입니다.
한 문장으로 정리하면 이렇습니다.
key는 "이전 렌더의 어떤 컴포넌트가 = 이번 렌더의 이 컴포넌트인가?"를 알려주는 식별자다.
리스트에서 같은 타입의 요소(<li> 여러 개)가 나열되면, React는 각각을 구분할 방법이 필요합니다. 그 역할을 key가 합니다. 이때 key는 형제 요소들 사이에서만 유일하면 되고, 전역에서 유일할 필요는 없습니다.
한 가지 중요한 점. key는 props가 아닙니다. 컴포넌트 안에서 props.key로 읽을 수 없고, 오직 React 내부의 재조정용으로만 쓰입니다.
파일에 이름이 없고 "순서"로만 부른다고 상상해 봅시다. "첫 번째 파일, 두 번째 파일..." 이렇게요. 쓸 수는 있지만, 파일 하나를 지우는 순간 순서가 전부 밀려서 헷갈립니다. 반면 파일에 이름(= 안정적인 고유 key)이 있으면, 순서가 바뀌어도 어떤 게 어떤 건지 정확히 식별할 수 있습니다.
key={index}를 쓰면 린트 경고는 사라집니다. 하지만 리스트가 바뀌는 순간(추가·삭제·정렬) 문제가 터집니다.
[Alice, Bob, Charlie]에 index로 key를 준다고 해봅시다.
<li key="0">Alice</li>
<li key="1">Bob</li>
<li key="2">Charlie</li>
여기서 맨 앞 Alice를 삭제하면 [Bob, Charlie]가 됩니다. 그런데 key가 index 기준이라 React는 이렇게 해석합니다.
key="0": Alice → Bob 으로 바뀜 → 텍스트 업데이트
key="1": Bob → Charlie 로 바뀜 → 텍스트 업데이트
key="2": 사라짐 → <li> 제거
실제로 필요한 작업은 "맨 앞 노드 1개 삭제"뿐인데, 업데이트 2번 + 삭제 1번을 하고 있습니다. 리스트가 크고 각 항목이 무거우면 이 낭비가 커집니다.
고유 id를 key로 쓰면 이렇게 됩니다.
<li key="a1">Alice</li> ← 이 key가 사라짐 → Alice만 정확히 제거
<li key="b2">Bob</li>
<li key="c3">Charlie</li>
각 항목이 자체 상태를 가진 컴포넌트일 때(펼침/접힘, 체크박스, 입력값 등) 진짜 문제가 생깁니다.
리스트를 뒤집거나 정렬하면 index는 그대로인데 그 자리에 들어오는 내용물이 바뀝니다. React는 key=1이면 같은 컴포넌트라고 판단해서 상태를 그대로 유지합니다. 그 결과 Alice에 입력한 값이 엉뚱하게 Bob 자리에 남는 버그가 생깁니다.
여기서 반드시 기억할 규칙이 있습니다.
같은 key = 같은 컴포넌트 인스턴스 = 상태 유지
다른 key = 새 인스턴스 = 상태 초기화
| 상황 | key로 쓸 것 |
|---|---|
| 데이터에 고유 id가 있음 (DB에서 온 데이터 등) | 그 id 사용 ← 최선 |
| 고유 id가 없음 (로컬에서 만든 데이터) | 생성 시점에 고유 id 부여 (crypto.randomUUID(), nanoid 등) |
| 리스트가 절대 바뀌지 않음 (정렬·추가·삭제·필터 전혀 없음) | index도 기술적으로는 안전 (예외적인 경우) |
// 권장: 안정적이고 고유한 값
{products.map((product) => (
<ProductCard key={product.id} {...product} />
))}
좋은 key가 갖춰야 할 조건은 세 가지입니다.
key={Math.random()}은 절대 금지입니다. 매번 새 값이 나와서 모든 항목이 매 렌더마다 새로 만들어집니다.상품 리스트는 정렬·필터·무한스크롤로 항목이 계속 바뀌는 리스트입니다. 그래서 index를 key로 쓰면 정렬할 때 상태 버그가 날 위험이 있고, product.id를 key로 쓰는 게 정답입니다.
<ProductCard key={`product-card-${product.id}`} {...product} />