목차
8 장

리스트와 key

배열을 컴포넌트로 매핑하는 패턴과 key의 의미, 그리고 인덱스를 key로 쓰면 망가지는 실제 예시를 살펴봅니다.

7장에서 화면을 조건에 따라 다르게 그리는 패턴들을 다뤘습니다. 본 챕터에서는 또 하나의 필수 주제인 여러 개의 데이터를 한꺼번에 그리는 법과 거기에 빠지지 않고 등장하는 특별한 prop인 **key**를 살펴봅니다.

key는 단순한 작명 규칙이 아니라 14장 (성능 최적화)의 reconciliation 알고리즘과 직결되는 도구입니다. 본 챕터에서 key의 기본 원리를 단단히 잡아 두면 14장이 가볍게 읽힙니다.

배열을 화면에 그리는 법 #

화면에 그릴 데이터가 배열이라면 map 메소드로 각 항목을 JSX로 변환해 그대로 JSX 안에 넣습니다.

src/FruitList.jsx
function FruitList() {
  const fruits = ['사과', '바나나', '체리'];

  return (
    <ul>
      {fruits.map(fruit => <li key={fruit}>{fruit}</li>)}
    </ul>
  );
}

export default FruitList;

핵심은 두 가지입니다.

  1. fruits.map(...)이 JSX 요소들의 배열을 만든다
  2. 리액트는 JSX 안에 JSX 배열이 들어오면 그 요소들을 차례대로 렌더링한다

배열을 그대로 JSX에 넣어도 되는 것입니다. 다만 거기서 한 가지 약속이 있는데, 각 요소마다 key라는 prop을 줘야 한다는 것입니다.

key는 왜 필요한가 #

key는 리액트가 각 항목을 식별하기 위한 고유 ID 역할을 합니다. 리스트가 변할 때 (추가 / 삭제 / 순서 변경) 리액트가 무엇이 어떻게 변했는지 효율적으로 알아내려면 각 항목을 구별할 수 있어야 합니다.

key가 없으면 리액트는 매번 모든 요소를 처음부터 다시 그릴지, 기존 것을 재사용할지 정확히 판단하기 어렵습니다. 결과적으로 성능이 떨어지거나, 어떤 경우에는 화면이 이상하게 깜빡이거나, 입력 필드의 포커스가 엉뚱한 곳으로 옮겨가는 미묘한 버그가 생기기도 합니다.

key를 빼먹으면 리액트는 콘솔에 경고를 띄웁니다.

콘솔 경고
Warning: Each child in a list should have a unique "key" prop.

좋은 key란 #

좋은 key는 다음 조건을 만족합니다.

  • 고유함 — 형제 항목들 사이에서 겹치지 않아야 합니다. 전 세계에서 유일할 필요는 없고, 같은 리스트 안에서만 유일하면 됩니다.
  • 안정적 — 같은 항목이라면 렌더링이 다시 일어나도 같은 key를 가져야 함

가장 자연스러운 후보는 데이터가 가지고 있는 고유 ID입니다. 데이터베이스의 PK, 서버에서 받은 id 필드 같은 것들입니다.

src/PostList.jsx
function PostList({ posts }) {
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          {post.title}
        </li>
      ))}
    </ul>
  );
}

ID가 없는 단순한 데이터 (문자열 배열 같은) 라면, 값이 고유하다는 보장이 있다면 값 자체를 key로 써도 됩니다.

값이 고유한 경우
{fruits.map(fruit => <li key={fruit}>{fruit}</li>)}

단, “사과"가 두 번 들어 있는 배열이라면 같은 key가 두 개라 경고가 뜹니다. 그런 위험이 있다면 ID를 부여해 객체로 다루는 편이 안전합니다.

인덱스를 key로 쓰면 안 되나요 #

map의 두 번째 인자로 인덱스를 받을 수 있어서, “그냥 인덱스 쓰면 되지 않나?“라는 생각이 들 수 있습니다.

안티패턴
{fruits.map((fruit, index) => <li key={index}>{fruit}</li>)}

이건 동작은 하지만 리액트 공식 문서가 명시적으로 권장하지 않는 방식입니다. 리스트의 순서가 바뀌거나 중간에 항목이 추가 / 삭제될 가능성이 있다면 버그를 유발 하기 때문입니다.

인덱스 key가 망가지는 예 #

다음 상황을 상상해 보세요.

잘못된 예 — 인덱스 key + 입력 필드
function TodoList() {
  const [todos, setTodos] = useState([
    { text: '리액트 공부' },
    { text: '운동하기' },
    { text: '책 읽기' },
  ]);

  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          {todo.text} <input type="text" placeholder="메모" />
        </li>
      ))}
    </ul>
  );
}

각 항목 옆에 메모 입력 필드가 있고, 사용자가 “운동하기” 옆에 “오후 7시"라고 입력했다고 가정합시다. 그 상태에서 맨 앞에 새로운 할 일이 추가되면 어떻게 될까요?

  • 인덱스 0이었던 “리액트 공부"는 이제 인덱스 1
  • 인덱스 1이었던 “운동하기"는 이제 인덱스 2
  • 새로 들어온 항목이 인덱스 0

리액트는 key를 보고 “어, 0번 항목은 그대로 있네"라고 판단합니다. 하지만 실제 데이터는 다른 항목으로 바뀌었습니다. 그 결과 사용자가 “운동하기” 옆에 입력한 “오후 7시"가 엉뚱한 항목 옆에 그대로 남아 있는 이상한 일이 일어납니다.

해결책은 각 항목에 진짜 고유 ID를 부여하는 것입니다.

고친 코드
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 'a1', text: '리액트 공부' },
    { id: 'a2', text: '운동하기' },
    { id: 'a3', text: '책 읽기' },
  ]);

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          {todo.text} <input type="text" placeholder="메모" />
        </li>
      ))}
    </ul>
  );
}

이제 항목이 추가되거나 순서가 바뀌어도 각 항목의 입력 필드 내용이 정확히 따라갑니다.

인덱스 key가 안전한 경우 #

리스트가 정적이고 (항목 추가 / 삭제 / 순서 변경 없음) 단순 표시용일 때는 인덱스 key를 써도 큰 문제는 없습니다. 하지만 그런 경우에도 고유 ID가 있다면 그걸 쓰는 습관을 들이는 게 좋습니다. 처음에는 정적이었던 리스트가 나중에 동적이 되는 경우가 자주 있기 때문입니다.

ID가 없는 데이터를 다룰 때는 데이터를 만들 때 ID를 같이 부여하세요. 브라우저에서 crypto.randomUUID()를 호출하면 고유 ID 문자열을 만들 수 있습니다. 또는 단순히 증가하는 숫자를 써도 됩니다 (Date.now() 등).

컴포넌트로 분리해서 그리기 #

<li> 안의 내용이 길어지면 별도 컴포넌트로 분리하는 게 보통입니다. 이때 keymap이 만들어내는 최상위 요소에 달아야 한다는 점을 기억하세요.

src/TodoItem.jsx
function TodoItem({ todo }) {
  return (
    <li>
      <strong>{todo.text}</strong>. {todo.completed ? '완료' : '진행 중'}
    </li>
  );
}

export default TodoItem;
src/TodoList.jsx
import TodoItem from './TodoItem';

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

export default TodoList;

keyTodoItem 안의 <li>에 달지 말고 map이 반환하는 <TodoItem> 그 자체에 달아야 합니다. 자식 컴포넌트 안쪽 어디에 다는 게 아니라, 리스트를 만드는 그 위치 (map의 콜백이 반환하는 요소)에 다는 것입니다.

filter와 결합하기 #

자바스크립트 배열 메소드들은 자유롭게 조합할 수 있습니다. 완료되지 않은 할 일만 보여 주려면 filter로 걸러낸 뒤 map을 연결합니다.

src/TodoList.jsx
function TodoList({ todos }) {
  return (
    <ul>
      {todos
        .filter(todo => !todo.completed)
        .map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
    </ul>
  );
}

정렬도 마찬가지로 sort (또는 더 안전하게는 [...todos].sort(...))와 함께 쓸 수 있습니다.

노트
sort는 원본 배열을 직접 수정합니다. props로 받은 배열을 직접 수정하는 건 4장에서 배운 “props는 읽기 전용” 원칙에 위배되고, state 배열에 대해서도 5장에서 배운 “직접 수정 금지” 원칙에 위배됩니다. 정렬이 필요하면 항상 [...todos].sort(...)처럼 사본을 만들어 정렬하세요.

빈 배열 처리하기 #

데이터가 비어 있을 때 “비어 있다"는 메시지를 보여 주려면 7장에서 배운 조건부 렌더링과 결합합니다.

src/TodoList.jsx
function TodoList({ todos }) {
  if (todos.length === 0) {
    return <p> 일이 없습니다.</p>;
  }

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

<ul>은 의미상 어색하므로 early return으로 처리하는 쪽이 자연스럽습니다.

직접 해보기 #

6장에서 만든 MessageForm을 진짜 메시지 목록으로 발전시키겠습니다. 본 챕터까지 배운 것을 모두 씁니다.

src/MessageForm.jsx를 다음과 같이 바꿉니다.

src/MessageForm.jsx
import { useState } from 'react';

function MessageForm() {
  const [name, setName] = useState('');
  const [message, setMessage] = useState('');
  const [messages, setMessages] = useState([]);

  const isValid = name.length > 0 && message.length > 0;

  function handleSubmit(e) {
    e.preventDefault();
    if (!isValid) return;
    const newMessage = {
      id: crypto.randomUUID(),
      name,
      message,
      createdAt: new Date().toLocaleTimeString(),
    };
    setMessages(prev => [newMessage, ...prev]);
    setName('');
    setMessage('');
  }

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="이름"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        <input
          type="text"
          placeholder="메시지"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          style={{ marginLeft: '8px' }}
        />
        <button type="submit" disabled={!isValid} style={{ marginLeft: '8px' }}>
          추가
        </button>
      </form>

      <div style={{ marginTop: '16px' }}>
        {messages.length === 0 ? (
          <p style={{ color: '#888' }}>아직 메시지가 없습니다.</p>
        ) : (
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {messages.map(item => (
              <li
                key={item.id}
                style={{ borderBottom: '1px solid #eee', padding: '8px 0' }}
              >
                <strong>{item.name}</strong>
                <span style={{ color: '#888', marginLeft: '8px', fontSize: '12px' }}>
                  {item.createdAt}
                </span>
                <p style={{ margin: '4px 0 0 0' }}>{item.message}</p>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

export default MessageForm;

여러 개의 메시지를 추가해 보세요. 각 메시지가 위쪽부터 쌓이고, crypto.randomUUID()로 만든 고유 ID가 key로 쓰입니다. 빈 배열일 때는 안내 문구가 나오고, 메시지가 있으면 목록이 그려집니다.

지금까지 배운 모든 것이 한 화면에 섞여 있습니다. props (자식 요소에 데이터 전달), state (useState), 이벤트 핸들링 (onSubmit, onChange), 조건부 렌더링 (messages.length === 0 ? ... : ...), 그리고 본 챕터에서 배운 리스트 렌더링 (map + key). 짧은 코드지만 리액트의 핵심을 거의 다 보여주는 예제입니다.

연습문제 #

  1. MessageForm에 각 메시지 항목 옆에 “삭제” 버튼을 추가해 보세요. 클릭 시 해당 메시지가 목록에서 사라지도록 만듭니다. setMessages(prev => prev.filter(m => m.id !== item.id)) 패턴.
  2. 인덱스 key 함정 직접 만나 보기. MessageFormkey={item.id}를 잠시 key={index} (map 콜백의 두 번째 인자)로 바꾼 뒤, 각 항목에 별도 메모 입력 필드 <input type="text" placeholder="메모" />를 추가해 보세요. 메모를 두세 줄 입력한 상태에서 새 메시지를 추가하면 메모가 엉뚱한 항목으로 따라가는 현상이 보입니다. 그 뒤 key={item.id}로 다시 돌려 현상이 사라지는 것을 확인합니다.
  3. filter + map 조합. MessageForm에 검색창을 하나 추가하고, 입력한 단어가 메시지 본문에 포함된 항목만 표시하도록 만들어 보세요. messages.filter(m => m.message.includes(search)).map(...) 패턴. 검색창 자체도 controlled 입력 (9장에서 다룹니다)입니다.

한 줄 요약: 배열은 map으로 JSX 배열을 만들어 JSX 안에 넣는다. 각 요소에는 **고유하고 안정적인 key**가 필요하다. 가능하면 데이터의 ID를 쓰고, 인덱스 key는 안티패턴이다. keymap 콜백이 반환하는 최상위 요소에 단다. filter · 정렬 · 조건부 렌더링과 자유롭게 조합할 수 있다.

다음 챕터 #

이 챕터까지가 1부 1차 마무리입니다. 카운터, 토글, 메시지 폼 같은 작은 인터랙티브 컴포넌트는 무리 없이 만들 수 있게 되셨을 겁니다. 1부의 마지막 챕터인 다음 9장 폼 다루기에서는 거의 모든 앱에 등장하는 을 다루는 정석 패턴 — controlled component — 을 본격적으로 살펴봅니다. 19장 (이벤트와 폼 타이핑)과 27장 (Server Actions)의 토대가 됩니다.

X