React로 개발할 때 배열의 요소들을 map을 통해 렌더링하는 패턴은 흔하게 사용됩니다. 이런 경우에, 컴포넌트에 key
속성을 전달하지 않으면 아래의 경고가 출력되는 것을 확인할 수 있습니다.
각각의 child 컴포넌트에 고유한 key 속성을 전달하지 않았다는 경고입니다. React를 시작한지 얼마 되지 않은 개발자라면, 가장 쉽게 생각할 수 있는 고유한 값인 index를 전달하는 상황이 많이 발생합니다. (React는 key를 명시적으로 전달해주지 않으면 자동으로 index를 사용합니다.)
todoList.map((todo, index) => {
<Todo data={todo} key={index} />
});
하지만 key 속성으로 index를 사용하는 것은 좋지 못한 패턴인데요, 지금부터 그 이유를 알아보겠습니다.
우선, React의 공식문서에 나와있는 내용을 확인해보겠습니다.
위 내용을 간단히 정리하면 이렇습니다.
key={Math.random()}
처럼 즉석에서 키를 생성하지 마세요. 매 렌더링마다 모든 컴포넌트와 DOM이 다시 생성되므로 성능에 영향이 있을 수 있고 사용자 입력이 초기화 될 수 있습니다.그렇다면 index를 key로 사용하면 왜 이런 상황이 발생하는걸까요? 이유를 알아보기 전에 React가 어떻게 동작하는지 먼저 알아보겠습니다.
컴포넌트가 화면에 보여지기 전에 각각의 컴포넌트들은 React를 통해 구성되는데, 이 작업을 Render
라고 합니다. 그 후에 렌더링된 컴포넌트들을 화면에 보이도록 실제 DOM에 반영하는 작업을 Commit
이라고 합니다.
그렇다면 Render와 Commit은 어떻게 동작하는걸까요? React에서는 가상 DOM(Virtual DOM)
이라는 개념을 활용했습니다.
React는 화면을 렌더링할 때 가상 DOM
이라는 개념을 활용합니다. 데이터에 변화가 생겼을 때 실제 DOM을 바로 조작하는 것이 아닌, 가상 DOM을 통해 어느 부분이 변했는지 먼저 확인합니다.
왜 이런 번거로워보이는 작업을 거치는걸까요? 바로 성능 최적화와 관련이 되어 있기 때문입니다. 실제 DOM을 조작하는 것은 생각보다 큰 비용이 드는 작업입니다. DOM을 화면에 보여주기 위해 브라우저는 Reflow와 Repaint 등 일련의 과정을 거치는데, 데이터가 변할 때마다 이 과정들을 다시 진행하는 것은 성능에 영향을 끼칠 수 있습니다.
이런 문제가 있기 때문에 React는 가상 DOM을 활용해서 실제 변경사항을 먼저 확인하고, 변경된 부분만을 실제 DOM에 적용하는 방식을 사용하고 있는 것입니다. React에서는 이 과정을 재조정(Reconciliation)
이라고 합니다.
React는 가상 DOM을 이중으로 사용하는 더블 버퍼링(Double Buffering)
형태로 관리하는데, current 트리
와 workInProgress 트리
로 구성되어 있습니다.
데이터에 변화가 생기면(Trigger)
workInProgress 트리를 재구성하고 current 트리와의 변경사항을 확인합니다. 변경사항이 있다면 current 트리를 업데이트(Render)
하고, current 트리를 실제 DOM에 반영(Commit)
합니다.
쉽게 생각하면, current 트리는 현재의 DOM 구조이고 workInProgress 트리는 바뀔 DOM 구조입니다.
정리
- React는 가상 DOM을 활용해서 렌더링하는데, 이 과정을 재조정이라고 합니다.
- 재조정은 두 개의 가상 DOM(workInProgress, current)을 사용하고, 두 단계(Render, Commit)에 걸쳐서 실제 DOM이 업데이트됩니다.
- 실제 DOM을 업데이트 하기 전에 현재 버전(current)과 바뀔 버전(workInProgress)의 가상 DOM끼리 비교합니다.
- 변경된 부분만을 다시 렌더링하기 때문에 성능이 최적화됩니다.
key는 재조정 단계에서 React가 각각의 컴포넌트를 비교할 때의 식별자 역할을 합니다. 배열 요소의 위치가 변화(정렬, 삭제 등등)되는 경우에 이 개념이 중요합니다. 제대로 전달된 key는 배열에서 어떤 변화가 일어났는지 React가 정확히 추론할 수 있게 해주고, 따라서 DOM 트리를 정상적으로 업데이트할 수 있게 해줍니다.
예시로 알아보겠습니다.
위처럼 리스트가 구성되어 있습니다. 리스트 마지막에 <li>스무디</li>
를 추가한다고 했을 때, <li>아메리카노</li>
와 <li>카페라떼</li>
는 바뀌지 않을 것입니다.
하지만 리스트 처음에 추가한다고 하면 어떨게 될까요? 리스트의 순서가 유지되지 않기 때문에 화면에 보여지는 연산을 다시 해야합니다.
리스트 마지막에 추가 | 리스트 처음에 추가 |
---|---|
![]() | ![]() |
React는 이런 상황에서 불필요한 재연산을 막는 것이 목적이었고, 그 수단이 key인 것입니다. key 값이 변하지 않는다면 React는 기존의 값을 재사용함으로써 성능 최적화를 할 수 있게 되었습니다.
이전 상태 | 이후 상태 |
---|---|
![]() | ![]() |
그렇다면 key에 index 또는 즉석에서 생성된 값을 전달하면 어떻게 될까요?
아래 그림에서 볼 수 있듯이, 배열에서 특정 요소가 삭제되면 삭제된 요소 다음 요소들의 index가 바뀌게 되면서 불필요한 연산이 발생합니다.
즉석에서 생성된 값을 사용하는 경우는 index를 사용하는 경우보다 더 안좋은 결과를 얻을 수 있습니다. 배열에 변화가 생기면 모든 요소들의 key 값이 바뀌게 되면서 연산이 발생합니다.
정리
- React는 key 값을 기준으로 해당 컴포넌트를 다시 연산할지 말지를 결정합니다.
- key 값으로 index를 사용하는 경우, 목록에서 특정 항목이 추가되거나 제거되면 해당 항목 이후의 컴포넌트들에 대한 재연산이 발생합니다.
- key 값으로 즉석에서 생성된 값을 사용하는 경우, 목록에 변화가 생기면 모든 컴포넌트들에 대한 재연산이 발생합니다.
- 따라서 key 값은 고유한 값(id, 이름 등등)을 사용하는 것이 좋습니다.
React 개발을 시작한 지 얼마 되지 않은 시기에는 index를 key 값으로 사용하는 것이 편해서 무의식적으로 사용하기도 했었습니다. 그러던 와중에 배열의 데이터를 헤비하게 다뤄야하는 경우가 있었는데, 동작이 이상해서 원인을 찾아보다가 이런 내용들이 있다는 것을 알게 되었었습니다.
개발을 하다보면 빠르게 구현하는 것도 중요하지만, 코어 개념을 챙기는 것도 필요하다는 것을 항상 느낍니다.