본문 바로가기

Frontend/성능 최적화

[이미지 갤러리 리덕스 렌더링 최적화] 웹 성능 최적화까지 해보자-16

리덕스 렌더링 최적화

리액트의 렌더링

리액트는 렌더링 사이클을 갖는다.

서비스의 상태가 변경되면 화면에 반영하기 위해서 리렌더링 과정을 거친다.

 

그래서 렌더링이 오래 걸리는 코드가 있거나 렌더링하지 않아도 되는 컴포넌트에서 불필요하게 리렌더링이 발생하면 메인 스레드의 리소스를 차지해서 서비스 성능에 영향을 준다.

 

이번에 React Developer Tools를 사용해서 이미지 갤러리 서비스는 어떤 상황에서 리렌더링이 발생하는지, 또한 불필요한 렌더링은 없는지 분석해보자.

리렌더링 시 테두리가 나타날 수 있게 Highlight updates when components render 옵션을 클릭해서 어떤 컴포넌트가 어느 시점에 리렌더링되는지 알아보자.

 

확인해보면, 이미지를 클릭해서 이미지 모달을 띄울 때 모달만 렌더링되지 않고 모달과 전혀 상관없는 헤더와 이미지 리스트 컴포넌트까지 리렌더링되고 있다.

이 현상은 모달을 띄우는 순간, 모달의 이미지가 로드된 후 배경 색이 바뀌는 순간, 모달을 닫는 순간 모두에서 나타난다.

앞으로 이런 불필요한 리렌더링을 줄일 수 있는 방법에 대해서 알아볼 것이다.

 

리렌더링의 원인

위와 같은 원하지 않는 리렌더링은 리덕스 때문에 발생하는 것이다.

서비스에서 사용하는 이미지 리스트와 헤더의 카테고리, 모달에 관한 정보는 리덕스에서 관리한다.

컴포넌트들은 리덕스의 상태를 구독해서 상태가 변했을 때를 감지하고 리렌더링한다.

 

모달이 뜨는 과정을 살펴보면, PhotoItem 컴포넌트에서 dispatch를 통해 imageModal 스토어의 상태를 변경했다.

이 과정에서 리덕스를 구독하고 있는, 즉 useSelector를 사용하고 있는 컴포넌트는 리렌더링이 발생하게 된다.

리덕스에서 변경된 상태는 모달에 관련된 상태이지, PhotoListContainer에서 구독하고 있는 category나 photos 상태가 아니다.

 

하지만, useSelector는 서로 다른 상태를 참조할 때는 리렌더링을 하지 않도록 구현되어 있는데, 그 판단 기준이 useSelector에 인자로 넣은 함수의 반환 값이다.

반환 값이 이전과 같으면 해당 컴포넌트는 리덕스 상태 변화에 영향이 없다고 판단해 리렌더링을 하지 않고, 다르면 리렌더링을 하게 된다.

 

PhotoListContainer의 useSelector 코드에서는 인자로 들어간 함수가 객체를 반환한다.

객체를 새로 만들어서 새로운 참조 값을 반환하는 형태이기 때문에 useSelector는 리덕스를 통해 구독한 값이 변했다고 판단한다.

useSelector를 사용할 때 함수가 객체 형태를 반환하게 되면 매번 새로운 값으로 인지해 상관없는 리덕스 상태 변화에도 리렌더링이 발생한다.

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PhotoList from '../components/PhotoList';
import { fetchPhotos } from '../redux/photos';

function PhotoListContainer() {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(fetchPhotos());
  }, [dispatch]);

  const { photos, loading } = useSelector(state => ({
    photos:
      state.category.category === 'all'
        ? state.photos.data
        : state.photos.data.filter(
            photo => photo.category === state.category.category
          ),
    loading: state.photos.loading,
  }));

  if (loading === 'error') {
    return <span>Error!</span>;
  }

  if (loading !== 'done') {
    return <span>loading...</span>;
  }

  return <PhotoList photos={photos} />;
}

export default PhotoListContainer;

 

이는 ImageModalContainer와 Header도 마찬가지다.

import React from 'react';
import { useSelector } from 'react-redux';
import ImageModal from '../components/ImageModal';

function ImageModalContainer() {
  const { modalVisible, bgColor, src, alt } = useSelector(state => ({
    modalVisible: state.imageModal.modalVisible,
    bgColor: state.imageModal.bgColor,
    src: state.imageModal.src,
    alt: state.imageModal.alt,
  }));

  return (
    <ImageModal
      modalVisible={modalVisible}
      bgColor={bgColor}
      src={src}
      alt={alt}
    />
  );
}

export default ImageModalContainer;
import React from 'react';
import styled from 'styled-components';
import { useDispatch, useSelector } from 'react-redux';
import { setCategory } from '../redux/category';

function Header() {
  const dispatch = useDispatch();
  const { category } = useSelector(state => ({
    category: state.category.category,
  }));

  return (
    <HeaderWrap>
      <Nav>
        <NavList>
          <NavItem
            active={category === 'all'}
            onClick={() => {
              dispatch(setCategory('all'));
            }}
          >
            <span>All</span>
          </NavItem>
          <NavItem
            active={category === 'random'}
            onClick={() => {
              dispatch(setCategory('random'));
            }}
          >
            <span>Random</span>
          </NavItem>
          <NavItem
            active={category === 'animals'}
            onClick={() => {
              dispatch(setCategory('animals'));
            }}
          >
            <span>Animals</span>
          </NavItem>
          <NavItem
            active={category === 'food'}
            onClick={() => {
              dispatch(setCategory('food'));
            }}
          >
            <span>Food</span>
          </NavItem>
          <NavItem
            active={category === 'fashion'}
            onClick={() => {
              dispatch(setCategory('fashion'));
            }}
          >
            <span>Fashion</span>
          </NavItem>
          <NavItem
            active={category === 'travel'}
            onClick={() => {
              dispatch(setCategory('travel'));
            }}
          >
            <span>Travel</span>
          </NavItem>
        </NavList>
      </Nav>
    </HeaderWrap>
  );
}

const HeaderWrap = styled.header`
  border-bottom: 1px solid #666;
`;

const Nav = styled.nav`
  height: 64px;
`;
const NavList = styled.ul`
  display: flex;
  height: 64px;
  align-items: center;
  justify-content: center;
`;
const NavItem = styled.li`
  span {
    padding: 0 2rem;
    font-size: 1.5em;
    font-weight: 700;
    color: ${({ active }) => (active ? '#fff' : '#999')};
    cursor: pointer;
  }

  span:hover {
    color: #fff;
  }
`;

export default Header;

 

useSelector 문제 해결

이 문제는 크게 두 가지 방법으로 해결할 수 있는데, 하나는 객체를 새로 만들지 않도록 반환 값을 나누는 방법, 다른 하나는 Equality Function을 사용하는 방법이다.

 

객체를 새로 만들지 않도록 반환 값 나누기

이 방법은 객체로 묶어서 반환하면 참조가 바뀌어 버리기 때문에 객체로 반환하지 않는 형태로 useSelector를 나누는 방법이다.

ImageModalContainer의 useSelector 코드를 수정해보면 다음과 같다.

import React from 'react';
import { useSelector } from 'react-redux';
import ImageModal from '../components/ImageModal';

function ImageModalContainer() {
  const modalVisible = useSelector(state => state.imageModal.modalVisible);
  const bgColor = useSelector(state => state.imageModal.bgColor);
  const src = useSelector(state => state.imageModal.src);
  const alt = useSelector(state => state.imageModal.alt);

  return (
    <ImageModal
      modalVisible={modalVisible}
      bgColor={bgColor}
      src={src}
      alt={alt}
    />
  );
}

export default ImageModalContainer;

객체로 묶어서 한 번에 반환하던 것을 단일 값으로 반환하고 있다.

이렇게 하면 참조 값이 변경되지 않아 리렌더링을 발생시키지 않을 것이다.

 

Header 컴포넌트는 하나의 값만 반환하기 때문에 객체로 반환할 필요가 없다.

아래와 같이 변경해주자.

import React from 'react';
import styled from 'styled-components';
import { useDispatch, useSelector } from 'react-redux';
import { setCategory } from '../redux/category';

function Header() {
  const dispatch = useDispatch();
  const { category } = useSelector(state => state.category.category);

  return (
    <HeaderWrap>
      <Nav>
        <NavList>
          <NavItem
            active={category === 'all'}
            onClick={() => {
              dispatch(setCategory('all'));
            }}
          >
            <span>All</span>
          </NavItem>
          <NavItem
            active={category === 'random'}
            onClick={() => {
              dispatch(setCategory('random'));
            }}
          >
            <span>Random</span>
          </NavItem>
          <NavItem
            active={category === 'animals'}
            onClick={() => {
              dispatch(setCategory('animals'));
            }}
          >
            <span>Animals</span>
          </NavItem>
          <NavItem
            active={category === 'food'}
            onClick={() => {
              dispatch(setCategory('food'));
            }}
          >
            <span>Food</span>
          </NavItem>
          <NavItem
            active={category === 'fashion'}
            onClick={() => {
              dispatch(setCategory('fashion'));
            }}
          >
            <span>Fashion</span>
          </NavItem>
          <NavItem
            active={category === 'travel'}
            onClick={() => {
              dispatch(setCategory('travel'));
            }}
          >
            <span>Travel</span>
          </NavItem>
        </NavList>
      </Nav>
    </HeaderWrap>
  );
}

const HeaderWrap = styled.header`
  border-bottom: 1px solid #666;
`;

const Nav = styled.nav`
  height: 64px;
`;
const NavList = styled.ul`
  display: flex;
  height: 64px;
  align-items: center;
  justify-content: center;
`;
const NavItem = styled.li`
  span {
    padding: 0 2rem;
    font-size: 1.5em;
    font-weight: 700;
    color: ${({ active }) => (active ? '#fff' : '#999')};
    cursor: pointer;
  }

  span:hover {
    color: #fff;
  }
`;

export default Header;

 

새로운 Equality Function 사용

Equality FunctionuseSelector의 옵션으로 넣는 함수로, 리덕스 상태가 변했을 때 useSelector가 반환해야 하는 값에도 영향을 미쳤는지 판단하는 함수이다.

직접 구현해서 넣을 수도 있고, 리덕스에서 제공하는 함수를 사용할 수도 있다.

 

여기서는 리덕스에서 제공하는 함수를 사용해서 ImageModalContainer에 다시 적용해보자.

아래 코드에서는 useSelector의 두 번째 인자로 shallowEqual이라는 값을 넣어준다.

이는 리덕스에서 제공하는 객체를 얕은 비교하는 함수이다.

import React from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import ImageModal from '../components/ImageModal';

function ImageModalContainer() {
  const { modalVisible, bgColor, src, alt } = useSelector(
    state => ({
      modalVisible: state.imageModal.modalVisible,
      bgColor: state.imageModal.bgColor,
      src: state.imageModal.src,
      alt: state.imageModal.alt,
    }),
    shallowEqual
  );

  return <ImageModal modalVisible={modalVisible} bgColor={bgColor} src={src} alt={alt} />;
}

export default ImageModalContainer;

 

PhotoListContainer도 같은 방식으로 수정해보자.

import React, { useEffect } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import PhotoList from '../components/PhotoList';
import { fetchPhotos } from '../redux/photos';

function PhotoListContainer() {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(fetchPhotos());
  }, [dispatch]);

  const { photos, loading } = useSelector(
    state => ({
      photos:
        state.category.category === 'all'
          ? state.photos.data
          : state.photos.data.filter(photo => photo.category === state.category.category),
      loading: state.photos.loading,
    }),
    shallowEqual
  );

  if (loading === 'error') {
    return <span>Error!</span>;
  }

  if (loading !== 'done') {
    return <span>loading...</span>;
  }

  return <PhotoList photos={photos} />;
}

export default PhotoListContainer;

이렇게 수정하고 나서 All 카테고리의 이미지 모달을 띄우면 이전과는 다르게 이미지 리스트가 렌더링되지 않는다.

 

하지만 다른 카테고리에서 이미지 모달을 띄우면 여전히 이미지 리스트가 리렌더링된다.

이는 filter 메서드 때문인데, 카테고리가 all이 아니면 filter 메서드를 통해 필터링된 이미지 리스트를 가져온다.

이때 가져온 배열은 새롭게 만들어진 배열이라 참조 값이 달라지게 된다.

filter로 새로운 배열을 꺼내는 대신, state.photos.data와 state.category.category를 따로 꺼낸 후 useSelector 밖에서 필터링해야 한다.

import React, { useEffect } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import PhotoList from '../components/PhotoList';
import { fetchPhotos } from '../redux/photos';

function PhotoListContainer() {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(fetchPhotos());
  }, [dispatch]);

  const { category, allPhotos, loading } = useSelector(
    state => ({
      category: state.category.category,
      allPhotos: state.photos.data,
      loading: state.photos.loading
    }),
    shallowEqual
  );

  const photos = category === 'all' ? allPhotos : allPhotos.filter(photo => photo.category === category);

  if (loading === 'error') {
    return <span>Error!</span>;
  }

  if (loading !== 'done') {
    return <span>loading...</span>;
  }

  return <PhotoList photos={photos} />;
}

export default PhotoListContainer;

이렇게 하면 모달을 띄워도 이미지 리스트가 리렌더링되지 않는다.