React에서의 `key`

2021. 6. 15.
  • 왜 반복되는 엘리먼트를 추가할 때는 key prop을 넣으라고 Warning을 띄우는 걸까?
  • key prop은 왜 존재하며, 어떤 값을 넣어주어야 할까?
  • 리액트 공식 문서에서는 key를 다음과 같이 설명하고 있다.

Key는 React가 어떤 항목을 변경, 추가 또는 삭제할지 식별하는 것을 돕습니다. key는 엘리먼트에 안정적인 고유성을 부여하기 위해 배열 내부의 엘리먼트에 지정해야 합니다.

Diffing Algorithm

  • 리액트는 컴포넌트를 렌더하면서 어떤 변화가 있었는지를 비교하는 알고리즘(Diffing Algorithm)을 가지고 있다. render 함수는 이 알고리즘을 이용하여 기존 React 엘리먼트 트리와 비교하고 새로운 React 엘리먼트 트리를 생성해서 반환한다.
  • 기존 트리를 가지고 다른 트리로 변환하기 위해 최소한의 연산을 하는 알고리즘 문제를 해결하기 위해 일반적으로 O(n^3)의 시간 복잡도를 가진다. 이대로 리액트에 적용한다면, 1000개의 엘리먼트를 그리기 위해 10억 번의 비교 연산을 해야하며, 이는 너무 비싼 연산이라고 리액트에서는 설명한다.
  • 따라서, 리액트에는 두 가지 가정을 통한 휴리스틱 알고리즘을 적용하여 이 연산을 O(n)까지 줄일 수 있었다고 설명한다.
    1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
    2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.
  • 이 때, 사용되는 것이 key prop이다. 리액트에서는 key prop을 통해 컴포넌트를 새로 렌더하면서 엘리먼트 트리를 생성할 때, 더 빠르게 비교 연산을 수행하고 렌더할 수 있다.

리스트의 자식 요소가 변경될 때

  • 다음과 같이, 리스트의 마지막에 새로운 엘리먼트를 추가할 때는 큰 성능 문제를 유발하지 않는다.
// before
<ul>
  <li>first</li>
  <li>second</li>
</ul>

// after
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>
  • 리액트에서는 렌더할 때, 기존 리스트와 바뀔 리스트를 동시에 순회하면서 차이점을 찾는다. 첫 번째 엘리먼트(first)와 두 번째 엘리먼트(second)는 차이점이 없고, 마지막에 새로운 엘리먼트(third)만 추가하면 된다.
  • 실제 렌더 시에도, 기존 엘리먼트의 변화 없이 새롭게 추가된 마지막 엘리먼트만 새로 렌더한다.
  • 하지만 다음과 같이 엘리먼트를 맨 위에 추가했을 때는 상황이 다르다.
// before
<ul>
  <li>대구</li>
  <li>부산</li>
</ul>

// after
<ul>
  <li>서울</li>
  <li>대구</li>
  <li>부산</li>
</ul>
  • 위에서 리액트에서는 기존 리스트와 바뀔 리스트를 동시에 순회한다고 했는데, 이미 첫 번째 엘리먼트에서 차이점이 생긴다. 첫 번째 자식 엘리먼트는 대구에서 서울로 바뀐다.
  • 이후의 비교에도 차이점이 계속 생길 것이다. (부산대구 / 부산 추가)
  • 따라서, 모든 엘리먼트가 새롭게 렌더된다.
  • 이미 렌더된 엘리먼트가 분명히 있는데 찾지 못하고 다시 렌더하는 과정은 불필요한 연산이며 성능 이슈로 이어질 수 있을 것이다.
  • 이러한 문제를 해결하기 위해서 key prop을 넣을 수 있다.
// before
<ul>
  <li key="daegu">대구</li>
  <li key="busan">부산</li>
</ul>

// after
<ul>
  <li key="seoul">서울</li>
  <li key="daegu">대구</li>
  <li key="busan">부산</li>
</ul>
  • key를 추가하면, 리액트에서는 자식 엘리먼트의 key 값의 비교를 통해 렌더한다.
  • 리액트는 keyseoul이라는 엘리먼트가 새로 생성되었으며, keydaegu, busan인 엘리먼트에는 변화가 없기 때문에 이동만 하면 된다는 것을 알 수 있다.

key를 지정할 때의 주의사항

  1. key를 지정할 때 index를 넣는 것은 안티 패턴이다.
  • map 함수의 index 값을 key로 지정하게 된다면, map 함수가 다시 실행되면서 index도 다시 생성된다. 이렇게 될 경우, 기존 리스트와 비교해서 최적화하려는 시도 자체가 무의미해진다. 즉, 안티 패턴이다.
  • 심지어, key값을 지정하지 않았을 때 리액트에서는 기본적으로 indexkey로 사용한다.
  • 리스트를 렌더할 때는 각 아이템이 고유한 id를 가질 수 있도록 하고, 해당 id를 key로 지정하는 것이 좋다.
const list = ['서울', '대전', '대구', '부산'];
{list.map((item, index) => <Item key={index}>{item}</Item>}

// 위는 다음과 같이 반환될 것이다.
<Item key={0}>서울</Item>
<Item key={1}>대전</Item>
<Item key={2}>대구</Item>
<Item key={3}>부산</Item>

// 다음과 같이 앞부분에 '경기'를 추가했다면 index값이 하나씩 뒤로 밀리게 될 것이다.
const list = ['경기', '서울', '대전', '대구', '부산'];
{list.map((item, index) => <Item key={index}>{item}</Item>}

// 위는 다음과 같이 반환될 것이다.
<Item key={0}>경기</Item>
<Item key={1}>서울</Item>
<Item key={2}>대전</Item>
<Item key={3}>대구</Item>
<Item key={4}>부산</Item>
  1. key는 주변 배열의 컨텍스트에서만 의미가 있다.
  • key는 리스트의 순서를 비교하는 데 사용하는 값이며, 리스트를 렌더하고 있는 곳에서 key 를 넣어주어야 한다.
  • map 함수 내부에 있는 엘리먼트에 key를 넣어주는 것이 가장 일반적이다.
function ListItem(props) {
  // 이 곳에서 li에 key 값을 지정해도 별 효과는 없다.
  return <li key={props.id}>{props.value}</li>
}

function List(props) {
  const data = props.data;
  
  // 다음과 같이 ListItem 엘리먼트가 key를 가져야 한다.
  // 참고: key는 ListItem 엘리먼트의 prop으로 전달되지 않는다.
  return (
    <ul>
      {data.map((item) => <ListItem key={item.id} />}
    </ul>
  );
}
  1. key는 형제 사이에서만 고유한 값이면 된다.
  • 모든 범위에서 자식 엘리먼트의 key 값이 고유할 필요는 없으면, 특정 리스트 안에서만 고유한 값을 가지고 있으면 된다.

부록: nanoiduuid 같은 라이브러리를 사용하여 key를 지정할 때의 주의점

  • 다음과 같이 렌더할 때마다 nanoiduuid가 실행되지 않도록 주의하자. 렌더할 때마다 nanoid가 실행되는 불필요한 연산이 이루어질 뿐만 아니라, 기존 리스트와 비교하려는 시도가 무의미해지는 안티 패턴이다.
function List(props) {
  const data = props.data;
  return (
    <ul>
      {data.map((item) => <ListItem key={**nanoid(8)**} /> /* <- DON'T DO THAT */}
    </ul>
  );
}

심문성

심문성

커피를 유별나게 좋아하는 프론트엔드 개발자입니다.