기초

React 강좌 05강 — 조건부 렌더링과 리스트: map()과 key prop

🎯 학습 목표

  • 삼항 연산자, &&, 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가 허용되는 조건은 세 가지를 모두 충족할 때입니다.

  1. 리스트가 변경되지 않는 정적 데이터일 때
  2. 항목에 고유 ID가 없을 때
  3. 리스트가 재정렬·삭제·삽입되지 않을 때

📖 핵심 개념 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 세 가지 상태를 모두 처리하는 것이 실무 표준입니다.