라이트하우스는 웹앱 품질 측정도구이다. 웹앱의 성능, 접근성, SEO 등을 검사해주고, PWA 조건을 만족하는지도 검사해준다. 크롬 확장 프로그램으로 다운받아서 사용할 수 있고, 혹은 크롬 개발자 도구에서 Lighthouse 탭으로 들어가 사용할 수도 있다.
라이트하우스로 개발하고 있는 웹 프로젝트의 성능을 개선해 보기로 했다. 배포 환경에서 성능을 측정한 결과,
노란 불이 세 개나 켜졌다. 라이트하우스는 점수와 함께 어떤 부분이 미흡하고 어떻게 개선시키면 되는지를 참고할 문서 링크와 함께 알려준다.
Performance
퍼포먼스 점수가 가장 낮게 나왔다. 첫 콘텐트풀 페인트(First Contentful Paint)는 첫 번째 텍스트와 이미지 요소가 화면에 렌더링되는 시간인데, 그 시간이 2초나 걸리고 있다. 상호작용이 가능해지는 시간은 7.9초로, 전체적으로 매우 느리게 나타났다.
width
and height
img
요소에는 명시적으로 width와 height 속성값을 넘겨줘야 한다. 명시적으로 넘겨줘야 한다는 의미는 100%
, auto
처럼 다른 요소에 종속적인 값이 아니라 200px처럼 값 자체를 넘겨줘야 한다는 것이다.
이미지는 보통 용량이 크기 때문에 로드되는 데 오래 걸린다. 이미지가 로드되기 전까지 브라우저는 불러오는 이미지의 너비와 높이를 알 수 없기 때문에 img
태그의 자리를 할당해 주기 어려워 빈 공간으로 남겨 둔다. 그리고 이미지가 로드되면 그 너비와 높이만큼 자리를 만들어 준다.
이렇게 되면 레이아웃이 갑자기 변경되는 CLS(Cumulative Layout Shift, 누적 레이아웃 이동)가 발생한다. CLS는 페이지 레이아웃이 예상치 못하게 갑자기 바뀌는 현상이다. 사용자 경험 면에서 매우 나쁘다.
따라서 img
태그에 명시적인 속성값을 넘겨주어야 한다. width
와 height
값을 주면 이미지가 로드되기 전에도 그 크기만큼 공간을 할당해 줄 수 있다.
그런데 사용자가 업로드하는 이미지는 width 값과 height 값을 예상하기 어렵다. 그래서 width 값과 height 값을 줘버리면 이미지가 찌그러질까봐 속성값을 주지 않았었는데, CSS를 통해 다시 조정해 주니 이미지가 찌그러지지 않았다.
Next.js에서는 내장된 next/image
라이브러리를 사용하면 위의 내용을 해결할 수 있다. 프로젝트 내에서 사용하고 있던 공통 ImageComponent
에서는 이미지 표시를 위해 기본 HTML img 요소를 사용했는데, 이를 next/image
의 Image 컴포넌트로 대체했다.
속성으로 loading="lazy"
값을 주면 Lazy loading이 적용된다. next.config.js
파일에서 이미지의 캐시 TTL을 지정해 줄 수 있다. 외부 도메인에서 이미지를 가져올 경우, next.config.js
파일에 도메인 정보를 추가해 주어야 한다.
// next.config.js
module.exports = {
images: {
domains: [
"fork-fork-cake.s3.ap-northeast-2.amazonaws.com",
"cdn.pixabay.com",
],
minimumCacheTTL: 60,
}
};
next/image
의 Image 컴포넌트를 사용하면
<Image
src={imgSrc || fallback}
alt={alt}
width={width}
height={height}
loading="lazy"
placeholder="empty"
onError={onError}
/>
CSS의 @font-face
부분에 font-display: swap
를 넣어주면 폰트가 로딩되지 않았을 때나 폰트를 로드하지 못했을 때 시스템 폰트를 보여준다. 이렇게 넣어주지 않으면 폰트가 로드되지 않았을 때 글씨가 화면에 나타나지 않는다.
link 태그로 불러오는 웹 폰트일 경우, <link rel="preload" as="font">
처럼 preload
를 넣어 주면 폰트를 먼저 불러오기 때문에 렌더링된 처음부터 폰트가 적용되어 있다.
만약 구글 폰트라면, url 뒤에 &display=swap
을 넣어주면 swap이 적용된다. 예시: <link href="https://fonts.googleapis.com/css?family=Roboto:400,700&display=swap" rel="stylesheet">
CSS Import로 가져오던 웹 폰트가 있었는데, 폰트를 다운받아 프로젝트 내부에서 정적 리소스로 가져다 쓰도록 바꾸어 주었다.
(정적 리소스로 바꾼 것이 캐시나 네트워킹 속도에 영향을 주었는지 정확하지 않다. 뒤에 나올 서비스워커 때문에 캐싱 되었을 수 있다.)
GmarketSans 폰트를 사용했는데, 다운로드할 수 있는 폰트는 otf와 ttf만 제공하길래 ttf를 다운로드해서 static resource에 넣어 두고 사용했다. 그런데 네트워크 페이로드 총 4,856 KiB 중에 GmarketSans만 2,289 KiB를 차지하고 있었다.
찾아 보니 폰트를 woff 형식으로 바꿔주는 사이트가 있었다. WOFF(Web Open Font Format)는 웹 글꼴 형식으로, 압축된 형식이라 TTF나 OTF보다 빠르게 로드된다. 메타 데이터로 라이센스 정보를 포함할 수도 있다고 한다.
GmarketSans는 누구나 제약 없이 자유롭게 수정하고 재배포할 수 있다고 되어 있어서, WOFF로 변환해서 정적 리소스에 다시 넣어 주었다.
종류가 문서로 되어 있는 파일이 WOFF로 변환된 폰트이다. TTF는 2MB 이상인 반면에(ㅎㅎ) WOFF 파일은 600KB 내외인 것을 확인할 수 있다.
Avoid document.write()
도대체 쓴 적이 없는데 자꾸 쓰지 말라 해서 뭐가 잘못된 건가 했는데, 카카오 주소 API에서 document.write()
를 써서 스크립트를 넣어준다고 한다. 2020년 6월 답변에 따르면 사용자 경험을 크게 해치지 않는다고 판단해 브라우저에서 사용 불가 수준의 선고가 내려지지 않는 이상 변경할 예정이 없다고 한다. 다른 걸 고쳐 두고 보니 빨간 경고가 뜨긴 해도 성능 점수에는 영향이 없길래 신경쓰지 않기로 했다. (ㅎㅎ)
Next.js 번들 사이즈를 줄이려면
<link rel="preload" ...>
처럼 preload를 붙여 미리 로드해올 수 있다.html, js, 이미지 등의 리소스를 캐싱하기 위해 서비스 워커를 도입했다. 그 결과 웹 로딩 속도가 5배 정도 빨라졌다. (점수가 낮은 이유는 로컬 개발 환경에서 성능을 측정했기 때문이다)
서비스 워커는 브라우저가 백그라운드에서 실행하는 스크립트로, 웹 응용 프로그램, 브라우저, 네트워크 사이에 프록시 서버 역할을 한다. 서비스 워커로 푸시 알림이나 오프라인 상태에서의 백그라운드 동기화와 같은 기능을 구현할 수 있다. 이를 통해 효과적인 오프라인 경험을 제공하고 네트워크 요청을 가로채 네트워크 사용 여부에 따라 적절한 행동을 취하게 할 수 있다. 리소스 요청을 가로채서 수정하거나 캐싱할 수도 있다. (참고 1: MDN 문서, 참고 2: 구글 Web Fundamentals 문서)
(이미지 출처: https://web.dev/apply-instant-loading-with-prpl/)
그림에서 첫 HTML, CSS 요청에서는 캐시에 해당하는 파일이 저장되어 있지 않았기 때문에 서버로 요청을 보내주었다. 그 응답으로 받은 HTML과 CSS를 서비스 워커가 가로채 캐시에 저장한 후에 클라이언트에 파일을 넘겨준다. 이후에 같은 요청이 들어올 때는 서버에 요청을 보내지 않고 캐싱해 두었던 HTML과 CSS를 넘겨준다.
서비스 워커는 보안 상의 이유로 HTTPS 혹은 localhost
에서만 작동한다. 서비스 워커를 통해 네트워크 요청을 수정할 수 있기 때문에 공격에 매우 취약하기 때문이다.
이 프로젝트에서는 리소스 캐싱을 위해 서비스 워커를 사용했다. 서비스 워커와 관련된 코드는 아래와 같다.
// /public/sw.js
const cacheName = "::myServiceWorker";
const version = "v0.0.1";
const cacheList = [];
// 네트워크 fetch 시
self.addEventListener("fetch", (e) => {
// 응답을 수정한다
e.respondWith(
// 요청에 대한 응답을 캐싱한 적이 있는지 확인한다
caches.match(e.request).then((r) => {
// 캐싱된 데이터가 있으면 그것을 반환한다
if (r) {
return r;
}
const fetchRequest = e.request.clone();
// 캐싱된 데이터가 없으면 원래의 요청을 보낸다
return fetch(fetchRequest).then((response) => {
if (!response) {
return response;
}
const requestUrl = e.request.url || "";
const responseToCache = response.clone();
// POST 요청에 대한 응답이나 chrome extension에 대한 응답은 캐싱 불가능하다.
if (
!requestUrl.startsWith("chrome-extension") &&
e.request.method !== "POST"
)
// 캐싱 가능한 응답이면 캐시에 요청에 대한 응답을 저장한다.
caches.open(version + cacheName).then((cache) => {
cache.put(e.request, responseToCache);
});
// 요청을 반환한다.
return response;
});
}),
);
});
// _app.js
function MyApp({ Component, pageProps }) {
useEffect(() => {
// 서비스 워커를 사용 가능하면
if ("serviceWorker" in navigator) {
// 서비스 워커를 설치한다.
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js");
});
}
}, []);
return <Component {...pageProps} />;
}
export default MyApp;
서비스 워커가 돌고 있는지 확인하려면 개발자 도구의 Application 탭의 Service Workers를 확인하면 된다. 실행되고 있으면 Status에 초록불이 들어온다.
사용하고 있는 캐시의 용량은 아래의 Storage 메뉴에서 확인할 수 있다. Clear site data를 누르면 해당 사이트의 모든 캐시 데이터가 비워진다.
일단 GET 요청 보내는 것들을 다 캐싱했더니 캐시 용량이 31MB를 차지하고 있다... 자바스크립트 크기를 줄이거나 꼭 필요한 것만 캐싱하게 수정이 필요할 것 같다. (아래 스크린샷)
....라고 생각했는데 구글 개발자 문서는 500MB가 넘어가서 그냥 이대로도 괜찮겠다고 생각했다. (아래 스크린샷)
Accessibility
color 값과 background-color 값의 대비가 충분하지 않아 발생한 경고이다. 배경 색이 흐리한 연핑크라 대비가 충분하지 않았던 것 같다. 좀 더 강한 핫핑크를 주었더니 쓸데없이 강조가 되긴 하지만 확실히 글씨는 잘 보인다.
cmd + Shift + C 를 눌러 요소를 선택하면 styles 탭이 열리면서 해당 요소의 스타일 값을 보여준다. color
의 색상 미리보기 사각형을 클릭해 보면 Contrast ratio를 확인할 수 있다. (아래 사진 참고)
AAA까지 만족하면 Contrast ratio 옆에 붙는 체크가 쌍체크가 된다. 색에 검은색을 좀 많이 섞어 줘야 쌍체크가 떠서.. 슬프지만 체크 하나로 일단은 만족..
내용이 없고 아이콘만 있는 버튼에 이름이 붙어 있지 않아서 발생한 경고이다. 버튼에 컨텐츠가 있으면 자동으로 그 컨텐츠가 이름이 되는데, 플로팅 버튼에 아이콘만 넣어 두었더니 기본 설정된 이름이 없어서 오류가 발생했다. button 요소에 title 속성에 플로팅 버튼의 이름을 넣어주었다.
요소의 이름을 보려면, 개발자 도구의 Elements 탭의 패널에서 Accessibility 탭을 확인하면 된다.
[user-scalable="no"]
is used in the <meta name="viewport">
element or the [maximum-scale]
attribute is less than 5.viewport
옵션 때문에 발생했다. 네이버 모바일 화면과 구글 모바일 화면을 참고해서 넣었는데, user-scalable
을 금지하거나 maximum-scale
이 5보다 작으면 접근성 이슈가 있다고 한다. 시력이 낮은 분들이 화면을 확대할 수 없기 때문인데.. 그런데 이걸 허용하면 텍스트를 입력할 때 화면이 확대되고 입력 완료 후에도 확대된 채로 있기 때문에 좀 불편한 옵션이다. 일단은... 그대로 두기로 했다. 웹뷰를 염두에 두고 만든 프로젝트라 확대는 막아줘야 할 것 같다.
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,minimal-ui"
/>
SEO
분명 최근에 메타 태그를 열심히 넣어 준 기억이 있는데 description을 포함해서 og graph 메타 태그까지 아무 것도 없어서 당황했다... 대체 어느 프로젝트였던건지... _document.tsx
파일에 메타 태그를 넣어주었다.
<meta
name="description"
content="페이지 설명"
/>
PWA
PWA의 조건 중 하나는 manifest.json
파일에 있는 start_url
에서 서비스 워커를 작동시키는 것이다. 서비스 워커 구성은 위에서 알아봤다.
manifest
icons: [{
"src": "\/maskable_icon_x384.png",
"sizes": "384x384",
"type": "image\/png",
"density": "4.0",
"purpose": "maskable"
}]
// manifest.json
{
name: 앱 이름,
background_color: "#fff",
icons: [
{
"src": "\/icon-512x512.png",
"sizes": "512x512",
"type": "image\/png",
"density": "4.0"
}
]
// 그 외 여러가지 설정...
}
<meta name="theme-color" content="#bbb" />
installable, PWA optimized 뱃지가 달렸다! 이제 크롬 주소창에서 '크롬 앱으로 다운받기' 버튼이나 '크롬 앱으로 열기' 버튼을 확인할 수 있다.
가끔식 라이트하우스가 98% 까지 가서는 안 될 때가 있다..... 심증으로는 서비스워커가 원인인 것 같다.
그럴 때 해결 방법
전부 백 점 만들면 폭죽 터뜨려 준대서 몇 줄 주석 치고 백 점으로 만들어 봤는데 폭죽은 안 터뜨려 줬다. 쩝
성능에서는 서비스 워커의 캐싱이 큰 역할을 했다.. js, 폰트, 이미지 등의 리소스를 캐싱해줘서 페이지 로딩 시간이 정말 빨라졌다. 모든 수치를 1초 미만으로 낮출 수 있었다.
접근성 점수를 잠깐 좀 포기했다... 그 이유는
새로고침을 마구 눌러도 깜빡임이 거의 보이지 않을 정도로 빠르게 로드된다. 🙌
라이트하우스 덕분에 접근성이나 이미지, 폰트 등 정적 리소스 로딩 성능 등 놓치고 있던 것들을 개선할 수 있었다. 그런데 자바스크립트 코드 splitting, minify, compress에 대해서는 아직 따로 해 준 것이 없어서 js 청크 하나하나의 크기가 큰 상태다. 서비스 워커로 캐싱을 해줘서 한 번 로드한 후에는 빠르게 페이지를 그려내지만, 근본적인 문제는 해결되지 않았기 때문에 캐시가 없는 상태이거나 서비스 워커가 동작하지 않는 상태에서 사이트에 접근하면 여전히 로딩이 매우 오래 걸린다. 어떻게 해야 할 지 추가 조사가 필요하다.