본문으로 건너뛰기
개발 머꼬
개발 노트React
hohyeon.dev4

Each child in a list should have a unique key prop 해결 방법, key={index}로 껐다가 데이터가 섞인 이유

  • #Common Pitfall
  • #Engineering Note
  • #React

문제 발생

참석자 목록을 보여 주는 화면을 만들었습니다. 화면은 멀쩡히 나오는데 콘솔에 빨간 줄이 떴습니다.

Each child in a list should have a unique "key" prop.
See https://react.dev/link/warning-keys for more information.
Check the render method of `NoKey`.

화면이 깨진 것도 아니고 에러로 멈추지도 않아서, 일단 key={index}를 넣어 경고만 껐습니다. 그런데 며칠 뒤 이상한 제보가 왔습니다. 목록에서 첫 번째 사람을 삭제하면, 두 번째 사람의 행에 첫 번째 사람이 입력해 둔 메모가 그대로 남아 있다는 겁니다.

직접 재현해 봤습니다. 세 행의 입력칸에 각각 수정0, 수정1, 수정2를 넣고 첫 행을 지웠더니 이렇게 됐습니다.

삭제 전: 김철수[수정0]  이영희[수정1]  박민수[수정2]
삭제 후: 이영희[수정0]  박민수[수정1]

이름은 한 칸씩 당겨졌는데 입력값은 제자리에 남았습니다. 이영희 행에 김철수가 쓴 값이 붙은 겁니다. 경고를 끄려고 넣은 한 줄이 데이터가 섞이는 버그를 만들었습니다. 이 글의 재현은 React 19.2와 Chromium에서 했습니다.

원인 분석

key는 React가 "이 항목이 그 항목"이라고 알아보는 이름표입니다. React 공식 문서의 목록 렌더링 페이지는 이걸 폴더의 파일 이름에 비유합니다. 파일에 이름이 없고 순서로만 부른다면, 첫 파일을 지우는 순간 두 번째가 첫 번째가 되고 세 번째가 두 번째가 되어 헷갈린다고요. 그래서 잘 고른 key는 배열 안의 위치보다 많은 정보를 주고, 순서가 바뀌어도 React가 그 항목을 끝까지 알아볼 수 있게 해 줍니다.

key={index}는 그 이름표를 다시 "순서"로 되돌립니다. 같은 문서가 정확히 경고하는 지점입니다. 배열의 인덱스를 key로 쓰고 싶을 수 있고 사실 key를 아예 안 주면 React가 그렇게 동작하지만, 항목이 추가·삭제되거나 순서가 바뀌면 렌더되는 순서가 달라지므로 인덱스를 key로 쓰는 건 미묘하고 헷갈리는 버그로 이어진다고 적혀 있습니다.

위 재현이 그 버그입니다. 첫 행을 지우면 이영희의 key가 1에서 0으로 바뀝니다. React 입장에서는 "0번 항목의 이름이 김철수에서 이영희로 바뀌었다"로 보일 뿐이라, DOM 노드와 그 안의 입력칸은 그대로 두고 텍스트만 교체합니다. 그래서 입력값이 제자리에 남습니다. 같은 코드에서 key만 t.id로 바꾸자 결과가 달라졌습니다.

key={index} 삭제 후: 이영희[수정0]  박민수[수정1]
key={t.id}  삭제 후: 이영희[수정1]  박민수[수정2]

id를 쓰면 React가 1번 항목만 사라졌다고 판단해 그 행의 DOM을 통째로 제거하고, 나머지 행은 입력값까지 자기 자리를 지킵니다.

key={Math.random()}은 정반대로 매번 전부 새로 만듭니다. 중복 경고를 피하려고 이렇게 쓰는 코드를 종종 봅니다. 문서는 즉석에서 key를 만들지 말라고 하면서, 그러면 렌더 사이에 key가 절대 일치하지 않아 모든 컴포넌트와 DOM이 매번 새로 만들어진다고 설명합니다. 느릴 뿐 아니라 목록 안의 사용자 입력도 전부 잃는다고요. 입력칸에 타이핑한 뒤 부모를 리렌더해 보니 그대로였습니다.

key={Math.random()} 리렌더 후: ['김철수', '이영희', '박민수']   ← 타이핑한 값 사라짐
key={t.id}          리렌더 후: ['타이핑중', '이영희', '박민수']  ← 그대로

key가 중복이면 경고 문구가 달라집니다. 같은 key를 두 번 쓰면 React는 다른 메시지를 냅니다. 재현에서 나온 문구는 이렇습니다. Encountered two children with the same key. Keys should be unique so that components maintain their identity across updates. 문서의 규칙도 같습니다. key는 형제 사이에서 유일해야 하고, 다만 서로 다른 배열이라면 같은 key를 써도 됩니다.

해결 방안

  1. 데이터가 가진 고유 ID를 씁니다. 문서는 데이터 출처별로 이렇게 안내합니다. DB에서 온 데이터라면 본래 유일한 DB 키나 ID를 쓰고, 로컬에서 만들어 저장하는 데이터라면 항목을 만들 때 증가하는 카운터나 crypto.randomUUID(), uuid 같은 패키지를 씁니다.
<ul>{rows.map((t) => <li key={t.id}>{t.name}</li>)}</ul>

핵심은 항목을 만들 때 한 번 정하고 그 뒤로 바뀌지 않는 값이라는 점입니다. 문서의 규칙도 "key는 바뀌면 안 되고, 렌더링 중에 생성하지 말라"입니다. Math.random()이 문제인 이유가 이겁니다. 서버가 ID를 안 주면 받아올 때 한 번 붙여 두고 그 값을 계속 씁니다.

  1. 인덱스를 써도 되는 경우를 구분합니다. 인덱스가 항상 틀린 건 아닙니다. 목록이 절대 바뀌지 않고(추가·삭제·정렬 없음), 항목에 입력칸이나 자체 state가 없다면 인덱스로도 문제가 생기지 않습니다. 반대로 이 중 하나라도 해당되면 인덱스는 위험합니다. 특히 입력칸, 체크박스, 펼침 상태처럼 사용자가 만든 상태가 행 안에 있으면 이번 사고처럼 값이 엉뚱한 행에 붙습니다. 애매하면 ID를 쓰는 쪽이 안전합니다.

  2. key는 map이 돌려주는 가장 바깥 요소에 답니다. 흔한 실수 하나는 안쪽 요소에 다는 겁니다.

// 경고가 그대로 남습니다
{rows.map((t) => <li><span key={t.id}>{t.name}</span></li>)}

재현해 보니 화면은 정상인데 Each child in a list should have a unique "key" prop. 경고가 계속 떴습니다. React가 보는 건 배열의 원소인 <li>라서, key도 거기 있어야 합니다.

  1. 한 항목이 여러 태그를 내보낸다면 <Fragment key=...>를 씁니다. <dt>와 <dd>를 한 쌍으로 내보내는 경우처럼 묶을 요소가 필요할 때, 짧은 문법 <>...</>에는 key를 줄 수 없어 경고가 그대로 납니다. React의 Fragment 문서는 반복문 안의 요소가 Fragment라면 key 속성을 주기 위해 일반 JSX 문법을 써야 한다고 적습니다.
import { Fragment } from "react";

<dl>{rows.map((t) => (
  <Fragment key={t.id}>
    <dt>{t.id}</dt>
    <dd>{t.name}</dd>
  </Fragment>
))}</dl>

재현에서 <>를 <Fragment key={t.id}>로 바꾸자 경고가 사라졌고, DOM에는 감싸는 태그가 추가되지 않았습니다.

  1. 자식 컴포넌트에서 ID가 필요하면 따로 넘깁니다. key를 줬으니 자식에서 props.key로 읽으면 된다고 생각하기 쉬운데, 그렇지 않습니다. React 문서는 ref와 key를 React가 쓰는 특별한 prop이라 컴포넌트로 전달되지 않는다고 설명합니다. 재현해 보니 자식에서 props.key는 undefined였고 콘솔에 이런 경고가 떴습니다. `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. 그러니 ID가 필요하면 이름을 달리해 한 번 더 넘깁니다.
{rows.map((t) => <Row key={t.id} id={t.id} />)}

참고로 key의 이 성질은 목록 밖에서도 쓸 수 있습니다. 문서의 state 보존 페이지에 따르면 key는 목록 전용이 아니라서, 같은 위치의 컴포넌트에 다른 key를 주면 React가 다른 컴포넌트로 보고 state를 초기화합니다. 사용자를 바꿀 때 폼 입력을 전부 비우고 싶다면 <Form key={userId} />처럼 쓰면 됩니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.