Frontend

[Frontend] React key, 그리고 인덱스를 key로 쓰면 생기는 일

teddy bear 2026. 7. 9. 18:52
반응형

들어가며

"key 경고 뜨는데 그냥 index 넣어" — 신입 시절 리뷰어에게 가장 자주 들었던 조언 아닌 조언이다. 콘솔에 뜨는 노란 경고를 없애는 게 목적이었으니, key={index}를 넣으면 경고가 사라진다는 사실만으로 충분했다. 그 뒤에 어떤 문제가 숨어있는지는 실제로 버그를 겪고 나서야 알게 됐다.

체크리스트 항목을 삭제하는 기능을 만들었는데, 삭제한 항목이 아니라 엉뚱한 항목의 체크 상태가 지워지는 버그였다. 로직을 아무리 봐도 문제가 없어 보였다. 원인은 로직이 아니라 key에 있었다. 이 글은 그때 파고들며 정리한 key의 동작 원리와, 인덱스를 key로 쓰면 왜 문제가 생기는지에 대한 기록이다.


1. key는 대체 왜 필요한가

1-1. 재조정(Reconciliation)의 기본 전제

React는 상태가 바뀔 때마다 전체 DOM을 새로 그리지 않는다. 대신 이전 렌더링 결과(가상 DOM 트리)와 새 렌더링 결과를 비교해서, 달라진 부분만 실제 DOM에 반영한다. 이 비교 과정을 재조정이라고 부른다.

문제는 리스트다. 배열 형태의 자식 요소들을 비교할 때, React는 "이전 배열의 몇 번째 항목이 새 배열의 몇 번째 항목과 같은 항목인가"를 알아야 한다. key가 없다면 React는 어쩔 수 없이 같은 위치(인덱스)에 있는 요소는 같은 요소라고 가정하고 비교한다.

// key 없이 렌더링한다면
// React는 "위치가 같으면 같은 요소"라고 가정할 수밖에 없다
{items.map((item) => (
  <li>{item.name}</li>
))}

이 가정이 항상 맞다면 문제없지만, 리스트 중간에 항목이 추가되거나 삭제되거나 순서가 바뀌는 순간 이 가정은 깨진다. key는 React에게 "이 요소는 이전 렌더링의 그 요소와 동일한 정체성을 가진 요소"라는 걸 명시적으로 알려주는 식별자다.

1-2. key가 있을 때와 없을 때의 차이

세 개짜리 리스트에서 맨 앞에 항목 하나를 추가하는 상황을 비교해보자.

이전: [A, B, C]
이후: [X, A, B, C]

key가 없을 때(인덱스 기반으로 가정): React는 위치 0의 A가 X로, 위치 1의 B가 A로, 위치 2의 C가 B로 "바뀌었다"고 인식한다. 그리고 위치 3에 C가 새로 "추가"됐다고 판단한다. 실제로는 하나만 추가됐을 뿐인데, React 입장에서는 기존 항목 세 개가 전부 내용이 바뀐 것처럼 보이는 것이다. 그 결과 A, B, C 각각의 DOM 노드에 대해 불필요한 업데이트가 발생한다.

key가 각 항목의 고유 id일 때: React는 X가 새로 추가됐을 뿐, A/B/C는 기존과 동일한 요소라는 걸 정확히 인식한다. 기존 A/B/C의 DOM 노드는 그대로 재사용하고, X에 해당하는 DOM 노드만 새로 생성해서 앞에 끼워 넣는다.

이 차이는 단순히 "효율성" 문제로 끝나지 않는다. DOM 노드가 그대로 유지되는지 여부는, 그 노드에 얽혀있는 상태에도 직접 영향을 준다.


2. 인덱스를 key로 쓰면 실제로 어떤 문제가 생기는가

2-1. 항목 내부에 자체 상태가 있는 경우

가장 흔하게 겪는 문제 상황을 재현해보면 이렇다. 체크박스가 달린 할 일 목록이 있고, 각 항목이 체크되었는지 여부를 컴포넌트 자체 상태(useState)로 관리한다고 하자.

function TodoItem({ text }) {
  const [checked, setChecked] = useState(false);
  return (
    <li>
      <input type="checkbox" checked={checked} onChange={() => setChecked(!checked)} />
      {text}
    </li>
  );
}

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <TodoItem key={index} text={todo.text} /> // 인덱스를 key로 사용
      ))}
    </ul>
  );
}

초기 상태: ['빨래하기', '설거지하기', '청소하기'] 중 '설거지하기'(인덱스 1)를 체크했다고 하자.

여기서 맨 앞의 '빨래하기'를 배열에서 삭제하면, 배열은 ['설거지하기', '청소하기']가 된다. 원래 사용자 입장에서 기대하는 결과는 "설거지하기가 여전히 체크되어 있어야 한다"는 것이다. 하지만 실제로는 '설거지하기' 항목이 아니라 '청소하기' 위치(인덱스 0)에 체크 표시가 남는다.

왜 이런 일이 벌어지는가. key가 인덱스이기 때문에, React 입장에서는 "인덱스 0 컴포넌트"와 "인덱스 1 컴포넌트"가 이전과 동일한 정체성을 가진 컴포넌트라고 판단한다. 실제로는 빨래하기가 사라지고 나머지가 한 칸씩 당겨진 것뿐인데, React는 **"인덱스 0의 컴포넌트 내용물(text)만 바뀌었다"**고 해석한다. 그 결과 인덱스 0 컴포넌트는 DOM 노드와 내부 상태(checked)를 그대로 유지한 채로 text prop만 '빨래하기'에서 '설거지하기'로 바뀐다. 체크 상태(checked=false였던 상태)는 그대로 유지된 채, 텍스트만 바뀐 것이다.

원래 체크되어 있던 건 '설거지하기'였는데, 삭제 이후 인덱스 0 자리에는 이제 '설거지하기'가 오게 되고, 이 컴포넌트는 원래 인덱스 0이었던 '빨래하기' 컴포넌트의 상태(checked=false)를 그대로 물려받는다. 반대로 인덱스 1 자리(원래 '청소하기'였고 checked=false)에는 원래 인덱스 1이었던 '설거지하기' 컴포넌트의 상태(checked=true)가 남는다. 결과적으로 텍스트는 앞으로 당겨졌는데, 체크 상태는 원래 위치에 남아버리는 어긋남이 발생한다.

2-2. 고유 id를 key로 쓰면 해결되는 이유

{todos.map((todo) => (
  <TodoItem key={todo.id} text={todo.text} /> // 데이터 자체의 고유 id 사용
))}

이렇게 바꾸면, '빨래하기'가 삭제됐을 때 React는 key를 기준으로 "id가 1번인 컴포넌트 자체가 트리에서 사라졌다"는 걸 정확히 인식한다. id가 2번, 3번인 컴포넌트는 위치만 한 칸씩 당겨졌을 뿐 정체성은 그대로이므로, 각각의 DOM 노드와 내부 상태를 그대로 유지한 채 위치만 이동시킨다. '설거지하기'의 체크 상태는 정확히 '설거지하기'를 따라간다.

2-3. 폼 입력값이 꼬이는 경우

체크박스 예시와 본질은 같지만, 실무에서 더 자주 만나는 형태는 리스트 안에 입력 필드가 있는 경우다.

function EditableList({ items, onRemove }) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>
          <input defaultValue={item.value} />
          <button onClick={() => onRemove(item.id)}>삭제</button>
        </li>
      ))}
    </ul>
  );
}

사용자가 두 번째 입력창에 직접 텍스트를 타이핑해서 값을 바꿔놓은 상태에서, 첫 번째 항목을 삭제하면 어떻게 될까. input은 defaultValue(비제어 컴포넌트)를 쓰고 있으므로, 입력창의 실제 값은 React가 아니라 DOM 자체가 들고 있다. 인덱스 기반 key 때문에 React는 첫 번째 <input> DOM 노드를 재사용하려 들고, 그 결과 사용자가 두 번째 칸에 입력했던 값이 첫 번째 칸의 자리에 그대로 남아 보이는, 매우 혼란스러운 현상이 발생한다. 실제로 겪었던 체크리스트 버그도 정확히 이 패턴이었다 — 상태가 텍스트를 "따라가지" 못하고 "위치를" 따라가면서 생긴 문제였다.


3. 그렇다면 인덱스를 key로 써도 되는 경우는 없는가

무조건 금지는 아니다. 다음 조건을 모두 만족하는 경우에는 인덱스를 key로 써도 실질적인 문제가 없다.

  1. 리스트와 그 안의 항목들이 정적이다 — 계산되어 변하지 않고, 다시 렌더링되지 않는다.
  2. 리스트 항목에 고유 id가 없다 — 정말로 식별할 방법이 데이터 자체에 없는 경우다.
  3. 리스트가 재정렬되거나, 중간에서 추가/삭제되지 않는다 — 맨 끝에 추가되는 것 정도만 일어난다.
// 정적인 안내 문구 목록처럼, 절대 순서가 바뀌거나 중간 삭제가 없는 경우
const staticNotices = ['이용약관이 개정되었습니다', '점검 안내: 매주 화요일 02시~04시'];

function NoticeList() {
  return (
    <ul>
      {staticNotices.map((notice, index) => (
        <li key={index}>{notice}</li> // 이 경우는 실질적으로 문제 없음
      ))}
    </ul>
  );
}

다만 실무에서는 "지금은 정적이지만 나중에 동적으로 바뀔 수도 있는" 리스트가 생각보다 많다. 처음엔 고정된 안내 문구였다가, 나중에 관리자가 순서를 바꾸거나 항목을 추가/삭제할 수 있게 기능이 확장되는 경우를 실제로 몇 번 겪었다. 그래서 팀 컨벤션으로는 **"데이터에 고유 id를 만들 수 있다면 무조건 만들어서 쓴다"**는 원칙을 정해뒀다. 처음부터 안전한 패턴으로 짜두면, 나중에 요구사항이 바뀌었을 때 key 관련 버그를 신경 쓸 필요가 없어진다.

3-1. 백엔드에서 id를 안 줄 때는 어떻게 하는가

가끔 API 응답에 고유 id가 없는 경우가 있다. 이럴 때 인덱스로 대충 때우기보다, 클라이언트에서 안정적인 id를 생성해서 붙이는 방법을 쓴다.

// API 응답에 id가 없을 때, 받는 시점에 한 번만 id를 부여
const itemsWithId = useMemo(
  () => rawItems.map((item) => ({ ...item, _localId: crypto.randomUUID() })),
  [rawItems] // rawItems가 바뀔 때만 새로 id 부여
);

여기서 중요한 건 crypto.randomUUID()를 렌더링 함수 본문에서 매번 호출하면 안 된다는 점이다. 렌더링될 때마다 새 id가 생성되면, 매 렌더링마다 모든 항목의 key가 바뀌는 셈이 되어 오히려 key가 없는 것보다 못한 상황(매번 전체 리마운트)이 벌어진다. useMemo로 감싸서 원본 데이터가 실제로 바뀔 때만 새로 id를 부여하도록 해야 한다.


4. key를 잘못 쓰면 생기는 또 다른 비용 — 불필요한 마운트/언마운트

상태 어긋남 외에, 인덱스 key의 또 다른 실질적 문제는 불필요한 DOM 재생성 비용이다. 리스트 맨 앞에 항목을 계속 추가하는 상황을 생각해보면, 인덱스 기반 key에서는 매번 기존 항목 전체의 key가 다시 매겨지는 셈이라, React는 기존 항목들도 전부 "내용이 바뀐 것"으로 보고 매번 diff를 다시 수행한다.

만약 리스트 항목 안에 애니메이션이 있거나, useEffect로 마운트 시점에 무거운 초기화 작업(외부 라이브러리 초기화, 데이터 페칭 등)을 하고 있다면 상황은 더 심각해진다. key가 자주 바뀌면 React는 "이 컴포넌트는 이전 것과 다른 컴포넌트"라고 판단해서 기존 컴포넌트를 언마운트하고 새로 마운트시킬 수 있는데, 이 경우 useEffect의 클린업과 재실행이 리스트 조작마다 반복해서 일어난다. 실제로 리스트 항목 안에 차트 라이브러리를 초기화하는 코드가 있던 화면에서, 항목을 하나 추가할 때마다 기존 차트들이 전부 깜빡이며 다시 그려지는 문제를 겪은 적이 있다. key를 고유 id 기반으로 바꾸는 것만으로 기존 차트들은 전혀 건드리지 않고 새 항목의 차트만 마운트되도록 고칠 수 있었다.


5. 정리

상황 권장 key

데이터에 고유 id가 있다 그 id를 그대로 사용
데이터에 id가 없지만 동적으로 추가/삭제/재정렬된다 받는 시점에 한 번만 안정적인 id를 생성해서 부여
완전히 정적이고 절대 순서/구성이 바뀌지 않는 리스트 인덱스도 실질적으로 무방 (다만 팀 컨벤션상 id 권장)
항목 안에 자체 상태나 비제어 입력, 무거운 마운트 로직이 있다 반드시 고유 id — 인덱스는 절대 금지

마치며

key 경고를 없애는 게 목적이었을 때는 인덱스를 넣으나 id를 넣으나 결과가 똑같아 보였다. 하지만 key는 단순히 React가 콘솔에 경고를 안 띄우게 하기 위한 요식행위가 아니라, "이 화면 요소가 이전 렌더링의 어떤 요소와 같은 존재인가"를 React에게 알려주는 정체성 식별자라는 걸 버그를 직접 겪고 나서야 체감했다.

지금은 리스트 코드를 짤 때 key={index}가 보이면 반사적으로 "이 리스트, 정말 순서도 구성도 안 바뀌나?"를 한 번 더 따져보는 습관이 생겼다. 대부분의 경우 답은 "언젠가는 바뀐다"였다.


참고하면 좋을 자료