React 리스트에서 index 대신 안정적인 id를 key로 써야 하는 이유는 무엇인가요?
리스트의 key는 경고를 없애는 장식이 아니라 이전과 다음 렌더의 항목을 연결하는 식별자입니다. 추가·삭제로 순서가 달라지는 목록에서는 안정적인 고유 id를 사용합니다.
적용 조건·범위: 하지만 첫 번째 항목을 삭제하면 나머지 항목의 index가 달라집니다. 컴포넌트 내부 state나 DOM 상태가 있는 경우 React가 다른 항목을 같은 항목처럼 재사용해 예상하지 못한 결과가 나타날 수 있습니다.
본문 근거: React Rendering Lists
독립 실습: 삭제 후 입력값과 항목의 연결 확인하기
목록을 바꾸면서 항목의 key와 입력 상태를 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useState } from 'react';
export default function App() {
const [rows, setRows] = useState([
{ id: 'a', title: '사과' },
{ id: 'b', title: '배' },
]);
return (
<main>
<button onClick={() => setRows(previous => previous.slice(1))}>첫 항목 삭제</button>
<ul>
{rows.map((row, index) => (
<li key={row.id}>
<label>{row.title}<input defaultValue={row.title} /></label>
<small>위치 {index}</small>
</li>
))}
</ul>
</main>
);
}
직접 확인: 사과 input만 메모로 바꾼 뒤 첫 항목을 삭제하세요. id key에서는 남은 배 항목의 input도 배입니다. key={index}로 수정하고 새로고침해 같은 절차를 반복하면 배 항목에 메모가 남는 문제를 비교할 수 있습니다.
변형 과제: 비교를 마친 후 key를 row.id로 복구하고 key와 index의 역할을 설명합니다.
key는 React의 항목 식별자입니다
tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))
React는 key를 이용해 이전 목록의 항목과 다음 목록의 항목을 연결합니다.
목록이 추가·삭제·재정렬될 수 있다면 데이터 자체가 가진 안정적인 id를 사용하는 것이 좋습니다.
index key가 문제가 되는 이유
다음 코드는 문법적으로 동작합니다.
tasks.map((task, index) => (
<TaskItem key={index} task={task} />
))
하지만 첫 번째 항목을 삭제하면 나머지 항목의 index가 달라집니다. 컴포넌트 내부 state나 DOM 상태가 있는 경우 React가 다른 항목을 같은 항목처럼 재사용해 예상하지 못한 결과가 나타날 수 있습니다.
랜덤 key는 더 피해야 합니다
<TaskItem key={Math.random()} task={task} />
렌더링할 때마다 key가 바뀌므로 React 입장에서는 매번 완전히 새로운 컴포넌트입니다. 기존 컴포넌트를 재사용하지 못하고 내부 state도 유지되지 않습니다.
안정적인 id를 사용합니다
서버 데이터라면 보통 서버가 제공하는 id를 사용합니다.
로컬에서 새 항목을 만드는 학습 예제에서는 다음처럼 생성 시점에 id를 한 번 만듭니다.
const newTask = {
id: crypto.randomUUID(),
title,
completed: false,
};
렌더링할 때 id를 생성하는 것이 아니라 데이터가 생성될 때 id를 확정하는 것이 중요합니다.
자주 발생하는 오류와 해결
Each child in a list should have a unique key prop
map으로 반환하는 가장 바깥 요소에 key가 있는지 확인합니다.
key가 있는데 입력값이 다른 항목으로 이동합니다
index key인지 확인합니다. 목록 순서가 바뀌거나 삭제되는 구조라면 데이터 id를 사용합니다.
key를 props에서 읽고 싶습니다
key는 React가 내부적으로 사용하는 특별한 값이라 일반 props처럼 전달되지 않습니다. 컴포넌트 안에서도 id가 필요하면 별도의 id 또는 task 객체를 전달합니다.
학습 완료 체크리스트
- key가 항목 식별에 사용된다고 설명할 수 있습니다.
- index key의 위험 조건을 설명할 수 있습니다.
- 랜덤 key를 피해야 하는 이유를 설명할 수 있습니다.
- 데이터 생성 시점에 안정적인 id를 만들 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
Task 타입의 id를 목록 식별 기준으로 확정합니다. 새 작업은 생성 시점에 crypto.randomUUID로 id를 받습니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 9단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React state 업데이트 안됨 문제 해결의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/TaskList.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: src/App.tsx의 작업 생성 시점에 id를 확정하고 src/components/TaskList.tsx의 map에서 key={task.id}를 사용합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: 첫 작업을 삭제하면 index key를 쓴 목록의 내부 상태가 다른 항목으로 옮겨갈 수 있나요?
컴포넌트 실습: step-09-list-key의 목록에서 먼저 id key를 유지한 채 첫 작업을 삭제하고 남은 제목을 확인합니다. 이어서 코드 예시의 index key 문제를 비교하고 최종 코드는 task.id로 돌려놓습니다.
예상 화면·상태: id key라면 남은 작업은 같은 id를 유지합니다. index key는 삭제·정렬 뒤 위치가 바뀌어 항목별 입력 상태가 잘못 이어질 수 있습니다. 현재 체크포인트에서 상태 이동 재현 여부는 직접 확인해야 합니다. 이 내용은 독자가 확인할 기준이며 이번 편집에서 앱을 실행하거나 브라우저 화면을 관찰한 결과가 아닙니다.
오류 점검: 경고가 남으면 map이 반환하는 최상위 반복 요소에 key가 있는지와 id 중복 여부를 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 10회에서 key가 컴포넌트 state 보존·초기화에 주는 영향을 살펴봅니다.
완료 코드 ZIP 다운로드 — step-09-list-key
이전 체크포인트
step-08-state-fix
현재 상태
작업 추가·삭제가 가능하므로 목록의 순서와 길이가 계속 바뀔 수 있습니다.
이번 목표
Task 타입의 id를 목록 식별 기준으로 확정합니다. 새 작업은 생성 시점에 crypto.randomUUID로 id를 받습니다.
확인 방법
- 작업을 여러 개 추가합니다.
- 중간 항목을 삭제합니다.
- 남은 항목이 의도한 상태를 그대로 유지하는지 확인합니다.
완료 체크포인트:
step-09-list-key
전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.
전체 파일·코드 보기
실습 프로젝트 전체 코드
index.html
<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
package.json
{
"name": "taskflow-step-09-list-key",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.0",
"@vitejs/plugin-react": "6.1.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "26.6.2"
}
}
src/App.tsx
import { useState } from 'react';
import Header from './components/Header';
import TaskList from './components/TaskList';
import type { Task } from './types/task';
const initialTasks: Task[] = [
{
id: 'task-1',
title: '안정적인 key 사용하기',
completed: false,
},
];
function App() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const handleAddSample = () => {
const newTask: Task = {
id: crypto.randomUUID(),
title: '새 작업',
completed: false,
};
setTasks((previousTasks) => [
...previousTasks,
newTask,
]);
};
const handleToggle = (id: string) => {
setTasks((previousTasks) =>
previousTasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
);
};
const handleDelete = (id: string) => {
setTasks((previousTasks) =>
previousTasks.filter((task) => task.id !== id),
);
};
return (
<main className="app">
<Header />
<button type="button" onClick={handleAddSample}>
샘플 작업 추가
</button>
<TaskList
tasks={tasks}
onToggle={handleToggle}
onDelete={handleDelete}
/>
</main>
);
}
export default App;
src/components/Header.tsx
export default function Header() {
return (
<header>
<p className="eyebrow">React Learning Project</p>
<h1>TaskFlow</h1>
<p>오늘 할 일을 하나씩 정리해 보세요.</p>
</header>
);
}
src/components/TaskCard.tsx
import type { ReactNode } from 'react';
type TaskCardProps = {
children: ReactNode;
actions?: ReactNode;
};
export default function TaskCard({
children,
actions,
}: TaskCardProps) {
return (
<li className="task-card">
<div className="task-card__content">
{children}
</div>
{actions ? (
<div className="task-card__actions">
{actions}
</div>
) : null}
</li>
);
}
src/components/TaskItem.tsx
import type { Task } from '../types/task';
import TaskCard from './TaskCard';
type TaskItemProps = {
task: Task;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
};
export default function TaskItem({
task,
onToggle,
onDelete,
}: TaskItemProps) {
return (
<TaskCard
actions={
<>
<button
type="button"
onClick={() => onToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<button
type="button"
onClick={() => onDelete(task.id)}
>
삭제
</button>
</>
}
>
<span>{task.title}</span>
</TaskCard>
);
}
src/components/TaskList.tsx
import TaskItem from './TaskItem';
import type { Task } from '../types/task';
type TaskListProps = {
tasks: Task[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
};
export default function TaskList({
tasks,
onToggle,
onDelete,
}: TaskListProps) {
return (
<ul>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
src/index.css
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
color: #111;
background: #f5f5f5;
}
.app {
width: min(720px, calc(100% - 32px));
margin: 48px auto;
}
.eyebrow {
color: #666;
font-size: 14px;
}
ul {
padding-left: 20px;
}
button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }
src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
src/types/task.ts
export type Task = {
id: string;
title: string;
completed: boolean;
};
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });
글 소개·학습 준비·전체 목차

React · 9/32 · TaskFlow 누적 실습
React 리스트 key 경고 해결 기준 index key를 피해야 하는 이유
React · 9 / 32 · 필수 선수 · 오류 해결
React에서 배열을 map으로 렌더링하면 각 항목에 key가 필요합니다. key는 단순히 경고를 없애기 위한 값이 아니라, 이전 렌더와 다음 렌더 사이에서 “이 항목이 같은 항목인가?”를 React가 판단하는 기준입니다.
상단에서는 key 경고와 index key 문제만 독립적으로 해결합니다. 하단에서는 TaskFlow의 작업 id 정책을 안정적으로 정리합니다.
이번 글에서 배우는 내용
- key의 역할
- 같은 목록 안에서 key가 고유해야 하는 이유
- index key를 사용해도 되는 경우와 피해야 하는 경우
- Math.random 같은 매 렌더 새 key가 문제인 이유
- TaskFlow에서 안정적인 id를 만드는 방법
목차
다음 학습
다음 글에서는 리스트 식별자와 연결되는 React의 identity 개념을 확장해, 같은 위치의 state가 언제 보존되고 key로 언제 초기화되는지 배웁니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.