행동대장 서비스의 v2.1에 맞추어 새로 추가된 기능에 맞춘 새로운 랜딩 페이지를 제작했다.
각 섹션에 맞는 텍스트와 설명이 한 페이지를 차지하는 구조이고 스크롤을 내리면서 확인할 수 있도록 제작했다.
이미지를 사용해 사용자들에게 우리 서비스를 직관적으로 소개할 수 있지만 그만큼 이미지를 많이 사용하므로 이미지를 불러오는 시간이 걸리게 된다. 왜 이미지를 불러오는데 시간이 걸리게 될까?
이미지를 요청하는 흐름
이미지를 불러오는 과정을 설명하자면 html 태그에서 src="https://www.~~~/이미지.png" 속성을 만나게 되면 (1) 이미지가 저장되어있는 곳으로 요청을 보내게 된다.
<img src="https://www.~~~/이미지.png" alt="행댕이" />
이미지 요청을 보내면 (2) 저장소에서 이미지를 클라이언트로 전송해주게 된다. 그제서야 전달받은 이미지를 화면에 보여줄 수 있게 된다.
우리 서비스는 행댕이 이외에 다른 이미지들을 한 페이지에서 요청하고 있는 상황이어서 S3 저장소에 여러 번 요청을 해야한다.
<img src="https://www.~~~/이미지.png" alt="행댕이" />
<img src="https://www.~~~/이미지2.png" alt="이미지2" />
<img src="https://www.~~~/이미지3.png" alt="이미지3" />
<img src="https://www.~~~/이미지4.png" alt="이미지4" />
<img src="https://www.~~~/이미지5.png" alt="이미지5" />
<img src="https://www.~~~/이미지6.png" alt="이미지6" />
그만큼 S3서버에 이미지 리소스 요청을 해야하고 그만큼 사용자에게 컨텐츠를 늦게 보여주게 된다.
랜딩페이지 현재 성능
그렇다면 이미지들이 얼마나 안 좋은 영향을 미치고 있는지를 분석해보자
총 20번의 request가 발생했고 5MB의 데이터 전송량, 7.3MB의 리소스 사용량, 모든 요소가 로드된 Finish time은 4.67초, DomContentLoaded는 631ms, Load는 632ms가 걸렸다.
랜딩페이지에 처음 접속했을 때 보이는 화면은 맨 처음 화면 뿐이지만 그 외 화면에 보이지 않는 이미지를 (feature1~5) 불러오는 요청으로 인해 로딩 속도가 지연됨을 볼 수 있다.
그렇다면 랜딩페이지에서 사용자가 스크롤을 해서 해당 이미지가 필요한 순간에 이미지 요청을 한다면 초기 로딩 속도를 개선할 수 있지 않을까 생각했고 이를 적용해보기로 했다.
Image Lazy Loading
이미지를 Lazy Loading 하는 가장 쉬운 방법을 소개하자면 img 태그에 loading="lazy" 속성을 추가하는 것이다.
<img css={imgStyle} src={imageSrc} alt="행댕이" loading="lazy" />
이렇게 선언하게되면 이 이미지는 필요할 때 요청할 수 있게 된다.
하지만 행동대장 랜딩 페이지에선 이 방식으로 충분하지 않았다. 이 방식을 적용하고 실행했지만 화면에 보이는 만큼만 이미지를 불러오지 않았고 아래아래에 있는 이미지를 미리 불러오게 되는 현상이 있었다. 정말 필요한 순간에 이미지 요청을 하고 싶었지만 그렇지 않았고 이를 세세하게 적용하고 싶었다. 그래서 떠올린 키워드가 Intersection Observer다.
Intersection Observer를 이용한 Image Lazy Loading
Intersection Observer는 웹 페이지에서 특정 요소가 사용자가 보고 있는 화면에 관측됨을 감지하는 API이다.
const observer = new IntersectionObserver(callback, options);
observer.observe(targetRef.current);
위와 같이 observer를 생성한 뒤 observe 메서드로 어떤 요소를 관측할지를 넣어준다. 그리고 callback으로 관측될 때 실행되는 기능을 넣어주면 된다. 추가로 option으로는 특정 요소가 얼마나 관측될 때 콜백을 실행할 지 threshold를 줄 수 있다.
더 자세한 설명은 MDN을 참고.
우리는 각 section을 나누어 section 상단의 10%가 사용자의 화면에 관측될 때 이미지 요소를 불러오게 한다면 사용자에게 적절한 타이밍에 이미지를 보여줄 수 있을 것이라 생각했다.
설계를 끝냈으니 코드로 구현해보자면 먼저 위 기능을 실행할 useImageLazyLoading hook을 정의해줬다.
type UseImageLazyLoadingProps<T extends HTMLElement> = {
targetRef: React.RefObject<T>;
src: string;
threshold?: number;
};
관측할 요소 ref인 targetRef, 불러올 이미지의 주소 src, 특정 요소가 얼마나 관측될 때 callback을 실행할지 결정하는 threshold를 인자로 받게했다.
다음으로 useState로 imageSrc를 관리해준다. 특정 요소가 관측될 때 img 태그에 src를 넣어주기 위함이다.
속성의 타입은 string | undefined로 img 태그의 src 타입과 동일하게 맞춰주었다.
const [imageSrc, setImageSrc] = useState<string | undefined>(undefined);
그 다음 핵심은 IntersectionObserver API를 사용한 코드이다.
useEffect(() => {
if (targetRef.current && !imageSrc) {
const observer = new IntersectionObserver(
([entry]) => {
// 특정 요소가 관측될 때 setImageSrc 실행
if (entry.isIntersecting) {
setImageSrc(src);
// 요소가 관측된 후에는 더 이상 관측할 필요가 없으므로 unobserve
if (targetRef.current) {
observer.unobserve(targetRef.current);
}
}
},
{threshold},
);
if (targetRef.current) {
observer.observe(targetRef.current);
}
// 컴포넌트가 unmount될 때 unobserve
return () => {
if (targetRef.current) {
observer.unobserve(targetRef.current);
}
};
}
return;
}, [targetRef, src]);
useEffect 내부에 Intersection Observer 생성자를 사용해 observer를 생성해준다.
그 이유는 컴포넌트가 마운트 될 때 observer를 생성하고 observe를 시작하며, 컴포넌트가 언마운트 될 때 unobserve 해주는 것을 보장하기 위함이다.
entry.isIntersecting이 true일 때 (사용자 화면에 특정 요소가 threshold만큼 관측됐을 때) setImageSrc를 실행해주면 요소가 관측될 때 동적으로 img 태그에 src 값을 넣어줄 수 있다. img src가 채워질 때 S3에 이미지 요청을 보내게 되므로 필요한 시점에 이미지 요청을 할 수 있다.
관측이 된 후 setImageSrc를 실행했다면 더 이상 이 observer는 관측할 필요가 없으므로 unobserve 해줘서 observe 기능을 해제시킨다.
그리고 컴포넌트가 마운트되고 targetRef.current가 null이 아닐 때 observe를 시작함으로써 위 기능을 실행할 수 있다.
마지막으로 해당 컴포넌트가 언마운트 됐을 때 실행되는 cleanup으로 observer를 unobserve를 실행하면 된다.
전체 hook으로는
import {useEffect, useState} from 'react';
type UseImageLazyLoadingProps<T extends HTMLElement> = {
targetRef: React.RefObject<T>;
src: string;
threshold?: number;
};
const useImageLazyLoading = <T extends HTMLElement>({targetRef, src, threshold = 0.1}: UseImageLazyLoadingProps<T>) => {
const [imageSrc, setImageSrc] = useState<string | undefined>(undefined);
useEffect(() => {
if (targetRef.current && !imageSrc) {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setImageSrc(src);
if (targetRef.current) {
observer.unobserve(targetRef.current);
}
}
},
{threshold},
);
if (targetRef.current) {
observer.observe(targetRef.current);
}
return () => {
if (targetRef.current) {
observer.unobserve(targetRef.current);
}
};
}
return;
}, [targetRef, src]);
return {
imageSrc,
};
};
export default useImageLazyLoading;
useImageLazyLoading hook을 사용하는 쪽에는 ref를 넣어주고 hook의 반환값인 imageSrc를 img 태그의 src에 넣어주면 된다.
const DescriptionSection = () => {
const descriptionRef = useRef<HTMLDivElement>(null);
const {imageSrc} = useImageLazyLoading({
targetRef: descriptionRef,
src: `${process.env.IMAGE_URL}/standingDog.svg`,
threshold: 0.1,
});
return (
<div ref={descriptionRef}>
<img src={imageSrc} alt="행댕이" />
...
</div>
);
};
export default DescriptionSection;
개선 결과 영상
위 영상과 같이 필요할 때 이미지를 요청할 수 있게 됐다!
랜딩 페이지 개선된 성능
그렇다면 수치로도 얼마나 개선이 됐는지 알아보면
총 14번의 request가 발생했고 761KB의 데이터 전송량, 1.3MB의 리소스 사용량, 모든 요소가 로드된 Finish time은 2.43초, DomContentLoaded는 331ms, Load는 332ms가 걸렸다.
화면이 보이지 않는 이미지를 미리 가져오지 않게 개선한 결과 데이터 전송량은 5.0MB에서 761KB로 3~4MB 감소, 리소스는 7.3MB에서 1.3MB로 6MB감소, Finish time은 4.67에서 2.43 s로 2s 감소, DomContentLoaded은 631ms에서 331ms로 절반 감소, Loading은 632ms에서 332ms로 절반이 감소한 모습을 보였다.
사용자에게 필요하지 않은 데이터를 나중에 불러옴으로써 초기 화면을 더 빠르게 보여줄 수 있게 되었다. 생각보다 유의미한 수치가 있음을 확인할 수 있었고 사용자에게 필요할 때 리소스를 요청하는 것이 빠른 로딩을 위해 중요하다는 것을 알게 되었다.
추가 개선사항
1. 화면에 맞게 이미지를 필요한 만큼만 가져오기
아직 개선할 사항은 더 있다. 아래 화면을 보면 첫 페이지에 오는 뒷 배경의 이미지가 상당히 크다. 가로가 1920px 세로가 1080px인 이미지를 불러오고 있는데 사용자에게 보여지는 화면은 이 이미지의 일부이다. 그렇다면 사용자에게 필요한만큼만 이미지를 가져와서 보여준다면 이를 개선할 수 있을 것 같다.
2. 파일이 큰 이미지 용량을 줄이기
이 이미지의 경우 화면의 바닥에 있어서 초기 로딩속도에 영향을 주지는 않지만 5.4MB를 차지한다. 아마 우리 행동대장 팀의 사진이 들어가있어서 그런 것 같은데, 이 이미지 용량을 줄여본다면 리소스 양을 줄여볼 수도 있을 것 같다.
'우아한테크코스 > 행동대장' 카테고리의 다른 글
행동대장 성능 : bundle main.js 스크립트 크기 줄이기 (2) | 2024.10.24 |
---|---|
행동대장 성능 : React SPA 환경에서 Kakao Script 필요할 때 다운로드 받기 (3) | 2024.10.20 |
행동대장 : 사용자 행동 분석을 위한 Amplitude 도입 및 Frontend내 구현 (1) | 2024.10.12 |
행동대장 v2.1 : KPI 설정 (사용자 행동 분석) (8) | 2024.10.11 |
행동대장 성능 : image sprite 기법 (1) | 2024.10.01 |