프론트엔드는 성능 개선을 위해 무엇을 해야 할까?

Hardy·2021년 11월 30일
68

끄적끄적

목록 보기
2/2
post-thumbnail

이 글은 프로젝트의 성능 개선을 진행하며 겪은 경험과 성능 측정, 개선과 관련된 문서들을 토대로 작성되었습니다.

웹 사이트에서 성능은 아주 중요한 부분이다. 구글 리서치 자료에 따르면 모바일 웹 사이트의 로딩 시간이 3초 이상일 때 32%, 5초 이상은 90%, 6초 이상은 106% 마지막으로 10초가 넘으면 123%의 이탈률이 발생한다고 한다. 고객의 이탈률은 매출과 연관 되는데 Amazon은 페이지 로드 시간이 100ms 추가될 때마다 매출의 1%가 손실된다는 사실을 발표하기도 했다.

Amazon study: Every 100ms in Added Page Load Time Cost 1% in Revenue

이렇듯 웹 사이트의 성능은 프론트엔드 개발자라면 꼭 해결해야 할 문제라고 생각했고 이전에 만들어둔 프로젝트인 Reciper의 성능을 개선하고자 지금의 성능은 어느정도인지 체크해보기 위해 구글에서 제공하는 LightHouse를 이용해 성능 체크를 진행했다.

Reciper에는 약 12페이지가 존재하는데 평균적으로는 68점이라는 점수가 나왔다. 높지도 낮지도 않은 애매한 점수이지만 높을수록 좋은 부분이니 90점 이상을 목표로 잡고 성능 개선을 시작하려 한다.

불필요한 Paint 최소화

첫번째는 Chrome DevTools의 Performance 패널을 이용해 발견한 문제점인데 미친듯한 Recalculate StyleRePaint가 발생하고 있었다. 해당 문제는 Skeleton UI에 넣어둔 CSS 애니메이션이 문제 였다. CSS의 다양한 속성들 중 RePaint를 발생 시키는 속성들이 있다. 나 같은 경우는 backgroun-position을 이용해 Skeleton UI에 빛이 나는 느낌의 애니메이션을 만들어줬다. 이 backgroun-position 속성은 RePaint를 발생 시키는 속성이였고 애니메이션을 이용해 끝도 없이 계속 값을 변경해줬기 때문에 Skeleton UI가 사라질 때 까지 많은 RePaint가 있었다.

Chrome DevTools의 Layers 패널을 이용해 얼마나 많은 Paint가 발생하는지 직관적으로 확인해 볼 수 있다. 역시나 Performance 패널에서 보았듯이 엄청나게 많은 Paint가 축적되고 있다. 이 문제를 해결하기 위해선 애니메이션을 없애는 방법도 있겠지만 애니메이션을 살리고 싶은 욕심에 RePaint가 발생하지 않는 속성인 transform로 대체하기로 했다.
예상한대로 RePaint는 더 이상 일어나지 않고 있다.

CSS 속성이 발생 시키는 영향은 csstriggers.com에서 확인 할 수 있다.

결과


이렇게 불필요한 RePaint가 발생하지 않도록 작업하니 Rendering과 Painting 작업이 약 150ms 가량 줄어든걸 확인 할 수 있었다.

참고
CSS 애니메이션의 성능 아는 만큼 좋아져요!

추천
Understanding Repaint and Reflow in JavaScript

코드 스플리팅

LightHouse 점수 중 TTI와 TBT의 점수가 낮게 측정되고 있다. 이를 해결하기 위해 LightHouse의 문서를 참고해 TTI와 TBT가 무엇을 뜻하고 어떻게 해결해야 할 지 찾아봤다.

Time to Interactive ( TTI )

web.dev TTI Link
Time to Interactive는 사용자가 사이트를 접근하고 상호 작용 할 수 있을 때 까지의 시간을 의미한다. 즉, JS와 같은 리소스들을 얼마나 빠르게 처리하는지 판단해 점수로 매기고 있다. TTI 점수를 높게 받기 위해선 JS 파일의 코드를 개선해 파일 자체의 용량을 줄이거나 코드 스플리팅을 이용해 필요한 리소스들만 요청하도록 해야한다.

Total Blocking Time ( TBT )

web.dev TBT Link
Total Blocking Time는 메인 스레드가 입력 응답을 막을 만큼 오래 차단되었을 때 FCP와 TTI의 시간 차이를 계산하여 점수로 매기고 있다. FCP는 First Contents Paint로 간단하게 화면에 컨텐츠가 처음으로 페인트 된 시간을 뜻한다. 즉, TBT는 첫 컨텐츠가 눈에 보이는 시간과 컨텐츠를 상호 작용 할 수 있는 시간까지의 차이를 점수로 매기고 있다.FCP가 빠르고 TTI가 느리다면 사용자 경험 측면에서 좋지 못하기 때문에 TBT는 중요한 점수라고 생각한다.

TBT는 FCP와 TTI를 개선하면 저절로 개선되니 코드 스플리팅을 적용해 TTI를 개선해보려 한다.

Next vs React

코드 스플리팅을 하는 방법은 다양하다 Next가 제공하는 Automatic Code Splitting을 이용하거나 React.lazy와 Suspense를 이용할 수도 있다. Next를 이용하면 부가적인 기능들이 뒤따라 온다는 장점이 있지만 지금 개선하고 있는 Reciper라는 프로젝트는 4명의 팀원과 진행한 프로젝트이여서 단독으로 결정 할 수 없었다. 그리고 Next로 마이그레이션 해야하는 시간 비용을 생각해보니 Next를 사용하기 보단 기존의 React를 이용해 스플리팅하는 것으로 결정했다.

React.lazy와 Suspense에 대해서는 해당 문서에서 다루지 않고 참고 링크로 대체합니다.

결과


코드 스플리팅을 함으로서 하나의 커다란 JS파일이 여러개의 청크로 나뉘게 되었고 불필요한 청크들은 트리 쉐이킹으로 인해 로드되지 않고 있다. 개선 전과 후를 비교하면 JS파일 용량/총 용량이 1.6MB/5.7MB에서 544KB/4.8MB로 눈에 띄게 개선된 것을 확인할 수 있다.

참고
React CodeSpliting 공식 문서
트리 쉐이킹으로 자바스크립트 페이로드 줄이기
Google web.dev

이미지 최적화

코드 스플리팅을 이용해 JS파일의 용량을 줄였지만 사이트의 용량 중 가장 크게 차지하는 것은 이미지다. 보다싶이 이미지가 총 용량의 50%를 차지하고 있다. infinite scroll을 적용해 컨텐츠를 더 불러올 수 있는데 아직 로드되지 않은 이미지들이 있다는걸 감안한다면 이미지의 용량은 50% 이상이 된다. 리소스의 용량이 크면 그 만큼 로드 시간에 영향이 가기 때문에 해결해야 한다.

Largest Contentful Paint ( LCP )

web.dev LCP
LightHouse의 지표 중 LCP는 처음으로 로드된 시점부터 뷰포트 내에서 사용자에게 표시되는 가장 큰 크기의 페인트 작업이 일어나기 까지의 시간이다. 느린 리소스 로드는 LCP에 안좋은 영향을 주기 때문에 이미지를 최적화해 리소스의 용량을 줄이며 리소스 로드 속도를 올려야한다.

webp

구글도 이미지 용량의 문제점을 파악하고 이를 개선하기 위해 webp라는 이미지 확장자를 제공해주고 있다. 기존의 확장자와 webp의 큰 차이점은 용량인데 기존 확장자의 용량보다 크게 줄일 수 있는 장점이 있다. 다양한 확장자를 webp로 변환 할 수 있으며 gif와 같은 애니메이션 확장자도 Animation webp로 변환할 수 있다. 웹 호환성도 IE를 제외하고는 모든 브라우저에서 사용이 가능하지만 아직 ios의 Safari에서는 Animation webp를 지원하지 않는다고 들었다.

기존에 사용하던 png 이미지의 리소스는 2.4MB인데에 비해 webp로 변환한 이미지는 186KB로 엄청난 절감 효과를 느낄 수 있었다.

위의 경우는 서비스에서 제공하는 이미지라 webp 변환기로 간단하게 변환 후 변경해주면 되지만 사용자가 업로드하는 이미지는 png, jpeg일 경우가 대부분이다. 이를 해결하기 위해선 서버 측에서 webp 변환을 지원하는 Sharp 라이브러리를 이용해 사용자가 보낸 이미지를 webp로 변환해 저장하는 방법도 있다.

Lazy Loading

webp 확장자로 변환하였지만 한가지 불편한 점이 있었다. recruit 페이지의 card는 한번에 24개가 로드되고 Infinite Scroll을 적용해 스크롤이 아래로 내려가면 추가로 24개가 로드되고 있다. 여기서 불편한 점은 첫 화면에 보이는 card는 8개고 보이지 않는 card는 16개가 된다. 그럼 아직 뷰포트 내에 보여지지 않은 card의 이미지를 미리 로드 할 필요가 있나 생각이 들었다. 보여지는 화면의 이미지만 처리한다면 보다 효율적일 것이다. 이 부분을 개선하기 위해서 Image Lazy Loading 기법을 활용했다.

Lazy Loading은 내가 했던 고민에 가장 적합한 기법이였다. 간단히 설명하자면 이미지가 화면에 보여질 때 로드를 해주는 기법이다. 아직 화면에 보일 필요가 없는 이미지들은 로드되지 않기 때문에 처음 로드되는 용량을 많이 줄일 수 있다. 구현 방법에는 여러가지 방법이 있지만 나는 Intersection Observer API를 이용해 타겟이 뷰포트에 들어오면 이미지를 로드하는 방법을 택했다. React를 이용하고 있기 때문에 LazyImage라는 Component로 만들어 관리했다.

import React, { useCallback, useEffect, useRef } from 'react';
import styled from 'styled-components';

interface Props {
	src: string;
	alt: string;
}

const LazyImage = ({ src, alt }: Props): JSX.Element => {
	const observeTarget = useRef<HTMLImageElement>(null);

	const onLoadImg = useCallback(
		([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => {
			if (!entry.isIntersecting || !observeTarget.current) return;
			observer.unobserve(entry.target);

			observeTarget.current.src = src;
			observeTarget.current.alt = alt;
		},
		[observeTarget],
	);

	const onLoaded = useCallback(() => {
		if (!observeTarget.current) return;

		observeTarget.current.classList.remove('lazy');
	}, [observeTarget]);

	useEffect(() => {
		const observer = new IntersectionObserver(onLoadImg, { threshold: 0 });

		observer.observe(observeTarget.current as Element);
	}, [observeTarget]);

	return <Img className="lazy" ref={observeTarget} onLoad={onLoaded} />;
};

export default LazyImage;

const Img = styled.img`
	&.lazy {
		width: 100%;
		height: 150px;
		background-color: #d6d6d8;
	}
`;

LazyImage에는 props로 이미지의 src와 alt로 사용 될 문자를 전달 받고 Intersection Observer API 이용해 img 태그가 뷰포트 내로 진입하면 src와 alt를 적용시켜 이미지를 화면에 보여줍니다. 여기서 한가지 문제점이 생기는데 이미지가 로드 되기 전의 card는 이미지의 공간이 빈 공간이라 백그라운드와 경계가 애매해져 UI적으로 좋지 못한 경험을 줄 수 있습니다. 이를 해결하기 위해 이미지 공간에 회색 배경이 적용되는 클래스를 넣어주고 이미지가 로드되면 클래스를 지워주는 방식으로 해결했습니다.

결과

이미지 확장자를 webp로 변환하고 Lazy Loading 기법을 활용함으로서 리소스 용량을 2.3MB 절감해 리소스의 총 양이 2.5MB로 줄어들었습니다.

한가지 찝찝한 부분이 있는데 LazyImage에 개별로 Intersection Observer를 이용해서 한 페이지에 n개의 Intersection Observer가 적용되는데 이 부분에 성능상 이슈가 있는지 찾지 못했습니다. 과연 성능상 이슈가 없을지 아주 찝찝합니다. ㅠ

참고
An image format for the Web
웹 성능 최적화를 위한 Image Lazy Loading 기법
Intersection Observer API MDN

폰트 최적화

이미지를 최적화해 총 용량을 2.5MB로 줄였지만 폰트 용량이 무려 1.8MB를 차지하고 있다. 지금의 폰트의 포맷은 otf와 ttf 두 가지로 사용하고 있는데 woff 포맷의 용량이 비교적 적다는걸 발견해 woff 포맷도 추가해 주었다.

결과

단순히 woff 포맷을 사용했을 뿐인데 폰트 용량이 절반 이상 줄어들었고 총 용량 2.5MB에서 1.6MB로 줄어들었다.

성능 개선 결과

개선 전

개선 후

모든 페이지에서 목표였던 90점 이상이 나오고 있다. 이미지를 주로 쓰는 페이지는 LCP가 아쉽게 측정되고 있지만 일단은 여기서 만족하려 한다.


프로젝트를 성능 개선을 하며 많은 것들을 알아갈 수 있었다. 대표적으로는 Chrome DevTools의 Console 탭을 제외하곤 어지럽게만 느껴지던 Performance와 Network를 보는 방법을 터득했다. 아직은 완전히 터득한건 아니기 때문에 앞으로 더 공부해야 한다. 그리고 성능 개선 전에는 이미지의 용량, 라이브러리의 용량에 대해서 별 생각이 없었는데 개선을 하고 보니 아주 중요한 부분이였음 깨달았다. 앞으로 이런 부분을 고려하며 개발할 수 있는 계기가 되었다. 본문에 포함하진 않았지만 webpack-bundle-analyzer를 통해 번들 사이즈를 확인하고 불필요한 라이브러리들을 지우기도 했고 접근성과 SEO도 개선해 LightHouse에서 높은 점수를 받고 있다.

profile
안녕하세요 주니어 프론트엔드 개발자입니다.

8개의 댓글

comment-user-thumbnail
2021년 12월 8일

링크 가져갔다가 나중에 하나하나 점검해봐야겠네요 좋은글 감사합니다.

답글 달기
comment-user-thumbnail
2021년 12월 8일

좋은 글 감사합니다 :)

답글 달기
comment-user-thumbnail
2021년 12월 10일

좋은글 감사합니다 :)

답글 달기
comment-user-thumbnail
2022년 1월 8일

좋은 글 감사합니다 ㅎㅎ

1개의 답글
comment-user-thumbnail
2022년 1월 29일

성능상의 이슈를 추적하는 모습을 배웠습니다. 아주 끈질기고 확실하게 정리하시는 군요!! 멋있으십니다!

답글 달기
comment-user-thumbnail
2022년 5월 22일

잘 읽었습니다

답글 달기