티스토리 뷰

웹 개발/React

[React] 리스트와 Key

곤이씨 2021. 6. 22. 00:32

 리액트에 익숙한 사람이라면 반복되는 컴포넌트 혹은 엘리먼트에는 고유한 키 값이 필요함을 알고 있다. 동시에 키값으로 인덱스 값을 주는 것이 안티패턴이라는 사실도 알고 있다. 그렇다면 왜 키값으로 인덱스를 주는 것이 안티패턴일까?

 

 

재조정 (Reconciliation) – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

 

 

 리액트에서 Key는 리액트가 어떤 항목을 변경, 추가 또는 삭제할 때 트리를 매번 새로 그리는 것이 아니라 기존과 비교하여 효율적으로 트리를 변경할 수 있도록 돕는 역할이다.

 

 이제 키 값이 왜 사용되는지는 알겠다. 그러면 왜 키 값으로 index가 아닌 고유한 값을 사용해야하는 것일까? 리액트 공식문서에 있는 codepen을 통해 간단한 실험을 해보자.

 

 

 먼저, 키 값을 인덱스로 받을 때의 상황이다.

https://ko.reactjs.org/redirect-to-codepen/reconciliation/index-used-as-key

 

우리가 의도한 것과는 다르게 input이 해당 ID를 따라가지 못하고 제자리에 머물러있는 모습이다.

 

 다음으로 고유한 키 값을 받을 때의 상황이다.

https://ko.reactjs.org/redirect-to-codepen/reconciliation/no-index-used-as-key

 

우리가 원하는 대로 input이 ID를 따라가는 모습이다.

 

그렇다면 왜 이런 차이가 생기는 걸까? 🤔

 

결론부터 말하면 키값으로 인덱스를 사용하는 경우 키값의 위치가 변하지 않기 때문에 예상과는 다른 결과를 렌더링하게 된다.

 

 먼저 리액트의 동작원리를 간단하게 짚고 넘어가보자. 리액트에서는 키값이 바뀌지 않으면 새로운 인스턴스를 생성하지 않는다. 즉, 이전에 만들어졌던 컴포넌트 혹은 DOM 엘리먼트를 그대로 재활용한다(=초기화하지 않는다는 의미로 useState가 유지된다). 배열에 새로운 요소가 추가되어 리렌더링이 일어나면 리액트는 배열을 돌면서 각 컴포넌트를 실행시킨다. 이때 앞서 키 값이 변하지 않았다면 이전에 만들어두었던 컴포넌트 혹은 DOM 엘리먼트를 재사용한다. 단. 컴포넌트나 DOM에 들어가는 Props 혹은 content 등이 바뀌면 바뀐 내용으로 리렌더링이 일어난다. 즉, 리액트는 메모리 상에 기존 DOM 엘리먼트는 그대로 존재하고 바뀐 내용만 반영한다.

 

 위의 예제에서 다른 모든 요소들은 바뀌는데 input만 위치가 그대로인 이유는 바로 input element가 가지는 특징 때문이다. 예제의 input은 비제어 컴포넌트로 리액트의 제어를 받지 않는다. 따라서 input 스스로 값을 가진다.

 

 키값을 인덱스로 했을 때의 상황을 차근차근 정리해보자.

 

 

 가장 처음 렌더링 된 화면은 위의 컴포넌트가 렌더링 된 결과이다. 이후 input에 'first'라는 값을 입력하고 추가 버튼을 누르면 배열에 ID가 2인 요소가 추가된다. 리액트는 상태의 변경을 감지하고 ID가 1인 요소와 ID가 2인 요소를 내림차순으로 렌더링한다(Add New to Start 버튼은 배열의 가장 맨 앞에 요소를 추가한다는 의미).

 

 

 리액트는 새롭게 생성된 배열을 반영하여 map을 돈다. Add New to Start 버튼으로 추가된 요소는 배열의 가장 앞에 추가되기 때문에 ID 2 요소가 index 0번이 된다.

 

 index를 키값으로 줬기 때문에 리액트는 key가 0인 컴포넌트에 들어오는 props가 달라졌음에도 불구하고 기존에 ID 1이 만들어놓은 컴포넌트와 DOM 엘리먼트를 그대로 사용한다.

 

 이때 key가 0인 컴포넌트는 처음에 'first'가 입력된 input 역시 그대로 사용한다.

 

 ID 1은 기존에 없던 index 1번을 키값으로 하기 때문에 새롭게 DOM 엘리먼트를 추가한다. 이 때문에 ID 1의 input은 비어있는 것이다.

 

 

다음으로 키값으로 인덱스를 사용했을 때 상태값이 개발자가 의도한대로 관리되지 않는 경우를 알아보자.

import { useState } from "react";

const ListItem = ({ initName }) => {
  const [name, setName] = useState(initName);

  return <div>{name}</div>;
};

const App = () => {
  const [crews, setCrews] = useState([
    { id: 1, name: "지그" },
    { id: 2, name: "피터" },
    { id: 3, name: "크리스" },
  ]);
  const [newCrewName, setNewCrewName] = useState("");
  const [idCounter, setIdCounter] = useState(crews.length + 2);

  const addNewCrew = (e) => {
    e.preventDefault();
    setCrews((state) => [{ id: idCounter, name: newCrewName }, ...state]);
    setIdCounter((state) => ++state);
  };

  return (
    <>
      <form onSubmit={addNewCrew}>
        새로운 크루
        <input
          value={newCrewName}
          onChange={(e) => {
            setNewCrewName(e.target.value);
          }}
        />
      </form>
      <br />
      <div>크루들 이름</div>
      {crews.map((crew, index) => (
        <ListItem key={index} initName={crew.name} />
      ))}
    </>
  );
};

export default App;

 

key 값에 index를 넣는 경우

 키 값에 index를 넣는 경우, 새로운 크루를 배열의 가장 앞에 추가했음에도 지그, 피터, 크리스 순서가 변경되지 않는다. 또한 '곤이'를 추가하여도 '크리스'라는 이름만 추가되고 있다.

 

 useState는 인스턴스가 새로 생성되거나 setState로 상태를 바꾸기 전까지 state 값이 변경되지 않는다. 키 값으로 index를 사용한 경우 배열에 요소가 추가됐을 때 이전 키 값의 위치는 변하지 않는다. 때문에 initname으로 다른 값이 들어가더라도 key 값이 변하지 않았기 때문에 useState가 state를 그대로 유지하고 기존 state 값을 출력한다.

 

 어떤 값을 입력하더라도 마지막 크루의 이름이 추가되는 이유는 무엇 때문일까? 배열에 요소가 추가될 때 새로운 크루가 배열의 가장 앞에 위치하기 때문에 가장 마지막 요소였던 크리스는 항상 배열의 뒤쪽에 위치한다. 때문에 새로운 컴포넌트의 props로 크리스가 넘어가게 된다.

 

key 값에 유니크한 id를 넣는 경우

 

 

 이상으로 키 값으로 index를 주면 안되는 이유에 대해 알아보았다.

 

 하지만 키 값으로 마땅하게 줄 유니크한 값이 없는 상황이라면 불가피하게 index를 줄 수 밖에 없다. 다음과 같은 상황에서는 키 값으로 index를 주더라도 문제가 발생하지 않는다. DOM 엘리먼트의 변경이 불필요하기 때문이다!

 

  • 목록의 순서가 변경되지 않는다.
  • 목록이 정렬되거나 필터링되지 않는다.

'웹 개발 > React' 카테고리의 다른 글

React Router의 Hash Router와 Browser Router  (1) 2021.06.10
Redux / React Redux / Redux Thunk  (0) 2021.06.08
props를 변경하지 못하는 이유  (0) 2021.04.26
제어 컴포넌트와 비제어 컴포넌트  (1) 2021.04.26
JSX  (0) 2021.04.26