리액트 리스트 렌더링: key에 index를 사용해도 괜찮을까?

리액트에서 key에 index 사용을 권장하지 않는 이유

리액트에서 리스트 렌더링을 할 때 key를 지정해줘야 하는데, 리액트는 key에 index를 사용하는 것을 권장하지 않는다.

리액트가 key를 사용하는 이유에는 key를 이용해 변경이 일어난 부분만 확인해 최소한의 DOM 업데이트를 하여 성능 최적화를 하기 위함이다. Index는 순서가 바뀌면 렌더링마다 달라질 수 있기 때문에 이러한 성능 최적화를 어렵게 한다.


리액트는 어떻게 동작할까? - 재조정(Reconciliation)

리액트는 렌더링할 때 메모리 상의 Virtual DOM을 활용한다. 데이터의 변경이 발생했을 때 바로 실제 돔을 조작하는 것이 아닌, 가상 돔으로 변경 사항을 먼저 확인하는 작업을 거친다.

실제 돔을 바로 조작하지 않는 이유는 ‘성능 최적화’ 때문이다. 일반적으로 실제 돔을 조작하는 것은 큰 비용을 일으킨다. 돔이 변경되면 브라우저는 리플로우(Reflow) 및 리페인트(Repaint)를 포함한 일련의 과정을 진행하게된다. 데이터가 변경될 때마다 전체 돔에 대해 이 과정을 거치려면 큰 비용일 들 것이다.

따라서 가상 돔으로 변경 사항을 먼저 확인하고, 실제 변경이 필요한 부분만 이를 진행하며 불필요한 비용이 들어가지 않도록 한다.


key는 이 과정에서 어떻게 사용될까?

가상 돔 객체는 트리 구조를 갖고 있다. FiberNode는 트리의 개별 노드이다. 리액트 공식문서에 따르면 두 개 가상 돔 트리를 비교할 때, n개의 엘리먼트에 대해 O(n^3)의 복잡도를 가진다고 한다. 즉, 1000개의 엘리먼트를 그리기 위해서는 10억 번의 비교 연산을 진행해야 한다. 따라서 리액트는 성능 최적화를 위해 2가지 가정을 도입한다.

1️⃣ 각기 서로 다른 두 요소는 다른 트리를 구축할 것이다.
2️⃣ 개발자가 제공하는 key 프로퍼티를 가지고, 여러 번 렌더링을 거쳐도 변경되지 말아야 하는 자식 요소가 무엇인지 알아낼 수 있을 것이다.

📌 리액트의 FiberNode : 리액트 컴포넌트를 하나하나 표현하는 객체로, 리액트가 렌더링할 때 사용하는 내부 데이터 구조

속성 설명
type 어떤 컴포넌트인지(ex. 함수형 컴포넌트, div , etc)
key React key 값
stateNode 실제 DOM 노드 또는 컴포넌트 인스턴스
child/sibling/return Fiber 트리 구조 (자식/형제/부모 참조)
pendingProps, memoizedProps 새로 들어온 props / 이전 렌더에 쓰였던 props
flags 이 Fiber에서 어떤 작업이 필요한지(업데이트, 삽입, 삭제 등)
alternate 이전 렌더링에서 사용된 Fiber(더블 버퍼링 구조)


리액트의 비교 알고리즘(Diffing Algorithm)

1️⃣ DOM 엘리먼트 타입이 달라지는 경우

  • <div><span>으로 바뀌었거나 <Calculator /><TodoList/> 로 달라진 경우 리액트는 이전 트리를 버리고 완전히 새로운 트리를 생성한다.
<ul>
  <li>바나나</li>
  <li>사과</li>
  <li>체리</li>
</ul>
<div>
  <li>바나나</li>
  <li>사과</li>
  <li>체리</li>
</div>

⇒ 루트 엘리먼트가 달라지면 내부의 li 태그들의 결과가 같더라도 전체 태그들을 언마운트시키고 새 트리를 구축한다.


2️⃣ DOM 엘리먼트 타입이 같은 경우

  • 리액트는 동일한 내역은 유지하고 변경된 속성만 갱신한다.
  • 하나의 DOM 노드의 처리가 끝나면 리액트는 이어서 해당 노드의 자식들을 재귀적으로 처리한다. 리액트는 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성한다.
  • Virtual DOM은 key값을 캐치해서 두 요소의 차이점이 있을 때 DOM을 변화시키는데 key값이 존재할 경우 자식 엘리먼트를 모두 파괴하지 않고 변경된 것만 새로 마운트한다.


key에 고유한 ID값을 사용했을 때

<ul>
  <li key="banana">바나나</li>
  <li key="apple">사과</li>
  <li key="cherry">체리</li>
</ul>
<ul>
  <li key="cherry">체리</li>
  <li key="apple">사과</li>
  <li key="banana">바나나</li>
</ul>

key가 고유하다는 가정 하에 바나나-사과-체리 의 리스트 요소가 체리-사과-바나나 순으로 바뀌었을 때 리액트는 key가 동일하기 때문에 같은 컴포넌트로 간주한다. 따라서 기존 컴포넌트의 리스트 요소를 파괴하지 않고 재활용한다.(언마운트시키지 않는다는 뜻)


key값을 ${id}-${index} 형태로 사용하면 괜찮을까?

<ul>
  <li key="banana-0">바나나</li>
  <li key="apple-1">사과</li>
  <li key="cherry-2">체리</li>
</ul>
<ul>
  <li key="cherry-0">체리</li>
  <li key="banana-1">바나나</li>
  <li key="apple-2">사과</li>
</ul>

리스트 요소의 순서가 바뀌면서 banana-0 -> banana-1 로, apple-1 -> apple-2로, cherry-2 -> cherry-0 으로 key값이 바뀌었다. 리액트는 리스트 요소의 key값이 달라졌기 때문에 기존 트리의 리스트 요소를 모두 언마운트 시키고 재생성한다.

⇒ 따라서 리스트 요소의 재정렬, 추가, 삭제 등의 작업이 일어날 수 있는 리스트에서는 인덱스 요소를 key에 사용하게 되면 렌더링 간 key값이 일치되지 않을 수 있기 때문에 불필요한 재연산이 일어날 수 있고 이는 성능을 떨어트리고 버그를 발생시킬 수 있다.


데이터 기반의 안정적인 ID가 없는 상태라면? ⇒ UUID 라이브러리

UUID란? 범용 고유 식별자로 소프트웨어 구축에 사용하는 식별자 표준이다. UUID 같은 경우 고유성을 완벽하게 보장할 수는 없지만 실제 사용 속에서 거의 중복될 가능성이 없어 많이 사용하고 있다.

주의 모든 항목이 매번 동일한 키를 받아야 리액트에서 key기반으로 최적화가 가능하다. 따라서 uuid를 사용할 때는 render()함수에서 사용하는 것이 아닌 데이터를 다루는 곳에서 사용해 key값이 변하지 않도록 해야 한다.


📌 결론

  • 리액트에서 key는 컴포넌트 식별자 역할을 함
  • 리액트는 각 컴포넌트를 key 기준으로 비교하고 재사용 여부를 결정
  • 컴포넌트 단위에서 판단함
  • key가 바뀌면 해당 컴포넌트는 unmount → 새로 mount됨

리스트 항목에 변경이 발생할 수 있다면 key에는 고유한 불변값만을 사용하는 것이 좋다



Categories:

Updated:

Leave a comment