리액트 컴포넌트 반복: map과 key로 리스트 렌더링 최적화

리액트 컴포넌트 반복: map과 key로 리스트 렌더링 최적화

반복 렌더링은 리액트에서 가장 자주 쓰이는 패턴입니다. Array.prototype.map으로 데이터를 컴포넌트로 변환하고, 변경 최소화를 위해 각 항목에 안정적인 key를 부여합니다. 이 글에서는 map 기본 패턴부터 key 선택 규칙, 인덱스 key 안티패턴 재현, 성능 팁까지 한 번에 알아봅니다.

1) 핵심 요약

  • map로 렌더링: 데이터 → JSX로 변환
  • key는 형제 사이에서 고유해야 하며, 항목의 정체성을 나타내야 함
  • 인덱스 key 지양: 삽입·삭제·정렬 시 버그 유발
  • 안정적인 식별자 사용: DB id, slug, UUID 등
  • key는 “반복을 수행하는 곳”에 둔다(자식 내부가 아님)

2) map 기본 패턴

가장 단순한 리스트 렌더링 예제입니다. 각 항목에 안정적인 id를 key로 사용합니다.

import React, { useRef, useState } from 'react';

const initialTodos = [
  { id: 't1', text: 'JSX 복습' },
  { id: 't2', text: '컴포넌트 구조 정리' },
  { id: 't3', text: 'map & key 예제 실습' },
];

export default function BasicList() {
  const [todos, setTodos] = useState(initialTodos);
  const [input, setInput] = useState('');
  const seqRef = useRef(4);

  const addTodo = () => {
    const text = input.trim();
    if (!text) return;
    setTodos((prev) => [...prev, { id: `t${seqRef.current++}`, text }]);
    setInput('');
  };

  return (
    <section style={{ border: '1px solid #ddd', padding: 12, borderRadius: 8 }}>
      <h3>BasicList 예제 (map과 key)</h3>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>

      <div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
        <input
          type="text"
          value={input}
          placeholder="할 일을 입력 후 Enter 또는 추가"
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && addTodo()}
          style={{ flex: 1 }}
        />
        <button onClick={addTodo}>추가</button>
      </div>
    </section>
  );
}

▼ 실행 화면 설명: 테두리 박스 안에 3개의 리스트 아이템이 순서대로 렌더링됩니다.

map 기본 패턴 예제 코드 실행 화면

3) 인덱스 key 안티패턴 재현(정렬/삽입 시 버그)

아래 예제는 배열 인덱스를 key로 사용했을 때 어떤 문제가 생기는지 실제로 재현합니다. 정렬/삽입/삭제가 일어나면 입력값이 엉뚱한 항목으로 이동하거나 체크 상태가 뒤섞이는 문제를 볼 수 있습니다.

import React, { useState } from 'react';

const seed = [
  { id: 'a', label: '사과',   note: '' },
  { id: 'b', label: '바나나', note: '' },
  { id: 'c', label: '체리',   note: '' },
];

export default function ListBadIndexKey() {
  const [items, setItems] = useState(seed);
  const [asc, setAsc] = useState(true);

  const sortToggle = () => {
    const sorted = [...items].sort((x, y) =>
      asc ? x.label.localeCompare(y.label) : y.label.localeCompare(x.label)
    );
    setAsc(!asc);
    setItems(sorted);
  };

  const addFront = () => {
    setItems([{ id: String(Date.now()), label: '신규', note: '' }, ...items]);
  };

  return (
    <section style={{ border: '1px solid #f87171', padding: 12, borderRadius: 8 }}>
      <h3 style={{ color: '#b91c1c' }}>안티패턴: 인덱스를 key로 (map과 key)</h3>

      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <button onClick={sortToggle}>정렬 토글</button>
        <button onClick={addFront}>맨 앞에 추가</button>
      </div>

      {items.map((item, index) => (
        // ❌ 나쁜 예: key로 인덱스를 사용 (정렬/삽입 시 상태가 섞임)
        <div
          key={index}
          style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 8, marginBottom: 8 }}
        >
          <strong>{item.label}</strong>
          <input
            placeholder="메모를 입력"
            value={item.note}
            onChange={(e) => {
              const next = [...items];
              next[index] = { ...next[index], note: e.target.value };
              setItems(next);
            }}
          />
        </div>
      ))}

      <p style={{ color: '#b91c1c', marginTop: 8 }}>
        정렬/추가 후 입력한 메모가 다른 항목으로 이동하는 현상을 확인해 보세요.
      </p>
    </section>
  );
}

▼ 실행 화면 설명: 각 과일 오른쪽 입력창에 메모를 적고 “정렬 토글” 또는 “맨 앞에 추가”를 누르면, 입력했던 메모가 다른 행으로 이동하거나 엉뚱한 항목과 매칭되는 문제가 발생합니다. 이는 key가 항목의 정체성을 보장하지 못했기 때문입니다.

인덱스 key 안티패턴 재현(정렬/삽입 시 버그) 에제 코드 실행 결과 화면

4) 올바른 key 적용(안정적 식별자 사용)

같은 UI를 안정적인 id를 key로 사용해서 수정하면 문제가 해결됩니다.

import React, { useState } from 'react';

const seed = [
  { id: 'a', label: '사과',   note: '' },
  { id: 'b', label: '바나나', note: '' },
  { id: 'c', label: '체리',   note: '' },
];

export default function ListStableKey() {
  const [items, setItems] = useState(seed);
  const [asc, setAsc] = useState(true);

  const sortToggle = () => {
    const sorted = [...items].sort((x, y) =>
      asc ? x.label.localeCompare(y.label) : y.label.localeCompare(x.label)
    );
    setAsc(!asc);
    setItems(sorted);
  };

  const addFront = () => {
    setItems([{ id: String(Date.now()), label: '신규', note: '' }, ...items]);
  };

  const updateNote = (id, value) => {
    setItems((prev) => prev.map((it) => (it.id === id ? { ...it, note: value } : it)));
  };

  return (
    <section style={{ border: '1px solid #10b981', padding: 12, borderRadius: 8 }}>
      <h3 style={{ color: '#065f46' }}>권장: 안정적인 id를 key로 (map과 key)</h3>

      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <button onClick={sortToggle}>정렬 토글</button>
        <button onClick={addFront}>맨 앞에 추가</button>
      </div>

      {items.map((item) => (
        // ✅ 좋은 예: stable id를 key로 사용
        <div
          key={item.id}
          style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 8, marginBottom: 8 }}
        >
          <strong>{item.label}</strong>
          <input
            placeholder="메모를 입력"
            value={item.note}
            onChange={(e) => updateNote(item.id, e.target.value)}
          />
        </div>
      ))}

      <p style={{ color: '#065f46', marginTop: 8 }}>
        정렬/추가 후에도 메모가 해당 항목에 안정적으로 유지됩니다.
      </p>
    </section>
  );
}

▼ 실행 화면 설명: 이제 정렬/삽입을 반복해도 각 항목의 메모가 원래 항목에 정확히 붙어 있습니다.

올바른 key 적용(안정적 식별자 사용) 예제 실행 결과 화면

5) 컴포넌트 분리 시 key의 위치(“반복하는 곳”에 key)

리스트 아이템을 별도 컴포넌트로 분리할 때 key는 map을 호출하는 곳에 둬야 합니다. 자식 컴포넌트 내부에 key를 두면 리액트가 형제 간 구분을 못 하므로 의미가 없습니다.

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

const seed = [
  { id: 'a', label: '사과',   note: '' },
  { id: 'b', label: '바나나', note: '' },
  { id: 'c', label: '체리',   note: '' },
];

// 행 컴포넌트는 memo로 불필요 렌더를 줄임
const Row = memo(function Row({ item, onChange }) {
  return (
    <div style={{display:'grid', gridTemplateColumns:'120px 1fr', gap:8, marginBottom:8}}>
      <strong>{item.label}</strong>
      <input
        value={item.note}
        onChange={(e) => onChange(item.id, e.target.value)}
        placeholder="메모"
      />
    </div>
  );
});

// ✅ key는 map을 호출하는 “부모” 쪽에서 지정
function KeyPlacementList({ items, onChange }) {
  return (
    <div>
      {items.map((it) => (
        <Row key={it.id} item={it} onChange={onChange} />
      ))}
    </div>
  );
}

export default function ListKeyPlacement() {
  const [items, setItems] = useState(seed);

  const updateNote = (id, value) => {
    setItems((prev) => prev.map((it) => (it.id === id ? { ...it, note: value } : it)));
  };

  return (
    <section style={{ border: '1px solid #60a5fa', padding: 12, borderRadius: 8 }}>
      <h3 style={{ color: '#1d4ed8' }}>key 위치: 부모 map에 key (map과 key)</h3>
      <p style={{marginTop:0, color:'#475569'}}>
        행(Row)을 분리했을 때도 key는 <em>반복을 수행하는 부모</em> 쪽에 둡니다.
      </p>
      <KeyPlacementList items={items} onChange={updateNote} />
    </section>
  );
}

▼ 실행 화면 설명: 행(Row)들이 정상적으로 업데이트되며, 불필요한 재마운트가 줄어듭니다.

컴포넌트 분리 시 key의 위치(“반복하는 곳”에 key) 예제 코드 실행 화면

6) 조건부/중첩 리스트 & Fragment key

조건부 그룹이나 다중 요소를 한 번에 반환할 때는 <React.Fragment key="...">를 사용할 수 있습니다. 단, 반드시 필요한 경우에만 fragment에 key를 부여하세요.

import React, { useState } from 'react';

const seed = [
  {
    code: 'fruit',
    name: '과일',
    items: [
      { id: 'f1', title: '사과' },
      { id: 'f2', title: '바나나' },
      { id: 'f3', title: '체리' },
    ],
  },
  {
    code: 'veg',
    name: '야채',
    items: [
      { id: 'v1', title: '당근' },
      { id: 'v2', title: '오이' },
    ],
  },
];

export default function CategoryListDemo() {
  const [data, setData] = useState(seed);
  const [asc, setAsc] = useState(true);

  const sortCats = () => {
    const sorted = [...data].sort((a, b) =>
      asc ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
    );
    setAsc(!asc);
    setData(sorted);
  };

  const addCategory = () => {
    const t = Date.now();
    setData((prev) => [
      { code: `new-${t}`, name: `신규 카테고리 ${prev.length + 1}`, items: [] },
      ...prev,
    ]);
  };

  const addItemTo = (code) => {
    const t = Date.now();
    setData((prev) =>
      prev.map((c) =>
        c.code === code
          ? { ...c, items: [...c.items, { id: `i-${t}`, title: `추가 ${c.items.length + 1}` }] }
          : c
      )
    );
  };

  return (
    <section style={{ border: '1px solid #93c5fd', padding: 12, borderRadius: 8 }}>
      <h3 style={{ color: '#1e40af' }}>Fragment key 데모 (카테고리/하위 아이템)</h3>

      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <button onClick={sortCats}>카테고리 정렬 토글</button>
        <button onClick={addCategory}>카테고리 맨 앞에 추가</button>
      </div>

      <div>
        {data.map((cat) => (
          // ✅ 그룹(제목+리스트)을 하나의 Fragment로 묶고, 카테고리 식별자에 key 부여
          <React.Fragment key={cat.code}>
            <h4 style={{ marginBottom: 6 }}>
              {cat.name}{' '}
              <button onClick={() => addItemTo(cat.code)} style={{ marginLeft: 8 }}>
                이 카테고리에 아이템 추가
              </button>
            </h4>
            <ul style={{ marginTop: 0, marginBottom: 16 }}>
              {cat.items.map((it) => (
                <li key={it.id}>{it.title}</li> // ✅ 하위 아이템에는 item id를 key로
              ))}
            </ul>
          </React.Fragment>
        ))}
      </div>
    </section>
  );
}

▼ 실행 화면 설명: 카테고리 제목과 그 하위 항목들이 그룹 단위로 렌더링됩니다. 카테고리 재정렬 시에도 올바르게 유지됩니다.

조건부/중첩 리스트 & Fragment key 예제 코드 실행 결과 화면

7) 성능 최적화 팁(실무 체크리스트)

  • key 안정성이 최우선(인덱스 key 지양)
  • 리스트가 큰 경우 가상 스크롤 라이브러리 검토(react-window 등)
  • 비싼 행 렌더링은 React.memo로 메모이제이션(필요 시 useCallback 병행)
  • 파생 리스트(정렬·필터 결과)는 useMemo로 캐시(의존성 정확히)
  • 무분별한 DOM key 변경(랜덤 key 재생성)은 항상 재마운트 유발 → 상태 유실 주의

8) 자주 하는 실수 & 베스트 프랙티스

  • 자식 내부 key: 의미 없음 → 반복하는 부모 쪽에 key
  • 인덱스 key: 고정 목록(절대 변하지 않음)에서만 제한적으로 고려
  • 안정적 식별자: DB id/slug/UUID 등으로 일관성 유지
  • 폼 상태가 행에 붙는 UI는 특히 인덱스 key 사용 금지

참고 링크

함께 보면 좋은 게시글

위로 스크롤