한국어

React의 재조정 프로세스를 마스터하세요. 'key' prop의 올바른 사용법을 배워 리스트 렌더링을 최적화하고 버그를 예방하며 애플리케이션 성능을 향상시키는 방법을 알아보세요.

성능 잠금 해제: 리스트 최적화를 위한 React 재조정 Key 심층 분석

현대 웹 개발의 세계에서, 데이터 변화에 신속하게 반응하는 동적 사용자 인터페이스를 만드는 것은 매우 중요합니다. React는 컴포넌트 기반 아키텍처와 선언적 특성으로 이러한 인터페이스를 구축하기 위한 글로벌 표준이 되었습니다. React 효율성의 중심에는 재조정(reconciliation)이라는 프로세스가 있으며, 여기에는 가상 DOM(Virtual DOM)이 관여합니다. 하지만 가장 강력한 도구라도 비효율적으로 사용될 수 있으며, 신입 개발자와 숙련된 개발자 모두가 흔히 어려움을 겪는 부분이 바로 리스트 렌더링입니다.

아마 data.map(item => <div>{item.name}</div>)와 같은 코드를 수없이 작성해보셨을 겁니다. 이는 간단하고 사소해 보입니다. 그러나 이 단순함 이면에는, 무시할 경우 애플리케이션 속도 저하와 혼란스러운 버그로 이어질 수 있는 중요한 성능 고려 사항이 숨어있습니다. 해결책은 무엇일까요? 작지만 강력한 prop, 바로 key입니다.

이 종합 가이드에서는 React의 재조정 프로세스와 리스트 렌더링에서 key가 수행하는 필수적인 역할에 대해 깊이 파고들 것입니다. 우리는 '무엇을' 뿐만 아니라 '왜'에 대해서도 탐구할 것입니다. 즉, key가 왜 필수적인지, 어떻게 올바르게 선택해야 하는지, 그리고 잘못 선택했을 때의 중대한 결과는 무엇인지 알아봅니다. 이 가이드를 다 읽고 나면, 더 성능이 좋고 안정적이며 전문적인 React 애플리케이션을 작성할 수 있는 지식을 갖추게 될 것입니다.

1장: React의 재조정과 가상 DOM 이해하기

key의 중요성을 이해하기 전에, 우리는 먼저 React를 빠르게 만드는 기본 메커니즘인 가상 DOM(VDOM) 기반의 재조정을 이해해야 합니다.

가상 DOM이란 무엇인가?

브라우저의 문서 객체 모델(DOM)과 직접 상호작용하는 것은 연산 비용이 많이 듭니다. DOM에서 노드 추가, 텍스트 업데이트, 스타일 변경 등 무언가를 변경할 때마다 브라우저는 상당한 양의 작업을 수행해야 합니다. 전체 페이지의 스타일과 레이아웃을 다시 계산해야 할 수도 있는데, 이 과정을 리플로우(reflow) 및 리페인트(repaint)라고 합니다. 복잡한 데이터 기반 애플리케이션에서 잦은 직접적인 DOM 조작은 성능을 급격히 저하시킬 수 있습니다.

React는 이 문제를 해결하기 위해 추상화 계층인 가상 DOM을 도입했습니다. VDOM은 실제 DOM을 메모리에 경량으로 표현한 것입니다. UI의 청사진이라고 생각하면 됩니다. React에게 UI를 업데이트하라고 지시하면(예: 컴포넌트의 state 변경), React는 즉시 실제 DOM을 건드리지 않습니다. 대신 다음 단계를 수행합니다.

  1. 업데이트된 state를 나타내는 새로운 VDOM 트리가 생성됩니다.
  2. 이 새로운 VDOM 트리는 이전 VDOM 트리와 비교됩니다. 이 비교 과정을 "비교(diffing)"라고 합니다.
  3. React는 이전 VDOM을 새로운 VDOM으로 변환하는 데 필요한 최소한의 변경 사항을 파악합니다.
  4. 이러한 최소한의 변경 사항은 함께 묶여(batched) 한 번의 효율적인 작업으로 실제 DOM에 적용됩니다.

재조정이라고 알려진 이 과정이 바로 React를 매우 효율적으로 만드는 이유입니다. 집 전체를 재건축하는 대신, React는 어떤 벽돌을 교체해야 하는지 정확하게 파악하여 작업과 중단을 최소화하는 전문 계약자처럼 행동합니다.

2장: Key 없이 리스트를 렌더링할 때의 문제점

이제 이 우아한 시스템이 어디에서 문제를 겪을 수 있는지 살펴보겠습니다. 사용자 목록을 렌더링하는 간단한 컴포넌트를 생각해보세요.


function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
}

이 컴포넌트가 처음 렌더링될 때, React는 VDOM 트리를 구축합니다. 만약 `users` 배열의 *끝*에 새로운 사용자를 추가하면, React의 비교(diffing) 알고리즘은 이를 원활하게 처리합니다. 이전 리스트와 새 리스트를 비교하여 끝에 새 항목이 있는 것을 보고, 실제 DOM에 새로운 `<li>`를 추가하기만 하면 됩니다. 효율적이고 간단합니다.

하지만 리스트의 *시작* 부분에 새 사용자를 추가하거나 항목의 순서를 바꾸면 어떻게 될까요?

초기 리스트가 다음과 같다고 가정해 봅시다.

그리고 업데이트 후에는 다음과 같이 변경됩니다.

고유 식별자가 없으면, React는 순서(인덱스)를 기준으로 두 리스트를 비교합니다. React가 보는 것은 다음과 같습니다.

이것은 매우 비효율적입니다. 시작 부분에 "Charlie"를 위한 새 요소 하나만 삽입하는 대신, React는 두 번의 변경과 한 번의 삽입을 수행했습니다. 큰 리스트나 자체 state를 가진 복잡한 컴포넌트의 리스트 항목의 경우, 이러한 불필요한 작업은 심각한 성능 저하와 더 중요하게는 컴포넌트 state와 관련된 잠재적 버그로 이어집니다.

이것이 바로 위 코드를 실행하면 브라우저 개발자 콘솔에 다음과 같은 경고가 표시되는 이유입니다: "Warning: Each child in a list should have a unique 'key' prop." React는 작업을 효율적으로 수행하기 위해 도움이 필요하다고 명시적으로 알려주고 있는 것입니다.

3장: 구원투수 `key` Prop

key prop은 React가 필요로 하는 힌트입니다. 이것은 요소의 리스트를 생성할 때 제공하는 특별한 문자열 속성입니다. key는 각 요소에 리렌더링 전반에 걸쳐 안정적이고 고유한 정체성을 부여합니다.

우리의 `UserList` 컴포넌트를 key를 사용하여 다시 작성해 봅시다.


function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

여기서 우리는 각 `user` 객체가 고유한 `id` 속성(예: 데이터베이스에서 가져온)을 가지고 있다고 가정합니다. 이제 우리의 시나리오를 다시 살펴보겠습니다.

초기 데이터:


[{ id: 'u1', name: 'Alice' }, { id: 'u2', name: 'Bob' }]

업데이트된 데이터:


[{ id: 'u3', name: 'Charlie' }, { id: 'u1', name: 'Alice' }, { id: 'u2', name: 'Bob' }]

key가 있으면 React의 비교(diffing) 과정이 훨씬 더 똑똑해집니다.

  1. React는 새로운 VDOM에서 `<ul>`의 자식들을 보고 그들의 key를 확인합니다. `u3`, `u1`, `u2`를 봅니다.
  2. 그런 다음 이전 VDOM의 자식들과 그들의 key를 확인합니다. `u1`과 `u2`를 봅니다.
  3. React는 `u1`과 `u2` key를 가진 컴포넌트가 이미 존재한다는 것을 압니다. 그것들을 변경할 필요 없이, 해당 DOM 노드를 새로운 위치로 이동시키기만 하면 됩니다.
  4. React는 `u3` key가 새로운 것임을 확인합니다. "Charlie"를 위한 새로운 컴포넌트와 DOM 노드를 생성하고 맨 앞에 삽입합니다.

그 결과는 단일 DOM 삽입과 약간의 순서 변경이며, 이는 우리가 이전에 본 여러 번의 변경과 삽입보다 훨씬 효율적입니다. key는 안정적인 정체성을 제공하여 React가 배열 내 위치에 관계없이 렌더링 간에 요소를 추적할 수 있게 해줍니다.

4장: 올바른 Key 선택하기 - 황금률

key prop의 효과는 전적으로 올바른 값을 선택하는 데 달려 있습니다. 알아두어야 할 명확한 모범 사례와 위험한 안티패턴이 있습니다.

최고의 Key: 고유하고 안정적인 ID

이상적인 key는 리스트 내에서 항목을 고유하고 영구적으로 식별하는 값입니다. 이것은 거의 항상 데이터 소스의 고유 ID입니다.

Key를 위한 훌륭한 소스는 다음과 같습니다.


// GOOD: 데이터로부터 안정적이고 고유한 ID를 사용합니다.
<div>
  {products.map(product => (
    <ProductItem key={product.sku} product={product} />
  ))}
</div>

안티패턴: 배열 인덱스를 Key로 사용하기

흔한 실수는 배열 인덱스를 key로 사용하는 것입니다.


// BAD: 배열 인덱스를 key로 사용합니다.
<div>
  {items.map((item, index) => (
    <ListItem key={index} item={item} />
  ))}
</div>

이렇게 하면 React 경고는 사라지지만, 심각한 문제로 이어질 수 있으며 일반적으로 안티패턴으로 간주됩니다. 인덱스를 key로 사용하면 React에게 항목의 정체성이 리스트 내 위치에 묶여 있다고 말하는 것과 같습니다. 이것은 리스트가 재정렬되거나, 필터링되거나, 맨 앞이나 중간에서 항목이 추가/제거될 수 있을 때 key가 전혀 없는 것과 근본적으로 같은 문제입니다.

State 관리 버그:

인덱스 key를 사용할 때 가장 위험한 부작용은 리스트 아이템이 자체 state를 관리할 때 나타납니다. 입력 필드 리스트를 상상해보세요.


function UnstableList() {
  const [items, setItems] = React.useState([{ id: 1, text: 'First' }, { id: 2, text: 'Second' }]);

  const handleAddItemToTop = () => {
    setItems([{ id: 3, text: 'New Top' }, ...items]);
  };

  return (
    <div>
      <button onClick={handleAddItemToTop}>Add to Top</button>
      {items.map((item, index) => (
        <div key={index}>
          <label>{item.text}: </label>
          <input type="text" />
        </div>
      ))}
    </div>
  );
}

다음과 같은 정신적 훈련을 해보세요.

  1. 리스트가 "First"와 "Second"로 렌더링됩니다.
  2. 첫 번째 입력 필드("First"용)에 "Hello"라고 입력합니다.
  3. "Add to Top" 버튼을 클릭합니다.

어떤 일이 일어날 것으로 예상하나요? "New Top"을 위한 새롭고 비어있는 입력 필드가 나타나고, "First"를 위한 입력 필드(여전히 "Hello"를 포함)가 아래로 이동할 것으로 예상할 것입니다. 실제로는 어떻게 될까요? 첫 번째 위치(인덱스 0)에 있는 입력 필드는 여전히 "Hello"를 포함한 채로 남아 있습니다. 하지만 이제는 새로운 데이터 항목인 "New Top"과 연결됩니다. 입력 컴포넌트의 state(내부 값)는 그것이 나타내야 할 데이터가 아니라 그 위치(key=0)에 묶여 있습니다. 이것이 바로 인덱스 key로 인해 발생하는 고전적이고 혼란스러운 버그입니다.

만약 `key={index}`를 `key={item.id}`로 바꾸기만 하면 문제는 해결됩니다. 이제 React는 컴포넌트의 state를 데이터의 안정적인 ID와 올바르게 연관시킬 것입니다.

언제 인덱스 Key를 사용해도 괜찮을까?

인덱스를 사용하는 것이 안전한 드문 상황이 있지만, 다음 모든 조건을 만족해야 합니다.

  1. 리스트가 정적이다: 절대로 재정렬되거나, 필터링되거나, 맨 끝이 아닌 다른 곳에서 항목이 추가/제거되지 않습니다.
  2. 리스트의 항목들에 안정적인 ID가 없습니다.
  3. 각 항목에 대해 렌더링되는 컴포넌트는 단순하고 내부 state가 없습니다.

그렇다 하더라도, 가능하다면 임시적이지만 안정적인 ID를 생성하는 것이 더 좋습니다. 인덱스를 사용하는 것은 항상 의도적인 선택이어야 하며, 기본값이 되어서는 안 됩니다.

최악의 주범: `Math.random()`

절대로 `Math.random()`이나 다른 비결정적인 값을 key로 사용하지 마세요.


// TERRIBLE: 절대 이렇게 하지 마세요!
<div>
  {items.map(item => (
    <ListItem key={Math.random()} item={item} />
  ))}
</div>

`Math.random()`으로 생성된 key는 모든 렌더링에서 다를 것이 보장됩니다. 이것은 React에게 이전 렌더링의 모든 컴포넌트 리스트가 파괴되고 완전히 다른 새로운 컴포넌트 리스트가 생성되었다고 말하는 것과 같습니다. 이로 인해 React는 모든 이전 컴포넌트를 마운트 해제(unmount)하고(그들의 state를 파괴하며) 모든 새 컴포넌트를 마운트(mount)하게 됩니다. 이것은 재조정의 목적을 완전히 무너뜨리고 성능에 있어 최악의 선택입니다.

5장: 고급 개념 및 자주 묻는 질문

Key와 `React.Fragment`

때때로 `map` 콜백에서 여러 요소를 반환해야 할 때가 있습니다. 이를 위한 표준 방법은 `React.Fragment`를 사용하는 것입니다. 이렇게 할 때, `key`는 `Fragment` 컴포넌트 자체에 위치해야 합니다.


function Glossary({ terms }) {
  return (
    <dl>
      {terms.map(term => (
        // key는 자식이 아닌 Fragment에 위치합니다.
        <React.Fragment key={term.id}>
          <dt>{term.name}</dt>
          <dd>{term.definition}</dd>
        </React.Fragment>
      ))}
    </dl>
  );
}

중요: 단축 문법 `<>...</>`는 key를 지원하지 않습니다. 리스트에 프래그먼트가 필요한 경우, 명시적인 `<React.Fragment>` 구문을 사용해야 합니다.

Key는 형제 요소들 사이에서만 고유하면 됩니다

일반적인 오해는 key가 전체 애플리케이션에서 전역적으로 고유해야 한다는 것입니다. 이것은 사실이 아닙니다. key는 직계 형제 리스트 내에서만 고유하면 됩니다.


function CourseRoster({ courses }) {
  return (
    <div>
      {courses.map(course => (
        <div key={course.id}>  {/* 강좌를 위한 Key */} 
          <h3>{course.title}</h3>
          <ul>
            {course.students.map(student => (
              // 이 학생 key는 이 특정 강좌의 학생 리스트 내에서만 고유하면 됩니다.
              <li key={student.id}>{student.name}</li>
            ))}
          </ul>
        </div>
      ))}
    </div>
  );
}

위의 예에서, 서로 다른 두 강좌에 `id: 's1'`을 가진 학생이 있을 수 있습니다. key가 서로 다른 부모 `<ul>` 요소 내에서 평가되기 때문에 이것은 완벽하게 괜찮습니다.

의도적으로 컴포넌트 State를 리셋하기 위해 Key 사용하기

key는 주로 리스트 최적화를 위한 것이지만, 더 깊은 목적을 가지고 있습니다: 바로 컴포넌트의 정체성을 정의하는 것입니다. 컴포넌트의 key가 변경되면, React는 기존 컴포넌트를 업데이트하려고 시도하지 않습니다. 대신, 이전 컴포넌트(와 모든 자식들)를 파괴하고 처음부터 새로운 컴포넌트를 생성합니다. 이것은 이전 인스턴스를 마운트 해제하고 새로운 인스턴스를 마운트하여 사실상 그 state를 리셋합니다.

이것은 컴포넌트를 리셋하는 강력하고 선언적인 방법이 될 수 있습니다. 예를 들어, `userId`를 기반으로 데이터를 가져오는 `UserProfile` 컴포넌트를 상상해보세요.


function App() {
  const [userId, setUserId] = React.useState('user-1');

  return (
    <div>
      <button onClick={() => setUserId('user-1')}>View User 1</button>
      <button onClick={() => setUserId('user-2')}>View User 2</button>
      
      <UserProfile key={userId} id={userId} />
    </div>
  );
}

`UserProfile` 컴포넌트에 `key={userId}`를 배치함으로써, `userId`가 변경될 때마다 전체 `UserProfile` 컴포넌트가 버려지고 새로운 컴포넌트가 생성되도록 보장합니다. 이는 이전 사용자의 프로필에서 온 state(예: 양식 데이터나 가져온 콘텐츠)가 남아있을 수 있는 잠재적 버그를 방지합니다. 이것은 컴포넌트 정체성과 생명주기를 관리하는 깔끔하고 명시적인 방법입니다.

결론: 더 나은 React 코드 작성하기

key prop은 콘솔 경고를 없애는 방법 그 이상입니다. 이것은 React에게 재조정 알고리즘이 효율적이고 올바르게 작동하는 데 필요한 중요한 정보를 제공하는 근본적인 지시입니다. key 사용법을 마스터하는 것은 전문적인 React 개발자의 특징입니다.

핵심 내용을 요약해 보겠습니다.

이러한 원칙을 내재화함으로써, 여러분은 더 빠르고 신뢰할 수 있는 React 애플리케이션을 작성할 수 있을 뿐만 아니라, 라이브러리의 핵심 메커니즘에 대한 더 깊은 이해를 얻게 될 것입니다. 다음에 배열을 순회하여 리스트를 렌더링할 때, `key` prop에 마땅한 주의를 기울여 주세요. 여러분의 애플리케이션 성능과 미래의 여러분이 감사할 것입니다.