프론트엔드/React

[React] 리액트에서 key의 역할( + 배열 데이터 변경으로 인한 렌더링 버그 트러블 슈팅)

순코딩 2025. 2. 8. 09:49

들어가며

바야흐로, 한 기업의 '칸반보드 구현과제'를 수행하던 중이었습니다.

해당 과제는 여러 개의 작업 카드를 DnD(드래그앤드랍) 기능을 통해 컬럼(작업 단위)별로 관리할 수 있는 칸반보드를 구현하는 과제였습니다.

칸반보드 예시

기본적인 기능 구현을 완료한 후, 기능 테스트 도중 DnD를 통한 작업 카드의 컬럼(작업 단위) 변경 시 칸반보드의 카드가 랜덤하게 뒤섞이는 버그를 발견하였습니다. (ex : 할 일 -> 진행 중으로 변경 시 카드가 뒤죽박죽됨)

결론부터 말씀드리자면 배열 렌더링 시 항상 일정하게 고유하지 않은 값을 카드 컴포넌트의 key값으로 전달하여 발생한 것이었습니다.

이 글에서는 리액트에서 key가 무엇인지, 왜 해당 버그가 발생했는 지에 대해 살펴봅니다.

 

기본 개념

리액트는 배열렌더링 시 컴포넌트에 전달하는 key값을 통해 각 요소를 고유하게 식별합니다.

(ex : <Card key={1} />)

즉, 리액트는 key를 통해 배열에 어떤 항목이 추가/삭제/변경 되었는지 감지합니다.

따라서, 리액트에서 배열 렌더링 시 key 값에는 항상 고유하게 유지되는 값을 전달해야 합니다.

 

문제 배경

문제 원인을 알아보기 전, 어떤 데이터를 사용했는지 설명드리겠습니다.

cards: [
    {
      id: 1,
      columnName: "진행 중",
      TagText: "관리자페이지",
      TagTextColor: "#7C0491",
      ContentText: "회원을 블랙리스트로 지정할 수 있는 기능을 제작합니다.",
    },
    {
      id: 2,
      columnName: "완료",
      TagText: "사용자화면",
      TagTextColor: "",
      ContentText: "장바구니에 상품을 추가하고 수정, 삭제하는 기능이 포함된 컴포넌트를 제작합니다.",
    },
    {
      id: 3,
      columnName: "완료",
      TagText: "문서화",
      TagTextColor: "#0052EA",
      ContentText: "디자인시스템 2.1 버전로그를 작성합니다.",
    },
  ],

위는 모든 카드의 데이터가 담긴 배열입니다(이하 '전체 카드 배열'로 명칭하겠습니다).

각 컬럼에서는 전체 카드 배열을 가져와 (컬럼 이름 === 카드의 columnName)인 카드만 필터링해 각 컬럼에 속하는 카드를 분리합니다.

 

  // 카드 생성
  addCard: (cardInfo) =>
    set((state) => ({
      cards: [...state.cards, cardInfo], // 새로운 카드 추가
    })),
  // 카드 삭제
  deleteCard: (id) =>
    set((state) => ({
      cards: state.cards.filter((card) => card.id !== id),
  })),

위 코드는 Zustand를 활용한 전체 카드 배열을 변경하는 함수입니다.

카드 생성, 카드 삭제 함수가 있으며 각 함수의 동작은 아래와 같습니다.

1. 카드 생성 : 카드 데이터를 인수로 받아 카드 배열에 해당 카드 데이터 추가

2. 카드 삭제 : 카드의 고유 id를 인수로 받아 카드 배열에서 해당 id와 일치하는 데이터 삭제

 

 // 카드 필터링(컬럼에 속하는 카드만 필터링)
 const filteredCards = cards?.filter((el) => {
    return columnName === el.columnName;
 });

// 카드 렌더링(필터링된 카드 배열 렌더링)
const RenderCards = filteredCards?.map((el, idx) => {
    return (
      <Card
        key={idx}
      />
    );
  });

위 코드는 컬럼 컴포넌트에서 카드 데이터를 필터링 후 렌더링하는 코드입니다.

(cards는 전체 카드 배열, filteredCards는 필터링된 카드 배열 입니다.)

이 때, 필터링된 카드 배열을 렌더링하는 과정에서 <Card /> 컴포넌트의 key 값으로 요소의 index 값을 전달합니다.

key값으로 요소의 index가 전달되었기 때문에 리액트는 <Card /> 컴포넌트를 index를 통해 식별합니다.

이로 인해 버그가 발생하게 됩니다.

 

문제 원인

위에서 카드 생성, 카드 삭제 함수가  전체 카드 배열의 데이터를 수정하는 것을 확인했습니다.

필자의 칸반보드에서는 DnD 기능을 통한 카드의 컬럼 이동 기능을 제공하며, 특정 컬럼에 카드를 드랍(Drop) 시 카드의 컬럼 이동을 위해 아래와 같이 작동됩니다.

예시를 위해 어떤 카드를 (할 일 -> 작업 중)으로 변경하는 상황을 가정하겠습니다.

 

1. 카드 삭제 함수를 통해 전체 카드 배열에서 드랍된 카드를 찾아 삭제합니다.

2. 카드 생성 함수를 통해 전체 카드 배열에 (columnName = "작업 중")으로 변경된 카드를 추가합니다.

 

이 때, 카드가 삭제되고 생성되는 과정에서 전체 카드 배열 데이터가 변경됩니다.

전체 카드 배열 데이터가 수정되면 각 컬럼의 필터링된 카드 배열이 변경됩니다. 

 컬럼의 필터링된 카드 배열이 변경되면 <Card /> 컴포넌트에 전달되는 key 값이 변경됩니다.

즉, 항상 고유하게 유지되어야하는 key값에 변화하는 값이 전달됩니다.

이로 인해 리액트에서는 카드 배열을 렌더링 시 각 카드를 식별하지 못하게 되고 필자가 겪은 카드 뒤죽박죽 버그가 발생하게 됩니다.

 

결론

배열 렌더링 시 key 값에는 변화하지 않는 고유한 값을 전달해야한다.

그렇지않으면 인생이 뒤죽박죽된다.

 

GPT 답변

🔍 1. key의 역할과 왜 idx가 문제였는지?

📌 React에서 key란?

  • key는 React에서 리스트를 렌더링할 때 각 요소를 고유하게 식별하는 역할을 해.
  • key가 있어야 React는 어떤 항목이 추가되었고, 삭제되었고, 변경되었는지 효율적으로 감지할 수 있어.
  • 리스트의 key가 제대로 설정되지 않으면, React가 항목을 재조정할 때 잘못된 상태를 적용할 수 있음.

잘못된 예: key={idx}를 사용한 경우

 
const RenderCards = filteredCards?.map((el, idx) => (
  <Card
    key={idx} // ❌ 잘못된 key (idx 사용 금지)
    id={el.id}
    columnName={el.columnName}
    TagText={el.TagText}
    TagTextColor={el.TagTextColor}
    ContentText={el.ContentText}
  />
));
⚠️ 왜 idx(배열 인덱스)를 key로 사용하면 안 될까?
  1. 배열 순서가 바뀌면 React가 다른 항목을 동일한 요소로 착각함.
    • React는 idx를 기반으로 비교하기 때문에, 배열이 변경되면 기존 요소를 새로운 요소로 착각하고 상태를 잘못 매칭할 가능성이 커.
    • 특히 카드를 다른 컬럼으로 이동하면 순서가 바뀌기 때문에 카드 데이터가 꼬이게 됨.
  2. 삭제 또는 추가 시 잘못된 UI 업데이트 발생
    • 예를 들어 deleteCard()가 실행되면, 기존 인덱스(idx)가 당겨지고, React는 기존 요소가 바뀌었다고 착각해서 잘못된 데이터가 적용될 수 있음.

정답: key={el.id}를 사용한 경우

 

✔️ id를 key로 사용하면?

  • id는 유일한 값(고유 식별자) 이므로, React가 정확하게 어떤 항목이 이동되었는지 인식할 수 있음.
  • 카드의 순서가 바뀌어도, id는 변하지 않으므로 React가 정확히 "이 카드가 이동한 것이다!" 라고 판단함.
  • 불필요한 재렌더링을 방지하고, 올바른 데이터를 유지할 수 있음.