React and Key 1편- Key에 관한 고찰 

장동혁·2022년 4월 21일
0

Key?

Key를 한국어로 직역하면 열쇠입니다. 열쇠는 자물쇠를 풀거나 문을 여는 등 잠겨있는 무언가를 여는 도구입니다. 굉장히 중요한 물건이며 중요한 뜻을 지녔기에 비유적으로도 많이 사용되곤 합니다.

Key money : 보증금, Key man : 중추인물

프로그래밍에서도 Key라는 용어가 사용되는데 대표적으로 map 자료구조에서 등장합니다.

// JS
const map = {foo: 'Im Foo', bar: 'Im Bar'}

위 예시에서 foo는 'Im Foo'라는 value를 가리키는 key가 되며, 마찬가지로 bar는 'I'm Bar'라는 value를 가리키는 key가 됩니다. 즉, map 자료구조는 key- value의 구조를 가지고 있습니다. 

key라는 용어는 React에서도 등장합니다. 모든 컴포넌트는 props로 지정하지 않았지만, key라는 props를 가지고 있습니다. 만약 key를 props로 직접 정의하게 된다면 key props에 특정값을 전달해도 undefined로 받아오게 되며 콘솔 창에 경고 문구가 뜨게 됩니다. 

React에서 key는 이름처럼 중요한 역할을 하게 되는데, 이번 포스팅에서는 이 key가 어떻게 사용되며 어떤 동작을 하는지를 분석한 내용을 다루려고 합니다.

React의 Key

React를 접하고 사용했다면 key의 존재에 대해 알고 있을 것입니다. 배열의 내용을 렌더링하기 위해 엘리먼트나 컴포넌트로 변환할 때 일반적으로 map이라는 메서드를 사용하게 됩니다.

const array = ['foo', 'bar', 'baz'];
return <ul>
  {array.map(item => <li>{item}</li>)}
</ul>

그런데 콘솔 창에서는 다음과 같은 경고 메세지가 뜨게 됩니다.

Warning: Each child in a list should have a unique "key" prop.

React 공식 문서에서는 다음과 같이 key를 설명합니다.

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

요약하면 다음과 같습니다.
key는 엘리먼트(또는 컴포넌트)에 안정적인 고유성을 부여해 줍니다.
React는 이 key를 가지고 어떤 항목을 변경, 추가 또는 삭제할지 식별합니다.

확 와 닿지 않는 설명이라고 느껴지는데 그 이유는 개발자는 React가 원하기 때문에 그런가 보다 하고 key를 넣어주지만, key가 왜 고유성을 부여해주고 React가 이 key를 어떻게 이용해서 엘리먼트나 컴포넌트를 식별하며 삭제하는지는 숨겨져 있기 때문입니다.

숨겨져 있는 것은 좋을 수 있습니다. React는 자기가 해야 할 일을 하고 개발자는 개발을 하면 되기 때문에 간단하게만 이해하고 넘어가도 되기 때문입니다. 하지만 React를 오래 사용하다 보면 key가 의도치 않게 중복되어서 버그가 발생하기도 하고 때에 따라서는 key의 동작을 이용해야 할 상황도 발생할 수 있기 때문에 살짝만 더 깊게 파고들어도 나쁘지 않을 것 같습니다. 

React의 속사정

위에서 React는 key로 " React가 어떤 항목을 변경, 추가 또는 삭제할지 식별" 한다고 했는데 React가 내부적으로 어떻게 동작하는지 살펴보면 그때 key가 어떻게 사용되는지도 알 수 있을 것입니다.

선언적 프로그래밍

Vanila JS또는 JQuery로 웹을 개발하다 Angular, React, Vue등으로 갈아탄 개발자라면 처음엔 생소했던 경험이 있을 것입니다. Vanila JS또는 JQuery는 UI를 변경하기 위해 직접 DOM을 조작하는 반면에 Angular, React, Vue는 상태에 따라 그려질 UI를 선언하고 상태를 변경시켜 UI가 자동으로 변경되기 때문입니다. 이

런식으로 일부 동작(DOM을 변경하기)은 추상화(React가 해줌)하고 무엇을 해야 하는지를 프로그래밍하는 방식을 선언적 프로그래밍이라고 합니다.

// Vanila JS : 명령적
<ul id="list">
  <li>foo</li>
  <li>bar</li>
</ul>
<button id="addBtn">add</button>
<script>
  document.querySelector('#addBtn').onclick = () => {
    const list = document.querySelector('#list');
    const item = document.createElement('li');
    item.innerText = 'baz';
    list.appendChild(item)
  }
</script>

// React : 선언적
function List() {
  const [items, setItems] = useState(['foo', 'bar']);
  const addItem = () => {
    setItems(draft => draft.concat('baz'));
  }
  return <>
    <ul>
      {items.map(item => <li>{item}</li>)}
    </ul>
    <button onClick={addItem}>add</button>
  </>
}

위 예시처럼 React를 사용하게 되면 직접 DOM을 조작하는 것이 아닌 JSX 문법을 사용해서 선언적으로 UI를 그리고 상태를 변경하는 코드를 추가하기만 하면 React에서 DOM을 알아서 조작해 줍니다. 그렇다면 React가 어떤식으로 DOM을 조작하는지 알아보겠습니다.

Virtual DOM

React는 JSX 문법으로 선언한 엘리먼트와 컴포넌트를 추상화하여 Tree 구조의 객체로 변환합니다. 이를 Virtual DOM이라고 합니다. 유저의 이벤트 등으로 인해 상태(props 또는 state)가 변경되면 상태에 따라서 Virtual DOM을 변경하고 이를 기반으로 실제 DOM을 조작하게 됩니다. 이 과정을 reconciliation이라고 합니다.

Virtual DOM 업데이트 과정 ⓒOreilly하나의 트리를 새로운 트리로 변환하기 위해서는 O(n^3)의 복잡도를 가지게 되는데 1,000개의 엘리먼트를 그리기 위해서는 10억 번의 비교 연산을 수행해야 합니다. React는 reconciliation을 효율적으로 처리하기 위해 Diffing 알고리즘을 사용합니다. Diffing 알고리즘을 사용하게 되면 O(n)의 복잡도만으로 새로운 트리를 만들어낼 수 있습니다.

Diffing Algorithm

엘리먼트의 타입이 다른 경우 : 이전 트리를 버리고 완전히 새로운 트리를 구축합니다. 

이전엔 div 엘리먼트였는데 상태가 변경되니 section 엘리먼트로 변경되었다면 기존 div 엘리먼트를 루트로 하는 트리는 삭제되고 section 엘리먼트를 루트로 하는 새로운 트리를 다시 구축하게 됩니다.

엘리먼트의 타입이 같은 경우 : 이전 엘리먼트와 새로운 엘리먼트의 속성을 확인하여 동일한 내역은 유지하고 변경된 속성들만 갱신합니다.

자식에 대한 재귀적 처리 : DOM 노드의 자식들을 재귀적으로 처리할 때, React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경합니다.

위에 예시로 들었던 List 컴포넌트에 위 알고리즘을 대입해서 생각해 보면 List컴포넌트에 상태가 변경되었기 때문에 React는 List 컴포넌트를 rerendering 합니다. 기존에는 루트가 ul 엘리먼트였고 변경 후에도 ul이니 2번에 해당하게 되고 속성들만 갱신되는데 갱신될 속성이 없으니 DOM을 조작할 필요가 없습니다. 그리고 3번 자식에 대한 재귀적 처리를 하게 되는데

<ul>
  <li>foo</li>
  <li>bar<li>
</ul>
----------------
<ul>
  <li>foo</li>
  <li>bar<li>
  <li>baz</li>
</ul>

첫 번째, 두 번째 li 엘리먼트에는 변경사항이 없으니 처리를 안하면 되고 세 번째 li 엘리먼트가 추가되었으니 다음과 같은 동작이 React에 의해서 진행될 것입니다.

const li = document.createElement('li');
li.innerText = 'baz';
ul.appendChild(li)

그런데 만약 리스트 뒤에 추가된 것이 아니라 앞에 추가되었을 경우에는 다음과 같을 것입니다.

<ul>
  <li>foo</li>
  <li>bar<li>
</ul>
----------------
<ul>
  <li>baz</li>
  <li>foo</li>
  <li>bar<li>
</ul>

우리는 이미 히스토리를 알고 있기 때문에 appendChild 메서드 대신 prependChild 메서드를 사용하면 되지만 React는 히스토리를 알 수 없기 때문에 첫 번째 li 엘리먼트의 내용이 바뀌었고 두 번째 li 엘리먼트의 내용이 바뀌었고 세번째 li 엘리먼트가 추가된 것으로 판단하고 다음과 같은 처리를 할 것입니다.

li1.innerText = 'baz';
li2.innerText = 'foo';
const li = document.createElement('li');
li.innerText = 'bar';
ul.appendChild(li)

앞에 추가하던 뒤에 추가하던 하나의 엘리먼트만 추가되었을 뿐인데 동작이 다르게 됩니다. 만약 1,000개의 li 엘리먼트가 있었고 앞에 추가가 되었다면 차이는 훨씬 더 커지게 될 것입니다. 이러한 문제를 해결하기 위해 엘리먼트 또는 컴포넌트에 key props가 존재하게 되는 것입니다.

다시 React의 Key

React에서 배열을 랜더링할 때 key를 받는 이유는 "변경 전 이 엘리먼트가 변경 후 이 엘리먼트다" 라는 것을 알려주기 위함입니다. 위에서 히스토리라는 말을 했었는데 개발자는 히스토리를 알기 때문에 key가 없어도 "이 엘리먼트는 이 엘리먼트지"라고 판단할 수 있지만 React는 그렇지 못하기 때문에 히스토리를 key라는 props로 알려주는 것입니다.

<ul>
  <li key="1">foo</li>
  <li key="2">bar<li>
</ul>
----------------
<ul>
  <li key="3">baz</li>
  <li key="1">foo</li>
  <li key="2">bar<li>
</ul>

위의 케이스처럼 key를 전달하면 React는 다음과 같은 처리를 할 것입니다. 

// 새로운 key 3이 추가 되었음
const li = document.createElement('li');
li.innerText = 'baz';
ul.prependChild(li)
// key 1이 그대로 있고 innerText가 같으니 변경할 필요 없음
// key 2가 그대로 있고 innerText가 같으니 변경할 필요 없음
const li = document.createElement('li');
li.innerText = 'baz';
ul.prependChild(li)

엘리먼트가 삭제되었을 경우나 변경(컨텐츠나 속성) 되었을 경우에도 key는 위처럼 변경 전 어떤 엘리먼트가 변경 후 어떤 엘리먼트인지 알려주게 되어 변경이 필요한 엘리먼트만 변경하거나 삭제하면 되기 때문에 효율적인 처리가 가능해집니다. 

마무리

이번 포스팅을 통해 React에서 key가 어떤 역할을 하는지 알기 위해서 선언적 프로그래밍부터 시작해서 Virtual DOM, Diffing Algorism까지 가볍게알아봤습니다. 다음 2편에서는 실제 코드상에서 key를 사용해 보면서 key의 변화에 따라 예상되는 결과가 나오는지, 왜 그렇게 나오는지 다뤄보도록 하려고 합니다. 

참고 : https://ko.reactjs.org/, 리액트를 다루는 기술 | 김민준 지음

profile
기록하는 습관

0개의 댓글