본문 바로가기

리스트와 키

2. 리스트와 키

React에서 여러 개의 데이터를 화면에 표시하는 방법과 각 항목을 고유하게 식별하기 위한 키(Key)의 중요성을 배워보겠습니다.

2.1 기본 리스트 렌더링

배열 데이터 렌더링

function SimpleList() {
  const fruits = ['사과', '바나나', '오렌지', '포도', '딸기'];

  return (
    <div>
      <h2>과일 목록</h2>
      <ul>
        {fruits.map((fruit, index) => (
          <li key={index}>{fruit}</li>
        ))}
      </ul>
    </div>
  );
}

객체 배열 렌더링

import { useState } from 'react';

function UserList() {
  const [users] = useState([
    { id: 1, name: '김개발', age: 25, job: '프론트엔드 개발자' },
    { id: 2, name: '이디자인', age: 28, job: 'UI/UX 디자이너' },
    { id: 3, name: '박백엔드', age: 30, job: '백엔드 개발자' },
    { id: 4, name: '최데이터', age: 27, job: '데이터 사이언티스트' }
  ]);

  return (
    <div>
      <h2>팀원 목록</h2>
      <div>
        {users.map(user => (
          <div
            key={user.id}
          >
            <h3>{user.name}</h3>
            <p>나이: {user.age}세</p>
            <p>직업: {user.job}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

2.2 키(Key)의 중요성

키가 없을 때의 문제점

import { useState } from 'react';

function ProblematicList() {
  const [items, setItems] = useState(['첫 번째', '두 번째', '세 번째']);

  const addItem = () => {
    const newItem = `${items.length + 1}번째`;
    setItems([newItem, ...items]); // 맨 앞에 추가
  };

  const removeFirstItem = () => {
    setItems(prev => prev.slice(1));
  };

  return (
    <div>
      <h3>❌ 키가 인덱스인 경우 (문제가 있는 예제)</h3>
      <div>
        <button onClick={addItem}>맨 앞에 추가</button>
        <button onClick={removeFirstItem}>첫 번째 삭제</button>
      </div>
      
      <ul>
        {items.map((item, index) => (
          <li key={index}>
            <input type="text" defaultValue={item} />
            <span>{item}</span>
          </li>
        ))}
      </ul>
      
      <p>
        위 입력 필드에 값을 입력한 후 "맨 앞에 추가"를 클릭해보세요. 
        입력값이 의도와 다르게 이동하는 것을 확인할 수 있습니다.
      </p>
    </div>
  );
}

올바른 키 사용

import { useState } from 'react';

function CorrectList() {
  const [items, setItems] = useState([
    { id: 1, text: '첫 번째' },
    { id: 2, text: '두 번째' },
    { id: 3, text: '세 번째' }
  ]);
  const [nextId, setNextId] = useState(4);

  const addItem = () => {
    const newItem = { id: nextId, text: `${nextId}번째` };
    setItems([newItem, ...items]); // 맨 앞에 추가
    setNextId(prev => prev + 1);
  };

  const removeFirstItem = () => {
    setItems(prev => prev.slice(1));
  };

  return (
    <div>
      <h3>✅ 키가 고유 ID인 경우 (올바른 예제)</h3>
      <div>
        <button onClick={addItem}>맨 앞에 추가</button>
        <button onClick={removeFirstItem}>첫 번째 삭제</button>
      </div>
      
      <ul>
        {items.map(item => (
          <li key={item.id}>
            <input type="text" defaultValue={item.text} />
            <span>{item.text}</span>
          </li>
        ))}
      </ul>
      
      <p>
        고유한 ID를 키로 사용하여 입력값이 올바르게 유지됩니다.
      </p>
    </div>
  );
}

2.3 동적 리스트 조작

CRUD 기능이 있는 할 일 목록

import { useState } from 'react';

function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'React 공부하기', completed: false },
    { id: 2, text: 'JSX 이해하기', completed: true },
    { id: 3, text: '컴포넌트 만들기', completed: false }
  ]);
  const [inputValue, setInputValue] = useState('');
  const [nextId, setNextId] = useState(4);
  const [filter, setFilter] = useState('all'); // all, active, completed

  const addTodo = () => {
    if (inputValue.trim()) {
      const newTodo = {
        id: nextId,
        text: inputValue.trim(),
        completed: false
      };
      setTodos([...todos, newTodo]);
      setInputValue('');
      setNextId(prev => prev + 1);
    }
  };

  const toggleTodo = (id) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };

  const editTodo = (id, newText) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, text: newText } : todo
    ));
  };

  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true; // all
  });

  return (
    <div>
      <h2>할 일 관리</h2>
      
      {/* 할 일 추가 */}
      <div>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
          placeholder="새로운 할 일을 입력하세요"
        />
        <button
          onClick={addTodo}
        >
          추가
        </button>
      </div>

      {/* 필터 버튼 */}
      <div>
        {['all', 'active', 'completed'].map(filterType => (
          <button
            key={filterType}
            onClick={() => setFilter(filterType)}
          >
            {filterType === 'all' ? '전체' : 
             filterType === 'active' ? '미완료' : '완료'}
          </button>
        ))}
      </div>

      {/* 할 일 목록 */}
      {filteredTodos.length === 0 ? (
        <div>
          {filter === 'all' ? '할 일이 없습니다.' :
           filter === 'active' ? '미완료된 할 일이 없습니다.' : '완료된 할 일이 없습니다.'}
        </div>
      ) : (
        <ul>
          {filteredTodos.map(todo => (
            <TodoItem
              key={todo.id}
              todo={todo}
              onToggle={toggleTodo}
              onDelete={deleteTodo}
              onEdit={editTodo}
            />
          ))}
        </ul>
      )}

      {/* 통계 */}
      <div>
        <p>전체: {todos.length}개 | 
           완료: {todos.filter(t => t.completed).length}개 | 
           미완료: {todos.filter(t => !t.completed).length}개</p>
      </div>
    </div>
  );
}

function TodoItem({ todo, onToggle, onDelete, onEdit }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  const handleEdit = () => {
    if (editText.trim() && editText.trim() !== todo.text) {
      onEdit(todo.id, editText.trim());
    }
    setIsEditing(false);
  };

  const handleCancel = () => {
    setEditText(todo.text);
    setIsEditing(false);
  };

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      
      {isEditing ? (
        <div>
          <input
            type="text"
            value={editText}
            onChange={(e) => setEditText(e.target.value)}
            onKeyPress={(e) => {
              if (e.key === 'Enter') handleEdit();
              if (e.key === 'Escape') handleCancel();
            }}
            autoFocus
          />
          <button onClick={handleEdit}>저장</button>
          <button onClick={handleCancel}>취소</button>
        </div>
      ) : (
        <>
          <span
            onDoubleClick={() => setIsEditing(true)}
          >
            {todo.text}
          </span>
          
          <div>
            <button
              onClick={() => setIsEditing(true)}
            >
              수정
            </button>
            <button
              onClick={() => onDelete(todo.id)}
            >
              삭제
            </button>
          </div>
        </>
      )}
    </li>
  );
}

export { TodoApp, TodoItem };

2.4 중첩된 리스트 렌더링

카테고리별 제품 목록

import { useState } from 'react';

function NestedListExample() {
  const [categories] = useState([
    {
      id: 1,
      name: '전자제품',
      items: [
        { id: 101, name: '노트북', price: 1500000 },
        { id: 102, name: '스마트폰', price: 800000 },
        { id: 103, name: '태블릿', price: 600000 }
      ]
    },
    {
      id: 2,
      name: '의류',
      items: [
        { id: 201, name: '티셔츠', price: 25000 },
        { id: 202, name: '청바지', price: 80000 },
        { id: 203, name: '운동화', price: 120000 }
      ]
    },
    {
      id: 3,
      name: '도서',
      items: [
        { id: 301, name: 'React 완벽 가이드', price: 35000 },
        { id: 302, name: 'JavaScript 심화', price: 28000 },
        { id: 303, name: 'CSS 마스터', price: 32000 }
      ]
    }
  ]);

  const [expandedCategories, setExpandedCategories] = useState(new Set([1]));

  const toggleCategory = (categoryId) => {
    setExpandedCategories(prev => {
      const newSet = new Set(prev);
      if (newSet.has(categoryId)) {
        newSet.delete(categoryId);
      } else {
        newSet.add(categoryId);
      }
      return newSet;
    });
  };

  const formatPrice = (price) => price.toLocaleString() + '원';

  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h2>상품 카테고리</h2>
      
      {categories.map(category => (
        <div
          key={category.id}
          style={{
            marginBottom: '20px',
            border: '1px solid #ddd',
            borderRadius: '8px',
            overflow: 'hidden'
          }}
        >
          {/* 카테고리 헤더 */}
          <div
            style={{
              backgroundColor: '#f8f9fa',
              padding: '15px',
              cursor: 'pointer',
              display: 'flex',
              justifyContent: 'space-between',
              alignItems: 'center',
              borderBottom: expandedCategories.has(category.id) ? '1px solid #ddd' : 'none'
            }}
            onClick={() => toggleCategory(category.id)}
          >
            <h3 style={{ margin: 0 }}>{category.name}</h3>
            <span style={{ fontSize: '18px' }}>
              {expandedCategories.has(category.id) ? '▼' : '▶'}
            </span>
          </div>

          {/* 카테고리 아이템들 */}
          {expandedCategories.has(category.id) && (
            <div style={{ padding: '0' }}>
              {category.items.map((item, index) => (
                <div
                  key={item.id}
                  style={{
                    padding: '15px',
                    borderBottom: index < category.items.length - 1 ? '1px solid #f0f0f0' : 'none',
                    display: 'flex',
                    justifyContent: 'space-between',
                    alignItems: 'center',
                    backgroundColor: 'white'
                  }}
                >
                  <span style={{ fontWeight: 'medium' }}>{item.name}</span>
                  <span style={{ 
                    color: '#e91e63', 
                    fontWeight: 'bold',
                    fontSize: '16px'
                  }}>
                    {formatPrice(item.price)}
                  </span>
                </div>
              ))}
            </div>
          )}
        </div>
      ))}
      
      <div style={{ 
        marginTop: '30px', 
        padding: '15px', 
        backgroundColor: '#e3f2fd',
        borderRadius: '8px'
      }}>
        <h4>💡 인터랙션 가이드</h4>
        <p style={{ margin: '5px 0', fontSize: '14px' }}>
          • 카테고리 이름을 클릭하면 상품 목록이 펼쳐지거나 접힙니다
        </p>
        <p style={{ margin: '5px 0', fontSize: '14px' }}>
          • 각 리스트 항목은 고유한 키(key)를 가지고 있습니다
        </p>
      </div>
    </div>
  );
}

2.5 리스트 성능 최적화

React.memo를 활용한 최적화

import { useState, useCallback, memo } from 'react';

const ListItem = memo(function ListItem({ item, onUpdate, onDelete }) {
  console.log(`ListItem ${item.id} 렌더링됨`); // 렌더링 확인용

  return (
    <div>
      <div>
        <h4>{item.name}</h4>
        <p>
          {item.description}
        </p>
      </div>
      
      <div>
        <button
          onClick={() => onUpdate(item.id)}
        >
          업데이트
        </button>
        <button
          onClick={() => onDelete(item.id)}
        >
          삭제
        </button>
      </div>
    </div>
  );
});

function OptimizedList() {
  const [items, setItems] = useState([
    { id: 1, name: '아이템 1', description: '첫 번째 아이템입니다' },
    { id: 2, name: '아이템 2', description: '두 번째 아이템입니다' },
    { id: 3, name: '아이템 3', description: '세 번째 아이템입니다' }
  ]);
  const [counter, setCounter] = useState(0);

  // useCallback으로 함수 메모이제이션
  const handleUpdate = useCallback((id) => {
    setItems(prev => prev.map(item =>
      item.id === id 
        ? { ...item, name: `${item.name} (업데이트됨)` }
        : item
    ));
  }, []);

  const handleDelete = useCallback((id) => {
    setItems(prev => prev.filter(item => item.id !== id));
  }, []);

  return (
    <div>
      <h2>최적화된 리스트</h2>
      
      <div>
        <p>카운터: {counter}</p>
        <button
          onClick={() => setCounter(prev => prev + 1)}
        >
          카운터 증가
        </button>
      </div>
      
      <p>
        개발자 도구 콘솔을 확인해보세요. 카운터를 증가시켜도 리스트 아이템들이 
        불필요하게 재렌더링되지 않습니다.
      </p>

      <div>
        {items.map(item => (
          <ListItem
            key={item.id}
            item={item}
            onUpdate={handleUpdate}
            onDelete={handleDelete}
          />
        ))}
      </div>
    </div>
  );
}

2.6 키 사용 시 주의사항

올바른 키 vs 잘못된 키

import { useState } from 'react';

function KeyExamples() {
  const [items, setItems] = useState([
    { id: 'a', name: '아이템 A' },
    { id: 'b', name: '아이템 B' },
    { id: 'c', name: '아이템 C' }
  ]);

  const shuffleItems = () => {
    setItems(prev => [...prev].sort(() => Math.random() - 0.5));
  };

  return (
    <div>
      <h2>키(Key) 비교 예제</h2>
      <button onClick={shuffleItems}>
        항목 순서 섞기
      </button>

      <div>
        {/* 잘못된 예: 인덱스를 키로 사용 */}
        <div>
          <h3>❌ 인덱스를 키로 사용</h3>
          <ul>
            {items.map((item, index) => (
              <li key={index}>
                <input type="text" defaultValue={item.name} />
                {item.name}
              </li>
            ))}
          </ul>
        </div>

        {/* 올바른 예: 고유 ID를 키로 사용 */}
        <div>
          <h3>✅ 고유 ID를 키로 사용</h3>
          <ul>
            {items.map(item => (
              <li key={item.id}>
                <input type="text" defaultValue={item.name} />
                {item.name}
              </li>
            ))}
          </ul>
        </div>
      </div>

      <div>
        <strong>테스트 방법:</strong>
        <ol>
          <li>각 입력 필드에 서로 다른 값을 입력하세요</li>
          <li>"항목 순서 섞기" 버튼을 클릭하세요</li>
          <li>왼쪽(인덱스 키)과 오른쪽(ID 키)의 차이를 확인하세요</li>
        </ol>
      </div>
    </div>
  );
}

2.7 Best Practices

1. 안정적이고 고유한 키 사용

// ✅ 좋은 예
const items = [
  { id: 'uuid-1', name: 'Item 1' },
  { id: 'uuid-2', name: 'Item 2' }
];

return (
  <ul>
    {items.map(item => (
      <li key={item.id}>{item.name}</li>
    ))}
  </ul>
);

// ❌ 나쁜 예
return (
  <ul>
    {items.map((item, index) => (
      <li key={index}>{item.name}</li>
    ))}
  </ul>
);

2. 키로 Math.random() 사용 금지

// ❌ 절대 하지 말 것
return (
  <ul>
    {items.map(item => (
      <li key={Math.random()}>{item.name}</li>
    ))}
  </ul>
);

3. 리스트 컴포넌트 분리

// ✅ 좋은 예 - 컴포넌트 분리
function UserCard({ user }) {
  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

function UserList({ users }) {
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

2.8 정리

리스트와 키의 핵심 개념을 학습했습니다:

  1. map() 메서드를 활용한 배열 데이터 렌더링
  2. 고유하고 안정적인 키의 중요성과 올바른 사용법
  3. 동적 리스트 조작 (추가, 수정, 삭제)
  4. 중첩된 리스트 렌더링 방법
  5. 성능 최적화를 위한 React.memo와 useCallback 활용
  6. 키 사용 시 주의사항과 Best Practices

다음 장에서는 폼과 제어 컴포넌트에 대해 알아보겠습니다.

리스트와 키 - 리엑트 베이스캠프 | 위니버시티