본문 바로가기
IT/React

[React] Rerendering issues

by 무녈 2022. 9. 29.

상태 변경으로 인한 자식 컴포넌트 rerendering

새로운 프로젝트를 진행 중에 리렌더링 이슈가 너무 많이 발생해서 정리해보고자 한다.

다이어리 꾸미기 페이지에서 하단의 탭을 클릭하면 창이 height를 조절해서 더 많은 스티커 목록을 볼 수 있도록 기획하였다. (true/false로 상태를 관리하여 true 일 경우 max-height: 50vh, false 일 경우 120px로 설정하였다.)

// emotion을 적용한 스타일 컴포넌트

const StickerZone = styled.div<{ active: boolean }>`
  position: fixed;
  max-height: ${(props) => (props.active ? "50vh" : "120px")};
  bottom: 0;
  background-color: ${(props) =>
    props.active ? "rgba(40, 43, 68, 0.9)" : "rgba(40, 43, 68, 0.6)"};
  border-radius: 20px 20px 0 0;
  width: 100%;
  max-width: 550px;
  display: flex;
  flex-direction: column;
  transition: all 0.8s ease-in;
  justify-content: center;
`
<StickerZone active={toggle}>
  <button type="button" onClick={onClick}>
    보유한 스티커
  </button>
  <div>
    {dummyStickerList.map((sticker) => (
      <TransparentRoundButton type="button" key={v4()}>
        <img
          src={sticker.tokenURI}
          alt="#"
          width="40"
          loading="lazy"
        />
      </TransparentRoundButton>
    ))}
  </div>
</StickerZone>

보유한 스티커 버튼을 누를 경우 toggle의 state를 변경하여 StickerZone의 상태를 변경시켰다.

이때 버튼을 누를 때마다 부모 컴포넌트의 상태가 변경되어 자식 요소인 sticker img자 전부 rerendering이 발생하게 되었다.

매번 수 많은 이미지를 요청해야 하는 문제가 발생하였고, 이는 과도한 네트워크 낭비로 연결되는 문제였다. 뿐만 아니라 상태 변화에 따라 sticker의 이미지가 리렌더링이 되면서 ui가 깨지는 문제까지 발생했다.

문제 해결을 위한 접근

1. React.memo 활용하기

가장 처음 생각한 방법은 Img 컴포넌트를 별도로 만든 뒤 React.memo를 통해 캐싱한 후, 상태 변화에 해당 되지 않으면 렌더링이 발생하지 않을 것이라고 생각했다.

function Image({ src }: LazyImageProps) {
  const { imageSrc, imageRef } = useLazyImageObserver({ src });

  return <img ref={imageRef} src={imageSrc} alt="#" width="40" />;
}

const LazyImage = memo(Image);
export default LazyImage;

intersectionOberserver API를 활용해서 view에서 보이지 않은 이미지들의 요청과 렌더링을 방지하고, 메모이제이션된 Img 태그를 사용해보았다.

결과는 더욱 처참했다. viewport에 보이지 않는 부분의 렌더링을 막기 위해서 레이지 로드까지 적용을 시켰지만, toggle을 통해 부모의 상태가 변경이 되면서 자식들인 LazyImage 모두 영향을 받아 memo를 한 것도 무색하게 리렌더링이 되었다.

생각해보니 LazyImage 태그를 감싸고 있는 StickerZone이 변화하고 있기 때문에 재렌더링이 발생하는 건 당연한 문제였다.

우선 memo와 intersectionObserver API를 적용한 레이지 로드를 살릴 필요가 있다고 판단이 들었다.

(*intersectionObserver API를 사용하기 전, chrome 등의 브라우저에서 지원하는 img tag의 loading="lazy"를 적용했지만, threshold를 지정할 수 없기 때문에 viewport와 가까이에 있는 이미지들이 미리 load가 되어 가시성을 조절하기 위해 해당 API를 도입하였다)

2. useRef로 접근하기

style만 변경하면 되기 때문에, useRef로 target DOM을 선택하는 방법을 생각했다. 직접적으로 상태를 굳이 변경 시킬 필요가 없기 때문에 오히려 useState보다 접근하는 방법이 낫지 않을까에서 접근한 방식이다.

useRef를 통해 직접적으로 값을 주입하는 등의 방식이 좋지 않은 경우도 있기 때문에 가급적 사용을 자제하지만, state를 변경시킬 필요가 없거나 일부 styling 등을 위해서는 사용할 수 있다고 한다.

button을 통해 onClick 함수를 실행시키되, 이전에는 state를 변경하는 방식이었으나 현재는 current.style로 접근해서 style을 직접 변경시켜보자.

const onClick = () => {
    if (stickerRef.current.style.maxHeight === "50vh") {
      stickerRef.current.style.maxHeight = "120px";
      stickerRef.current.style.backgroundColor = "rgba(40, 43, 68, 0.6)";
      stickerBoxRef.current.style.overflowY = "hidden";
    } else {
      stickerRef.current.style.maxHeight = "50vh";
      stickerRef.current.style.backgroundColor = "rgba(40, 43, 68, 0.9)";
      stickerBoxRef.current.style.overflowY = "scroll";
    }
  };

return (
	<StickerZone ref={stickerRef}>
    <button type="button" onClick={onClick}>
      보유한 스티커
    </button>
    <div ref={stickerBoxRef}>
      {dummyStickerList.map((sticker) => (
        <TransparentRoundButton type="button">
          <LazyImage src={sticker.tokenURI} key={v4()} />
        </TransparentRoundButton>
      ))}
    </div>
  </StickerZone>
)

ref를 통해 styling을 변경을 한 결과, 최초 렌더링 시에만 image의 요청이 발생하였고, lazyload를 적용하였기 때문에 viewport에서 보이지 않는 스티커의 경우 image 요청이 발생하지 않았다.

뿐만 아니라 StickerZone의 height가 변하더라도 자식 요소인 LazyImage 컴포넌트에서 재렌더링이 발생하지 않았다.!!

새로운 문제 발생…

이제 스티커들을 클릭했을 경우 해당 스티커를 일기에 적용시켜야했다.

그래서 LazyImage 컴포넌트의 새로운 부모 요소로 button tag을 사용해서 image를 클릭할 경우 새로운 배열 state에 추가해서 해당 이미지를 새로 렌더링 시키려고 했다.`

const addSticker = useCallback((sticker) => {
    setStickerList((prev) => [
      ...prev,
      { tokenId: sticker.tokenId, tokenURI: sticker.tokenURI, x: 0, y: 0 },
    ]);
  }, []);

<StickerZone ref={stickerRef}>
  <button type="button" onClick={onClick}>
    보유한 스티커
  </button>
  <div ref={stickerBoxRef}>
    {dummyStickerList.map((sticker) => (
      <TransparentRoundButton onClick={() => addSticker(sticker)}>
        <LazyImage src={sticker.tokenURI} key={v4()} />
      </TransparentRoundButton>
    ))}
  </div>
</StickerZone>

스티커를 클릭할 경우 stickerList에 추가되는 방식으로, image의 URI가 필요하기 때문에 onClick method에 wrapper 객체의 형태로 addSticker 함수에 sticker 정보를 인자로 전달하였다.

스티커를 클릭하면 stickerList state에 새로운 스티커들이 추가되는 방식이었다.

하지만 상태가 변경이 되면서 또 리렌더링이 발생하는 이슈가 나타났다…

렌더링…ㅠㅠㅠ왜 계속 일어나는 걸까…

나는 이미 LazyImage를 React.memo로 메모이제이션을 시켰고, 상태 변경이 직접적으로 연관이 없기 때문에 렌더링이 발생하지 않을 것이라 생각했다. 어디서 잘못된 접근을 했을까?

문제 해결!

현재는 LazyImage 컴포넌트만 메모이제이션이 된 상태이며, LazyImage를 감싸고 있는 button tag는 메모이제이션이 되지 않았다! 그렇기 때문에 button을 클릭해서 상태를 변경할 경우 button이 렌더링이 발생하며 자식 요소인 LazyImage에서 또다시 렌더링이 발생한 것이다!

button까지 메모이제이션 시켜버리자!

function Button({ onClick, sticker }: any) {
  return (
    <TransparentRoundButton onClick={() => onClick(sticker)}>
      <LazyImage src={sticker.tokenURI} key={v4()} />
    </TransparentRoundButton>
  );
}

const MemoButton = memo(Button);

onClick 메서드와 sticker를 props형태로 전달한 후 LazyImage 컴포넌트를 Button이 감싼 형태의 컴포넌트를 만들었다. 그리고 해당 컴포넌트를 React.memo를 통해 메모이제이션을 시켜 각각 컴포넌트에서 메서드 실행이 별개의 영역으로 만들었다.

결과는 성공적이었다. 상태가 변경되어도 각각의 컴포넌트에 영향을 주지 않기 때문에 state가 변경되더라도 리렌더링이 발생하지 않았다.

이미지 요소를 많이 쓰는 프로젝트이기 때문에 사용자 경험과 더불어 최소의 렌더링과 데이터 요청이 필수적이라고 생각했다. 스티커가 많아질수록 이미지 요청이 많아질 텐데, box의 on/off의 변화, 그리고 클릭할 때마다 수많은 이미지가 리렌더링 된다면…. 정말 끔찍할 수도…

useRef와 React.memo를 적절히 활용해서 다행히 의도했던 대로 기능을 구현할 수 있게 되었다.

아직도 React에 대한 지식이 많이 부족한 것 같다. 조금 더 논리적으로 생각하며 렌더링을 최적화시킬 수 있는 방법을 찾아봐야겠다.

반응형

댓글