챕터 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 속성의 중요성, 필터링과 정렬, 중첩 리스트, 그리고 실용적인 테이블 컴포넌트까지 다양한 패턴을 배웠습니다.
핵심은 다음과 같습니다:
- map() 함수로 배열을 JSX 요소로 변환
- 각 요소에 고유한 key 속성 제공
- filter()와 sort()를 활용한 데이터 조작
- 페이지네이션으로 대량 데이터 처리
- 사용자 상호작용을 위한 선택과 정렬 기능
이러한 패턴들을 마스터하면 어떤 형태의 데이터 목록도 효과적으로 표시하고 관리할 수 있습니다. 다음 챕터에서는 CSS 애니메이션을 다루어 우리의 UI에 생동감을 더하는 방법을 배워보겠습니다!