June 25, 2025
배열 인덱스를 React `key`로 사용하기 🤦♂️
React
성능
컴포넌트
상태관리
Summary
React에서 목록을 렌더링할 때 배열 인덱스를 key
로 사용하면 비효율적인 렌더링, 컴포넌트 상태 손실, 예측 불가능한 UI 버그를 유발합니다. 대신 각 목록 항목의 고유하고 안정적인 ID를 key
로 사용해야 합니다.
Why Wrong?
React는 key
prop을 사용하여 목록의 항목을 고유하게 식별하고, 효율적인 렌더링을 위해 어떤 항목이 변경, 추가 또는 제거되었는지 추적합니다. key
가 없으면 React는 내부적으로 배열의 인덱스를 사용합니다. 그러나 배열 인덱스를 명시적으로 key
로 사용하는 것은 다음과 같은 심각한 문제를 야기합니다.
-
비효율적인 렌더링 및 성능 저하:
- 목록의 순서가 변경되거나, 중간에 항목이 추가/제거될 경우, 인덱스는 더 이상 항목의 고유한 식별자가 되지 못합니다.
- 예를 들어, 목록 중간에 새 항목이 추가되면, 그 뒤의 모든 항목의 인덱스가 변경됩니다. React는 이 변경된 인덱스를 보고 "이전 항목이 변경되었구나"라고 오해하고, 실제로는 업데이트가 아닌 재마운트(unmount 후 mount)를 수행할 수 있습니다. 이는 불필요한 DOM 조작과 리렌더링을 유발하여 성능을 저하시킵니다.
transform: translate
나opacity
같은 속성 변경은 브라우저의repaint
만 발생시키지만, DOM 구조를 변경하는position
이나margin
같은 속성은layout
(리플로우)을 발생시켜 브라우저 렌더링 파이프라인에서 비용이 훨씬 높습니다. 인덱스key
로 인한 불필요한 재마운트는 DOM 구조를 완전히 재구성할 수 있으므로, 브라우저의layout
및paint
단계를 더 많이 트리거하여 성능에 치명적입니다.
-
컴포넌트 내부 상태 및 스크롤 위치 손실:
- 컴포넌트가 자체적인 내부 상태(예: input 값, 체크박스 상태)를 가지고 있거나, 해당 컴포넌트 내부에 스크롤 위치가 있는 경우, 인덱스
key
로 인해 컴포넌트가 재마운트되면 이러한 내부 상태가 초기화되거나 스크롤 위치가 손실될 수 있습니다. - 예를 들어, 댓글 목록에서 특정 댓글의 좋아요 버튼을 눌렀는데, 다른 댓글이 중간에 추가되면서 내 댓글이 재마운트되어 좋아요 상태가 초기화되는 상황이 발생할 수 있습니다.
- 컴포넌트가 자체적인 내부 상태(예: input 값, 체크박스 상태)를 가지고 있거나, 해당 컴포넌트 내부에 스크롤 위치가 있는 경우, 인덱스
-
예측 불가능한 버그:
- 특히 정렬, 필터링, 삽입, 삭제 기능이 있는 동적인 목록에서 인덱스
key
는 데이터와 UI 간의 불일치를 유발하여 예상치 못한 버그를 발생시킵니다. 잘못된 데이터가 다른 컴포넌트와 연결되거나, UI가 엉키는 현상이 나타날 수 있습니다.
- 특히 정렬, 필터링, 삽입, 삭제 기능이 있는 동적인 목록에서 인덱스
How to Fix?
목록의 각 항목을 고유하게 식별할 수 있는 안정적인 ID를 key
prop으로 사용해야 합니다.
-
데이터에 고유 ID가 있는 경우:
- 대부분의 백엔드에서 제공하는 데이터는 고유한
id
필드를 가지고 있습니다. 이id
를key
로 사용하세요. - 예:
item.id
- 대부분의 백엔드에서 제공하는 데이터는 고유한
-
데이터에 고유 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;