June 25, 2025

배열 인덱스를 React `key`로 사용하기 🤦‍♂️

React
성능
컴포넌트
상태관리

Summary

React에서 목록을 렌더링할 때 배열 인덱스를 key로 사용하면 비효율적인 렌더링, 컴포넌트 상태 손실, 예측 불가능한 UI 버그를 유발합니다. 대신 각 목록 항목의 고유하고 안정적인 ID를 key로 사용해야 합니다.

Why Wrong?

React는 key prop을 사용하여 목록의 항목을 고유하게 식별하고, 효율적인 렌더링을 위해 어떤 항목이 변경, 추가 또는 제거되었는지 추적합니다. key가 없으면 React는 내부적으로 배열의 인덱스를 사용합니다. 그러나 배열 인덱스를 명시적으로 key로 사용하는 것은 다음과 같은 심각한 문제를 야기합니다.

  1. 비효율적인 렌더링 및 성능 저하:

    • 목록의 순서가 변경되거나, 중간에 항목이 추가/제거될 경우, 인덱스는 더 이상 항목의 고유한 식별자가 되지 못합니다.
    • 예를 들어, 목록 중간에 새 항목이 추가되면, 그 뒤의 모든 항목의 인덱스가 변경됩니다. React는 이 변경된 인덱스를 보고 "이전 항목이 변경되었구나"라고 오해하고, 실제로는 업데이트가 아닌 재마운트(unmount 후 mount)를 수행할 수 있습니다. 이는 불필요한 DOM 조작과 리렌더링을 유발하여 성능을 저하시킵니다.
    • transform: translateopacity 같은 속성 변경은 브라우저의 repaint만 발생시키지만, DOM 구조를 변경하는 position이나 margin 같은 속성은 layout(리플로우)을 발생시켜 브라우저 렌더링 파이프라인에서 비용이 훨씬 높습니다. 인덱스 key로 인한 불필요한 재마운트는 DOM 구조를 완전히 재구성할 수 있으므로, 브라우저의 layoutpaint 단계를 더 많이 트리거하여 성능에 치명적입니다.
  2. 컴포넌트 내부 상태 및 스크롤 위치 손실:

    • 컴포넌트가 자체적인 내부 상태(예: input 값, 체크박스 상태)를 가지고 있거나, 해당 컴포넌트 내부에 스크롤 위치가 있는 경우, 인덱스 key로 인해 컴포넌트가 재마운트되면 이러한 내부 상태가 초기화되거나 스크롤 위치가 손실될 수 있습니다.
    • 예를 들어, 댓글 목록에서 특정 댓글의 좋아요 버튼을 눌렀는데, 다른 댓글이 중간에 추가되면서 내 댓글이 재마운트되어 좋아요 상태가 초기화되는 상황이 발생할 수 있습니다.
  3. 예측 불가능한 버그:

    • 특히 정렬, 필터링, 삽입, 삭제 기능이 있는 동적인 목록에서 인덱스 key는 데이터와 UI 간의 불일치를 유발하여 예상치 못한 버그를 발생시킵니다. 잘못된 데이터가 다른 컴포넌트와 연결되거나, UI가 엉키는 현상이 나타날 수 있습니다.

How to Fix?

목록의 각 항목을 고유하게 식별할 수 있는 안정적인 IDkey prop으로 사용해야 합니다.

  1. 데이터에 고유 ID가 있는 경우:

    • 대부분의 백엔드에서 제공하는 데이터는 고유한 id 필드를 가지고 있습니다. 이 idkey로 사용하세요.
    • 예: item.id
  2. 데이터에 고유 ID가 없는 경우:

    • 가장 좋은 방법은 백엔드 개발자와 협의하여 고유 ID를 데이터에 추가하는 것입니다.
    • 불가피하게 고유 ID가 없다면, uuid 라이브러리 등을 사용하여 클라이언트에서 고유 ID를 생성하여 사용해야 합니다. 이 경우, 한 번 생성된 ID는 해당 컴포넌트의 수명 주기 동안 유지되어야 합니다. (예: useEffect를 사용하여 한 번만 생성하고, 컴포넌트 외부에서 관리하거나, useRef로 참조하여 유지).
    • 주의: 임시로 uuid()map 내에서 직접 호출하여 key로 사용하면, 렌더링될 때마다 새로운 ID가 생성되어 오히려 더 큰 문제를 야기합니다. 고유 ID는 각 항목에 대해 고정되어야 합니다.

Before Code (Bad)

import React, { useState } from 'react';

function TodoList() {
  const [todos, setTodos] = useState([
    { text: '커피 사기', done: false },
    { text: '운동하기', done: false },
    { text: '코드 리뷰', done: false },
  ]);

  const addTodo = () => {
    setTodos(prev => [{ text: '새로운 할 일', done: false }, ...prev]);
  };

  const toggleDone = (index) => {
    setTodos(prev => 
      prev.map((todo, i) => 
        i === index ? { ...todo, done: !todo.done } : todo
      )
    );
  };

  return (
    <div>
      <button onClick={addTodo}>맨 앞에 할 일 추가</button>
      <ul>
        {todos.map((todo, index) => (
          <li key={index} style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.text} 
            <input 
              type="checkbox" 
              checked={todo.done} 
              onChange={() => toggleDone(index)} 
            />
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoList;

After Code (Good)

import React, { useState } from 'react';
import { v4 as uuidv4 } from 'uuid'; // uuid 라이브러리 사용 예시 (실제 프로젝트에서는 백엔드 ID 권장)

function TodoList() {
  const [todos, setTodos] = useState([
    { id: uuidv4(), text: '커피 사기', done: false },
    { id: uuidv4(), text: '운동하기', done: false },
    { id: uuidv4(), text: '코드 리뷰', done: false },
  ]);

  const addTodo = () => {
    setTodos(prev => [{ id: uuidv4(), text: '새로운 할 일', done: false }, ...prev]);
  };

  const toggleDone = (targetId) => {
    setTodos(prev => 
      prev.map(todo => 
        todo.id === targetId ? { ...todo, done: !todo.done } : todo
      )
    );
  };

  return (
    <div>
      <button onClick={addTodo}>맨 앞에 할 일 추가</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.text} 
            <input 
              type="checkbox" 
              checked={todo.done} 
              onChange={() => toggleDone(todo.id)} 
            />
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoList;