WEGO ) Skeleton UI를 활용한 웹 성능 및 사용자 경험 최적화

웹 애플리케이션에서 데이터 로딩은 피할 수 없는 과정입니다. 특히 네트워크 상태가 불안정하거나 대량의 데이터를 처리해야 하는 경우, 사용자는 빈 화면이나 로딩 스피너를 보며 기다려야 합니다. 이러한 대기 시간은 사용자 경험을 저하시키는 주요 요인이 됩니다. 이 문제를 해결하기 위해 저희는 스켈레톤 UI를 구현하여 적용했습니다.

▪︎ Skeleton UI 구현 과정

▫︎ 기본 스타일 설정

먼저 Tailwind 설정 파일에서 스켈레톤 UI의 기본 스타일을 정의했습니다. 스켈레톤 UI의 핵심은 로딩 상태를 시각적으로 표현하는 애니메이션입니다. 이를 위해 그라데이션 효과와 움직임을 결합했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// tailwind.config.ts

addUtilities({
".skeleton-style": {
background: "linear-gradient(90deg, #E5E7EB 35%, #F3F4F6 50%, #E5E7EB 65%)",
backgroundSize: "300% auto",
animation: "skeleton-loading 1.5s ease-in-out infinite",
},
"@keyframes skeleton-loading": {
"0%": { backgroundPosition: "100% 0" },
"100%": { backgroundPosition: "0 0" },
},
});

이 설정은 세 가지 주요 요소로 구성됩니다.

  • 그라데이션 배경: 밝은 회색(#F3F4F6)에서 어두운 회색(#E5E7EB)으로 자연스럽게 변화
  • 배경 크기: 실제 요소보다 3배 큰 배경을 설정하여 부드러운 이동 효과 구현
  • 애니메이션: 1.5초 동안 배경이 좌우로 움직이며 무한 반복

▫︎ 재사용 가능한 스켈레톤 컴포넌트

1
2
3
4
5
// Skeleton.tsx

const Skeleton = ({ className, classNameCondition }: Props) => {
return <div className={cn("skeleton-style", className, classNameCondition)} />;
};

이 컴포넌트는 단순하지만 유연합니다. className prop을 통해 각 사용처에 맞는 크기와 스타일을 적용할 수 있으며, classNameCondition을 통해 조건부 스타일링도 가능합니다.

▫︎ 실제 적용 (여행 카드 스켈레톤)

마이페이지에서 MyTravelCardSkeleton 을 적용한 예시입니다. 실제 데이터가 로드되기 전의 UI를 표현합니다.

컴포넌트에 적용할 Skeleton UI 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// MyTravelCardSkeleton.tsx

const MyTravelCardSkeleton = () => {
return (
<div className="w-full max-w-[335px] pb-10 md:max-w-[688px] xl:max-w-[1400px]">
<div className="grid w-full gap-5 xl:grid-cols-2 xl:gap-6">
{Array.from({ length: 6 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={`skeleton-${index}`}>
<div className="relative flex max-w-[335px] gap-4 md:max-w-[688px] md:gap-9">
<Skeleton className="h-[120px] w-[100px] flex-shrink-0 rounded md:h-[160px] md:w-[223px]" />
<div className="relative flex w-full flex-col justify-between">
<div className="flex flex-col gap-1">
<Skeleton className="h-5 w-12 rounded-[20px]" />
<Skeleton className="h-6 w-full md:h-[26px]" />
</div>
<div className="flex flex-col gap-2.5">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</div>
</div>
<Skeleton className="mt-5 h-[1px] w-full max-w-[1400px]" />
</div>
))}
</div>
</div>
);
};

마이페이지 여행 카드에 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const Upcomming = () => {
const itemsPerPage = 6;
const [currentPage, setCurrentPage] = useState(1);
const { data: travels, isLoading } = useUpcommingTravel(itemsPerPage, currentPage - 1);
const totalPages = travels ? Math.ceil(travels.data.total / itemsPerPage) : 0;

const paginate = (pageNumber: number) => setCurrentPage(pageNumber);

if (isLoading) return <MyTravelCardSkeleton />;

return (
<section className="w-full max-w-[335px] pb-10 md:max-w-[688px] xl:max-w-[1400px]" data-testid="upcomming-travels">
{travels && travels.data.total > 0 ? (
<div className="grid w-full gap-5 xl:grid-cols-2 xl:gap-6">
{travels.data?.content.map((travel: TravelCardProps) => (
<div key={travel.travelId}>
<TravelCard
key={travel.travelId}
travelId={travel.travelId}
travelImage={travel.travelImage}
isDomestic={travel.isDomestic}
travelName={travel.travelName}
travelLocation={travel.travelLocation}
maxTravelMateCount={travel.maxTravelMateCount}
currentTravelMateCount={travel.currentTravelMateCount}
startAt={travel.startAt}
endAt={travel.endAt}
bookmarkFlag={travel.bookmarkFlag}
formattedStartDate={checkTomorrow(travel.startAt)}
/>
<HorizontalDivider className="mt-5 xl:mt-6" />
</div>
))}
</div>
) : (
<NoTravel message="아직 참여한 여행이 없어요!" />
)}

<Pagination totalPages={totalPages} currentPage={currentPage} paginate={paginate} />
</section>
);
};

이 컴포넌트는 실제 여행 카드 리스트와 동일한 레이아웃을 가지며, 반응형 디자인을 지원합니다. 6개의 더미 카드를 생성하여 데이터 로딩 중에도 페이지의 구조를 명확히 보여줍니다.

▫︎ 완성된 Skeleton UI

image.png

▪︎ 효과

스켈레톤 UI 구현은 Core Web Vitals를 포함한 주요 웹 성능 메트릭스의 개선에 큰 영향을 미쳤습니다. 성능의 개선은 사용자 경험 향상으로도 이어집니다.

▫︎ FCP(First Contentful Paint) 최적화

스켈레톤 UI는 FCP 개선에도 기여합니다. 페이지의 첫 번째 의미 있는 콘텐츠가 그려지는 시점이 빨라지기 때문입니다.

  • 즉각적인 시각적 피드백: 스켈레톤 UI는 실제 콘텐츠보다 훨씬 가벼워, 페이지 진입 시 즉시 렌더링됩니다.
  • 점진적 로딩: 메인 콘텐츠가 로드되는 동안 의미 있는 UI를 제공하여 체감 LCP를 개선합니다.
  • 레이아웃 안정성: CLS(Cumulative Layout Shift) 또한 개선되어, 콘텐츠 로드 시 레이아웃 변화를 최소화합니다.

Chrome Dev Tools의 성능 탭에서 같은 조건으로 확인해봤을 때 다음과 같은 차이가 있었습니다. 생각보다 큰 차이가 있어 놀랐습니다.

1
2
3
4
5
[스켈레톤 UI 적용 전]
FCP: 559.8ms

[스켈레톤 UI 적용 후]
FCP: 119ms (약 78% 개선)

이러한 성능 개선은 특히 모바일 사용자와 불안정한 네트워크 환경에서 더욱 큰 효과를 발휘합니다. Google의 Core Web Vitals 지표 개선은 검색 엔진 최적화(SEO)에도 긍정적인 영향을 미치며, 이는 결과적으로 웹사이트의 가시성 향상으로 이어집니다.

▪︎ 마치며

스켈레톤 UI 구현을 통해 다음과 같은 효과를 얻을 수 있었습니다:

  1. 인지된 성능 향상
    • 사용자는 빈 화면이나 로딩 스피너 대신 콘텐츠의 구조를 즉시 확인할 수 있습니다.
    • 이는 실제 로딩 시간이 동일하더라도 더 빠르게 느껴지는 효과를 줍니다.
  2. 자연스러운 전환
    • 스켈레톤에서 실제 콘텐츠로의 전환이 부드럽게 이루어집니다.
    • 레이아웃 시프트가 최소화되어 더 안정적인 사용자 경험을 제공합니다.
  3. 프로그레시브 로딩
    • 페이지의 구조가 먼저 로드되어 사용자가 전체적인 레이아웃을 파악할 수 있습니다.
    • 이는 특히 모바일 환경에서 중요한 이점을 제공합니다.
  4. 반응형 디자인 지원
    • 스켈레톤 UI도 실제 컴포넌트와 동일한 반응형 규칙을 따릅니다.
    • 모든 화면 크기에서 일관된 로딩 경험을 제공합니다.

이러한 스켈레톤 UI 구현은 단순한 시각적 개선을 넘어 전반적인 사용자 경험 향상에 기여했습니다.

특히 네트워크 상태가 불안정한 모바일 환경에서 더욱 효과적으로 작동하며, 사용자의 이탈률을 줄이는 데 도움이 될 것이라 생각합니다.