ReactMedium#17
React의 key prop은 왜 중요하며, index를 key로 사용하면 안 되는 이유는?
#React#key#성능#리스트
힌트
Reconciliation 과정에서 key의 역할을 생각해보세요.
정답 및 해설
React의 key prop은 왜 중요하며, index를 key로 사용하면 안 되는 이유는?
key는 React가 리스트를 렌더링할 때 각 항목을 고유하게 식별하기 위해 사용하는 특별한 prop입니다. React는 상태가 변경되어 리렌더링이 발생할 때 key를 기반으로 어떤 요소가 추가/수정/삭제되었는지 판단하여 최소한의 DOM 조작을 수행합니다. key가 안정적이지 않거나 index를 key로 사용하면 컴포넌트 상태가 의도치 않게 유지되거나 초기화되는 버그가 발생할 수 있습니다.
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.id | index |
| 재정렬 가능한 목록 | 고유 식별자 | index |
| 삭제/추가가 중간에서 일어나는 목록 | 고유 식별자 | index |
| 상태가 있는 자식 컴포넌트 목록 | 고유 식별자 | index |
| 완전히 정적이고 변경 없는 목록 | index 가능 | Math.random() |
| 컴포넌트 강제 리셋 | 변경되는 키 값 | 항상 동일한 값 |
핵심 정리
key는 React가 리스트에서 어떤 항목이 변경/추가/삭제되었는지 판단하는 유일한 단서입니다.index를key로 사용하면 순서 변경, 삭제, 삽입 시 컴포넌트 상태가 잘못된 항목에 연결되는 버그가 발생합니다.key는 반드시 안정적(stable)이고, 예측 가능하며(predictable), 형제 요소 간에 유일(unique)해야 합니다.key변경을 의도적으로 활용하면 컴포넌트를 강제로 언마운트/리마운트하여 상태를 초기화할 수 있습니다.