[React] 고유값 key={Math.random()}의 함정

정호·2024년 3월 31일

React

목록 보기
17/33
post-thumbnail

[React] 고유값key={Math.random()} 즉석 생성이 부르는 대참사

"리액트의 재조정(Reconciliation)에서 Key의 생명은 '고유성(Uniqueness)'만이 아닙니다. 더 중요한 본질은 바로 '렌더링 간의 불변성(Stability)'입니다."

key={index}가 안티 패턴이라는 사실을 접한 뒤, "그렇다면 겹치지 않는 고유값을 주면 되겠네!"라며 JSX 렌더링 함수 내부에서 key={Math.random()}이나 key={crypto.randomUUID()}를 호출하는 실수를 종종 범하곤 합니다.

하지만 이는 index를 Key로 쓰는 것보다 훨씬 파괴적인 버그와 렌더링 참사를 불러옵니다. 리액트 Fiber 아키텍처 관점에서 왜 인라인 동적 키 생성이 최악의 패턴인지 공학적으로 파헤쳐 봅니다.


1. Key의 핵심 2대 원칙: 고유성(Unique)과 불변성(Stable)

React 공식 문서와 가상 DOM 비교 알고리즘이 전제하는 Key의 조건은 두 가지입니다.

  1. 형제 노드 간의 고유성(Unique among siblings): 같은 부모 아래의 자식들끼리 Key가 겹치지 않아야 함.
  2. 렌더링 사이클 간의 불변성(Stable across renders): 부모가 리렌더링되어도 동일한 데이터 엔터티라면 항상 동일한 Key 값을 유지해야 함.

[인라인 동적 생성의 치명적 오류]

  • 1번째 렌더링: key="0.491823..."

  • 2번째 렌더링: key="0.884192..." (부모가 리렌더링될 때마다 새로운 난수/UUID 발급)
    ➔ 리액트의 판단: "완전히 다른 컴포넌트가 들어왔구나! 이전 인스턴스를 통째로 파괴(Unmount)하고 새로 생성(Mount)하자."


2. 내부 동작: 리액트 Fiber 노드의 파괴와 재생성 메커니즘

리액트의 Diffing 알고리즘은 이전 Fiber 트리와 새 JSX 요소를 비교할 때 key가 다르면 기존 컴포넌트를 즉시 파괴(Unmount)합니다.

[이전 Fiber Tree][새 렌더링 트리 (state 1개 변경)]
Fiber A (Key: "0.123") <-- 비교 --> JSX A (Key: "0.987")
➔ ⚠️ Key 불일치 판정!
➔ 1) Fiber A 및 실제 DOM 노드 강제 삭제 (Unmount)
➔ 2) 새로운 DOM 노드 생성 및 브라우저 DOM Tree에 재삽입 (Mount)

인라인 난수 Key 주입 시 발생하는 연쇄 반응:

  • DOM 노드 매번 재생성: DOM 노드를 재사용하지 못하고 매 렌더링마다 document.createElement 수준의 비용이 발생합니다.
  • 내부 State 완전 증발: 컴포넌트 내부의 useState, useRef, 타이머, 네트워크 요청 등이 리렌더링마다 초기화됩니다.
  • 애니메이션 및 UX 파괴: Framer Motion 같은 애니메이션 라이브러리의 AnimatePresence는 매번 마운트 애니메이션을 다시 실행하거나, DOM 노드가 즉시 파괴되어 Exit 효과가 씹히는 현상이 발생합니다.

3. 대표적인 재앙 시나리오: 입력창 포커스 탈락 (Focus Loss)

이 패턴이 실제 UI에서 일으키는 가장 대표적인 치명적 버그는 "글자 하나 칠 때마다 포커스가 풀리는 현상"입니다.

// ❌ 최악의 안티 패턴: 렌더링할 때마다 새로운 Key를 즉석 생성
function SearchFilterList({ items }) {
  const [query, setQuery] = useState('');

  return (
    <div>
      <input 
        value={query} 
        onChange={(e) => setQuery(e.target.value)} 
        placeholder="검색어 입력..." 
      />
      <ul>
        {items.map((item) => (
          // ⚠️ 렌더링 함수 실행 시마다 매번 새로운 UUID 발급!
          <li key={crypto.randomUUID()}>
            <input defaultValue={item.name} />
          </li>
        ))}
      </ul>
    </div>
  );
}

버그 발생 및 사용자 경험 파괴 흐름
1. 사용자가 리스트 내부의 인풋창을 클릭하여 포커스(Focus)를 맞추고 텍스트를 한 글자 입력함.

  1. 부모 컴포넌트의 State(query)가 변경되며 자식 리스트 전체가 리렌더링됨.

  2. crypto.randomUUID()가 새로운 값을 반환 ➔ 리액트는 기존 <li><input> DOM 노드를 브라우저에서 완전히 삭제(Unmount) 후 새로 생성.

  3. [참사 발생]: 브라우저에 존재하던 포커스 대상 DOM이 사라졌으므로 인풋의 포커스가 즉시 풀리고 키보드 입력이 끊어짐 (사용자는 한 글자 치고 마우스로 다시 클릭해야 하는 최악의 UX를 겪음).

4. 올바른 엔터티 ID 생성 및 바인딩 패턴

고유 ID는 "렌더링하는 시점"이 아니라, "데이터가 생성되거나 가공되는 시점"에 단 한 번만 생성하여 데이터 객체 내부에 고정(Persist)시켜야 합니다.

// ✅ 올바른 패턴: 데이터 생성 시점에 ID 부여
const addItem = (text) => {
  const newItem = {
    id: crypto.randomUUID(), // 데이터가 생성되는 이벤트 시점에 딱 1번만 발급!
    text: text
  };
  setItems((prev) => [...prev, newItem]);
};

// 렌더링 시에는 불변하는 데이터의 고유 ID를 참조
return (
  <ul>
    {items.map((item) => (
      <li key={item.id}>
        <input defaultValue={item.text} />
      </li>
    ))}
  </ul>
);

❖ 올바른 Key 바인딩의 이점:

  • 완벽한 DOM 인스턴스 재사용: 컴포넌트가 언마운트되지 않고 Props만 부드럽게 갱신.

  • 포커스 및 제어 상태 유지: 입력 중인 포커스, 스크롤 위치, 셀렉션 상태가 완벽히 보존됨.

  • 불필요한 브라우저 Layout/Paint 연산 차단.

3가지 Key 안티패턴 및 올바른 해결책 비교

구분key={index}key={Math.random()}key={item.id} (불변 고유값)
고유성 (Unique)⚠️ 순서에 의존✅ 고유함✅ 완벽히 고유함
불변성 (Stable)❌ 삽입/삭제 시 변경매 렌더링마다 변경✅ 렌더링 내내 불변
주요 발생 버그이전 상태가 엉뚱한 위치로 전이DOM 강제 파괴, 포커스 풀림, 깜빡임 없음 (버그 방어)
DOM 라이프사이클기존 DOM 강제 재사용매번 Unmount ➔ Mount 반복정상 Update / 필요한 노드만 Mount

📚 References

profile
열심히 기록할 예정🙃

0개의 댓글