RSS듀오랩스
React

React key와 index: 목록 입력값이 뒤섞이는 문제

작성자
듀오랩스 대표·9분 읽기

할 일 목록에서 줄마다 메모를 적는 입력칸이 있습니다.

function TodoList({ todos, onRemove }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <TodoRow key={index} todo={todo} onRemove={onRemove} />
      ))}
    </ul>
  );
}

function TodoRow({ todo, onRemove }) {
  const [memo, setMemo] = useState("");
  return (
    <li>
      {todo.title}
      <input value={memo} onChange={(e) => setMemo(e.target.value)} />
      <button onClick={() => onRemove(todo.id)}>삭제</button>
    </li>
  );
}

첫 줄 「견적서 보내기」의 메모칸에 「오늘 오후」라고 적고, 첫 줄을 삭제합니다. 목록에는 「견적서 보내기」가 사라지고 둘째 줄이던 「샘플 발송」이 맨 위로 올라옵니다. 그런데 「샘플 발송」 옆 메모칸에 「오늘 오후」가 적혀 있습니다.

key 는 경고를 없애는 값이라는 생각

목록을 map 으로 그리면 React 가 콘솔에 key 가 없다는 경고를 냅니다. 그래서 key 를 흔히 「경고를 끄기 위해 넣는 값」, 무엇이든 서로 다르기만 하면 되는 값으로 이해합니다. 가장 손쉬운 값이 map 의 두 번째 인자인 index 입니다. 경고가 사라지고, 화면도 잘 나옵니다.

이 이해로는 메모가 엉뚱한 줄로 옮겨 간 이유가 설명되지 않습니다. key 는 모두 달랐고, 경고도 없었습니다. key 의 역할은 서로 다르기만 한 것이 아니었습니다.

state 는 컴포넌트가 아니라 트리의 자리에 붙는다

먼저 메모가 어디에 저장돼 있는지부터 봐야 합니다. memoTodoRow 안의 useState 로 만들었으니 그 컴포넌트가 갖고 있다고 생각하기 쉽습니다. Preserving and Resetting State 문서는 다르게 설명합니다.

React preserves a component's state for as long as it's being rendered at its position in the UI tree.

state 를 실제로 들고 있는 것은 React 이고, React 는 그 state 를 렌더 트리의 자리에 연결해 둡니다. 다음 렌더링에서 같은 자리에 같은 종류의 컴포넌트가 오면, 그 컴포넌트가 지난번 자리의 state 를 이어받습니다.

그럼 목록 안에서 「같은 자리」는 무엇으로 정해질까요. 이것을 정하는 것이 key 입니다.

key 는 형제 사이에서 항목을 알아보는 이름표

Rendering Lists 문서는 key 를 폴더 안의 파일 이름에 비유합니다. 파일에 이름이 없고 「첫 번째 파일」, 「두 번째 파일」로만 부른다면, 첫 파일을 지우는 순간 두 번째 파일이 첫 번째가 되어 헷갈리게 된다는 설명입니다.

File names in a folder and JSX keys in an array serve a similar purpose. They let us uniquely identify an item between its siblings.

React 는 렌더링이 끝나면 지난번 목록과 이번 목록을 key 로 맞춰 봅니다. key 가 같은 항목은 같은 항목으로 보고 state 와 DOM 을 이어 줍니다. 지난번에 있던 key 가 없으면 그 항목을 지우고, 새 key 가 생기면 새로 만듭니다.

index 를 key 로 쓰면 이름표가 자리를 따라간다

처음의 목록에 대입해 보겠습니다. 삭제 전과 후의 key 는 이렇습니다.

key 삭제 전 삭제 후
0 견적서 보내기 (메모: 오늘 오후) 샘플 발송
1 샘플 발송 거래처 방문
2 거래처 방문 없음

React 가 보기에 key 0 은 삭제 전에도 있었고 삭제 후에도 있습니다. 그래서 key 0 자리의 TodoRow 를 그대로 이어 쓰고, props 의 todo 만 「샘플 발송」으로 바꿉니다. 메모 state 는 key 0 자리에 붙어 있으니 「오늘 오후」가 남습니다. 사라진 것으로 판단되는 것은 key 2, 즉 마지막 줄입니다.

사용자가 지운 것은 첫 줄인데, React 는 마지막 줄을 지우고 나머지 줄의 내용을 한 칸씩 당겨 쓴 것으로 처리했습니다. index 는 항목의 이름이 아니라 자리의 번호라서, 순서가 바뀌면 이름표가 항목이 아니라 자리를 따라갑니다. 문서도 이 점을 짚습니다.

But the order in which you render items will change over time if an item is inserted, deleted, or if the array gets reordered. Index as a key often leads to subtle and confusing bugs.

「subtle」이라는 단어가 정확합니다. props 로만 그리는 줄은 내용이 바로 바뀌어서 겉보기에 멀쩡합니다. 줄 안에 state 가 있거나, 입력칸의 포커스와 커서 위치, 펼침 상태, 애니메이션처럼 DOM 이 들고 있는 상태가 있을 때만 어긋남이 드러납니다.

데이터에서 온 안정된 id 를 쓰는 이유

해법은 항목 자체에 붙은 값을 key 로 쓰는 것입니다.

{todos.map((todo) => (
  <TodoRow key={todo.id} todo={todo} onRemove={onRemove} />
))}

이제 「견적서 보내기」를 지우면 그 id 가 목록에서 사라지므로 React 는 그 줄과 그 줄의 메모 state 를 함께 지웁니다. 「샘플 발송」은 자기 id 로 자기 state 를 그대로 이어받습니다.

문서가 정리한 key 의 규칙은 둘입니다. 형제 사이에서 유일해야 하고, 바뀌지 않아야 합니다. 서버 데이터라면 데이터베이스 id 를 쓰고, 브라우저에서 새로 만드는 항목이라면 만들 때 crypto.randomUUID() 나 증가하는 번호로 id 를 붙여 두면 됩니다. 중요한 것은 만들 때 한 번 붙인다는 점입니다.

그래서 반대 방향의 실수도 있습니다.

<TodoRow key={Math.random()} ... />

렌더링할 때마다 key 가 새로 만들어지면 React 는 매번 모든 줄을 처음 보는 항목으로 판단합니다. 문서의 표현대로 모든 컴포넌트와 DOM 이 매번 새로 만들어져 느리고, 입력하던 내용도 렌더링마다 사라집니다. index 가 이름표를 자리에 묶는다면, 무작위 key 는 이름표를 매번 바꿔 다는 셈입니다.

index 가 괜찮은 경우도 있습니다. 문서의 예처럼 시의 행처럼 순서가 절대 바뀌지 않고 추가나 삭제도 없는 목록입니다. 다만 저는 「지금은 바뀌지 않는다」가 「앞으로도 바뀌지 않는다」를 보장하지 않으므로, id 가 있다면 id 를 쓰는 편이 안전하다고 봅니다.

목록 밖에서 key 로 state 를 초기화하는 방법

key 가 「이 자리의 컴포넌트가 같은 것인가」를 정한다는 점을 이용하면 목록이 아닌 곳에서도 쓸 수 있습니다. 같은 문서가 이 용도를 따로 소개합니다.

<ChatInput key={contactId} contactId={contactId} />

대화 상대를 바꿀 때 입력하던 메시지가 남으면 안 되는 화면입니다. contactId 를 key 로 주면, 상대가 바뀔 때 React 는 다른 컴포넌트로 판단해 기존 것을 지우고 새로 만듭니다. 입력칸 state 가 초기화됩니다. 효과로 「상대가 바뀌면 state 를 비운다」를 짜는 것보다 단순하고, 한 렌더링 동안 옛 값이 보이는 순간도 없습니다.

이 힘은 반대로 비용이 되기도 합니다. key 에 자주 바뀌는 값을 넣으면 그때마다 컴포넌트가 지워지고 새로 만들어집니다. 지도 컴포넌트의 key 에 좌표를 넣었다가 확대할 때마다 지도가 통째로 다시 만들어진 사례를 Leaflet 지도가 느려지는 3가지 원인에 적어 두었습니다. key 에는 「이 값이 바뀌면 완전히 다른 것으로 봐야 하는가」에 예라고 답할 수 있는 값만 넣습니다.

여기까지가 확실한 부분

state 가 트리의 자리에 붙는다는 점, key 의 두 규칙, index 와 무작위 key 에 대한 경고, key 로 state 를 초기화하는 방법은 react.dev 문서 기준입니다. key 는 컴포넌트에 prop 으로 전달되지 않으므로, 컴포넌트 안에서 id 가 필요하면 todo.id 처럼 따로 넘겨야 합니다. 목록을 비교하는 내부 알고리즘의 세부 동작은 문서가 보장하는 범위가 아니라서 이 글에서 다루지 않았습니다.

이 게시글 공유하기

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.