React 에서의 List와 key

2022년 2월 24일 · #공부


다들 아시다시피 리액트에서 리스트를 구현할 때 우리는 리스트 아이템의 가장 부모되는 jsx 태그에 key 속성을 부여한다. 안그러면 자꾸 에러가 뜬다.

{ list.map((item, i) => (
  <ListItem key={i} data={item} />
)}

위 코드에서 어떤 점이 잘못됐는지 바로 알아챘을 것이다. key={i} 부분을 우리는 index 값이 아닌, 각 아이템의 고유한 값으로 줘야 한다는 사실을 잘 알고있다.

왜 그래야만 하는지 이유를 우리는 알고 있지만, 막상 설명하기 힘들때가 있다. 그래서 이 글을 정리해본다.


리액트는 기존의 리스트와 새로운 리스트를 순회하면서 차이점이 있으면 변경을 생성한다. 아래와 같은 리스트가 있다고 가정해보자.

key: 0, children: 영수
key: 1, children: 철이

<ul>
  <li>영수</li>
  <li>철이</li>
</ul>

위의 리스트에 숙자를 추가해보자.

key: 0, children: 영수
key: 1, children: 철이
key: 2, children: 숙자

<ul>
  <li>영수</li>
  <li>철이</li>
  <li>숙자</li>
</ul>

리액트는 영수와 철이를 기존 리스트와 비교하고, 변화가 없기 때문에 변경을 생성하지 않는다. 그리고 숙자 가 새로 추가 된 것을 감지하고, 리스트 트리의 마지막에 숙자를 추가한다.

여기는 특별히 문제가 없어 보인다.


위 예시와 같은 코드를 보자.

key: 0, children: 영수
key: 1, children: 철이

<ul>
  <li>영수</li>
  <li>철이</li>
</ul>

여기서 리스트의 맨 앞에 숙자를 추가해보자.

key: 0, children: 숙자
key: 1, children: 영수
key: 2, children: 철이

<ul>
  <li>숙자</li> // key: 0 입장에서, 영수 -> 숙자로 값이 변경 됨.
  <li>영수</li> // key: 1 입장에서, 철이 -> 영수로 값이 변경 됨.
  <li>철이</li> // key: 2 입장에서, 철이 값이 새로 추가 됨.
</ul>

뭐가 잘못 됐는지 눈치 챘는가? 0번이 철수였는데, 숙자로 바뀌면서 key가 모두 한칸씩 밀리게 되었다. 우리 입장에서야 한칸씩 밀린 것이지, 리액트 입장에서는 리스트 전체가 변경 됐다고 감지하게 된다. key와 children이 각각 모두 다르기 때문이다. 따라서 숙자 만 렌더하면 될 것을, 리스트 전체를 다시 렌더하게 된다. 이는 성능 저하를 야기하게 된다.

위 상황을 올바른 id 값을 부여했을 경우는 어떻게 될까?

key: '영수', children: 영수
key: '철이', children: 철이

<ul>
  <li>영수</li>
  <li>철이</li>
</ul>

위 리스트에서 마찬가지로 맨 앞에 숙자를 추가해보자.

key: '숙자', children: 숙자
key: '영수', children: 영수
key: '철이', children: 철이

<ul>
  <li>숙자</li> // key: '숙자' 입장에서, 숙자 값이 새로 추가 됨.
  <li>영수</li> // key: '영수' 입장에서, 아무런 변화 없음. (렌더되지 않음)
  <li>철이</li> // key: '철이' 입장에서, 아무런 변화 없음. (렌더되지 않음)
</ul>

key를 제대로 준 경우에는 새로 추가 된 숙자만 다시 렌더되기 때문에 성능이 낭비되는 것을 방지할 수 있다.


리스트에 사용자 입력을 입력하는 상황을 만들어보자. 그리고 Add 버튼을 누를 경우 맨 위에 새로운 input을 추가하는 상황이다. 아래 그림과 같이, 사용자가 ID: 3046 입력칸에 영수라는 이름을 입력 해 놓은 상태이다.

다시 말하지만, <맨 위>에 새로운 input을 추가 하는 기능이다.

  • Add 버튼을 누르면 맨 위에 새로운 빈 input을 추가한다.
  • ID 3046 칸에 사용자가 영수라고 입력했다.

여기서, Add를 누르면 리스트의 맨 위에 빈 Input을 추가하게 된다. Add를 눌러보자.

뭔가 이상하다. 분명 ID: 3046의 input에 영수라고 써놨었는데, 새로 추가 된 ID: 6470에 영수가 씌여있다. 왜 이런걸까? Add를 누르기 전과 후의 렌더링 된 데이터는 아래와 같다.

// Add 누르기 전
key: 0, id: 3046, input: '영수'
key: 1, id: 9369, input: ''
key: 2, id: 5789, input: ''
// Add 누른 후
key: 0, id: 6470, input: '영수'
key: 1, id: 3046, input: ''
key: 2, id: 9369, input: ''
key: 3, id: 5789, input: '' // <-- key 3 새로 추가 됨.

리액트는 기존 리스트와 새 리스트를 비교하여 변경된 부분을 업데이트 한다고 위에서 이야기 했다. 우리는 맨 위에 리스트를 한 것으로 의도 했지만, 리액트는 key를 비교하기 때문에, 0, 1, 2 는 순서가 변한게 없다고 생각한다. 그리고 뒤에 새로운 key: 3 아이템이 추가 됐다고 생각한다.

즉, 새로운 아이템이 리스트 맨 위가 아닌 맨 아래에 추가 된다.

key: 0 아이템 입장에서는, item.id만 내용이 3046에서 6470으로 변화 됐지, 그 외에는 아무런 변화도 없다.

그래서 0, 1, 2 항목에 대하여 변경된 id 값만 새로 바뀐 값으로 업데이트 하게 되고 실제로 영수가 써 있는 input은 아무런 변화가 없는 것이다.

요약

Before: key 0, 1, 2
After: key 0, 1, 2, 3 ... 3만 맨 뒤에 추가 됐다.

// Add 누르기 전
key: 3046, id: 3046, input: '영수'
key: 9369, id: 9369, input: ''
key: 5789, id: 5789, input: ''
// Add 누른 후
key: 6470, id: 6470, input: '' // <-- key 6470 새로 추가 됨.
key: 3046, id: 3046, input: '영수'
key: 9369, id: 9369, input: ''
key: 5789, id: 5789, input: ''

이 경우, 리액트가 기존 리스트와 새 리스트를 비교할 때, key: 6470이 맨 앞에 추가 됐고, 나머지는 변화가 없다는 것을 key를 통해 감지하고, 우리의 의도 대로 실제로 리스트의 맨 앞에 <li>를 추가하여 Rerender 되는 것을 확인할 수 있다.

요약

Before: key 3046, 9369, 5789
After: key 6470, 3046, 9369, 5789 ... 맨 앞에 6470이 추가 됐다.