
Next.js Skeleton 만들기 (feat. HOC)
2022.09.30
nextjs
dev
blog
CLS와 SkeletonSkeleton을 만들어보기1. next/image의 placeholder 이용하기2. Skeleton Div 적용하기3. Skeleton Image 컴포넌트화 하기4. HOC로 만들기나의 생각 🤔References
Blog 페이지에서는 포스트마다 썸네일 이미지를 보여준다. 블로그 페이지와 포스트별 페이지들은 모두 Server Side Generation(SSG)로 생성되는 페이지이지만, 이미지 리소스의 경우 response를 받기까지 시간이 걸리기 때문에 빈 화면이 보이는 것을 확인했다. 사실 SSG에서는 빌드 타임에 생성된 pre-rendered HTML을 내려주기 때문에 Client Side Rendering에 비해 레이아웃이 shift될 일이 적다. 하지만 SSG 방식을 택했다고 해서 아예 없는 것은 아니다.


CLS와 Skeleton
CLS(Cumulative Layout Shift)는 사이트의 레이아웃의 시각적 안정성을 측정하는 지표이다. 사이트 내의 컨텐츠(이미지, 포스트 등)가 로딩되면서 레이아웃이 급작스럽게 이동한다면 사용자가 원치 않은 클릭을 할 가능성이 높아지고 사용자 경험의 만족도가 크게 떨어질 것이다. 레이아웃 변동이 적고 예측이 가능할수록 CLS 값이 0에 가까워지고 우수한 사용자 경험을 보장한다. CLS 점수가 0.1 이하면 좋은 것으로 판단되고 0.25 이상이면 좋지 못한 것으로 여겨지는데, 지금 Blog 사이트는 0.677점이었다. 흠…🤔
참고) 레이아웃 이동 점수 산정 방식
layout shift score = impact fraction(영향분율) * distance fraction(거리분율)
(CLS = 페이지 내 가장 큰 레이아웃 이동 점수 버스트)
출처 : https://web.dev/cls/
Skeleton을 리소스가 로딩되기 전에 리소스가 보여질 형태와 위치를 미리 보여줌으로써 사용자가 로딩시간을 덜 지루하게 느낄 수 있도록 하는 UI이다. 덕분에 사용자는 로딩 중인 상황에서 이미지, 텍스트, 카드 등의 데이터가 로딩 중인 상태라는 것을 알 수 있다. 실제로 내용이 로딩되기 전에 어떤 데이터가 보여질 예정인지 인지할 수 있기 때문에 사용자는 웹사이트가 더 빠르다는 인상을 받는데, 이는 ‘인지된 성능’을 높인 좋은 예시라고 할 수 있다.
인지된 성능(Perceived Performance)은 사용자의 관점에서 느끼는 주관적인 성능(웹 퍼포먼스, 반응성, 신뢰성 등)을 말한다. 실제 성능을 증가시키면 인지 성능도 증가하겠지만, 물리적/기술적 한계로 인해 실제 성능을 증가시킬 수 없는 경우 인지 성능을 증가시키는 것이 좋은 방법이라 생각한다.
또한 Skeleton은 인지 성능을 높이기도 하지만 조금 전 설명한 CLS를 개선할 수 있는 좋은 방법이다. Skeleton으로 width, height을 미리 잡아주어 의도치 않은 레이아웃 시프트를 방지할 수 있기 때문이다.

MUI의 Skeleton 컴포넌트나 react-loading-skeleton 과 같은 라이브러리가 있지만, 직접 스켈레톤을 구현을 해보면서 배워보고자 한다.
Skeleton을 만들어보기
1. next/image의 placeholder 이용하기
우선 HTML의 기본
<img />
태그 대신 next.js에서 권장하는 next.js의 <Image/>
를 사용하자.Image에는 여러가지 옵션이 있는데
import * as React from "react"; import type { InferGetStaticPropsType } from "next"; import Image from "next/image"; import { getPlaiceholder } from "plaiceholder"; export const getStaticProps = async () => { const { base64, img } = await getPlaiceholder("/path-to-your-image.jpg"); return { props: { imageProps: { ...img, blurDataURL: base64, }, }, }; }; const Page: React.FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ imageProps, }) => ( <div> <Image {...imageProps} placeholder="blur" /> </div> ); export default Page;


next/image에서
placeholder=”blur”
, blurDataURL={}
설정을 하니 위와 같은 모습이었다.2. Skeleton Div 적용하기
import React, { useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { Post } from "pages/blog"; import { parsePostTime } from "src/utils/parseTime"; import styled, { css, keyframes } from "styled-components"; const gradientEffect = keyframes` 50% { opacity: 0.5; } `; const StyledSkeleton = styled.div` & > * { transition: 1s; background-color: #828282; animation: 2s ${gradientEffect} infinite; } `; const Skeleton = (props) => { return <StyledSkeleton>{props.children}</StyledSkeleton>; }; interface PostCardInterface { key: number; post: Post; children?: React.ReactNode; } const blurDataUrl = "iVBORw0KGgoAAAANSUhEUgAAAAoAAAABCAQAAABN/Pf1AAAADUlEQVR42mM8Uc+AAQAZNwFJMLm0nQAAAABJRU5ErkJggg=="; const PostCard = (props: PostCardInterface) => { const { post } = props; const postTitle = post.properties.Name.title[0].plain_text; const postCreatedTime = post.created_time; const postImgUrl = post?.cover?.external?.url || post?.cover?.file?.url; const [loading, setLoading] = useState(true); return ( <Link href={`/posts/${post.id}`}> <div className="p-4 rounded-lg cursor-pointer flex flex-row gap-4 hover:bg-gray-500 hover:bg-opacity-10" > <div className="flex-1"> <h1 className="font-medium">{postTitle}</h1> <p className="text-gray-400">{parsePostTime(postCreatedTime)}</p> </div> {postImgUrl && ( <div className="relative"> {loading && ( <Skeleton> <div className="absolute bg-gray-200 rounded-lg" style={{ width: "112px", height: "63px" }} ></div> </Skeleton> )} <Image src={postImgUrl} alt="postThumbnailImg" width={160 * 0.7} height={90 * 0.7} placeholder="blur" blurDataURL={blurDataUrl} onLoadingComplete={() => setLoading(false)} className="rounded-md w-28 h-16 object-cover" /> </div> )} </div> </Link> ); }; export default PostCard;
const [loading, setLoading] = useState(true);
방법은 간단했다. 그냥 이미지와 동일한 사이즈의 회색 애니메이션된 div를 absolute로 위에 그려주고, 이미지가 로딩되면 제거하면 되는 거였다. 이미지 로딩상태는 next/image의 Image에서 제공하는 onLoadingComplete
에 콜백을 넣어주면 된다.(
onLoad
가 아닌 onLoadingComplete
에 넣어주어야 이미지가 완전히 로딩되었을 시 콜백이 호출된다)
회색의 skeleton component 가 없어진 후에도 blur처리된 이미지가 조금 보이는 것을 확인할 수 있다.
3. Skeleton Image 컴포넌트화 하기
onLoadingComplete가 잘 작동되는지 확인하기 위해 PostCard안에서 작업해서 이미지 관련 코드들이 width, height이 하드코딩이 되어있다. PostCard 컴포넌트에서 스켈레톤이 들어가야할 이미지만 빼서 컴포넌트화해주기로 했다.
Skeleton은 로딩될 콘텐츠와 가능한 같아야 한다. 이미지의 사이즈가 변한다면 스켈레톤도 그것에 맞춰 미리 보여주어야 CLS점수를 개선할 수 있을 것이다.
// SkeletonImage.tsx import React, { useState } from "react"; import styled, { keyframes } from "styled-components"; import Image from "next/image"; const gradientEffect = keyframes` 50% { opacity: 0.5; } `; const StyledSkeleton = styled.div` & > * { transition: 1s; background-color: rgba(229, 231, 235); animation: 2s ${gradientEffect} infinite; } `; const Skeleton = (props) => { return <StyledSkeleton>{props.children}</StyledSkeleton>; }; const blurDataUrl = "iVBORw0KGgoAAAANSUhEUgAAAAoAAAABCAQAAABN/Pf1AAAADUlEQVR42mM8Uc+AAQAZNwFJMLm0nQAAAABJRU5ErkJggg=="; interface SkeletonImage { src: string; width: number; height: number; } const SkeletonImage = ({ src, width, height }: SkeletonImage) => { const [loading, setLoading] = useState(true); return ( <div className="relative"> {loading && ( <Skeleton> <div className="absolute bg-gray-200 rounded-lg" style={{ width: `${width}px`, height: `${height}px` }} ></div> </Skeleton> )} <Image src={src} alt="img" width={width} height={height} placeholder="blur" blurDataURL={blurDataUrl} onLoadingComplete={() => setLoading(false)} className="rounded-md w-28 h-16 object-cover" /> </div> ); }; export default SkeletonImage;
// PostCard.tsx import React from "react"; import Link from "next/link"; import { Post } from "pages/blog"; import { parsePostTime } from "src/utils/parseTime"; import SkeletonImage from "./SkeletonImage"; interface PostCardInterface { key: number; post: Post; children?: React.ReactNode; } const PostCard = (props: PostCardInterface) => { const { post } = props; const postTitle = post.properties.Name.title[0].plain_text; const postCreatedTime = post.created_time; const postImgUrl = post?.cover?.external?.url || post?.cover?.file?.url; // const tags = post?.properties.Tags.multi_select.map((tag) => tag.name); return ( <Link href={`/posts/${post.id}`}> <div className="p-4 rounded-lg cursor-pointer flex flex-row gap-4 hover:bg-gray-500 hover:bg-opacity-10"> <div className="flex-1"> <h1 className="font-medium">{postTitle}</h1> <p className="text-gray-400">{parsePostTime(postCreatedTime)}</p> {/* <p className="text-gray-500 text-xs">{tags[0]}</p> */} </div> {postImgUrl && ( <SkeletonImage src={postImgUrl} width={160 * 0.8} height={90 * 0.8} /> )} </div> </Link> ); }; export default PostCard;
4. HOC로 만들기
다시 생각해보니 이미지 뿐 아니라 여러 컴포넌트에 적용할 수 있는
withSkeleton
이라는 HOC로 만들어주면 재사용성을 올릴 수 있을 것 같았다.포스트의 썸네일 이미지 뿐 아니라 Title과 description에도 적용할 수 있도록 하고 싶었다.
// Skeleton.tsx import React, { useState, ReactNode } from "react"; import styled, { keyframes } from "styled-components"; const gradientEffect = keyframes` 50% { opacity: 0.5; } `; const StyledSkeleton = styled.div` & > * { transition: 1s; background-color: rgba(229, 231, 235); animation: 2s ${gradientEffect} infinite; } `; const Skeleton = (props: { children: ReactNode }) => { return <StyledSkeleton>{props.children}</StyledSkeleton>; }; interface WithSkeletonInterface { width?: number; height?: number; className?: string; onLoad?: () => void; } // HOC to wrap the component with a skeleton export const withSkeleton = <T extends WithSkeletonInterface>( WrappedComponent: React.ComponentType<T>, ): React.ComponentType<T> => { return (props: T) => { const width: number = props.width || 200; const height: number = props.height || 20; const [loading, setLoading] = useState(true); return ( <div className={props.className}> {loading && ( <Skeleton> <div className="absolute bg-gray-200 rounded-lg" style={{ width: `${width}px`, height: `${height}px` }} ></div> </Skeleton> )} <WrappedComponent {...props} onLoad={() => setLoading(false)} /> </div> ); }; };
// PostCard.tsx import React from "react"; import Link from "next/link"; import Image from "next/image"; import { Post } from "pages/blog"; import { parsePostTime } from "src/utils/parseTime"; import { withSkeleton } from "./Skeleton"; const PostText = ({ content, className, width, onLoad, }: { content: string; className?: string; width?: number; onLoad?: () => void; }) => { useEffect(() => { if (onLoad) onLoad(); }, [content]); return <p className={className}>{content}</p>; }; const PostImage = ({ src, width, height, onLoad, }: { src: string; width: number; height: number; onLoad?: () => void; }) => { const blurDataUrl = "iVBORw0KGgoAAAANSUhEUgAAAAoAAAABCAQAAABN/Pf1AAAADUlEQVR42mM8Uc+AAQAZNwFJMLm0nQAAAABJRU5ErkJggg=="; return ( <Image src={src} alt="img" width={width} height={height} placeholder="blur" blurDataURL={blurDataUrl} className="rounded-md w-28 h-16 object-cover" onLoadingComplete={onLoad} /> ); }; const PostTextWithSkeleton = withSkeleton(PostText); const PostImageWithSkeleton = withSkeleton(PostImage); interface PostCardInterface { key: number; post: Post; children?: React.ReactNode; } const PostCard = (props: PostCardInterface) => { const { post } = props; const postTitle = post.properties.Name.title[0].plain_text; const postCreatedTime = post.created_time; const postImgUrl = post?.cover?.external?.url || post?.cover?.file?.url; return ( <Link href={`/posts/${post.id}`}> <div className="p-4 rounded-lg cursor-pointer flex flex-row gap-4 hover:bg-gray-500 hover:bg-opacity-10"> <div className="flex-1"> <PostTextWithSkeleton content={postTitle} className="font-medium" width={250} /> <PostTextWithSkeleton content={parsePostTime(postCreatedTime)} className="text-gray-400" width={100} /> </div> {postImgUrl && ( <PostImageWithSkeleton src={postImgUrl} width={160 * 0.8} height={90 * 0.8} /> )} </div> </Link> ); }; export default PostCard;
HOC 컴포넌트에 TypeScript의 제너릭을 쓰려다보니 마지막에 조금 헤맸지만, 위와 같이 HOC를 구현할 수 있었다.

포스트의 Title과 created time은 텍스트로 로딩이 매우 빨라 크롬 성능측정으로 스냅샷을 확인해보니 withSkeleton HOC로 감싸준 컴포넌트(PostInfoWithSkeleton)가 잘 작동하는 것을 확인할 수 있었다.

참고) 0이 아닌 0.001이 나온 이유는 next/image의
Image
컴포넌트가 explicit width와 height을 내보내지 않았고, Lighthouse 9.6.2버전에서 이를 제대로 측정하지 못하는 오류가 있었던 것으로 보인다. 현재 기준 Chrome에서는 여전히 9.6.2 버전의 Lighthouse를 사용하기 때문에 업데이트 전까지는 수정된 버전으로 측정이 불가능하다. 때문에 나는 Lighthouse Cli를 Node로 설치해서 측정해보니 CLS가 0으로 잘 뜨는 것을 확인할 수 있었다.

‘사용자 중심의 디자인’을 기술적으로 구현하는 프론트엔드 개발자로써 이런 지표를 알고 개선하기 위해 노력하는 자세는 중요하다.
나의 생각 🤔
- 컴포넌트간의 결합도와 컴포넌트의 응집도가 적절한가?
- Text, Image 컴포넌트 말고 다른 컴포넌트가 들어왔을 때 withSkeleton HOC에 적용가능할까?
- 크기가 정해지지 않은 동적 컨텐츠도 현재 Skeleton HOC로 잘 작동하는 것을 보장할 수 있을까?
- width, height을 명시하지 않고도 HOC로 감쌀 수 있을까? Child Skeleton Skeleton은 반응형 웹페이지와 같은 width, height 속성이 static하지 않고
“100%”
,“auto”
와 같은 형태로 주어져도 제대로 작동하는가?
- (이 글과 별개의 내용이지만) Vercel의 Free plan을 사용해서 배포해서 그런지 오래 접속하지 않은 페이지에 접속 시 이미지 요청을 Vercel 서버로 보낼 때
504 error
가 뜨고, 3~4번 새로고침하면 제대로 뜬다. 이 경우에도 Skeleton 처리를 하고 재요청을 보내도록 하는 컴포넌트를 만드는 것은 어떨까?