React를 사용해 본 적이 있다면 콘솔에서 다음과 같은 노란색 경고를 본 적이 있을 것입니다: “Each child in a list should have a unique ‘key’ prop.” 이는 단순히 정중한 제안처럼 들릴지 모르지만, 사실 React가 리스트 아이템들을 서로 구분할 수 없다고 경고하는 것입니다. 이를 무시하면 결국 재현하기 매우 까다로운 버그를 마주하게 됩니다. 상태가 잘못된 행으로 튀거나, 텍스트 입력창의 포커스를 잃거나, 애니메이션이 엉뚱한 요소에서 실행되는 등의 문제가 발생할 수 있습니다.
React의 렌더링 엔진은 UI를 픽셀 단위로 비교하지 않습니다. 대신 가상 DOM(virtual DOM)이라고 불리는 가벼운 객체 트리를 구축하고, 새로운 트리와 이전 트리를 비교하여 실제 DOM에 적용할 최소한의 변경 사항을 계산합니다. 리스트를 렌더링할 때 React는 형제 요소들의 배열을 보게 됩니다. 키(key)가 없다면, React는 특정 아이템이 이동했는지, 교체되었는지, 아니면 삭제되었는지 알 수 있는 신뢰할 수 있는 방법이 없습니다. 기본적으로 React는 위치(position)를 기준으로 매칭을 시도하는데, 이는 매우 취약한 방식입니다. 키는 안정적인 식별자 역할을 합니다. 키는 React에게 "이 요소는 위치가 바뀌었더라도 이전과 동일한 요소입니다"라고 알려줍니다. 키를 잘못 사용하면 결정론적인 업데이트 대신 추측에 의존하는 업데이트를 하게 됩니다.
최소한의 해결 방법
이 경고는 보통 map 호출 내부에서 나타납니다. 반드시 반복문(iterator)에서 반환되는 최상위 요소의 key 속성에 고유한 값을 할당해야 합니다.
경고를 유발하는 모든 코드베이스에서 볼 수 있는 패턴은 다음과 같습니다:
const UserList = ({ users }) => {
return (
<ul>
{users.map((user) => (
<li>{user.name}</li>
))}
</ul>
);
};
React는 세 개의 <li> 태그를 보지만, 각각이 무엇인지 알 수 없습니다. 수정 방법은 속성 하나를 추가하는 것입니다:
const UserList = ({ users }) => {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
key는 map 콜백 함수 바로 아래에 있는 요소에 할당되어야 합니다. 만약 <li>를 별도의 UserItem 컴포넌트로 추출하더라도, 키는 호출부의 컴포넌트에 있어야 합니다:
{users.map((user) => (
<UserItem key={user.id} user={user} />
))}
UserItem 내부의 <div>에 키를 넣는 방식으로는 경고를 없앨 수 없으며, 재조정(reconciliation) 동작도 수정할 수 없습니다. React는 반복문이 반환하는 요소에서 키를 찾기 때문입니다.
인덱스를 키로 사용하는 것이 위험한 이유
map의 두 번째 인자를 사용하여 경고를 없애고 싶은 유혹이 생길 수 있습니다:
{users.map((user, index) => (
<li key={index}>{user.name}</li>
))}
이렇게 하면 콘솔의 노이즈는 사라지지만, 근본적인 문제는 해결되지 않습니다. 배열 인덱스는 식별자가 아닙니다. 인덱스는 위치일 뿐이며, 위치는 변합니다.
다음과 같은 순서로 렌더링된 세 명의 사용자 리스트가 있다고 가정해 봅시다:
- Alice (index 0)
- Bob (index 1)
- Charlie (index 2)
만약 Alice를 삭제하면, Bob은 인덱스 0으로 이동하고 Charlie는 인덱스 1로 이동합니다. React는 새 트리와 이전 트리를 비교합니다. 인덱스 0에 이제 Bob의 데이터가 있다는 것을 확인하면, 이전에 Alice를 보여주던 기존 DOM 노드를 수정(mutate)합니다. 만약 그 노드에 포커스가 있었다면, 커서는 첫 번째 행에 그대로 남아있지만 텍스트는 Bob으로 바뀝니다. 만약 해당 행에 로컬 상태를 가진 <input>이 있었다면, 그 상태는 인덱스 0에 계속 묶여 있게 됩니다. 사용자는 Bob의 행처럼 보이는 곳에 타이핑을 하지만, 실제 상태는 Alice의 것이었습니다. 정렬, 필터링, 또는 아이템을 앞에 추가할 때도 똑같은 혼란이 발생합니다. 인덱스를 키로 사용해도 안전한 유일한 경우는 리스트가 완전히 정적인 경우입니다. 즉, 순서 변경, 필터링, 삽입, 삭제가 전혀 없는 경우입니다. 절대 변하지 않는 하드코딩된 네비게이션 링크가 좋은 예입니다. 그 외의 모든 경우에는 실제 식별자가 필요합니다.
안정적인 키를 찾는 방법
가장 좋은 키는 데이터 모델에 이미 존재하는 고유 식별자입니다. 데이터베이스의 기본 키(primary key)인 id는 고유함이 보장되고 렌더링 간에도 유지되므로 이상적입니다. 백엔드에서 uuid, slug 또는 기타 자연스럽게 고유한 필드를 포함한 객체를 반환한다면 그것을 사용하세요.
API 응답에 고유한 필드가 없는 경우, 두 가지 실질적인 방법이 있습니다. 첫째, 백엔드 팀에 요청하여 id를 포함하도록 합니다. 기본 키 없이 관계형 데이터를 전달하는 것은 코드 스멜(smell)이며, 소스 단계에서 이를 수정하면 스택 전체의 모호함을 제거할 수 있습니다. 둘째, 만약 사용자가 서버에 전달하기 전에 작업을 생성하는 할 일 목록(todo list)처럼 클라이언트 측에서 완전히 아이템을 생성하는 경우라면, 생성 시점에 ID를 생성하세요. uuid나 nanoid 같은 라이브러리가 바로 이 용도로 만들어졌습니다. 사용자가 폼을 제출할 때 ID를 생성하여 객체에 저장하고, 이를 키로 영구적으로 사용하세요.
절대로 렌더링 경로(render path) 내부에서 키를 생성하지 마세요. 컴포넌트 렌더링 중에 Math.random()이나 Date.now()를 호출하면 매번 새로운 값이 생성됩니다. React는 새로운 키를 보고 이것이 완전히 새로운 요소라고 판단하여, 기존 DOM 노드를 파괴하고 새 노드를 생성합니다. 해당 요소 내부의 모든 상태는 초기화됩니다. 포커스는 사라집니다. React가 불필요한 DOM 작업을 수행하게 되므로 성능도 급격히 저하됩니다. 무작위로 생성된 키는 키가 아예 없는 것보다 더 나쁩니다.
Fragments, 컴포넌트, 그리고 스코프
덜 명백한 함정 중 하나는 React Fragment와 관련이 있습니다. 데이터를 map으로 순회하면서 래퍼 <div> 없이 여러 형제 요소를 반환해야 할 때, 다음과 같은 축약 문법을 사용하고 싶을 수 있습니다.
{items.map((item) => (
<>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</>
))}
<>...</> 축약형은 props를 지원하지 않으므로 key를 붙일 수 없습니다. 이 경우에는 다음과 같이 명시적인 전체 문법을 사용해야 합니다.
{items.map((item) => (
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</React.Fragment>
))}
React가 렌더링 과정에서 해당 쌍을 하나의 단위로 추적할 수 있도록 Fragment에 해당 key가 필요합니다.
또 다른 미묘한 점은 key가 일반적인 의미의 props가 아니라는 것입니다. 만약 <ListItem key={item.id} />라고 작성한다면, ListItem 컴포넌트는 props.key를 읽을 수 없습니다. React는 이를 내부적인 관리를 위해 사용하기 때문입니다. 만약 컴포넌트 자체 로직을 위해 식별자가 반드시 필요하다면, itemId와 같이 다른 이름으로 별도로 전달해야 합니다.
안전을 위한 실무 규칙
- 데이터베이스 ID를 우선적으로 사용하세요. 데이터베이스 ID는 고유하며, 숫자 또는 문자열 기반이고, 안정적입니다.
- 클라이언트 전용 데이터에는
uuid또는nanoid를 사용하세요. ID는 컴포넌트 렌더링 내부가 아니라, 레코드가 생성될 때 한 번만 생성해야 합니다. - 리스트가 변경될 가능성이 있다면 절대 배열 인덱스에서 key를 유도하지 마세요. 정렬, 필터링, 삭제 작업 시 시각적 오류나 상태 버그가 발생할 수 있습니다.
Math.random(),Date.now()또는 렌더링 사이에 값이 변하는 값을 절대 사용하지 마세요. 이는 불필요한 언마운트(unmounting)와 리마운트(remounting)를 유발합니다.map내부에서 사용되면서key가 필요한 Fragment는 반드시 전체 문법을 사용해야 함을 기억하세요.key는 자식 컴포넌트 내부가 아니라,map에 의해 반환되는 요소에 배치하세요.
핵심 요약
key prop은 단순히 장식적인 린트(lint) 규칙이 아닙니다. 이는 React가 렌더링 전반에 걸쳐 식별성을 유지하는 방법입니다. 데이터베이스 테이블의 기본 키(primary key)와 같다고 생각하면 됩니다. 이 식별성이 안정적일 때, React는 요소를 정확하게 이동, 업데이트 및 제거할 수 있습니다. 식별자가 없거나 불안정하면 UI 상태가 손상되거나 재조정(reconciliation) 속도가 느려지는 대가를 치르게 됩니다. 데이터 계층에서 한 번만 제대로 해결해 두면, 리스트가 아무리 커지거나 변경되더라도 예측 가능한 방식으로 동작할 것입니다.
