리스트와 키
배열을 map으로 컴포넌트 목록으로 변환하고 안정적인 key가 항목의 추가·삭제·재정렬을 식별하는 원리를 이해합니다.
우리는 조건부 렌더링을 통해 특정 조건에 따라 UI를 동적으로 변경하는 방법을 배웠습니다.
이번 장에서는 여러 개의 데이터를 효율적으로 화면에 나열하는 방법인 리스트(List) 렌더링과, 리액트에서 리스트를 렌더링할 때 반드시 필요한 key(키) 속성에 대해 알아보겠습니다.
게시판의 게시물 목록, 쇼핑몰의 상품 목록, 댓글 목록 등 대부분의 웹 애플리케이션은 여러 개의 유사한 데이터를 목록 형태로 보여주는 경우가 많습니다.
리액트는 이러한 리스트를 효율적으로 렌더링하기 위한 방법을 제공합니다.
map()으로 데이터 배열을 JSX 목록으로 바꾸고, key가 있을 때 기존 아이템을 안정적으로 재사용해 불필요한 업데이트를 줄이는 흐름을 정리한 다이어그램입니다.
리스트 key와 reconciliation — 상태 추적
재정렬 전후에 key 1/2/3의 위치 변화와 유지되는 컴포넌트 정체성을 구별한다. 같은 key와 타입 유지·index key 반례를 실제 항목 정체성으로 비교할 가치가 있다.
KEYS: ITEM IDENTITY
안정적인 key는 순서가 바뀌어도 같은 항목을 연결합니다
PostList의 post.id를 map이 반환하는 PostItem에 지정합니다. 같은 부모 아래 key와 컴포넌트 타입이 같으면 항목의 state를 이어받으며, props가 바뀌면 UI도 갱신됩니다.
ID로 연결 · key 2가 이동
전: [1, 2, 3]
후: [3, 1, 2]
key 2: 두 번째 → 세 번째교차선은 이전·다음 목록에서 같은 key의 대응입니다. 이동 전후 항목의 state는 같은 게시물에 연결됩니다.
map이 반환하는 바깥 요소
posts.map(post => (
<PostItem key={post.id}
post={post} />
))PostItem 안 li에만 key를 두는 것으로 이 배열의 형제를 식별할 수는 없습니다. key는 컴포넌트의 일반 prop으로 전달되지 않습니다.
인덱스 / 랜덤 key의 차이
인덱스는 위치를 나타내므로 삽입·삭제·정렬 시 state가 다른 데이터에 연결될 수 있습니다. 매 렌더마다 랜덤 key를 만들면 항목을 새로 만들어 state를 잃습니다.
ID의 범위와 수명
같은 부모 아래 형제끼리 고유하면 됩니다. ID가 없다면 데이터 생성 시 한 번 부여해 저장합니다. 정체성 유지가 재렌더나 DOM 갱신을 모두 막는 것은 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 변경 전 1·2·3과 재정렬 후 3·1·2의 같은 key를 세 개의 대응선으로 연결한다. 같은 부모 아래 컴포넌트 종류와 key가 같으면 정체성을 유지한다. 선의 교차는 병합이나 실행 순서가 아니다. 모바일은 key별 세 개의 독립 대응을 전후 위치 문구와 함께 위아래로 재배치한다. |
| 전체 관계·조건 설명 | 변경 전 1·2·3과 재정렬 후 3·1·2의 같은 key를 세 개의 대응선으로 연결한다. 같은 부모 아래 컴포넌트 종류와 key가 같으면 정체성을 유지한다. 선의 교차는 병합이나 실행 순서가 아니다. 모바일은 key별 세 개의 독립 대응을 전후 위치 문구와 함께 위아래로 재배치한다. 모바일은 같은 노드와 방향을 다른 배치로 보여 줍니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 변경 전 항목 | 순서: 1,2,3 / 정체성: key+같은 종류 | key 1/2/3이 순서 1/2/3에서 각 정체성을 갖는다 | key는 같은 부모의 형제 항목 기준이다 |
| 재정렬 | 순서: 3,1,2 / 항목 state: 안정 key에 연결 | 데이터 순서를 3/1/2로 바꾼다 | 위치 이동과 새 항목 생성은 다르다 |
| 다음 대응 | key3: 앞 위치로 이동 / key1/2: 각 정체성 유지 | 같은 종류/key의 각 항목이 자기 state를 유지한다 | index key 반례는 별도 모델이며 key를 자식 prop으로 읽지 않는다 |
리스트 key와 reconciliation — 상태 추적
재정렬 전후에 key 1/2/3의 위치 변화와 유지되는 컴포넌트 정체성을 구별한다. 같은 key와 타입 유지·index key 반례를 실제 항목 정체성으로 비교할 가치가 있다.
KEYS: ITEM IDENTITY
안정적인 key는 순서가 바뀌어도 같은 항목을 연결합니다
PostList의 post.id를 map이 반환하는 PostItem에 지정합니다. 같은 부모 아래 key와 컴포넌트 타입이 같으면 항목의 state를 이어받으며, props가 바뀌면 UI도 갱신됩니다.
ID로 연결 · key 2가 이동
전: [1, 2, 3]
후: [3, 1, 2]
key 2: 두 번째 → 세 번째교차선은 이전·다음 목록에서 같은 key의 대응입니다. 이동 전후 항목의 state는 같은 게시물에 연결됩니다.
map이 반환하는 바깥 요소
posts.map(post => (
<PostItem key={post.id}
post={post} />
))PostItem 안 li에만 key를 두는 것으로 이 배열의 형제를 식별할 수는 없습니다. key는 컴포넌트의 일반 prop으로 전달되지 않습니다.
인덱스 / 랜덤 key의 차이
인덱스는 위치를 나타내므로 삽입·삭제·정렬 시 state가 다른 데이터에 연결될 수 있습니다. 매 렌더마다 랜덤 key를 만들면 항목을 새로 만들어 state를 잃습니다.
ID의 범위와 수명
같은 부모 아래 형제끼리 고유하면 됩니다. ID가 없다면 데이터 생성 시 한 번 부여해 저장합니다. 정체성 유지가 재렌더나 DOM 갱신을 모두 막는 것은 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 변경 전 1·2·3과 재정렬 후 3·1·2의 같은 key를 세 개의 대응선으로 연결한다. 같은 부모 아래 컴포넌트 종류와 key가 같으면 정체성을 유지한다. 선의 교차는 병합이나 실행 순서가 아니다. 모바일은 key별 세 개의 독립 대응을 전후 위치 문구와 함께 위아래로 재배치한다. |
| 전체 관계·조건 설명 | 변경 전 1·2·3과 재정렬 후 3·1·2의 같은 key를 세 개의 대응선으로 연결한다. 같은 부모 아래 컴포넌트 종류와 key가 같으면 정체성을 유지한다. 선의 교차는 병합이나 실행 순서가 아니다. 모바일은 key별 세 개의 독립 대응을 전후 위치 문구와 함께 위아래로 재배치한다. 모바일은 같은 노드와 방향을 다른 배치로 보여 줍니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 변경 전 항목 | 순서: 1,2,3 / 정체성: key+같은 종류 | key 1/2/3이 순서 1/2/3에서 각 정체성을 갖는다 | key는 같은 부모의 형제 항목 기준이다 |
| 재정렬 | 순서: 3,1,2 / 항목 state: 안정 key에 연결 | 데이터 순서를 3/1/2로 바꾼다 | 위치 이동과 새 항목 생성은 다르다 |
| 다음 대응 | key3: 앞 위치로 이동 / key1/2: 각 정체성 유지 | 같은 종류/key의 각 항목이 자기 state를 유지한다 | index key 반례는 별도 모델이며 key를 자식 prop으로 읽지 않는다 |
map() 함수를 이용한 리스트 렌더링
리액트에서 리스트를 렌더링하는 가장 일반적이고 권장되는 방법은 자바스크립트 배열의 map() 메서드를 사용하는 것입니다.
map() 메서드는 배열의 각 요소에 대해 지정된 함수를 호출하고, 그 함수의 반환값으로 이루어진 새로운 배열을 생성합니다.
리액트에서는 이 새로운 배열에 담긴 JSX 요소들을 화면에 렌더링합니다.
예제: 간단한 숫자 목록 렌더링NumberList.jsx 컴포넌트 생성:
src/components 폴더 안에 NumberList.jsx 파일을 생성합니다.
function NumberList(props) {
const numbers = [1, 2, 3, 4, 5]; // 렌더링할 숫자 배열
// (1) map() 함수를 사용하여 각 숫자를 <li> JSX 요소로 변환
const listItems = numbers.map((number) =>
<li>{number}</li> // (2) 경고: 여기에 key 속성이 없습니다! (잠시 후에 추가 예정)
);
return (
<div style={{ border: '1px solid #6c757d', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>숫자 목록</h2>
<ul>{listItems}</ul> {/* (3) JSX 내부에서 배열 렌더링 */}
</div>
);
}
export default NumberList;numbers.map((number) => <li>{number}</li>):numbers배열의 각number에 대해<li>{number}</li>라는 JSX 요소를 반환하는 새로운 배열listItems를 생성합니다.{listItems}: JSX 내부에서 자바스크립트 표현식({})을 사용하여listItems배열을 그대로 렌더링합니다. 리액트는 배열 안에 있는 JSX 요소들을 자동으로 펼쳐서 나열합니다.
App.jsx에서 NumberList 컴포넌트 사용import './App.css';
import NumberList from './components/NumberList'; // NumberList 컴포넌트 불러오기
function App() {
return (
<div className="App">
<h1>React 리스트 렌더링</h1>
<NumberList />
</div>
);
}
export default App;결과 확인 및 경고 메시지 확인: 브라우저에서 페이지를 확인하면 1부터 5까지의 숫자가 목록으로 나타날 것입니다.
하지만 개발자 도구의 콘솔을 확인해 보면 다음과 같은 경고 메시지를 발견할 수 있습니다.
Warning: Each child in a list should have a unique "key" prop.이것이 바로 key 속성에 대한 경고입니다.
이제 이 key가 왜 필요하고 어떻게 사용하는지 알아보겠습니다.
key의 필요성
리액트에서 리스트를 렌더링할 때 각 리스트 아이템에 고유한 key prop을 부여해야 하는 것은 매우 중요합니다.
key는 리액트가 리스트 내의 어떤 아이템이 변경, 추가 또는 삭제되었는지 식별하는 데 사용되는 특별한 문자열 속성입니다.
key가 필요한 이유 (리액트의 "재조정" 원리)
리액트는 DOM을 효율적으로 업데이트하기 위해 재조정(Reconciliation)이라는 과정을 거칩니다.
새로운 UI 트리를 이전 UI 트리와 비교하여 어떤 부분이 변경되었는지 파악하고, 최소한의 DOM 조작만으로 화면을 업데이트합니다.
리스트 아이템의 경우, key가 없다면 리액트는 단순히 순서가 변경되거나 새로운 아이템이 추가될 때, 각 아이템이 재정렬된 것인지 아니면 완전히 새로운 아이템인지 구분하기 어렵습니다.
예를 들어, 중간 삽입·삭제에서 안정적인 key가 없으면 React가 위치를 기준으로 이전 항목을 이어 받아, 기존 state가 다른 데이터 항목에 연결될 수 있습니다.
이는 성능 저하로 이어집니다.
반면, key가 있다면 리액트는 각 아이템을 key로 식별하여, 같은 부모 아래에서 key와 타입이 일치하는 항목의 정체성을 유지하면서, 바뀐 props에 따른 UI도 업데이트합니다.
즉, key는 리액트가 재조정 과정을 최적화할 수 있도록 돕는 힌트(hint) 역할을 합니다.
map()으로 순회하고, 아이템 UI를 컴포넌트로 분리한 뒤 안정적인 key를 붙이면 재정렬이나 삽입이 일어나도 React가 같은 항목을 안정적으로 이어서 추적할 수 있습니다.
key가 없을 때 발생할 수 있는 문제점
- 성능 저하: 불필요한 재렌더링으로 인해 애플리케이션이 느려질 수 있습니다.
- 컴포넌트 내부 상태 문제: 리스트 아이템 컴포넌트가 자체적인
state를 가지고 있다면,key가 없을 때 아이템의 순서가 변경되거나 삭제되면state가 엉뚱한 아이템에 할당되거나 초기화되는 등 예측 불가능한 버그가 발생할 수 있습니다. (예: 체크박스 목록에서 특정 항목만 체크가 해제되어야 하는데 다른 항목이 해제되는 현상)
key 속성 올바르게 사용하기
key는 리스트의 각 아이템을 고유하게 식별할 수 있는 값이어야 합니다.
고유한 ID 사용 (가장 권장)
가장 이상적인 key 값은 데이터 자체가 가지고 있는 고유한 ID입니다.
데이터베이스에서 가져온 데이터라면 id 필드를 key로 사용하는 것이 일반적입니다.
PostList.jsx 컴포넌트 생성:
src/components 폴더 안에 PostList.jsx 파일을 생성합니다.
function PostItem({ post }) { // 각 게시물 데이터를 props로 받음
return (
<li style={{ border: '1px solid #ccc', padding: '10px', margin: '10px 0', borderRadius: '5px', backgroundColor: '#f9f9f9' }}>
<h4>{post.title}</h4>
<p>{post.content}</p>
</li>
);
}
function PostList() {
const posts = [
{ id: 1, title: '리액트 시작하기', content: '리액트의 기본을 배워봅시다.' },
{ id: 2, title: '컴포넌트의 세계', content: '재사용 가능한 UI를 만들어요.' },
{ id: 3, title: '상태 관리의 중요성', content: '데이터가 변경될 때 UI 업데이트!' },
];
return (
<div style={{ border: '1px solid #007bff', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>최신 게시물</h2>
<ul>
{/* map() 메서드를 사용하여 PostItem 컴포넌트 렌더링 */}
{posts.map(post => (
<PostItem key={post.id} post={post} /> // (1) key={post.id}로 고유 ID를 key로 지정
))}
</ul>
</div>
);
}
export default PostList;- 각
post객체가 고유한id를 가지고 있으므로,key={post.id}로key를 지정했습니다. key는map()함수 내부에서 JSX 요소를 반환하는 곳에 위치해야 합니다. 이 예제에서는<li>태그가 아닌PostItem컴포넌트 자체에key를 전달했습니다.
App.jsx에서 PostList 컴포넌트 사용import './App.css';
import PostList from './components/PostList'; // PostList 컴포넌트 불러오기
function App() {
return (
<div className="App">
<h1>React 리스트 렌더링 (Key 적용)</h1>
<PostList />
</div>
);
}
export default App;결과 확인:
이제 콘솔에 key 경고 메시지가 나타나지 않을 것입니다.
인덱스를 key로 사용하기 (주의 필요!)
만약 데이터에 고유한 ID가 없다면, map() 함수의 두 번째 인자인 index (배열의 인덱스)를 key로 사용할 수도 있습니다.
function NumberList(props) {
const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map((number, index) => // (1) map의 두 번째 인자로 index 받기
<li key={index}>{number}</li> // (2) key={index}로 지정
);
return (
<div style={{ border: '1px solid #6c757d', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>숫자 목록 (Key: Index)</h2>
<ul>{listItems}</ul>
</div>
);
}
export default NumberList;key로 사용하는 경우의 주의사항
인덱스를 key로 사용하는 것은 다음 조건에 모두 해당될 때만 안전하게 사용할 수 있습니다.
리스트 아이템의 순서가 변경되지 않을 때: 리스트가 정렬되거나, 중간에 아이템이 추가/삭제되지 않을 때.
리스트가 필터링되지 않을 때: 리스트에서 특정 아이템이 제거되지 않을 때.
리스트 아이템이 state를 가지고 있지 않을 때: 각 리스트 아이템 컴포넌트가 자신만의 내부 state를 가지고 있지 않을 때.
위 조건 중 하나라도 해당되지 않는다면, 인덱스를 key로 사용하는 것은 예측 불가능한 버그나 성능 문제를 야기할 수 있습니다.
key로 사용하는 것을 강력히 권장합니다.
고유 ID가 없다면 데이터를 생성할 때 ID를 한 번 부여해 항목과 함께 보관합니다. 렌더할 때마다 새 ID를 만들면 그림의 안정적인 정체성 연결이 끊깁니다.
컴포넌트로 리스트 아이템 분리하기
리스트를 렌더링할 때 각 아이템을 별도의 컴포넌트로 분리하는 것은 좋은 습관입니다.
이는 코드의 가독성, 재사용성, 그리고 유지보수성을 향상시킵니다.
위 PostList.jsx 예시에서 PostItem 컴포넌트를 분리하여 사용한 것처럼 말입니다.
// 각 게시물 아이템을 위한 별도의 컴포넌트
function PostItem({ post }) { // PostItem 컴포넌트는 post 객체를 props로 받습니다.
return (
<li style={{ border: '1px solid #ccc', padding: '10px', margin: '10px 0', borderRadius: '5px', backgroundColor: '#f9f9f9' }}>
<h4>{post.title}</h4>
<p>{post.content}</p>
</li>
);
}
function PostList() {
const posts = [
{ id: 1, title: '리액트 시작하기', content: '리액트의 기본을 배워봅시다.' },
{ id: 2, title: '컴포넌트의 세계', content: '재사용 가능한 UI를 만들어요.' },
{ id: 3, title: '상태 관리의 중요성', content: '데이터가 변경될 때 UI 업데이트!' },
];
return (
<div style={{ border: '1px solid #007bff', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>최신 게시물</h2>
<ul>
{posts.map(post => (
// key는 map 함수 안에서 배열의 각 요소를 렌더링하는 가장 바깥쪽 엘리먼트에 부여
<PostItem key={post.id} post={post} />
))}
</ul>
</div>
);
}
export default PostList;이렇게 컴포넌트를 분리하면 PostList는 자신이 선언한 posts 배열을 순회하여 PostItem들을 렌더링하는 역할을 하고, 각 PostItem은 자신에게 주어진 post 데이터만 가지고 독립적으로 렌더링됩니다.
이는 리액트의 컴포넌트 지향 개발 철학에도 부합합니다.
리스트와 키는 여기까지입니다.
map() 함수는 배열을 JSX 목록으로 바꾸는 도구이고, key는 그 목록 안에서 항목의 정체성을 React에 알려주는 힌트입니다.
특히 인덱스를 key로 사용할 때는 정렬, 삽입, 삭제, 내부 상태가 없는지 먼저 확인해야 합니다.
정렬 후에도 입력이 같은 항목에 남는지 확인하기
제목만 표시하는 원래 게시물 예제에서는 항목의 내부 상태가 없어 키 차이를 눈으로 확인하기 어렵습니다. 다음은 각 행이 작성 중인 메모를 가진 별도 실습입니다. src/components/KeyPractice.jsx에 저장하고 App.jsx에서 <KeyPractice />를 렌더합니다.
import { useState } from 'react';
function EditablePost({ post }) {
const [draft, setDraft] = useState('');
return (
<li>
<label>
{post.title}: 메모
<input value={draft} onChange={event => setDraft(event.target.value)} />
</label>
</li>
);
}
export default function KeyPractice() {
const [posts, setPosts] = useState([
{ id: 1, title: '기초' },
{ id: 2, title: '응용' },
]);
return (
<section>
<button onClick={() => setPosts(current => [...current].reverse())}>
순서 뒤집기
</button>
<ul>
{posts.map(post => <EditablePost key={post.id} post={post} />)}
</ul>
</section>
);
}기초 행에 내 메모를 입력하고 순서 뒤집기를 누릅니다. 같은 부모 아래에서 같은 컴포넌트 종류와 ID 1을 가진 행의 정체성이 유지되므로 메모는 기초 행에 남아야 합니다. key는 정렬 전후의 같은 항목을 연결하는 기준이며, 그 행의 렌더 함수를 다시 실행하지 않는다는 보장은 아닙니다.
이번에는 실습의 키만 posts.map((post, index) => <EditablePost key={index} post={post} />)로 바꾼 뒤 페이지를 새로고침하여 두 실험을 같은 초기 상태에서 비교합니다. 기초에 메모를 입력하고 뒤집으면 첫 위치의 키 0이 유지되어 메모는 이제 응용 제목 옆에 나타납니다. 데이터 ID와 화면 위치가 서로 다른 기준이라는 점을 확인합니다.
독립 연습
초기 데이터를 ID 7의 계획, ID 9의 복습으로 바꾸고 복습 행에만 완료 메모를 입력합니다. 뒤집은 뒤 메모가 어느 제목 옆에 있어야 하는지 ID 키와 인덱스 키 두 경우를 먼저 예측하고 확인하세요. 두 경우 모두 행 수는 2개이며, 입력 내용의 소속이 판단 기준입니다.
결과와 이유 확인
ID 키에서는 복습 / 완료가 유지됩니다. ID 9의 행이 두 번째에서 첫 번째로 옮겨도 같은 정체성을 찾습니다. 인덱스 키에서는 두 번째 위치의 키 1이 유지되어 계획 / 완료가 됩니다. 뒤집기 직전 두 번째 위치에 있던 입력 상태를 새 두 번째 항목이 사용하기 때문입니다.
React의 키 규칙은 같은 형제 목록에서 키가 고유하고 항목의 수명 동안 바뀌지 않아야 한다고 설명합니다.
다음 절에서는 사용자가 직접 값을 입력하는 폼을 React state와 연결하는 방법을 살펴봅니다.