Hits

key prop?

key propReact에서 특정 항목을 고유하게 식별할 수 있도록 도와주는 prop입니다.

key를 적절히 활용한다면, 같은 뎁스에 위치한 형제 항목 사이의 고유하게 위치를 식별할 수 있어, 해당 렌더링 시점에 이전 렌더링의 React 컴포넌트를 재활용하는 방식으로 성능상 이점을 누릴 수 있습니다.

일반적으로 React를 사용한 프로젝트에서는 JSX 배열을 반환하는 경우, key prop을 반드시 사용하도록 컨벤션이 정해져있습니다.

공식문서 - https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key

아래와 같이 리스트를 리렌더링하는 코드를 통해 예시를 살펴보겠습니다.

1번 케이스 각각의 Item 컴포넌트에 유니크한 key prop을 주었을 때

function shuffle(array: any[]) {
  const newArr = [...array].sort(() => Math.random() - 0.5);
  return newArr;
}
 
function App() {
  const [arr, setArr] = useState(
    Array.from({ length: 10 }, (_, idx) => ({
      id: idx + 1,
      text: `${idx + 1}번째 항목입니다.`,
    }))
  );
 
  return (
    <div>
      {arr.map(({ id, text }, idx) => (
        <Item key={id} text={text} />
      ))}
      <button onClick={() => setArr((prev) => shuffle(prev))}>shuffle</button>
    </div>
  );
}
 
function Item({ text }: { text: string }) {
  useEffect(() => {
    console.log("text : ", text);
  }, [text]);
 
  return <div>{text}</div>;
}
function shuffle(array: any[]) {
  const newArr = [...array].sort(() => Math.random() - 0.5);
  return newArr;
}
 
function App() {
  const [arr, setArr] = useState(
    Array.from({ length: 10 }, (_, idx) => ({
      id: idx + 1,
      text: `${idx + 1}번째 항목입니다.`,
    }))
  );
 
  return (
    <div>
      {arr.map(({ id, text }, idx) => (
        <Item key={id} text={text} />
      ))}
      <button onClick={() => setArr((prev) => shuffle(prev))}>shuffle</button>
    </div>
  );
}
 
function Item({ text }: { text: string }) {
  useEffect(() => {
    console.log("text : ", text);
  }, [text]);
 
  return <div>{text}</div>;
}

위 코드와 같이 각각의 리스트아이템(Item 컴포넌트)에 적절한 유니크한 key를 줄 경우,
상태가 변경되어 배열이 리렌더링 되었을 때(shuffle 클릭), 리스트아이템(Item 컴포넌트)을 새롭게 만드는게 아닌,
이전 렌더링 시점에 존재했던 리스트아이템(Item 컴포넌트)을 재활용하게 됩니다.

그리고 이는 26번 line의 console.log가 실행되지 않음으로서 알 수 있습니다.
Dependency Array가 존재하는 React.useEffect는 컴포넌트가 마운트 되었을 때, Dependency Array에 속한 값이 변경되었을 때 실행됩니다.
26번 line의 console.log가 실행되지 않았기 때문에, 컴포넌트가 마운트 되지 않았다는 사실을 알 수 있습니다.

withUniqueKeyGif

2번 케이스 각각의 Item 컴포넌트에 배열의 index를 key prop으로 주었을 때

function shuffle(array: any[]) {
  const newArr = [...array].sort(() => Math.random() - 0.5);
  return newArr;
}
 
function App() {
  const [arr, setArr] = useState(
    Array.from({ length: 10 }, (_, idx) => ({
      id: idx + 1,
      text: `${idx + 1}번째 항목입니다.`,
    }))
  );
 
  return (
    <div>
      {arr.map(({ id, text }, idx) => (
        <Item key={idx} text={text} />
      ))}
      <button onClick={() => setArr((prev) => shuffle(prev))}>shuffle</button>
    </div>
  );
}
 
function Item({ text }: { text: string }) {
  useEffect(() => {
    console.log("text : ", text);
  }, [text]);
 
  return <div>{text}</div>;
}
function shuffle(array: any[]) {
  const newArr = [...array].sort(() => Math.random() - 0.5);
  return newArr;
}
 
function App() {
  const [arr, setArr] = useState(
    Array.from({ length: 10 }, (_, idx) => ({
      id: idx + 1,
      text: `${idx + 1}번째 항목입니다.`,
    }))
  );
 
  return (
    <div>
      {arr.map(({ id, text }, idx) => (
        <Item key={idx} text={text} />
      ))}
      <button onClick={() => setArr((prev) => shuffle(prev))}>shuffle</button>
    </div>
  );
}
 
function Item({ text }: { text: string }) {
  useEffect(() => {
    console.log("text : ", text);
  }, [text]);
 
  return <div>{text}</div>;
}

위 코드와 같이 각각의 리스트아이템(Item 컴포넌트)에 배열의 index를 key로 줄 경우,
상태가 변경되어 배열이 리렌더링 되었을 때(shuffle 클릭), 이전 위치와 같은 위치에 같은 내용의 컴포넌트가 렌더링 되는게 아닌 이상,
컴포넌트를 새롭게 만들게 됩니다.

그리고 이는 26번 line의 console.log가 실행됨으로서 알 수 있습니다.

withIndexKeyGif

이슈1 - 조건부 useEffect 로직 실행

발생했던 첫 번째 이슈는 조건부 useEffect 로직 실행이었습니다.

이슈 상황때 발생했던 데이터는 아래와 같은 형태입니다.

const CATEGORY_LIST = ["category1", "category2", "category3"];
const COLOR_LIST = ["color1", "color2", "color3"];
const PRODUCT_LIST: Record<string, string[]> = {
  "category1&color1": ["product1", "product2"],
  "category1&color2": ["product2", "product3"],
  "category1&color3": ["product3", "product4"],
  "category2&color1": ["product4", "product5"],
  "category2&color2": ["product5", "product6"],
  "category2&color3": ["product6", "product7"],
  "category3&color1": ["product7", "product8"],
  "category3&color2": ["product8", "product9"],
  "category3&color3": ["product9", "product10"],
};
const CATEGORY_LIST = ["category1", "category2", "category3"];
const COLOR_LIST = ["color1", "color2", "color3"];
const PRODUCT_LIST: Record<string, string[]> = {
  "category1&color1": ["product1", "product2"],
  "category1&color2": ["product2", "product3"],
  "category1&color3": ["product3", "product4"],
  "category2&color1": ["product4", "product5"],
  "category2&color2": ["product5", "product6"],
  "category2&color3": ["product6", "product7"],
  "category3&color1": ["product7", "product8"],
  "category3&color2": ["product8", "product9"],
  "category3&color3": ["product9", "product10"],
};

위 데이터는 카테고리, 컬러 목록이 존재하고 선택된 카테고리와 컬러에 따라서 상품이 달라지는 데이터입니다.

위 데이터를 표현하는 코드는 아래와 같습니다.

function CategoryChip({
  name,
  onClickCategory,
}: {
  name: string;
  onClickCategory: (category: string) => void;
}) {
  return (
    <button onClick={() => onClickCategory(name)}>category : {name}</button>
  );
}
 
function ColorChip({
  name,
  onClickColor,
}: {
  name: string;
  onClickColor: (color: string) => void;
}) {
  return <button onClick={() => onClickColor(name)}>color : {name}</button>;
}
 
function ProductCard({ name }: { name: string }) {
  useEffect(() => {
    console.log("name : ", name);
  }, [name]);
  return <div>product : {name}</div>;
}
 
function App() {
  const [selectedData, setSelectedData] = useState({
    category: INITIAL_CATEGORY,
    color: INITIAL_COLOR,
  });
 
  const products = getTargetProducts(selectedData.category, selectedData.color);
 
  return (
    <div>
      <h2>카테고리</h2>
      {CATEGORY_LIST.map((category) => (
        <CategoryChip
          key={category}
          name={category}
          onClickCategory={(category: string) =>
            setSelectedData((prev) => ({ ...prev, category }))
          }
        />
      ))}
      <h2>컬러</h2>
      {COLOR_LIST.map((color) => (
        <ColorChip
          key={color}
          name={color}
          onClickColor={(color: string) =>
            setSelectedData((prev) => ({ ...prev, color }))
          }
        />
      ))}
      <h2>상품</h2>
      {products.map((product) => (
        <ProductCard key={product} name={product} />
      ))}
    </div>
  );
}
function CategoryChip({
  name,
  onClickCategory,
}: {
  name: string;
  onClickCategory: (category: string) => void;
}) {
  return (
    <button onClick={() => onClickCategory(name)}>category : {name}</button>
  );
}
 
function ColorChip({
  name,
  onClickColor,
}: {
  name: string;
  onClickColor: (color: string) => void;
}) {
  return <button onClick={() => onClickColor(name)}>color : {name}</button>;
}
 
function ProductCard({ name }: { name: string }) {
  useEffect(() => {
    console.log("name : ", name);
  }, [name]);
  return <div>product : {name}</div>;
}
 
function App() {
  const [selectedData, setSelectedData] = useState({
    category: INITIAL_CATEGORY,
    color: INITIAL_COLOR,
  });
 
  const products = getTargetProducts(selectedData.category, selectedData.color);
 
  return (
    <div>
      <h2>카테고리</h2>
      {CATEGORY_LIST.map((category) => (
        <CategoryChip
          key={category}
          name={category}
          onClickCategory={(category: string) =>
            setSelectedData((prev) => ({ ...prev, category }))
          }
        />
      ))}
      <h2>컬러</h2>
      {COLOR_LIST.map((color) => (
        <ColorChip
          key={color}
          name={color}
          onClickColor={(color: string) =>
            setSelectedData((prev) => ({ ...prev, color }))
          }
        />
      ))}
      <h2>상품</h2>
      {products.map((product) => (
        <ProductCard key={product} name={product} />
      ))}
    </div>
  );
}

위 코드에서 카테고리 또는 컬러를 클릭해 리렌더링 될 경우, 25 line의 console.log는 아래와 같은 경우에 조건부로 실행됩니다.

  • 이전 렌더링 시점에 존재했던 product가 현재 렌더링 시점에 존재하지 않는 경우
    • 이전 렌더링 시점에 1,2번 product가 존재하고 현재 렌더링 시점에는 2,3번 product가 존재
    • 3번 productuseEffect만 실행되고 console.log가 실행된다.
rerenderWithUniqueKey

이는 위에서 살펴보았듯, 같은 key를 사용하기 때문에 컴포넌트를 새롭게 만들지 않고 재사용했기 때문입니다.

그런데, 만약 위와 같은 케이스에서 카테고리가 변경되었을때는 25번 line의 console.log를 실행시키지 않아야 하고,
컬러가 변경되었을때는 25번 line의 console.log를 실행시켜야 한다면 어떻게 해야할까요?

일반적으로는 useEffectDependency Array에 선택된 컬러를 추가해 구현할 수 있습니다.

function ProductCard({
  name,
  selectedColor,
}: {
  name: string;
  selectedColor: string;
}) {
  useEffect(() => {
    console.log("name : ", name);
  }, [name, selectedColor]);
  return <div>product : {name}</div>;
}
function ProductCard({
  name,
  selectedColor,
}: {
  name: string;
  selectedColor: string;
}) {
  useEffect(() => {
    console.log("name : ", name);
  }, [name, selectedColor]);
  return <div>product : {name}</div>;
}

하지만 이미 만들어진 Custom Hook을 사용하거나 라이브러리에서 제공하는 Hook을 사용하는 경우 임의로 Dependency Array에 값을 추가할 수 없습니다.

이럴때는 Reactkey props를 사용해 컴포넌트를 강제로 다시 만들도록 할 수 있습니다.

function App() {
  const [selectedData, setSelectedData] = useState({
    category: INITIAL_CATEGORY,
    color: INITIAL_COLOR,
  });
 
  const products = getTargetProducts(selectedData.category, selectedData.color);
 
  return (
    <div>
      <h2>카테고리</h2>
      {CATEGORY_LIST.map((category) => (
        <CategoryChip
          key={category}
          name={category}
          onClickCategory={(category: string) =>
            setSelectedData((prev) => ({ ...prev, category }))
          }
        />
      ))}
      <h2>컬러</h2>
      {COLOR_LIST.map((color) => (
        <ColorChip
          key={color}
          name={color}
          onClickColor={(color: string) =>
            setSelectedData((prev) => ({ ...prev, color }))
          }
        />
      ))}
      <h2>상품</h2>
      {products.map((product) => (
        <ProductCard
          key={`${selectedData.colorCode}&${product}`}
          name={product}
        />
      ))}
    </div>
  );
}
function App() {
  const [selectedData, setSelectedData] = useState({
    category: INITIAL_CATEGORY,
    color: INITIAL_COLOR,
  });
 
  const products = getTargetProducts(selectedData.category, selectedData.color);
 
  return (
    <div>
      <h2>카테고리</h2>
      {CATEGORY_LIST.map((category) => (
        <CategoryChip
          key={category}
          name={category}
          onClickCategory={(category: string) =>
            setSelectedData((prev) => ({ ...prev, category }))
          }
        />
      ))}
      <h2>컬러</h2>
      {COLOR_LIST.map((color) => (
        <ColorChip
          key={color}
          name={color}
          onClickColor={(color: string) =>
            setSelectedData((prev) => ({ ...prev, color }))
          }
        />
      ))}
      <h2>상품</h2>
      {products.map((product) => (
        <ProductCard
          key={`${selectedData.colorCode}&${product}`}
          name={product}
        />
      ))}
    </div>
  );
}

리스트 아이템인 ProductCard 컴포넌트의 key prop에 선택된 컬러를 추가해,
컬러가 변경되었을 때 컴포넌트를 재활용이 아닌 생성하도록 만들어 Mount 로직을 실행되도록 만들었습니다.

rerenderWithColorKey

어떻게보면 useEffectDepedency Array에 값을 직접 추가하는게,
컴포넌트를 다시 만들지 않고 재활용하기 때문에 성능상 좋고 직관적이라고 생각할 수 있지만,

key prop을 사용한다면 컴포넌트 내부의 로직을 수정하지 않아도 되며,
컴포넌트에서 굳이 몰라도 되는 데이터를 props로 넘기지 않아도 되기 때문에 괜찮다고 생각합니다.


이슈2 - MasonryGrid UI 깨짐 현상

두 번재 이슈는 라이브러리를 이용해 리스트 UI를 그릴 때, UI가 깨지는 현상이었습니다.

이번 프로젝트에서는 @egjs/react-infinitegrid 라는 라이브러리에서 제공하는 MasonryInfiniteGrid 컴포넌트를 이용해 지그재그UI(MasonryGrid UI)를 구현했었고,

대략 아래와 같은 구조였습니다.

<MasonryInfiniteGrid>
  {productList?.map((product, index) => (
    <ProductCard
      key={product.productNo}
      data-grid-groupkey={product.groupKey}
      index={index}
      {...product}
    />
  ))}
</MasonryInfiniteGrid>
<MasonryInfiniteGrid>
  {productList?.map((product, index) => (
    <ProductCard
      key={product.productNo}
      data-grid-groupkey={product.groupKey}
      index={index}
      {...product}
    />
  ))}
</MasonryInfiniteGrid>

라이브러리의 직접적인 소스코드를 살펴보지는 못했지만,
Chrome 요소탭을 보았을 때, 리스트아이템 컴포넌트(ProductCard)가 Mount된 이후 절대위치(position: absolute)를 계산해, 위치시키는 방식으로 구현되어있다고 판단했습니다.

그리고 위에서 보았던 카테고리 컬러 선택에 따라 상품(productList)가 달라지는 구조가 사용되었습니다.

이때 만약 이전 렌더링 시점에 존재했던 상품목록과 현재 렌더링 시점에 존재하는 상품목록 중 겹치는 상품이 있다면 MasonryGrid UI가 깨지는 현상이 발생했습니다.

이 역시 위와 같이 리스트가 변경되었지만 이전 렌더링 시점의 컴포넌트를 그대로 가져와 사용해, 로직이 실행되지 않아 발생되었던 문제로 파악되었고,
매 렌더링 시점에 이전 렌더링 시점과 다른 Key를 가지도록 만들어 해결할 수 있었습니다.

<MasonryInfiniteGrid>
  {productList?.map((product, index) => (
    <ProductCard
      // productList가 바뀔때마다 unique하게 계산되는 key
      key={product["data-masonry-key"]}
      data-grid-groupkey={product.groupKey}
      index={index}
      {...product}
    />
  ))}
</MasonryInfiniteGrid>
<MasonryInfiniteGrid>
  {productList?.map((product, index) => (
    <ProductCard
      // productList가 바뀔때마다 unique하게 계산되는 key
      key={product["data-masonry-key"]}
      data-grid-groupkey={product.groupKey}
      index={index}
      {...product}
    />
  ))}
</MasonryInfiniteGrid>

Reference