React Each child in a list should have a unique key prop 경고와 index key 버그
- 원인:
map()으로 만든 목록의 최상위 요소에key를 주지 않아서 발생 — 화면을 다시 그릴 때 어떤 요소가 어떤 항목이었는지 React가 짝지을 근거가 없음 - 해결: 데이터가 원래 가진 고유 id를
key로 사용 / 빈 태그로 감쌌다면<Fragment key={...}>로 바꿔서 지정 - 주의: 배열 index를
key로 쓰면 경고는 사라지지만, 목록 순서가 바뀌거나 중간 항목이 삭제될 때 입력값·체크 상태가 엉뚱한 행에 남는 더 찾기 힘든 버그가 생김
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li> // index 말고 고유 id
))}
↓ 왜 이런 경고가 나는지 원리 보기
목록을 map()으로 그리다 보면 콘솔에 'Warning: Each child in a list should have a unique "key" prop' 경고가 뜹니다. 화면은 멀쩡히 나오니 일단 넘어가기 쉬운데, 이 경고는 단순한 잔소리가 아니라 나중에 훨씬 찾기 어려운 버그로 되돌아오는 경우가 많습니다.
React가 목록에 key를 요구하는 이유
React는 상태가 바뀔 때마다 화면 전체를 새로 그리는 대신, 이전 렌더링 결과와 새 결과를 비교해서 달라진 부분만 실제 DOM에 반영합니다. 이때 목록은 유독 까다로운 대상입니다. 항목이 추가되거나 삭제되거나 순서가 바뀌면, 새로 만들어진 요소 중 무엇이 이전의 어떤 요소와 같은 항목인지 판단할 근거가 필요하기 때문입니다.
key가 바로 그 근거입니다. React는 같은 key를 가진 요소를 같은 항목으로 취급해서 DOM 노드와 그 안의 상태를 그대로 유지하고, key가 사라지면 그 요소를 제거하며, 새 key가 나타나면 새로 만듭니다. key가 없으면 React는 이 짝짓기를 할 수 없어 위치(순서)에만 의존하게 되고, 그래서 개발 모드에서 경고를 띄워 알려주는 겁니다.
key는 전역에서 고유할 필요는 없고, 같은 배열 안의 형제 요소끼리만 구분되면 충분합니다. 서로 다른 목록끼리 같은 key가 겹치는 건 문제가 되지 않습니다.
예전에 외주로 맡았던 관리자 페이지에서 이것 때문에 한참 헤맨 적이 있습니다. 배송지 목록을 여러 줄 추가·삭제할 수 있는 화면이었는데, 중간 행을 삭제하면 지운 행의 입력값이 아래 행으로 밀려 올라오는 버그가 있었습니다. 처음엔 삭제 핸들러에서 배열을 잘못 자르고 있나 싶어 filter 로직만 몇 번을 들여다봤고, 상태를 콘솔에 찍어보면 배열은 분명히 정확하게 지워져 있었습니다. 데이터는 맞는데 화면만 틀린 상황이라 한동안 원인을 못 찾다가, key={index}로 되어 있던 걸 보고 나서야 짚였습니다. 행을 지워도 index는 그대로 0, 1, 2로 다시 매겨지니 React 입장에서는 "3번째 행이 사라졌다"가 아니라 "행들의 내용이 바뀌었다"로 해석했던 겁니다.
헷갈리기 쉬운 지점: 경고가 사라졌다고 해결된 게 아니다
key={index}를 넣으면 경고 메시지는 곧바로 사라집니다. 그래서 이걸 정답으로 오해하기 쉬운데, 실제로는 React에게 "위치를 기준으로 짝지어라"라고 명시한 것에 가깝습니다. 목록이 처음 그려진 뒤 순서가 절대 바뀌지 않고 중간 삽입·삭제도 없다면 문제가 드러나지 않지만, 정렬·필터·행 삭제가 들어가는 순간 위 사례처럼 상태가 엉뚱한 행에 붙습니다. 경고가 사라진 시점과 버그가 나타나는 시점이 멀리 떨어져 있어서 원인을 연결짓기가 더 어렵습니다.
실무 팁: key는 컴포넌트가 받아볼 수 없다
key는 React가 내부적으로 쓰는 특수한 값이라 자식 컴포넌트에 props로 전달되지 않습니다. <TodoItem key={todo.id} />라고 넘겨놓고 컴포넌트 안에서 props.key로 id를 꺼내 쓰려다 undefined만 나와서 한참 헤매는 경우가 흔합니다. 컴포넌트 내부에서도 그 id가 필요하다면 <TodoItem key={todo.id} id={todo.id} />처럼 별도 prop으로 한 번 더 넘겨야 합니다.
실전 해결 코드: 상황별 key 지정 방법
기본: 데이터의 고유 id를 사용
// 문제: key가 없어 경고 발생
<ul>
{todos.map((todo) => (
<li>{todo.text}</li>
))}
</ul>
// 해결: 항목을 식별할 수 있는 고유 id를 key로 지정
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
key는 map()이 반환하는 최상위 요소에 붙여야 합니다. 안쪽 자식 요소에 붙이면 경고가 그대로 남습니다.
Fragment로 감싼 경우
import { Fragment } from 'react';
// 문제: 축약 문법(<>)에는 key를 붙일 수 없음
{items.map((item) => (
<>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</>
))}
// 해결: Fragment를 풀어 쓰면 key를 지정할 수 있음
{items.map((item) => (
<Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</Fragment>
))}
Fragment는 React.Fragment와 같은 것으로, react에서 이름을 꺼내 쓰느냐 React 객체를 통해 접근하느냐의 차이일 뿐입니다. 어느 쪽으로 써도 동작은 같습니다.
고유 id가 없을 때
// 항목을 만들 때 id를 함께 부여해 두는 게 가장 확실합니다
const addRow = () => {
setRows((prev) => [
...prev,
{ id: crypto.randomUUID(), address: '' },
]);
};
// 렌더링 중에 즉석에서 만드는 방식은 피해야 합니다
<li key={Math.random()}>{row.address}</li> // 매 렌더마다 새 key
Math.random()이나 Date.now()를 렌더링 중에 key로 만들면 리렌더링마다 값이 달라집니다. React는 매번 전부 다른 항목이라고 판단해서 기존 DOM을 버리고 새로 만들기 때문에, 입력 중이던 값이 날아가고 포커스도 풀립니다. id를 붙일 곳이 정말 없다면 데이터를 만드는 시점에 crypto.randomUUID() 같은 값으로 한 번만 부여해두는 쪽이 맞습니다.
key 값으로 무엇을 쓸까
경고를 없애는 것과 버그를 막는 것은 다른 문제입니다.
| key 값 | 동작 | 적합한 상황 |
|---|---|---|
| 데이터 고유 id | 항목이 어디로 이동하든 DOM과 상태가 그 항목을 따라감 | 대부분의 경우에 권장되는 기본 선택 |
| 배열 index | 위치를 기준으로 짝지어져, 순서가 바뀌면 상태가 자리에 남음 | 정렬·삽입·삭제가 전혀 없는 고정 목록에 한정 |
| Math.random() 등 | 매 렌더마다 전부 새 항목으로 취급해 DOM을 다시 만듦 | 적합한 상황 없음 — 입력값·포커스가 사라짐 |
key의 규칙과 목록 렌더링 방식은 공식 문서에서 확인할 수 있습니다.
React의 렌더링 비교 과정과 상태 보존 규칙을 좀 더 자세히 다룬 글은 [호마다의 IT 개발 입문 블로그] 에서 볼 수 있습니다.
댓글
댓글 쓰기