전체 목록
ReactMedium#17

React의 key prop은 왜 중요하며, index를 key로 사용하면 안 되는 이유는?

#React#key#성능#리스트
힌트

Reconciliation 과정에서 key의 역할을 생각해보세요.

정답 및 해설

React의 key prop은 왜 중요하며, index를 key로 사용하면 안 되는 이유는?

key는 React가 리스트를 렌더링할 때 각 항목을 고유하게 식별하기 위해 사용하는 특별한 prop입니다. React는 상태가 변경되어 리렌더링이 발생할 때 key를 기반으로 어떤 요소가 추가/수정/삭제되었는지 판단하여 최소한의 DOM 조작을 수행합니다. key가 안정적이지 않거나 indexkey로 사용하면 컴포넌트 상태가 의도치 않게 유지되거나 초기화되는 버그가 발생할 수 있습니다.


key prop의 역할

Reconciliation(재조정) 과정에서의 역할

React는 Diffing 알고리즘을 통해 이전 Virtual DOM과 새 Virtual DOM을 비교합니다. 리스트의 경우 key가 없으면 순서에만 의존해 비교하므로 비효율적입니다.

// key 없이 리스트 렌더링 (권장하지 않음)
function BadList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li>{item.name}</li> // Warning: Each child should have a unique "key" prop
      ))}
    </ul>
  );
}

// key를 올바르게 사용한 리스트
function GoodList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

key가 있을 때와 없을 때의 동작 비교

초기 상태:
  <li key="a">사과</li>
  <li key="b">바나나</li>
  <li key="c">체리</li>

맨 앞에 "포도" 추가 후:
  <li key="d">포도</li>   ← 새로 추가
  <li key="a">사과</li>  ← 재사용 (이동)
  <li key="b">바나나</li> ← 재사용 (이동)
  <li key="c">체리</li>  ← 재사용 (이동)

key 덕분에 사과/바나나/체리는 DOM을 재사용하고 포도만 새로 삽입
key 없이 맨 앞에 "포도" 추가:
  "포도" → (이전 1번째 "사과"와 비교) → textContent 업데이트
  "사과" → (이전 2번째 "바나나"와 비교) → textContent 업데이트
  "바나나" → (이전 3번째 "체리"와 비교) → textContent 업데이트
  "체리" → (새 항목) → 새로 삽입

key 없이는 모든 항목을 업데이트하는 비효율적 작업 발생

index를 key로 사용하면 안 되는 이유

문제 1: 컴포넌트 상태가 잘못 유지됨

function TodoItem({ todo }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  return (
    <li>
      {isEditing ? (
        <input value={editText} onChange={e => setEditText(e.target.value)} />
      ) : (
        <span>{todo.text}</span>
      )}
      <button onClick={() => setIsEditing(e => !e)}>편집</button>
    </li>
  );
}

function BadTodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '할일 1' },
    { id: 2, text: '할일 2' },
    { id: 3, text: '할일 3' },
  ]);

  const deleteFirst = () => {
    setTodos(prev => prev.slice(1)); // 첫 번째 항목 삭제
  };

  return (
    <ul>
      {todos.map((todo, index) => (
        // 문제: index를 key로 사용
        <TodoItem key={index} todo={todo} />
      ))}
    </ul>
  );
}
시나리오: "할일 2"를 편집 모드(isEditing=true)로 열어둔 상태에서
         "할일 1"을 삭제하면?

삭제 전:
  key=0: TodoItem (todo="할일 1", isEditing=false)
  key=1: TodoItem (todo="할일 2", isEditing=true)  ← 편집 중
  key=2: TodoItem (todo="할일 3", isEditing=false)

삭제 후 (index가 key이므로):
  key=0: TodoItem (todo="할일 2")  ← key=0이었던 컴포넌트 재사용!
  key=1: TodoItem (todo="할일 3")  ← key=1이었던 컴포넌트 재사용!

결과: "할일 2"가 표시되는 컴포넌트가 key=0을 가지므로
     이전에 key=0이었던 컴포넌트의 상태(isEditing=false)를 그대로 가짐
     → 편집 모드가 사라져버림! (버그)

올바른 해결법: 고유한 id를 key로 사용

function GoodTodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '할일 1' },
    { id: 2, text: '할일 2' },
    { id: 3, text: '할일 3' },
  ]);

  const deleteFirst = () => {
    setTodos(prev => prev.slice(1));
  };

  return (
    <ul>
      {todos.map(todo => (
        // 올바른: todo.id를 key로 사용
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}
삭제 후 (id가 key이므로):
  key=2: TodoItem (todo="할일 2", isEditing=true)  ← 편집 상태 유지!
  key=3: TodoItem (todo="할일 3", isEditing=false)

React가 key=2 컴포넌트를 올바르게 재사용 → 상태 보존됨

문제 2: 입력 필드의 값이 잘못 유지됨

function InputList({ items }) {
  return (
    <div>
      {items.map((item, index) => (
        // index를 key로 사용: 순서 변경 시 입력값이 섞임
        <input key={index} defaultValue={item.text} />
      ))}
    </div>
  );
}

// 예시:
// items = ["사과", "바나나"]
// 두 번째 입력칸에 "바나나 수정"을 입력함
// 배열을 역순으로 바꾸면:
// items = ["바나나", "사과"]
// React는 key=0 컴포넌트에 "사과"를 주지만
// key=0 컴포넌트의 DOM 상태는 아직 "바나나 수정"을 기억함 (controlled가 아닌 경우)

문제 3: 애니메이션/트랜지션 오동작

import { CSSTransition, TransitionGroup } from 'react-transition-group';

function AnimatedList({ items }) {
  return (
    <TransitionGroup>
      {items.map((item, index) => (
        // index를 key로 사용 시: 항목 삭제/추가 애니메이션이 엉뚱한 요소에 적용됨
        <CSSTransition key={index} timeout={300} classNames="fade">
          <li>{item.text}</li>
        </CSSTransition>
      ))}
    </TransitionGroup>
  );
}

index를 key로 써도 되는 경우

모든 index 사용이 잘못된 것은 아닙니다. 다음 조건을 모두 만족할 때는 index를 사용해도 안전합니다.

// 안전한 index 사용 조건:
// 1. 리스트가 재정렬되지 않음
// 2. 필터링되지 않음 (삭제나 추가가 맨 뒤에서만 발생)
// 3. 항목에 컴포넌트 로컬 상태가 없음

// 안전한 예: 정적 목록
function StaticMenu({ menuItems }) {
  return (
    <nav>
      {menuItems.map((item, index) => (
        // 메뉴는 재정렬/삭제되지 않으므로 index 사용 가능
        <a key={index} href={item.href}>{item.label}</a>
      ))}
    </nav>
  );
}

// 안전한 예: 읽기 전용 단순 리스트
function ReadOnlyList({ names }) {
  return (
    <ul>
      {names.map((name, index) => (
        <li key={index}>{name}</li> // 로컬 상태 없음, 재정렬 없음
      ))}
    </ul>
  );
}

고유한 key 생성 전략

// 1. 데이터베이스 ID (가장 이상적)
items.map(item => <Item key={item.id} />)

// 2. 복합 키 (두 필드를 조합)
items.map(item => <Item key={`${item.userId}-${item.postId}`} />)

// 3. 새 항목 추가 시 ID 생성
import { v4 as uuidv4 } from 'uuid';

function AddItemForm() {
  const [items, setItems] = useState([]);

  const addItem = (text) => {
    setItems(prev => [...prev, { id: uuidv4(), text }]);
    // 또는: { id: crypto.randomUUID(), text }  (브라우저 내장)
  };
}

// 4. 안정적인 해시값 사용
// 주의: Math.random()은 매 렌더링마다 달라지므로 절대 사용 금지
items.map(item => <Item key={Math.random()} />) // 절대 금지!

key prop의 추가 활용: 컴포넌트 강제 리셋

key가 변경되면 React는 해당 컴포넌트를 언마운트하고 새로 마운트합니다. 이를 활용해 컴포넌트 상태를 의도적으로 초기화할 수 있습니다.

function UserProfile({ userId }) {
  const [formData, setFormData] = useState({});

  // userId가 바뀌어도 formData가 이전 사용자 것으로 유지되는 버그
  useEffect(() => {
    setFormData({}); // 이 방법은 깜빡임이 생길 수 있음
  }, [userId]);

  return <form>...</form>;
}

// key를 활용한 강제 리셋
function App() {
  const [selectedUserId, setSelectedUserId] = useState(1);

  return (
    // userId가 바뀌면 UserProfile이 완전히 새로 마운트됨
    <UserProfile key={selectedUserId} userId={selectedUserId} />
  );
}
// 폼 초기화에 활용
function FormContainer() {
  const [formKey, setFormKey] = useState(0);

  const resetForm = () => {
    setFormKey(prev => prev + 1); // key를 변경해 폼 컴포넌트 리마운트
  };

  return (
    <div>
      <ComplexForm key={formKey} />
      <button onClick={resetForm}>폼 초기화</button>
    </div>
  );
}

key prop 사용 규칙 요약

상황올바른 key잘못된 key
DB에서 온 데이터 목록item.idindex
재정렬 가능한 목록고유 식별자index
삭제/추가가 중간에서 일어나는 목록고유 식별자index
상태가 있는 자식 컴포넌트 목록고유 식별자index
완전히 정적이고 변경 없는 목록index 가능Math.random()
컴포넌트 강제 리셋변경되는 키 값항상 동일한 값

핵심 정리

  • key는 React가 리스트에서 어떤 항목이 변경/추가/삭제되었는지 판단하는 유일한 단서입니다.
  • indexkey로 사용하면 순서 변경, 삭제, 삽입 시 컴포넌트 상태가 잘못된 항목에 연결되는 버그가 발생합니다.
  • key는 반드시 안정적(stable)이고, 예측 가능하며(predictable), 형제 요소 간에 유일(unique)해야 합니다.
  • key 변경을 의도적으로 활용하면 컴포넌트를 강제로 언마운트/리마운트하여 상태를 초기화할 수 있습니다.