🎯 학습 목표
- 삼항 연산자, &&, if 패턴을 상황에 맞게 선택할 수 있다
- key prop의 역할과 인덱스를 key로 쓸 때 발생하는 실제 버그를 이해한다
- 안정적인 key를 선택하는 기준을 안다
- 빈 상태(empty state) UI를 올바르게 처리한다
- 복잡한 조건부 렌더링을 가독성 있게 작성한다
📖 핵심 개념 1 — 조건부 렌더링 패턴 비교
React에서 조건부 렌더링에는 세 가지 주요 패턴이 있습니다. 각각 적합한 상황이 다릅니다.
function ConditionalPatterns({ isLoggedIn, userRole, notifications, isLoading }) {
// 패턴 1: if 문 — 복잡한 조건, 얼리 리턴
if (isLoading) {
return <LoadingSpinner />;
}
if (!isLoggedIn) {
return <LoginPage />;
}
// 패턴 2: 삼항 연산자 — A 또는 B 중 하나
const greeting = isLoggedIn
? <span>환영합니다!</span>
: <span>로그인해주세요</span>;
return (
<div>
{greeting}
{/* 패턴 2: JSX 인라인 삼항 — 두 대안이 있을 때 */}
{userRole === 'admin'
? <AdminPanel />
: <UserPanel />
}
{/* 패턴 3: && — 보여주거나 안 보여주거나 */}
{notifications.length > 0 && (
<div className="badge">{notifications.length}</div>
)}
{/* ❌ 피해야 할 패턴 — 삼항 중첩은 가독성 저하 */}
{isLoggedIn
? userRole === 'admin'
? <AdminPanel />
: <UserPanel />
: <GuestPanel />
}
</div>
);
}
// ✅ 복잡한 조건은 함수나 변수로 분리
function renderPanel(isLoggedIn, userRole) {
if (!isLoggedIn) return <GuestPanel />;
if (userRole === 'admin') return <AdminPanel />;
return <UserPanel />;
}
패널에서 합의한 가이드라인: 삼항 중첩은 2단계가 최대. 그 이상이면 함수로 분리하거나 early return 패턴을 사용하세요.
📖 핵심 개념 2 — key prop과 인덱스 사용의 위험성
key는 React가 리스트의 각 항목을 추적하는 데 사용합니다. 잘못된 key는 UI 버그를 유발합니다.
// ⚠️ 인덱스를 key로 쓸 때 발생하는 실제 버그
function DangerousIndexKey() {
const [items, setItems] = useState([
{ id: 'a', text: '항목 A' },
{ id: 'b', text: '항목 B' },
{ id: 'c', text: '항목 C' },
]);
function deleteFirst() {
setItems(items.slice(1)); // 첫 번째 항목 삭제
}
return (
<ul>
{items.map((item, index) => (
// index를 key로 사용하면:
// 삭제 후 항목 B가 key=0, 항목 C가 key=1을 가짐
// React는 key=0인 요소가 "변경"됐다고 인식 → input 내용이 엉킴
<li key={index}>
{item.text}
<input placeholder="메모 입력" /> {/* 이 입력값이 잘못 유지됨 */}
</li>
))}
<button onClick={deleteFirst}>첫 항목 삭제</button>
</ul>
);
}
// ✅ 안정적인 고유 ID를 key로 사용
function SafeKey() {
const [items, setItems] = useState([
{ id: 'item-a', text: '항목 A' },
{ id: 'item-b', text: '항목 B' },
{ id: 'item-c', text: '항목 C' },
]);
function deleteFirst() {
setItems(items.slice(1));
}
return (
<ul>
{items.map(item => (
// 고유 ID를 key로 사용 → React가 각 항목을 정확히 추적
<li key={item.id}>
{item.text}
<input placeholder="메모 입력" /> {/* 삭제 후에도 올바르게 유지 */}
</li>
))}
<button onClick={deleteFirst}>첫 항목 삭제</button>
</ul>
);
}
인덱스를 key로 써도 되는 경우
인덱스 key가 허용되는 조건은 세 가지를 모두 충족할 때입니다.
- 리스트가 변경되지 않는 정적 데이터일 때
- 항목에 고유 ID가 없을 때
- 리스트가 재정렬·삭제·삽입되지 않을 때
📖 핵심 개념 3 — 안정적인 key 선택
// ✅ 좋은 key 소스들
// 1. 서버에서 받은 DB ID (가장 안정적)
items.map(item => <Item key={item.id} />)
// 2. crypto.randomUUID() — 아이템 생성 시 한 번만 생성
const newItem = { id: crypto.randomUUID(), text: '새 항목' };
// 3. 의미 있는 조합
categories.map(cat => <Category key={`cat-${cat.name}`} />)
// ❌ 나쁜 key 소스들
items.map((item, i) => <Item key={i} />) // 인덱스 (조건부 허용)
items.map(item => <Item key={Math.random()} />) // 렌더마다 변경 — 최악
items.map(item => <Item key={item.text} />) // 텍스트 (중복 가능성)
💻 코드 예제 — 빈 상태 처리 포함한 완전한 리스트
import { useState } from 'react';
// 빈 상태 컴포넌트
function EmptyState({ message, actionLabel, onAction }) {
return (
<div className="empty-state" style={{ textAlign: 'center', padding: '40px' }}>
<p style={{ color: '#666' }}>{message}</p>
{onAction && (
<button onClick={onAction}>{actionLabel}</button>
)}
</div>
);
}
// 완전한 할 일 목록
function SmartTodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const [filter, setFilter] = useState('all'); // all | active | done
function addTodo() {
if (!input.trim()) return;
setTodos(prev => [...prev, {
id: crypto.randomUUID(),
text: input.trim(),
done: false,
}]);
setInput('');
}
const filtered = todos.filter(todo => {
if (filter === 'active') return !todo.done;
if (filter === 'done') return todo.done;
return true;
});
return (
<div>
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="할 일 입력 후 Enter"
/>
<button onClick={addTodo}>추가</button>
</div>
<div>
{['all', 'active', 'done'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
style={{ fontWeight: filter === f ? 'bold' : 'normal' }}
>
{f === 'all' ? '전체' : f === 'active' ? '진행중' : '완료'}
</button>
))}
</div>
{/* 빈 상태 처리 */}
{todos.length === 0 ? (
<EmptyState
message="할 일이 없습니다. 새로운 할 일을 추가해보세요!"
actionLabel="샘플 추가"
onAction={() => setTodos([{ id: '1', text: 'React 복습', done: false }])}
/>
) : filtered.length === 0 ? (
<EmptyState message={`"${filter}" 필터에 해당하는 항목이 없습니다.`} />
) : (
<ul>
{filtered.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => setTodos(prev =>
prev.map(t => t.id === todo.id ? { ...t, done: !t.done } : t)
)}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => setTodos(prev => prev.filter(t => t.id !== todo.id))}>
삭제
</button>
</li>
))}
</ul>
)}
<p>전체 {todos.length}개 | 완료 {todos.filter(t => t.done).length}개</p>
</div>
);
}
export default SmartTodoList;
⚠️ 흔한 실수 (よくあるミス)
- key를 형제 요소 간에만 고유하면 된다는 것을 모름: key는 전역적으로 고유할 필요 없이, 같은 부모 아래 형제 요소 간에만 고유하면 됩니다.
- 컴포넌트에 key 전달: key는 React 내부 전용이라 props로 전달되지 않습니다. id가 필요하면 별도로
id={item.id}를 추가하세요. - Math.random()을 key로 사용: 렌더링마다 key가 바뀌어 React가 항목을 항상 새것으로 인식합니다. 모든 state가 초기화되고 성능이 크게 저하됩니다.
- 빈 상태 미처리: 데이터가 없을 때 빈 화면을 보여주면 사용자 경험이 나쁩니다. 항상 empty state UI를 준비하세요.
💡 실무 팁
- DB에서 받은 ID 활용: API 응답의
id필드를 항상 key로 사용하는 것을 팀 컨벤션으로 정하면 key 관련 버그가 사라집니다. - 새 항목 ID 생성: 클라이언트에서 생성하는 항목은
crypto.randomUUID()(최신 브라우저 지원) 또는nanoid라이브러리를 사용합니다. - 로딩/에러/빈 상태 트리플 처리: 데이터를 보여주는 모든 컴포넌트는 loading, error, empty 세 가지 상태를 모두 처리하는 것이 실무 표준입니다.