웹 개발 스킬을 한단계 높여주는 프론트엔드 성능 최적화 - 4장 단계 이미지 갤러리 최적화
이 장에서는 이미지 갤러리 서비스로 다양한 주제의 이미지를 격자 형태로 보여준다. 마치 인스타그램 사진 보기 처럼 배열이 되어있다. 그리고 클릭시 큰 이미지로 나타나고 배경색은 이미지 색상과 비슷한 색으로 맞춰진다.
이 장에서 학습할 최적화 기법
- 이미지 지연로딩
- 레이아웃 이동 피하기
- 리덕스 렌더링 최적화
- 병목 코드 최적화
이미지 지연로딩은 3장에서는 Intersection Observer API를 이용했고 이번에는 npm에 있는 이미지 지연 라이브러리를 사용해보자.
레이아웃 이동 피하기는 일단 레이아웃 이동(Layout shift)에 대해서 알아야하는데 이는 화면상의 요소 변화로 레이아웃이 갑자기 밀리는 현상을 의미한다. 특히 이미지 로딩 과정에서 레이아웃 이동이 많이 발생하는데 이 경험은 좋지 않은 경험을 준다. 이를 분석하고 해결해보자.
리덕스 렌더링 최적화는 useSelector라는 훅으로 손 쉽게 스토어에 저장된 데이터를 가져온다. 하지만 이 과정에서 다양한 성능 문제가 발생하는데 이를 알아보자.
병목코드 최적화는 1장과 같이 여기서도 적용해보고 메모이제이션을 이용하여 성능최적화를 해보자.
레이아웃 이동
레이아웃 이동이란 화면상의 요소 변화로 레이아웃이 갑자기 밀리는 현상을 말한다. 이미지가 로드될 때 아래 이미지보다 늦게 로드되는 경우, 뒤늦게 아래 이미지를 밀어내면서 화면에 그려진다. 이런 레이아웃 이동은 사용자의 주의를 산만하게 만들고 위치를 순간적으로 변경시키면서 의도와 다른 클릭을 유발할 수 있다. 즉, 사용자 경험에 좋지 않은 영향을 준다. performance 패널을 확인해보면 layout shift라는 막대가 표시되는데 해당 시간에 레이아웃이동이 발생했다는 의미이다.
레이아웃 이동의 원인은 크게 4가지 인데
- 사이즈가 미리 정의되지 않은 이미지 요소
- 사이즈가 미리 정의되지 않은 광고 요소
- 동적으로 삽입된 콘텐츠
- 웹 폰트(FOIT, FOUT)
이미지 갤러리 서비스에서는 사이즈가 미리 정의되지 않은 이미지 요소 때문에 발생했다. 브라우저는 이미지 사이즈를 다운로드전까지 모르기 때문에 미리 해당 영역을 확보할 수 없다.광고도 마찬가지이다.
이를 해결하기 위해서는 사이즈를 미리 지정하는 것이다. 하지만 이미지 사이즈는 가로 사이즈따라 변하기에 비율을 토대로 공간을 잡아두는 방식을 택한다. 그중 전통적인 방식은 padding을 이용하여 박스를 만든 뒤, 그 안에 이미지를 absolute로 띄우는 방식이다.
asepect-ratio를 이용하면 직관적으로 표현할 수 있다.
aspect-ratio: 16/9 // 16:9 이미지 비율 표시
하지만 브라우저 호환성 문제가 있으니 확인해야 한다.
이미지 지연로딩
이 장에서는 intersection observer가 아닌 npm 라이브러리 react-lazyload를 사용해보자.
단순하게 지연로딩을 원하는 컴포넌트를 감싸주면 된다. 이렇게 하면 화면이 보일때마다 하나씩 로드된다. 하지만 이러면 계속 이미지가 나중에 보이게 되서 문제가 있는데 이 문제를 해결하기 위해 조금 더 미리 준비해야한다. 이는 offset 옵션으로 가능하다. offset을 100으로 설정하면 화면에 들어오기 100px 전에 이미지를 로드하는 식이다.
리덕스 렌더링 최적화
리액트는 렌더링 사이클을 가진다. 서비스의 상태가 변경되면 화면에 반영하기 위해 리렌더링 과정을 거친다. 그렇기 때문에 렌더링에 시간이 오래걸리거나 불필요한 렌더링이 발생하면 서비스 성능에 영향을 준다. 불필요한 리렌더링을 지워 성능 최적화를 해보자.
리렌더링의 원인
이 코드에서 원하지 않는 리렌더링은 리덕스 때문에 발생한다. 리덕스 상태를 구독하여 상태가 변화되었을 때 리렌더링되는데, 모달이 뜨는 과정에서 imageModal 스토어의 상태를 변경하였고 이 과정에서 리덕스의 전체적인 상태는 변화고, 이 상태 변화는 useSelector를 사용하고 있는 컴포넌트에 신호를 보낸다. 신호를 받은 컴포넌트는 리렌더링을 하게 된다.
하지만 리덕스에서 변경된 상태는 imageModal 상태이지 다른 category나 photos가 아니다. 상관이 없는 데이터 인데 왜 리렌더링이 되는 것일까?
이 이유는 useSelector 동작 방식에 잇는데 서로 다른 상태를 참조할 때는 리렌더링을 하지 않도록 구현되어 있다. 하지만 그 판단 기준이 useSelector에 넣은 함수의 반환 값이다. 반환 값이 이전 값과 같다면 해당 컴포넌트는 리덕스 상태 변화에 영향이 없다고 판단하여 리렌더링을 하지 않고, 다르면 리렌더링을 한다.
또 값이 직접 변하지 않아도 참조값이 달라지므로 리렌더링을 하게 된다.
이를 해결하기 위해서는 2가지 방법이 있는데
- 객체를 새로 만들지 않도록 반환값을 나누는 방법
- Equality Function 을 사용하는 방법이다.
첫번째 방법은 객체로 묶어서 반환하면 참조가 바뀌어 버리므로 객체를 반환하지 않는 형태로 useSelector를 나누는 방법이다.
// as-is
const { modalVisible, bgColor, src, alt } = useSelector(state => ({
modalVisible: state.imageModal.modalVisible,
bgColor: state.imageModal.bgColor,
src: state.imageModal.src,
alt: state.imageModal.alt,
}));
// to-be
const modalVisible = useSelector(state => state.imageModal.modalVisible);
const bgColor = useSelector(state => state.imageModal.bgColor);
// 등등
이렇게 단일 값으로 반환하면 리렌더링을 발생시키지 않을 것이다.
두번째 방법은 Equality Function을 사용하는 방법인데 이전 반환 값과 현재 반환값을 비교하는 함수이다. 이는 useSelector의 옵션으로 넣는 함수인데 직접 구현하여 넣을수도, 리덕스에서 제공하는 함수를 사용할 수도 있다.
const { modalVisible, bgColor, src, alt } = useSelector(state => ({
modalVisible: state.imageModal.modalVisible,
bgColor: state.imageModal.bgColor,
src: state.imageModal.src,
alt: state.imageModal.alt,
}),
shallowEqual
);
두번째 인자로 shallowEqual이란 값을 넣어주는 것이다. 얇은 비교를 하는 함수로 참조값을 비교하는게 아니라 결과 값을 비교한다.
수정 후 모달 띄운 후 이미지 리스트가 리렌더링되지 않는다.
병목 코드 최적화
페이지가 최초로 로드될 때와 카테고리를 변경했을 때, 이미지 모달을 띄웠을 때 이 3가지를 확인해볼수 있다. 직접 이미지 갤러리를 탐색해보면 페이지 로딩 과정과 카테고리 변경 과정에서는 느린 느낌은 없다. 하지만 이미지를 클릭해서 모달을 띄웠을 때 늦다. 특히 배경색을 살펴보자.
getAverageColorOfImage을 살펴보면 이 함수가 굉장히 느리다는 것을 볼 수 있다. 여기에 메모이제이션을 도입할 수 있다.