웹 애플리케이션에서 데이터 로딩은 피할 수 없는 과정입니다. 특히 네트워크 상태가 불안정하거나 대량의 데이터를 처리해야 하는 경우, 사용자는 빈 화면이나 로딩 스피너를 보며 기다려야 합니다. 이러한 대기 시간은 사용자 경험을 저하시키는 주요 요인이 됩니다. 이 문제를 해결하기 위해 저희는 스켈레톤 UI를 구현하여 적용했습니다.
▪︎ Skeleton UI 구현 과정 ▫︎ 기본 스타일 설정 먼저 Tailwind 설정 파일에서 스켈레톤 UI의 기본 스타일을 정의했습니다. 스켈레톤 UI의 핵심은 로딩 상태를 시각적으로 표현하는 애니메이션입니다. 이를 위해 그라데이션 효과와 움직임을 결합했습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 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 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 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
▪︎ 효과 스켈레톤 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 구현을 통해 다음과 같은 효과를 얻을 수 있었습니다:
인지된 성능 향상
사용자는 빈 화면이나 로딩 스피너 대신 콘텐츠의 구조를 즉시 확인할 수 있습니다.
이는 실제 로딩 시간이 동일하더라도 더 빠르게 느껴지는 효과를 줍니다.
자연스러운 전환
스켈레톤에서 실제 콘텐츠로의 전환이 부드럽게 이루어집니다.
레이아웃 시프트가 최소화되어 더 안정적인 사용자 경험을 제공합니다.
프로그레시브 로딩
페이지의 구조가 먼저 로드되어 사용자가 전체적인 레이아웃을 파악할 수 있습니다.
이는 특히 모바일 환경에서 중요한 이점을 제공합니다.
반응형 디자인 지원
스켈레톤 UI도 실제 컴포넌트와 동일한 반응형 규칙을 따릅니다.
모든 화면 크기에서 일관된 로딩 경험을 제공합니다.
이러한 스켈레톤 UI 구현은 단순한 시각적 개선을 넘어 전반적인 사용자 경험 향상에 기여했습니다.
특히 네트워크 상태가 불안정한 모바일 환경에서 더욱 효과적으로 작동하며, 사용자의 이탈률을 줄이는 데 도움이 될 것이라 생각합니다.