학습 자료

리스트 렌더링과 key


Article

상품 열 개를 화면에 늘어놓는다. 카드 컴포넌트를 열 번 쓸 수는 없다. 데이터가 몇 개일지 미리 알 수도 없다.

3강에서 JSX는 값이라고 했다. 값이라면 배열에 담을 수 있다.

map 으로 배열을 만든다

자바스크립트 편 16강의 map이다. 각 데이터를 JSX로 바꾼다.

function ProductList({ products }) {
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}
데이터가 화면이 되는 길
  1. [{id:1,…}, {id:2,…}]배열
  2. map(p => <li>…</li>)각각을 JSX 로
  3. [<li/>, <li/>]JSX 배열
  4. 화면React 가 펼쳐 그린다

3강에서 "배열은 펼쳐서 이어붙인다"고 했다. {[<li/>, <li/>]}를 넣으면 React가 알아서 나란히 그린다.

key 를 빼면 경고가 뜬다

key를 빼면 화면은 나오지만 콘솔이 경고한다.

콘솔
  • Warning: Each child in a list should have a unique "key" prop.

경고를 무시하면 나중에 이상한 버그를 만난다. 왜 필요한지부터 이해한다.

React 는 무엇으로 구별하나

목록이 이렇게 바뀌었다고 하자.

바뀌기 전:  사과   바나나   포도
바뀐 후:    사과   포도

React는 이전 화면과 새 화면을 비교해서 바뀐 곳만 고친다. 그런데 key가 없으면 순서로만 비교한다.

key 없이 순서로 비교하면
  1. 01

    0번째끼리 비교

    사과 → 사과. 같다. 그대로 둔다.

  2. 02

    1번째끼리 비교

    바나나 → 포도. 글자가 다르다. 텍스트를 고친다.

  3. 03

    2번째 확인

    포도 → 없음. 마지막 요소를 지운다.

결과는 맞다. 하지만 실제로는 "바나나를 지웠을 뿐"인데 React는 1번을 고치고 2번을 지웠다. 헛수고를 했고, 더 나쁜 일도 생긴다.

key를 주면 React가 어느 것이 어느 것인지 안다.

key 가 있고 없고

key 없음

순서로 판단

  • "1번 자리의 내용이 바뀌었네"
  • 요소를 재사용하며 내용만 고친다
  • 안에 있던 상태가 엉뚱한 곳에 남는다

key 있음

정체로 판단

  • "바나나가 사라졌네"
  • 그 요소만 지운다
  • 나머지는 그대로 둔다

key는 React에게 **"이 요소의 신분증"**을 주는 일이다.

index 를 key 로 쓰면 안 되는 이유

가장 흔한 잘못이다.

{items.map((item, index) => (
  <li key={index}>{item.name}</li>
))}

동작하는 것처럼 보인다. 목록이 절대 안 바뀐다면 문제도 없다. 순서가 바뀌거나 중간이 지워지는 순간 무너진다.

각 항목에 입력창이 있다고 하자.

function TodoList({ todos, onRemove }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          <input defaultValue={todo.text} />
          <button onClick={() => onRemove(todo.id)}>삭제</button>
        </li>
      ))}
    </ul>
  );
}

세 개의 할 일이 있고, 각 입력창에 다른 글자를 쳐뒀다. 첫 번째를 지우면 무슨 일이 벌어질까.

첫 번째를 지웠을 때
  1. 01

    데이터는 정상적으로 줄어든다

    todos 는 [B, C] 가 된다.

  2. 02

    하지만 key 는 그대로 0, 1 이다

    B 의 key 가 1 에서 0 으로 바뀌었다. C 는 2 에서 1 로.

  3. 03

    React 는 "0번은 그대로 있네" 라고 판단한다

    요소를 재사용한다. 그런데 그 안의 입력창에는 A 에 쳐둔 글자가 남아 있다.

  4. 04

    글자가 한 칸씩 밀린다

    화면의 이름표는 B 인데 입력창에는 A 의 글자가 들어 있다.

무엇이 어긋났나

key 는 자리를 가리키는데, 데이터는 옮겨갔다

key 0
예전 A 의 입력창
지금은 B 를 그린다
key 1
예전 B 의 입력창
지금은 C 를 그린다

key자리 번호라서 생기는 문제다. 자리는 그대로인데 내용물이 옮겨갔다.

key 로 무엇을 줄까

데이터 자체에 붙어 있는 고유한 값을 쓴다.

쓸 수 있는가
좋다todo.id데이터를 따라다닌다
좋다user.email유일하고 안 바뀐다
나쁘다index자리 번호일 뿐이다
나쁘다Math.random()매번 달라져 전부 새로 그린다
나쁘다todo.text내용이 바뀌거나 겹칠 수 있다

Math.random()은 최악이다. 렌더링할 때마다 key가 달라지니 React가 전부 지우고 전부 새로 만든다. 상태도 매번 날아가고 느리다.

서버에서 받은 데이터에는 대개 id가 있다. 없다면 만들 때 붙인다.

function addTodo(text) {
  setTodos(prev => [...prev, { id: crypto.randomUUID(), text }]);
}

만들 때 한 번 부여하는 게 핵심이다. 렌더링할 때 만들면 Math.random()과 똑같아진다.

key 는 형제 사이에서만 유일하면 된다

전역에서 유일할 필요는 없다.

<ul>
  {fruits.map(f => <li key={f.id}>{f.name}</li>)}
</ul>
<ul>
  {vegetables.map(v => <li key={v.id}>{v.name}</li>)}
</ul>

fruitsidvegetablesid가 겹쳐도 상관없다. 같은 부모 아래 형제들끼리만 구별되면 된다.

key 가 비교되는 범위
  • App
    • ul (과일)이 안에서만 key 를 비교한다
      • li key=1
      • li key=2
    • ul (채소)key=1 이 또 있어도 무관
      • li key=1
      • li key=2

key 를 바꿔 상태를 초기화한다

key가 바뀌면 React는 다른 요소로 보고 통째로 새로 만든다. 이걸 역이용할 수 있다.

<ProfileForm key={userId} user={user} />

userId가 바뀌면 ProfileForm이 새로 만들어지고, 안에 있던 입력값도 초기화된다. 다른 사용자를 선택했는데 이전 사람의 입력이 남아 있는 문제가 사라진다.

7강에서 "조건부 렌더링으로 컴포넌트를 없애면 상태도 사라진다"고 했다. 원리가 같다. React는 화면의 어느 위치에 무엇이 있는지로 상태를 기억한다. 13강에서 정확히 다룬다.

프래그먼트에 key 를 달아야 할 때

map 안에서 두 요소를 반환해야 한다면 <> 로는 key를 못 단다.

{items.map(item => (
  <Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.desc}</dd>
  </Fragment>
))}

import { Fragment } from "react" 가 필요하다. 짧은 문법 <>에는 속성을 붙일 수 없다.

정리하면

  • 배열을 map으로 JSX 배열로 바꾸면 React가 펼쳐 그린다.
  • key는 요소의 신분증이다. React가 무엇이 추가·삭제·이동했는지 판단하는 근거다.
  • indexkey로 쓰지 않는다. 순서가 바뀌면 상태가 엉뚱한 곳에 남는다.
  • Math.random()은 최악이다. 매번 전부 새로 만든다.
  • key데이터에 붙어 있는 고유값을 쓰고, 만들 때 한 번 부여한다.
  • key형제 사이에서만 유일하면 된다.
  • key를 바꾸면 컴포넌트가 새로 만들어져 상태가 초기화된다.

다음 강의는 불변성이다. 9강에서 "state를 직접 고치면 React가 못 알아챈다"고 했던 그 규칙을 끝까지 파고든다.

리스트 렌더링과 key — 디코드랩(DCODELAB)