← 목록으로

챕터 15: 반복 렌더링과 리스트 처리

서론

웹 애플리케이션에서는 동일한 구조의 요소를 여러 개 표시해야 하는 경우가 매우 많습니다. 상품 목록, 댓글 리스트, 사용자 목록 등 거의 모든 웹사이트에는 반복되는 요소들이 있습니다. 이런 요소들을 하나씩 수동으로 작성한다면 비효율적이고 유지보수도 어려워집니다.

React는 JavaScript의 배열 메서드를 활용하여 효율적으로 리스트를 렌더링할 수 있습니다. 특히 map() 함수는 React에서 가장 많이 사용되는 배열 메서드입니다. 이번 챕터에서는 반복 렌더링의 다양한 패턴과 주의사항, 그리고 실제 활용 사례를 깊이 있게 다루어보겠습니다.

본론

map 함수 기초

map() 함수는 배열의 각 요소를 변환하여 새로운 배열을 만듭니다. React에서는 데이터 배열을 JSX 요소 배열로 변환하는 데 사용합니다:

'use client'

import { useState } from 'react'

export default function MapBasics() {
  const [fruits, setFruits] = useState([
    '사과', '바나나', '오렌지', '포도', '딸기'
  ])
  
  const [numbers, setNumbers] = useState([1, 2, 3, 4, 5])
  
  const [users, setUsers] = useState([
    { id: 1, name: '홍길동', age: 25 },
    { id: 2, name: '김철수', age: 30 },
    { id: 3, name: '이영희', age: 28 }
  ])
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-6">map 함수 기초</h2>
      
      {/* 간단한 문자열 배열 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">과일 목록</h3>
        <ul className="list-disc list-inside bg-gray-50 p-4 rounded">
          {fruits.map((fruit, index) => (
            <li key={index} className="text-gray-700">
              {fruit}
            </li>
          ))}
        </ul>
      </div>
      
      {/* 숫자 배열 변환 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">숫자 제곱</h3>
        <div className="flex gap-2">
          {numbers.map(num => (
            <div key={num} className="bg-blue-500 text-white p-4 rounded">
              <div className="text-xs text-blue-200">원본: {num}</div>
              <div className="text-xl font-bold">{num * num}</div>
            </div>
          ))}
        </div>
      </div>
      
      {/* 객체 배열 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">사용자 카드</h3>
        <div className="grid grid-cols-3 gap-4">
          {users.map(user => (
            <div key={user.id} className="border rounded p-4 bg-white shadow">
              <h4 className="font-semibold">{user.name}</h4>
              <p className="text-gray-600">나이: {user.age}세</p>
            </div>
          ))}
        </div>
      </div>
      
      {/* 인덱스 활용 */}
      <div>
        <h3 className="font-bold mb-3">순위 표시</h3>
        <div className="bg-gray-50 p-4 rounded">
          {fruits.slice(0, 3).map((fruit, index) => (
            <div key={index} className="flex items-center gap-3 mb-2">
              <span className={`text-2xl ${
                index === 0 ? 'text-yellow-500' :
                index === 1 ? 'text-gray-400' :
                'text-orange-600'
              }`}>
                {index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
              </span>
              <span className="font-medium">{fruit}</span>
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

key 속성의 중요성

React에서 리스트를 렌더링할 때 key 속성은 매우 중요합니다. key는 React가 어떤 항목이 변경, 추가, 제거되었는지 식별하는 데 도움을 줍니다:

'use client'

import { useState } from 'react'

export default function KeyImportance() {
  const [items, setItems] = useState([
    { id: 1, text: '아이템 1', color: 'blue' },
    { id: 2, text: '아이템 2', color: 'green' },
    { id: 3, text: '아이템 3', color: 'red' }
  ])
  
  const [inputValue, setInputValue] = useState('')
  
  // 아이템 추가
  const addItem = () => {
    if (inputValue.trim()) {
      const colors = ['blue', 'green', 'red', 'purple', 'orange']
      const randomColor = colors[Math.floor(Math.random() * colors.length)]
      
      setItems([...items, {
        id: Date.now(),  // 고유한 ID 생성
        text: inputValue,
        color: randomColor
      }])
      setInputValue('')
    }
  }
  
  // 아이템 제거
  const removeItem = (id) => {
    setItems(items.filter(item => item.id !== id))
  }
  
  // 아이템 순서 변경
  const moveUp = (index) => {
    if (index === 0) return
    const newItems = [...items]
    ;[newItems[index], newItems[index - 1]] = [newItems[index - 1], newItems[index]]
    setItems(newItems)
  }
  
  const moveDown = (index) => {
    if (index === items.length - 1) return
    const newItems = [...items]
    ;[newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]]
    setItems(newItems)
  }
  
  // 잘못된 key 사용 예시 (인덱스를 key로 사용)
  const [badKeyItems, setBadKeyItems] = useState(['A', 'B', 'C'])
  
  return (
    <div className="p-8 max-w-4xl mx-auto">
      <h2 className="text-2xl font-bold mb-6">Key 속성의 중요성</h2>
      
      {/* 올바른 key 사용 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">✅ 올바른 Key 사용 (고유 ID)</h3>
        
        <div className="flex gap-2 mb-4">
          <input
            type="text"
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)}
            onKeyPress={(e) => e.key === 'Enter' && addItem()}
            className="flex-1 border rounded px-3 py-2"
            placeholder="새 아이템 추가..."
          />
          <button
            onClick={addItem}
            className="bg-blue-500 text-white px-4 py-2 rounded"
          >
            추가
          </button>
        </div>
        
        <div className="space-y-2">
          {items.map((item, index) => (
            <div
              key={item.id}  // 고유한 ID를 key로 사용
              className={`flex items-center justify-between p-3 rounded bg-${item.color}-100 border border-${item.color}-300`}
            >
              <div className="flex items-center gap-3">
                <span className="text-gray-500">ID: {item.id}</span>
                <span className="font-medium">{item.text}</span>
              </div>
              
              <div className="flex gap-2">
                <button
                  onClick={() => moveUp(index)}
                  disabled={index === 0}
                  className="text-gray-600 hover:text-gray-800 disabled:opacity-30"
                >
                  ↑
                </button>
                <button
                  onClick={() => moveDown(index)}
                  disabled={index === items.length - 1}
                  className="text-gray-600 hover:text-gray-800 disabled:opacity-30"
                >
                  ↓
                </button>
                <button
                  onClick={() => removeItem(item.id)}
                  className="text-red-500 hover:text-red-700"
                >
                  삭제
                </button>
              </div>
            </div>
          ))}
        </div>
      </div>
      
      {/* 잘못된 key 사용 예시 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">❌ 잘못된 Key 사용 (인덱스)</h3>
        <p className="text-sm text-gray-600 mb-3">
          인덱스를 key로 사용하면 순서가 바뀔 때 문제가 발생할 수 있습니다
        </p>
        
        <div className="space-y-2">
          {badKeyItems.map((item, index) => (
            <div
              key={index}  // ⚠️ 인덱스를 key로 사용 (권장하지 않음)
              className="flex items-center justify-between p-3 bg-yellow-50 border border-yellow-300 rounded"
            >
              <input
                type="text"
                defaultValue={item}
                className="border rounded px-2 py-1"
              />
              <button
                onClick={() => {
                  const newItems = badKeyItems.filter((_, i) => i !== index)
                  setBadKeyItems(newItems)
                }}
                className="text-red-500"
              >
                삭제
              </button>
            </div>
          ))}
        </div>
        <p className="text-xs text-red-600 mt-2">
          * 아이템을 삭제하면 입력값이 잘못 유지될 수 있습니다
        </p>
      </div>
      
      {/* Key 사용 규칙 */}
      <div className="bg-blue-50 border border-blue-200 rounded p-4">
        <h3 className="font-bold text-blue-800 mb-2">Key 사용 규칙</h3>
        <ul className="space-y-1 text-sm text-blue-700">
          <li>• Key는 형제 요소들 사이에서 고유해야 합니다</li>
          <li>• Key는 변경되지 않고 예측 가능해야 합니다</li>
          <li>• 가능한 경우 배열 인덱스보다 고유 ID를 사용하세요</li>
          <li>• Math.random()을 key로 사용하지 마세요</li>
        </ul>
      </div>
    </div>
  )
}

필터링과 정렬

배열을 렌더링하기 전에 필터링하거나 정렬할 수 있습니다:

'use client'

import { useState } from 'react'

export default function FilterAndSort() {
  const [products] = useState([
    { id: 1, name: '노트북', price: 1500000, category: '전자기기', inStock: true },
    { id: 2, name: '마우스', price: 30000, category: '전자기기', inStock: true },
    { id: 3, name: '의자', price: 200000, category: '가구', inStock: false },
    { id: 4, name: '책상', price: 350000, category: '가구', inStock: true },
    { id: 5, name: '키보드', price: 100000, category: '전자기기', inStock: true },
    { id: 6, name: '모니터', price: 400000, category: '전자기기', inStock: false }
  ])
  
  const [filterCategory, setFilterCategory] = useState('all')
  const [showInStock, setShowInStock] = useState(false)
  const [sortBy, setSortBy] = useState('name')
  const [searchTerm, setSearchTerm] = useState('')
  
  // 필터링과 정렬 적용
  let filteredProducts = [...products]
  
  // 검색어 필터
  if (searchTerm) {
    filteredProducts = filteredProducts.filter(product =>
      product.name.toLowerCase().includes(searchTerm.toLowerCase())
    )
  }
  
  // 카테고리 필터
  if (filterCategory !== 'all') {
    filteredProducts = filteredProducts.filter(
      product => product.category === filterCategory
    )
  }
  
  // 재고 필터
  if (showInStock) {
    filteredProducts = filteredProducts.filter(product => product.inStock)
  }
  
  // 정렬
  filteredProducts.sort((a, b) => {
    switch(sortBy) {
      case 'name':
        return a.name.localeCompare(b.name)
      case 'price-asc':
        return a.price - b.price
      case 'price-desc':
        return b.price - a.price
      default:
        return 0
    }
  })
  
  return (
    <div className="p-8 max-w-6xl mx-auto">
      <h2 className="text-2xl font-bold mb-6">상품 목록 (필터 & 정렬)</h2>
      
      {/* 컨트롤 패널 */}
      <div className="bg-gray-50 p-4 rounded mb-6">
        <div className="grid grid-cols-4 gap-4">
          {/* 검색 */}
          <div>
            <label className="block text-sm font-medium mb-1">검색</label>
            <input
              type="text"
              value={searchTerm}
              onChange={(e) => setSearchTerm(e.target.value)}
              className="w-full border rounded px-3 py-2"
              placeholder="상품명 검색..."
            />
          </div>
          
          {/* 카테고리 필터 */}
          <div>
            <label className="block text-sm font-medium mb-1">카테고리</label>
            <select
              value={filterCategory}
              onChange={(e) => setFilterCategory(e.target.value)}
              className="w-full border rounded px-3 py-2"
            >
              <option value="all">전체</option>
              <option value="전자기기">전자기기</option>
              <option value="가구">가구</option>
            </select>
          </div>
          
          {/* 정렬 */}
          <div>
            <label className="block text-sm font-medium mb-1">정렬</label>
            <select
              value={sortBy}
              onChange={(e) => setSortBy(e.target.value)}
              className="w-full border rounded px-3 py-2"
            >
              <option value="name">이름순</option>
              <option value="price-asc">가격 낮은순</option>
              <option value="price-desc">가격 높은순</option>
            </select>
          </div>
          
          {/* 재고 필터 */}
          <div className="flex items-end">
            <label className="flex items-center">
              <input
                type="checkbox"
                checked={showInStock}
                onChange={(e) => setShowInStock(e.target.checked)}
                className="mr-2"
              />
              <span className="text-sm font-medium">재고 있는 상품만</span>
            </label>
          </div>
        </div>
      </div>
      
      {/* 결과 수 */}
      <p className="text-gray-600 mb-4">
        검색 결과: {filteredProducts.length}개
      </p>
      
      {/* 상품 그리드 */}
      <div className="grid grid-cols-3 gap-4">
        {filteredProducts.length === 0 ? (
          <div className="col-span-3 text-center py-12 text-gray-500">
            조건에 맞는 상품이 없습니다
          </div>
        ) : (
          filteredProducts.map(product => (
            <div
              key={product.id}
              className={`border rounded-lg p-4 ${
                product.inStock ? 'bg-white' : 'bg-gray-50 opacity-60'
              }`}
            >
              <div className="mb-2">
                <span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
                  {product.category}
                </span>
              </div>
              <h3 className="font-bold text-lg">{product.name}</h3>
              <p className="text-2xl font-bold text-blue-600 my-2">
                {product.price.toLocaleString()}원
              </p>
              <p className={`text-sm ${
                product.inStock ? 'text-green-600' : 'text-red-600'
              }`}>
                {product.inStock ? '✓ 재고 있음' : '✗ 품절'}
              </p>
            </div>
          ))
        )}
      </div>
    </div>
  )
}

중첩된 리스트 처리

때로는 리스트 안에 또 다른 리스트가 있는 중첩 구조를 처리해야 합니다:

'use client'

import { useState } from 'react'

export default function NestedLists() {
  const [categories, setCategories] = useState([
    {
      id: 1,
      name: '프로그래밍',
      items: [
        { id: 11, name: 'JavaScript', level: 'intermediate' },
        { id: 12, name: 'Python', level: 'beginner' },
        { id: 13, name: 'React', level: 'advanced' }
      ]
    },
    {
      id: 2,
      name: '디자인',
      items: [
        { id: 21, name: 'Photoshop', level: 'intermediate' },
        { id: 22, name: 'Figma', level: 'advanced' }
      ]
    },
    {
      id: 3,
      name: '언어',
      items: [
        { id: 31, name: '영어', level: 'advanced' },
        { id: 32, name: '중국어', level: 'beginner' },
        { id: 33, name: '일본어', level: 'intermediate' }
      ]
    }
  ])
  
  const [expandedCategories, setExpandedCategories] = useState([1, 2])
  
  const toggleCategory = (categoryId) => {
    setExpandedCategories(prev =>
      prev.includes(categoryId)
        ? prev.filter(id => id !== categoryId)
        : [...prev, categoryId]
    )
  }
  
  const getLevelColor = (level) => {
    switch(level) {
      case 'beginner': return 'bg-green-100 text-green-700'
      case 'intermediate': return 'bg-yellow-100 text-yellow-700'
      case 'advanced': return 'bg-red-100 text-red-700'
      default: return 'bg-gray-100 text-gray-700'
    }
  }
  
  const getLevelText = (level) => {
    switch(level) {
      case 'beginner': return '초급'
      case 'intermediate': return '중급'
      case 'advanced': return '고급'
      default: return ''
    }
  }
  
  return (
    <div className="p-8 max-w-2xl mx-auto">
      <h2 className="text-2xl font-bold mb-6">스킬 목록</h2>
      
      <div className="space-y-4">
        {categories.map(category => (
          <div key={category.id} className="border rounded-lg overflow-hidden">
            {/* 카테고리 헤더 */}
            <button
              onClick={() => toggleCategory(category.id)}
              className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors"
            >
              <h3 className="font-bold text-lg">{category.name}</h3>
              <div className="flex items-center gap-2">
                <span className="text-sm text-gray-500">
                  {category.items.length}개 스킬
                </span>
                <span className="text-gray-400">
                  {expandedCategories.includes(category.id) ? '▼' : '▶'}
                </span>
              </div>
            </button>
            
            {/* 아이템 리스트 */}
            {expandedCategories.includes(category.id) && (
              <div className="p-4 space-y-2">
                {category.items.map(item => (
                  <div
                    key={item.id}
                    className="flex items-center justify-between p-3 bg-white border rounded"
                  >
                    <span className="font-medium">{item.name}</span>
                    <span className={`px-3 py-1 rounded text-sm ${getLevelColor(item.level)}`}>
                      {getLevelText(item.level)}
                    </span>
                  </div>
                ))}
              </div>
            )}
          </div>
        ))}
      </div>
      
      {/* 통계 */}
      <div className="mt-8 grid grid-cols-3 gap-4">
        <div className="bg-gray-100 p-4 rounded text-center">
          <div className="text-2xl font-bold">
            {categories.reduce((sum, cat) => sum + cat.items.length, 0)}
          </div>
          <div className="text-sm text-gray-600">전체 스킬</div>
        </div>
        <div className="bg-green-100 p-4 rounded text-center">
          <div className="text-2xl font-bold text-green-700">
            {categories.reduce((sum, cat) => 
              sum + cat.items.filter(item => item.level === 'beginner').length, 0
            )}
          </div>
          <div className="text-sm text-green-600">초급</div>
        </div>
        <div className="bg-red-100 p-4 rounded text-center">
          <div className="text-2xl font-bold text-red-700">
            {categories.reduce((sum, cat) => 
              sum + cat.items.filter(item => item.level === 'advanced').length, 0
            )}
          </div>
          <div className="text-sm text-red-600">고급</div>
        </div>
      </div>
    </div>
  )
}

실습: 동적 테이블 컴포넌트

실제 프로젝트에서 자주 사용되는 테이블 컴포넌트를 만들어봅시다:

'use client'

import { useState } from 'react'

export default function DynamicTable() {
  const [employees] = useState([
    { id: 1, name: '김철수', department: '개발팀', position: '시니어 개발자', salary: 6000000, joinDate: '2020-03-15' },
    { id: 2, name: '이영희', department: '디자인팀', position: '디자인 팀장', salary: 7000000, joinDate: '2018-07-22' },
    { id: 3, name: '박민수', department: '개발팀', position: '주니어 개발자', salary: 3500000, joinDate: '2022-01-10' },
    { id: 4, name: '정수진', department: '마케팅팀', position: '마케팅 매니저', salary: 5500000, joinDate: '2019-11-03' },
    { id: 5, name: '최동훈', department: '개발팀', position: '풀스택 개발자', salary: 5000000, joinDate: '2021-05-17' },
    { id: 6, name: '한지원', department: '디자인팀', position: 'UI/UX 디자이너', salary: 4500000, joinDate: '2021-09-20' }
  ])
  
  const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' })
  const [selectedRows, setSelectedRows] = useState([])
  const [currentPage, setCurrentPage] = useState(1)
  const itemsPerPage = 3
  
  // 정렬 함수
  const handleSort = (key) => {
    let direction = 'asc'
    if (sortConfig.key === key && sortConfig.direction === 'asc') {
      direction = 'desc'
    }
    setSortConfig({ key, direction })
  }
  
  // 정렬된 데이터
  const sortedEmployees = [...employees].sort((a, b) => {
    if (!sortConfig.key) return 0
    
    const aValue = a[sortConfig.key]
    const bValue = b[sortConfig.key]
    
    if (aValue < bValue) {
      return sortConfig.direction === 'asc' ? -1 : 1
    }
    if (aValue > bValue) {
      return sortConfig.direction === 'asc' ? 1 : -1
    }
    return 0
  })
  
  // 페이지네이션
  const totalPages = Math.ceil(sortedEmployees.length / itemsPerPage)
  const startIndex = (currentPage - 1) * itemsPerPage
  const paginatedEmployees = sortedEmployees.slice(startIndex, startIndex + itemsPerPage)
  
  // 행 선택
  const handleSelectRow = (id) => {
    setSelectedRows(prev =>
      prev.includes(id)
        ? prev.filter(rowId => rowId !== id)
        : [...prev, id]
    )
  }
  
  const handleSelectAll = () => {
    if (selectedRows.length === paginatedEmployees.length) {
      setSelectedRows([])
    } else {
      setSelectedRows(paginatedEmployees.map(emp => emp.id))
    }
  }
  
  // 부서별 통계
  const departmentStats = employees.reduce((acc, emp) => {
    if (!acc[emp.department]) {
      acc[emp.department] = { count: 0, totalSalary: 0 }
    }
    acc[emp.department].count++
    acc[emp.department].totalSalary += emp.salary
    return acc
  }, {})
  
  return (
    <div className="p-8 max-w-6xl mx-auto">
      <h2 className="text-2xl font-bold mb-6">직원 관리 테이블</h2>
      
      {/* 테이블 */}
      <div className="bg-white rounded-lg shadow overflow-hidden">
        <table className="w-full">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-4 py-3 text-left">
                <input
                  type="checkbox"
                  checked={selectedRows.length === paginatedEmployees.length && paginatedEmployees.length > 0}
                  onChange={handleSelectAll}
                  className="rounded"
                />
              </th>
              <th 
                className="px-4 py-3 text-left font-medium text-gray-700 cursor-pointer hover:bg-gray-100"
                onClick={() => handleSort('name')}
              >
                <div className="flex items-center gap-1">
                  이름
                  {sortConfig.key === 'name' && (
                    <span>{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
                  )}
                </div>
              </th>
              <th 
                className="px-4 py-3 text-left font-medium text-gray-700 cursor-pointer hover:bg-gray-100"
                onClick={() => handleSort('department')}
              >
                <div className="flex items-center gap-1">
                  부서
                  {sortConfig.key === 'department' && (
                    <span>{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
                  )}
                </div>
              </th>
              <th className="px-4 py-3 text-left font-medium text-gray-700">
                직급
              </th>
              <th 
                className="px-4 py-3 text-left font-medium text-gray-700 cursor-pointer hover:bg-gray-100"
                onClick={() => handleSort('salary')}
              >
                <div className="flex items-center gap-1">
                  급여
                  {sortConfig.key === 'salary' && (
                    <span>{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
                  )}
                </div>
              </th>
              <th 
                className="px-4 py-3 text-left font-medium text-gray-700 cursor-pointer hover:bg-gray-100"
                onClick={() => handleSort('joinDate')}
              >
                <div className="flex items-center gap-1">
                  입사일
                  {sortConfig.key === 'joinDate' && (
                    <span>{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
                  )}
                </div>
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-200">
            {paginatedEmployees.map(employee => (
              <tr 
                key={employee.id}
                className={`hover:bg-gray-50 ${
                  selectedRows.includes(employee.id) ? 'bg-blue-50' : ''
                }`}
              >
                <td className="px-4 py-3">
                  <input
                    type="checkbox"
                    checked={selectedRows.includes(employee.id)}
                    onChange={() => handleSelectRow(employee.id)}
                    className="rounded"
                  />
                </td>
                <td className="px-4 py-3 font-medium">{employee.name}</td>
                <td className="px-4 py-3">
                  <span className="px-2 py-1 bg-gray-100 rounded text-sm">
                    {employee.department}
                  </span>
                </td>
                <td className="px-4 py-3">{employee.position}</td>
                <td className="px-4 py-3 font-medium">
                  {employee.salary.toLocaleString()}원
                </td>
                <td className="px-4 py-3 text-gray-600">
                  {employee.joinDate}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      
      {/* 페이지네이션 */}
      <div className="mt-4 flex items-center justify-between">
        <div className="text-sm text-gray-600">
          총 {employees.length}명 중 {startIndex + 1}-{Math.min(startIndex + itemsPerPage, employees.length)}명 표시
        </div>
        <div className="flex gap-2">
          <button
            onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
            disabled={currentPage === 1}
            className="px-3 py-1 border rounded disabled:opacity-50"
          >
            이전
          </button>
          {[...Array(totalPages)].map((_, i) => (
            <button
              key={i}
              onClick={() => setCurrentPage(i + 1)}
              className={`px-3 py-1 rounded ${
                currentPage === i + 1
                  ? 'bg-blue-500 text-white'
                  : 'border hover:bg-gray-50'
              }`}
            >
              {i + 1}
            </button>
          ))}
          <button
            onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
            disabled={currentPage === totalPages}
            className="px-3 py-1 border rounded disabled:opacity-50"
          >
            다음
          </button>
        </div>
      </div>
      
      {/* 선택된 항목 액션 */}
      {selectedRows.length > 0 && (
        <div className="mt-4 p-3 bg-blue-100 rounded flex items-center justify-between">
          <span className="text-blue-700">
            {selectedRows.length}개 항목 선택됨
          </span>
          <div className="space-x-2">
            <button className="bg-blue-500 text-white px-3 py-1 rounded text-sm">
              수정
            </button>
            <button className="bg-red-500 text-white px-3 py-1 rounded text-sm">
              삭제
            </button>
          </div>
        </div>
      )}
      
      {/* 부서별 통계 */}
      <div className="mt-8">
        <h3 className="font-bold mb-4">부서별 통계</h3>
        <div className="grid grid-cols-3 gap-4">
          {Object.entries(departmentStats).map(([dept, stats]) => (
            <div key={dept} className="bg-gray-50 p-4 rounded">
              <h4 className="font-semibold">{dept}</h4>
              <p className="text-sm text-gray-600">인원: {stats.count}명</p>
              <p className="text-sm text-gray-600">
                평균 급여: {Math.round(stats.totalSalary / stats.count).toLocaleString()}원
              </p>
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

결론

반복 렌더링은 React 애플리케이션에서 가장 자주 사용되는 패턴 중 하나입니다. 이번 챕터에서 우리는 map() 함수를 사용한 기본적인 리스트 렌더링부터 시작하여, key 속성의 중요성, 필터링과 정렬, 중첩 리스트, 그리고 실용적인 테이블 컴포넌트까지 다양한 패턴을 배웠습니다.

핵심은 다음과 같습니다:

  1. map() 함수로 배열을 JSX 요소로 변환
  2. 각 요소에 고유한 key 속성 제공
  3. filter()와 sort()를 활용한 데이터 조작
  4. 페이지네이션으로 대량 데이터 처리
  5. 사용자 상호작용을 위한 선택과 정렬 기능

이러한 패턴들을 마스터하면 어떤 형태의 데이터 목록도 효과적으로 표시하고 관리할 수 있습니다. 다음 챕터에서는 CSS 애니메이션을 다루어 우리의 UI에 생동감을 더하는 방법을 배워보겠습니다!