리액트 기초 강좌 #8 리스트와 key

15 분 소요

지난 시간에는 화면을 조건에 따라 다르게 그리는 패턴들을 다뤘습니다. 이번 시간에는 또 하나의 필수 주제인 여러 개의 데이터를 한꺼번에 그리는 법과, 거기에 빠지지 않고 등장하는 특별한 prop인 **key**에 대해 알아보겠습니다.

배열을 화면에 그리는 법

화면에 그릴 데이터가 배열이라면 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시"가 엉뚱한 항목 옆에 그대로 남아 있는 이상한 일이 일어납니다.

인덱스 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). 짧은 코드지만 리액트의 핵심을 거의 다 보여주는 예제입니다.

마무리

이번 글에서는 배열을 화면에 그리는 방법과 key의 역할을 살펴봤습니다. 핵심은:

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

이 글까지가 **리액트 기초 강좌 첫 번째 배치(#1~#8)**의 마무리입니다. 여기까지 따라오셨다면 카운터, 토글, 메시지 폼 같은 작은 인터랙티브 컴포넌트는 무리 없이 만들 수 있게 되셨을 겁니다. 정말 큰 진전입니다.

다음 글인 "리액트 기초 강좌 #9 폼 다루기"부터는 좀 더 실전적인 패턴으로 들어갑니다. 입력 폼을 다루는 정석적인 패턴(controlled component), 그리고 이어지는 글에서는 useEffect, 상태 끌어올리기(lifting state up), Context까지 차근차근 다뤄보겠습니다.