Lighthouse에 대한 간단한 설명과 CLS 항목을 개선한 글입니다.
개발자 도구에서 Lighthouse를 통해 원하는 웹페이지의 성능을 측정할 수 있습니다.
성능은 위의 사진처럼 FCP, LCP, TBT, CLS 로 나뉘어 측정되게 됩니다.
CLS는 예상치 않은 레이아웃의 이동에 대한 점수로 일반적으로 아래와 같은 이유로 발생합니다.
현재 제 프로젝트의 경우 CLS에 기여한 대부분이 footer입니다.
footer의 경우 반응형을 위해 높이가 지정되지 않은 상태였습니다.
min-height을 적용해봤지만 이 문제는 해결이 되지 않았습니다.
제 생각에는 상품리스트가 렌더링되기 전 footer가 나타나고 상품 리스트가 렌더링되면서 최하단으로 내려가게되고 대규모 레이아웃 변경이 발생했다고 판단하는 것 같습니다. 이 부분을 해결해주기 위해서 상품리스트가 렌더링되기 전 스켈레톤 UI를 추가하기로 결정했습니다.
SkeletonProduct.tsx
import { keyframes, styled } from "styled-components";
import { media } from "../style/media";
interface Props {
count: number;
}
export default function SkeletonProduct({ count }: Props) {
const array = Array.from({ length: count });
return (
<>
{array.map((_, index) => (
<SkeletonWrap key={index}>
<div></div>
<div></div>
<div></div>
<div></div>
</SkeletonWrap>
))}
</>
);
}
const loading = keyframes`
0% {
background-color: rgba(165, 165, 165, 0.1);
}
50% {
background-color: rgba(165, 165, 165, 0.3);
}
100% {
background-color: rgba(165, 165, 165, 0.1);
}
`;
const SkeletonWrap = styled.li`
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
div {
background-color: #f2f2f2;
position: relative;
overflow: hidden;
}
div::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
animation: ${loading} 1.5s infinite ease-in-out;
}
div:first-child {
border-radius: 10px;
height: 350px;
${media.Medium`
height: 250px;
`}
${media.Small`
height: 90px;
`}
}
div:not(:first-child) {
height: 22px;
}
div:nth-child(2) {
width: 50%;
}
div:last-child {
width: 30%;
}
`;
ProductList.tsx
스켈레톤 UI 적용한 컴포넌트
...
export default function ProductList() {
const [productListData, setProductListData] = useState<Product[]>([]);
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [pageEnd, setPageEnd] = useState(false);
const getProductList = async (page: number) => {
setIsLoading(true);
try {
const res = await loadAllProduct(page);
setProductListData((prev) => [...prev, ...res.data.results]);
setIsLoading(false);
if (res.data.next === null) {
setPageEnd(true);
}
} catch (err) {
if (axios.isAxiosError(err)) {
if (err.response?.data?.detail === "페이지가 유효하지 않습니다.") {
console.error(err);
}
}
}
};
const targetRef = useIntersectionObserver({
onIntersect: () => {
setPage((prev) => prev + 1);
},
options: { threshold: 1 },
pageEnd,
});
useEffect(() => {
getProductList(page);
}, [page]);
return (
<>
<S.ProductUl>
{isLoading && <SkeletonProduct count={3} />}
{productListData.map((product) => (
<li key={product.product_id}>
<S.ProductLink to={`/detail/${product.product_id}`}>
<S.ProductImg src={product.image} alt="상품이미지" />
{product.stock === 0 && <S.SoldOut />}
<S.ProductCorporation>{product.store_name}</S.ProductCorporation>
<S.ProductName className="ellipsis">
{product.product_name}
</S.ProductName>
<S.ProductPrice>
{product.price.toLocaleString("ko-KR")}
<S.ProductWon>원</S.ProductWon>
</S.ProductPrice>
</S.ProductLink>
</li>
))}
{isLoading && !pageEnd && <SkeletonProduct count={3} />}
</S.ProductUl>
<div ref={targetRef} />
</>
);
}
[해결방법]
이미지가 만약 width, height가 고정되어 있다면 실제 단위를 포함해서 width, height를 지정해주거나 아래처럼 단위 없이 비율을 적용해주면 해결됩니다.
// before
<S.LogoImg src={LogoIcon} alt="호두 로고" />
// after
<S.LogoImg src={LogoIcon} alt="호두 로고" width={124} height={38} />
// 이미지가 로드되기 전에 width, height 속성을 기반으로 가로세로 비율을 계산합니다.
웹 페이지를 새로고침하거나 다른 페이지로 이동하게 되면 폰트가 깜빡이는 문제가 있었습니다.(FOUT 현상)
styled-components를 사용하면서 createGlobalStyle로 폰트를 설정했는데 이때, style 태그가 재생성될 때 폰트를 다시 불러오게 되면서 깜빡이는 현상이 발생하게 됩니다.
기존에 GlobalStyle.tsx의 font-face를 font.css 파일을 생성하여 옮겨주었습니다. 그러고나서 App에 import 해주었습니다.
// font.css
@font-face {
font-family: "Spoqa Han Sans Neo";
src: url(./fonts/SpoqaHanSansNeo-Regular.ttf);
font-weight: 400;
}
@font-face {
font-family: "Spoqa Han Sans Neo";
src: url(./fonts/SpoqaHanSansNeo-Medium.ttf);
font-weight: 500;
}
@font-face {
font-family: "Spoqa Han Sans Neo";
src: url(./fonts/SpoqaHanSansNeo-Bold.ttf);
font-weight: 700;
}
사실 이 문제는 개발환경에서만 나타났고, 실제 배포된 웹에서는 발생하지 않는 문제입니다. 개발환경에서 폰트 깜빡임이 거슬리는 경우 위와 같이 수정해주면 됩니다!
CLS가 거의 0.3에 가깝게 나왔었는데 수정하고 나니 0으로 측정되었습니다!
[참고사이트]
https://web.dev/articles/optimize-cls?hl=ko
https://velog.io/@tech-hoon/skeleton-ui
https://tesseractjh.tistory.com/182