-
Notifications
You must be signed in to change notification settings - Fork 0
Skeleton UI 시스템 구축 및 전역 적용 (로딩 UX 개선)
본 문서에서는 서비스 전반의 로딩 경험을 개선하기 위해 Skeleton UI 시스템을 설계하고 전역에 적용한 과정을 정리합니다. 기존의 텍스트 기반 로딩 메시지나 스피너가 가진 한계를 분석하고, 실제 컴포넌트 구조와 동일한 Skeleton UI를 통해 시각적 안정성과 사용자 경험을 향상시킨 방법을 다룹니다.
- Skeleton UI 시스템 개요 및 도입 배경
- 기존 로딩 방식의 문제점 분석
- Skeleton UI 설계 원칙 및 Base 컴포넌트 구축
- 500ms 최소 로딩 시간 패턴 도입
- 페이지별 적용 사례
- 5-1. Memory/검색 페이지: 텍스트 로딩의 깜빡임 제거
- 5-2. 모임 검색/멤버 페이지: 구조 예측 가능성 확보
- 5-3. 팔로워 목록: 무한 스크롤 성능 최적화
- 폴더 구조 체계화 및 재사용성 확보
- 전체 적용 결과 및 기대 효과
현대 웹 애플리케이션에서 로딩 상태는 사용자 경험의 핵심 요소 중 하나입니다. 특히 비동기 데이터 페칭이 빈번한 독서 모임 서비스에서는 책 검색, 피드 로딩, 모임 목록 조회 등 다양한 시점에 로딩 상태가 발생합니다. 기존에는 "로딩 중...", "검색 중..." 같은 텍스트 메시지나 회전하는 스피너(LoadingSpinner)를 사용했지만, 이러한 방식은 다음과 같은 한계를 가지고 있었습니다:
- 레이아웃 예측 불가: 로딩이 완료되기 전까지 실제 콘텐츠의 구조를 전혀 파악할 수 없어, 데이터가 로드되는 순간 화면이 급격하게 변하는 레이아웃 점프(Layout Shift)가 발생했습니다.
- 시각적 불안정성: API 응답이 빠를 경우(200~300ms) 로딩 인디케이터가 깜빡이듯 나타났다 사라져, 오히려 사용자에게 혼란을 주었습니다.
- 일관성 부족: 페이지마다 다른 로딩 방식(텍스트/스피너/빈 화면)을 사용하여 서비스 전체의 UX 일관성이 떨어졌습니다.
이러한 문제를 해결하기 위해 Skeleton UI 시스템을 도입하기로 결정했습니다. Skeleton UI는 실제 콘텐츠가 로드되기 전에 콘텐츠의 형태를 미리 보여주는 플레이스홀더로, Facebook, LinkedIn, YouTube 등 주요 서비스에서 이미 검증된 UX 패턴입니다.
발생 페이지: Memory, GroupSearch, GroupMembers 등
기존에는 데이터를 불러오는 동안 단순히 "로딩 중..." 또는 "검색 중..." 텍스트만 표시했습니다. 이는 다음과 같은 문제를 야기했습니다:
- 사용자는 어떤 형태의 콘텐츠가 나올지 전혀 예측할 수 없어, 불필요한 인지 부하가 발생했습니다.
- 텍스트가 화면 중앙에 고립되어 있어, 전체 페이지의 맥락과 단절된 느낌을 주었습니다.
- 다크 테마 배경에 흰색 텍스트만 덩그러니 있는 형태가 시각적으로 빈약했습니다.
발생 페이지: SearchBook, FollowerListPage 등
LoadingSpinner 컴포넌트는 회전하는 애니메이션으로 로딩 중임을 명확히 알려주지만, 중요한 문제가 있었습니다:
- 스피너는 보통 화면 중앙에 작은 크기로 렌더링되는 반면, 실제 콘텐츠(책 상세 정보, 사용자 목록 등)는 훨씬 큰 영역을 차지합니다.
- 데이터가 로드되는 순간, 작은 스피너가 사라지고 큰 콘텐츠 영역이 갑자기 나타나면서 급격한 레이아웃 변화가 발생했습니다.
- 이러한 레이아웃 점프는 사용자가 다른 UI 요소(예: 뒤로가기 버튼)를 클릭하려는 순간 발생하면, 잘못된 위치를 터치하게 만드는 UX 사고로 이어질 수 있었습니다.
발생 현상: 전체 페이지
네트워크 환경이 좋거나 캐시된 데이터를 불러올 때 API 응답이 200~300ms 내로 돌아오는 경우가 빈번했습니다. 이때 다음과 같은 시퀀스가 발생했습니다:
- 페이지 진입 → 로딩 인디케이터 표시 (0ms)
- API 응답 도착 → 로딩 인디케이터 제거, 실제 콘텐츠 표시 (250ms)
이 250ms는 사람의 눈에 명확히 인지되기에는 짧지만, 무언가 "깜빡였다"는 느낌을 주기에는 충분한 시간이었습니다. 사용자는 화면이 불안정하다고 느끼게 되고, 심한 경우 앱이 버벅거린다는 인상을 받을 수 있었습니다.
Skeleton UI 시스템을 설계하면서 다음 원칙들을 수립했습니다:
- 구조적 일치성 (Structural Fidelity): Skeleton은 실제 콘텐츠와 정확히 동일한 레이아웃, 크기, 간격을 가져야 합니다. 이를 위해 원본 컴포넌트의 styled-components를 최대한 재사용하는 전략을 채택했습니다.
- 재사용성 (Reusability): 기본 요소(박스, 원형, 텍스트)를 조합하여 다양한 Skeleton을 만들 수 있도록, Base Skeleton 컴포넌트를 먼저 구축했습니다.
- 카테고리별 분리 (Separation of Concerns): 피드, 모임, 검색 등 도메인별로 폴더를 나누어 관리하여, 특정 영역의 Skeleton을 찾고 수정하기 쉽게 만들었습니다.
- Props 기반 유연성 (Props-driven Flexibility): 같은 Skeleton이라도 상황에 따라 다르게 표시될 수 있도록(예: 모임 카드는 메인/검색/모달 타입), Props를 통해 제어 가능하게 설계했습니다.
src/shared/ui/Skeleton/base/Skeleton.tsx에 3가지 기본 컴포넌트를 구축했습니다:
1. Skeleton.Box
사각형 모양의 스켈레톤입니다. 책 커버, 버튼, 이미지 등 다양한 박스형 요소를 표현할 때 사용합니다.
- Props:
width,height,borderRadius - 용도: 책 표지(80x107px), 버튼 영역, 카드 이미지 등
2. Skeleton.Circle
원형 스켈레톤으로, 주로 프로필 이미지를 표현할 때 사용합니다.
- Props:
width(동일한 값이 height에도 적용됨) - 용도: 사용자 프로필 이미지(36px), 아바타 등
- 특징:
styleprop을 직접 받지 않으므로, margin 등이 필요한 경우 div로 감싸서 사용합니다.
3. Skeleton.Text
텍스트 라인을 표현하는 스켈레톤입니다. 단일 라인뿐 아니라 여러 줄의 텍스트도 표현할 수 있습니다.
- Props:
width,height,lines(다중 라인),gap(라인 간 간격),lastLineWidth(마지막 라인 너비) - 용도: 제목, 설명, 닉네임, 날짜 등 모든 텍스트 요소
- 특징:
lines={2}와 같이 설정하면 2줄의 텍스트를 자동으로 생성하며, 마지막 줄은lastLineWidth로 너비를 조절하여 자연스러운 텍스트 형태를 만듭니다.
이 3가지 기본 요소를 조합하여 21개의 도메인별 Skeleton 컴포넌트를 구축했습니다.
앞서 언급한 "깜빡임" 문제를 해결하기 위해, 단순히 Skeleton UI를 적용하는 것만으로는 부족하다는 것을 인식했습니다. API 응답이 너무 빠르면 Skeleton이 짧게 나타났다 사라지면서 여전히 시각적 불안정성이 발생할 수 있었습니다.
모든 API 호출에 최소 500ms의 로딩 시간을 보장하는 패턴을 도입했습니다. 이는 다음과 같은 이유에서 결정되었습니다:
- 인간의 시각 인지: 인간의 눈은 약 100ms 이하의 변화는 거의 인지하지 못하지만, 200~400ms의 변화는 "깜빡임"으로 인식합니다. 500ms는 사용자가 "로딩 중이구나"를 명확히 인지할 수 있는 최소 시간입니다.
- Skeleton의 의미 전달: Skeleton UI는 단순히 빈 공간을 채우는 것이 아니라, "이런 형태의 콘텐츠가 곧 나올 것"이라는 정보를 전달하는 역할을 합니다. 이를 위해서는 사용자가 Skeleton의 구조를 파악할 수 있을 만큼 충분한 시간이 필요합니다.
- 일관된 경험: 네트워크 상태에 따라 로딩 시간이 들쭉날쭉하면 사용자는 앱의 성능이 불안정하다고 느낄 수 있습니다. 최소 시간을 보장함으로써 일관된 로딩 경험을 제공합니다.
const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500));
const [response] = await Promise.all([
apiCall(),
minLoadingTime,
]);
Promise.all을 사용하여 API 호출과 500ms 타이머를 동시에 실행합니다. 두 Promise 중 느린 것의 완료를 기다리므로:
- API가 300ms에 완료되면 → 500ms까지 대기 후 Skeleton 제거
- API가 800ms에 완료되면 → 800ms에 즉시 Skeleton 제거 (불필요한 지연 없음)
이 패턴은 Memory, SearchBook, Search, GroupSearch, GroupMembers, FollowerListPage 등 거의 모든 페이지의 초기 로딩에 적용되었습니다.
단, 무한 스크롤과 같이 추가 데이터를 로드하는 경우에는 최소 로딩 시간을 적용하지 않았습니다. 이미 화면에 콘텐츠가 있는 상태에서 추가 데이터를 불러올 때 인위적으로 지연시키면 오히려 사용자가 답답함을 느낄 수 있기 때문입니다.
// FollowerListPage 예시
const minLoadingTime = !cursor ? new Promise(resolve => setTimeout(resolve, 500)) : null;
cursor가 없으면 초기 로딩이므로 500ms 적용, cursor가 있으면 추가 로딩이므로 즉시 표시합니다.
Memory 페이지는 사용자의 독서 기록을 보여주는 핵심 페이지입니다. 기존에는 기록을 불러오는 동안 화면 중앙에 "로딩 중..." 텍스트만 표시되었습니다.
책 검색 페이지(Search, SearchBook)에서도 마찬가지로 검색어를 입력하고 Enter를 누르면 "검색 중..." 텍스트가 나타났다가, 결과가 로드되면 책 목록이 갑자기 화면에 펼쳐졌습니다.
이러한 방식은 다음과 같은 UX 문제를 발생시켰습니다:
- 콘텐츠 구조 예측 불가: 사용자는 어떤 형태의 기록이나 검색 결과가 나올지 전혀 알 수 없었습니다. 리스트 형태인지, 카드 형태인지, 얼마나 많은 항목이 있는지 등의 정보가 전무했습니다.
- 레이아웃 충격: 텍스트가 사라지고 실제 콘텐츠가 나타나는 순간, 화면 전체가 급격하게 재구성되며 사용자의 시선을 혼란스럽게 만들었습니다.
- 빈약한 시각적 피드백: 특히 다크 테마에서 흰색 텍스트 한 줄만 덩그러니 있는 모습이 앱이 멈춘 것처럼 보이기도 했습니다.
각 페이지의 실제 콘텐츠 구조와 정확히 일치하는 Skeleton 컴포넌트를 개발했습니다:
RecordItemSkeleton (Memory 페이지)
- 프로필 이미지(36px 원형), 닉네임(80px), 역할 뱃지(60px)
- 페이지 범위 정보("p.23 ~ p.45" 형태)
- 기록 내용(2줄 텍스트, 마지막 줄은 60% 너비)
- 날짜 및 좋아요 수 영역
- 실제 RecordItem과 동일한 padding, margin, gap 적용
BookItemSkeleton (Search 페이지)
- 책 커버 이미지(80x107px)
- 책 제목(2줄, 마지막 줄 60%)
- 저자 및 출판사 정보(12px 높이, marginTop 8px)
- 기존 BookSearchResult.styled.ts의 styled-components 재사용
BookDetailSkeleton (SearchBook 페이지)
- BannerSection, BookInfo, Intro, ButtonSection 등 원본 스타일 재사용
- 책 제목(200px), 저자명(150px), 소개(2줄) 등 실제 데이터 영역과 동일한 크기
MostSearchedBooksSkeleton, RecentSearchTabsSkeleton (Search 페이지)
- 헤더(제목 + 날짜), 도서 5개 항목 구조 유지
- 최근 검색어는 탭 형태(width 80px, height 40px, borderRadius 20px)로 표현
모든 Skeleton에 500ms 최소 로딩 시간을 적용하여, API가 빠르게 응답하더라도 사용자가 페이지 구조를 파악할 수 있도록 했습니다.
개선 전 (Before)
- 페이지 진입 → "로딩 중..." 텍스트 → (250ms 후) 갑자기 기록/검색 결과 나타남
- 레이아웃 점프 발생, 깜빡임 현상
개선 후 (After)
- 페이지 진입 → 실제 구조와 동일한 Skeleton 표시 → (최소 500ms 후) 부드럽게 실제 데이터로 전환
- 레이아웃 안정적, 구조 예측 가능
// src/pages/memory/Memory.tsx
const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500));
const [response] = await Promise.all([
getMemoryRecords(roomId),
minLoadingTime,
]);
// 렌더링
{isLoading ? (
<RecordList>
{Array.from({ length: 5 }).map((_, i) => (
<RecordItemSkeleton key={i} />
))}
</RecordList>
) : (
<RecordList>
{records.map(record => <RecordItem {...record} />)}
</RecordList>
)}
// src/shared/ui/Skeleton/memory/RecordItemSkeleton.tsx
const RecordItemSkeleton = () => {
return (
<Container>
<UserSection>
<div style={{ marginRight: '8px' }}>
<Skeleton.Circle width={36} />
</div>
<UserInfo>
<Skeleton.Text width={80} height={14} />
<Skeleton.Text width={60} height={12} />
</UserInfo>
<Skeleton.Text width={60} height={12} />
</UserSection>
<PageRange as="div">
<Skeleton.Text width={80} height={12} />
</PageRange>
<Content as="div">
<Skeleton.Text lines={2} height={14} gap={8} lastLineWidth="60%" />
</Content>
<Footer>
<Skeleton.Text width={100} height={12} />
<Skeleton.Text width={60} height={12} />
</Footer>
</Container>
);
};
GroupSearch 페이지에서 모임을 검색하거나, GroupMembers 페이지에서 독서메이트 목록을 불러올 때, 기존에는 "검색 중...", "로딩 중..." 같은 단순 텍스트만 표시되었습니다. 특히 GroupSearch는 다음과 같은 복잡한 UI 요소를 포함하고 있었습니다:
- 검색 결과 카드(책 표지 + 모임 제목 + 참여 인원 + 마감일)
- 필터 탭(마감임박순/인기순)
- 카테고리 선택
- 최근 검색어 탭
이처럼 정보 밀도가 높은 페이지에서 단순 텍스트 로딩을 사용하면, 실제 콘텐츠가 로드되었을 때 사용자가 느끼는 "정보의 폭발" 효과가 더욱 강했습니다. 갑자기 많은 정보가 화면에 쏟아지면서 어디를 봐야 할지 순간적으로 혼란스러웠습니다. GroupMembers 페이지도 마찬가지로, 멤버 리스트가 프로필 이미지, 닉네임, 역할, 팔로워 수 등 여러 정보를 담고 있어, 갑작스러운 레이아웃 변화가 사용자 경험을 저해했습니다.
모임 및 멤버 관련 Skeleton 컴포넌트를 구축하여 실제 UI 구조를 미리 보여주었습니다:
GroupCardSkeleton
-
typeprop: 'main', 'search', 'modal' 세 가지 타입 지원 -
isRecommendprop: 추천 모임 여부에 따라 크기 조정 - 책 표지 크기: search/recommend(60x80), main(80x107)
- 제목, 참여 인원, 마감일 영역을 실제 GroupCard와 동일한 위치에 배치
- Content 컴포넌트로 감싸서 그리드 레이아웃 유지
MemberListSkeleton
- 프로필 이미지(36px), 닉네임(80px), 역할(60px)을 가로로 배치
- 팔로워 수(80px), 화살표 아이콘(24px) 오른쪽 정렬
- ProfileSection의 gap(8px)을 활용하여 간격 조절
- 5개의 MemberItem을 렌더링하여 리스트 형태 표현
이러한 Skeleton들은 원본 컴포넌트의 styled-components를 최대한 재사용하여, padding, margin, border 등이 완벽하게 일치하도록 했습니다. 특히 GroupCardSkeleton은 실제 GroupCard 컴포넌트가 가진 type별 분기 로직을 그대로 따라가, 어떤 상황에서도 정확한 구조를 보여줍니다.
개선 전 (Before)
- 검색/페이지 진입 → "검색 중..." / "로딩 중..." → 갑자기 복잡한 카드 리스트 출현
- 정보의 갑작스러운 증가로 시각적 충격
개선 후 (After)
- 검색/페이지 진입 → 카드/리스트 구조의 Skeleton 5개 표시 → 부드럽게 실제 데이터로 채워짐
- 사용자는 "5개 정도의 결과가 나올 것이고, 각 항목은 이런 구조를 가지고 있구나"를 미리 파악 가능
// src/pages/groupSearch/GroupSearch.tsx
const searchFirstPage = useCallback(async (...) => {
const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500));
const [res] = await Promise.all([
getSearchRooms(term, sortKey, undefined, isFinalized, categoryParam, isAllCategory),
minLoadingTime,
]);
// ...
}, []);
// 렌더링 - 검색 결과
{isLoading && rooms.length === 0 ? (
<Content>
{Array.from({ length: 5 }).map((_, i) => (
<GroupCardSkeleton key={i} type="search" />
))}
</Content>
) : (
<GroupSearchResult rooms={rooms} {...otherProps} />
)}
// 렌더링 - 최근 검색어
{isLoadingRecentSearches ? (
<RecentSearchTabsSkeleton />
) : (
<RecentSearchTabs recentSearches={recentSearches} {...props} />
)}
// src/shared/ui/Skeleton/group/GroupCardSkeleton.tsx
interface Props {
type?: 'main' | 'search' | 'modal';
isRecommend?: boolean;
}
const GroupCardSkeleton = ({ type = 'search', isRecommend = false }: Props) => {
const coverWidth = type === 'search' || isRecommend ? 60 : 80;
const coverHeight = type === 'search' || isRecommend ? 80 : 107;
const titleHeight = isRecommend ? 14 : 18;
return (
<Card cardType={type} style={{ cursor: 'default', pointerEvents: 'none' }}>
<CoverWrapper>
<Cover as="div" cardType={type} isRecommend={isRecommend}>
<Skeleton.Box width={coverWidth} height={coverHeight} />
</Cover>
</CoverWrapper>
<Info>
<Title as="div" isRecommend={isRecommend}>
<Skeleton.Text width="100%" height={titleHeight} />
</Title>
<Bottom>
<Participant as="div" isRecommend={isRecommend}>
<Skeleton.Text width={60} height={12} />
</Participant>
<Skeleton.Text width={80} height={12} />
</Bottom>
</Info>
</Card>
);
};
FollowerListPage는 사용자의 팔로워 또는 팔로잉 목록을 보여주는 페이지로, 무한 스크롤 기능이 구현되어 있습니다. 기존에는 다음과 같은 로딩 처리를 사용했습니다:
- 초기 로딩: 화면 전체를 차지하는 큰 LoadingSpinner (size="medium", fullHeight=true)
- 추가 로딩 (무한 스크롤): 리스트 하단에 작은 LoadingSpinner (size="small")
이 방식의 문제점:
- 초기 로딩 시 스피너가 화면 중앙에 고립되어 있어, 리스트 형태의 콘텐츠가 나올 것이라는 예측이 불가능했습니다.
- 스피너에서 리스트로 전환될 때 레이아웃이 크게 변화했습니다.
- 무한 스크롤로 추가 항목을 로드할 때는 작은 스피너만 표시되어, 이미 일관성 있는 로딩 경험을 제공하고 있었습니다.
초기 로딩에만 Skeleton UI를 적용하고, 무한 스크롤 추가 로딩에는 기존 방식을 유지하는 하이브리드 접근법을 채택했습니다:
UserProfileItemSkeleton
-
typeprop: 'followerlist' 또는 'followlist'에 따라 오른쪽 버튼 영역이 다르게 표시됩니다.- followerlist: "N명이 띱하는 중" 텍스트 + 화살표 아이콘
- followlist: "띱 하기" / "띱 취소" 버튼
- userInfo 영역: 프로필 이미지(36px) + 닉네임(80px) + 별칭(60px)
- UserProfile의 flex justify-between 레이아웃 그대로 유지
- Wrapper의 높이(78px), padding(20px 0), border-bottom 등 모든 스타일 일치
성능 최적화: 조건부 500ms 적용
const minLoadingTime = !cursor ? new Promise(resolve => setTimeout(resolve, 500)) : null;
-
cursor가 없으면 초기 로딩 → 500ms 보장 + Skeleton 표시 -
cursor가 있으면 추가 로딩 → 즉시 처리 + 작은 스피너
이 접근법의 장점:
- 초기 진입 UX 개선: 사용자가 처음 페이지에 들어왔을 때 리스트 구조를 미리 파악할 수 있습니다.
- 스크롤 성능 유지: 이미 콘텐츠가 있는 상태에서의 추가 로딩은 인위적인 지연 없이 빠르게 처리되어, 무한 스크롤 경험이 끊기지 않습니다.
- 일관성과 성능의 균형: 의미 있는 곳(초기 로딩)에만 Skeleton을 사용하고, 불필요한 곳(추가 로딩)에는 적용하지 않아 리소스를 절약합니다.
개선 전 (Before)
- 페이지 진입 → 큰 스피너 (화면 중앙) → 갑자기 유저 리스트 출현
- 레이아웃 점프, 리스트 구조 예측 불가
개선 후 (After)
- 페이지 진입 → 5개의 UserProfileItemSkeleton → 부드럽게 실제 유저 정보로 채워짐
- 스크롤 시 추가 로딩 → 즉시 표시 (지연 없음)
// src/pages/feed/FollowerListPage.tsx
const loadUserList = useCallback(async (cursor?: string) => {
if (loading) return;
try {
setLoading(true);
const minLoadingTime = !cursor
? new Promise(resolve => setTimeout(resolve, 500))
: null;
if (type === 'followerlist') {
const [data] = await Promise.all([
getFollowerList(userId, { size: 10, cursor: cursor || null }),
minLoadingTime,
]);
response = data;
} else {
const [data] = await Promise.all([
getFollowingList({ size: 10, cursor: cursor || null }),
minLoadingTime,
]);
response = data;
}
// ...
}
}, [type, userId, loading]);
// 렌더링
{loading && userList.length === 0 ? (
<UserProfileList>
{Array.from({ length: 5 }).map((_, i) => (
<UserProfileItemSkeleton key={i} type={type as UserProfileType} />
))}
</UserProfileList>
) : (
<UserProfileList>
{userList.map(user => <UserProfileItem {...user} />)}
{loading && userList.length > 0 && (
<div style={{ padding: '16px 0' }}>
<LoadingSpinner size="small" />
</div>
)}
</UserProfileList>
)}
Skeleton UI가 많아질수록 관리의 복잡성도 증가합니다. 21개의 Skeleton 컴포넌트를 효율적으로 관리하고, 새로운 Skeleton을 추가할 때 어디에 배치해야 할지 명확한 기준을 세우기 위해 도메인 기반 폴더 구조를 채택했습니다.
src/shared/ui/Skeleton/
├── base/ # 기본 Skeleton 컴포넌트 (Box, Circle, Text)
│ └── Skeleton.tsx
│
├── feed/ # 피드 관련 Skeleton (5개)
│ ├── FeedPostSkeleton.tsx
│ ├── ProfileSkeleton.tsx
│ ├── OtherFeedSkeleton.tsx
│ ├── TotalBarSkeleton.tsx
│ ├── UserProfileItemSkeleton.tsx
│ └── index.ts
│
├── group/ # 모임 관련 Skeleton (8개)
│ ├── GroupDetailSkeleton.tsx
│ ├── BannerSkeleton.tsx
│ ├── BookSkeleton.tsx
│ ├── GroupBookSectionSkeleton.tsx
│ ├── RecordSectionSkeleton.tsx
│ ├── CommentSectionSkeleton.tsx
│ ├── HotTopicSectionSkeleton.tsx
│ ├── GroupCardSkeleton.tsx
│ └── index.ts
│
├── memory/ # 메모리/기록 관련 Skeleton (1개)
│ ├── RecordItemSkeleton.tsx
│ └── index.ts
│
├── search/ # 책 검색 관련 Skeleton (2개)
│ ├── MostSearchedBooksSkeleton.tsx
│ ├── RecentSearchTabsSkeleton.tsx
│ └── index.ts
│
├── searchBook/ # 책 상세 관련 Skeleton (2개)
│ ├── BookDetailSkeleton.tsx
│ ├── BookItemSkeleton.tsx
│ └── index.ts
│
├── members/ # 멤버 관련 Skeleton (1개)
│ ├── MemberListSkeleton.tsx
│ └── index.ts
│
├── todaywords/ # 오늘의 한마디 Skeleton (1개)
│ ├── MessageListSkeleton.tsx
│ └── index.ts
│
└── index.ts # 전체 통합 Export
각 도메인 폴더의 index.ts에서 해당 폴더의 Skeleton들을 export하고, 최상위 index.ts에서 모든 도메인의 Skeleton을 다시 export합니다.
// src/shared/ui/Skeleton/index.ts
// Base skeleton
export { default } from './base/Skeleton';
// Feed skeletons
export {
FeedPostSkeleton,
ProfileSkeleton,
OtherFeedSkeleton,
TotalBarSkeleton,
UserProfileItemSkeleton,
} from './feed';
// Group skeletons
export {
GroupDetailSkeleton,
BannerSkeleton,
BookSkeleton,
GroupBookSectionSkeleton,
RecordSectionSkeleton,
CommentSectionSkeleton,
HotTopicSectionSkeleton,
GroupCardSkeleton,
} from './group';
// ... 나머지 도메인들
이 구조의 장점:
- 명확한 책임 분리: 각 폴더는 특정 도메인의 Skeleton만 담당합니다.
-
쉬운 탐색: 피드 관련 Skeleton을 찾으려면
feed/폴더만 보면 됩니다. - 확장 용이성: 새로운 도메인(예: notification)이 추가되어도 새 폴더를 만들고 index.ts에 한 줄만 추가하면 됩니다.
-
Import 편의성: 사용하는 쪽에서는
import { GroupCardSkeleton } from '@/shared/ui/Skeleton'처럼 간단히 import 가능합니다.
Props 기반 유연성
같은 Skeleton이라도 상황에 따라 다르게 보여야 하는 경우, Props를 통해 제어합니다:
-
GroupCardSkeleton:type(main/search/modal),isRecommend(추천 모임 여부) -
UserProfileItemSkeleton:type(followerlist/followlist)
Styled-components 재사용
원본 컴포넌트의 styled-components를 import하여 재사용함으로써:
- 스타일 중복 제거
- 원본과의 완벽한 일치 보장
- 원본 스타일이 변경되면 Skeleton도 자동으로 반영됨
예시:
// RecordItemSkeleton.tsx
import {
Container,
UserSection,
UserInfo,
Content,
Footer,
} from '@/components/memory/RecordItem.styled';
총 15개 페이지에 Skeleton UI를 적용했습니다.
- 레이아웃 안정성 (Layout Stability)
- 기존: CLS (Cumulative Layout Shift) 점수 추정 0.15~0.25 (Poor)
- 개선: CLS 점수 추정 0.05 이하 (Good)
- 로딩 완료 시 레이아웃 점프가 사라져 시각적 안정성 크게 향상
- 인지된 성능 (Perceived Performance)
- 기존: API 응답 시간이 그대로 체감 성능으로 이어짐 (500ms면 500ms 대기)
- 개선: Skeleton 표시로 "무언가 일어나고 있다"는 즉각적 피드백 제공
- 사용자는 앱이 더 빠르다고 느낌 (실제 응답 시간은 동일하거나 500ms로 보장되지만)
- 일관성 (Consistency)
- 기존: 페이지별로 다른 로딩 방식 (텍스트/스피너/빈 화면)
- 개선: 모든 페이지에서 통일된 Skeleton UI 경험 제공
- 사용자는 서비스 전체가 하나의 일관된 디자인 시스템을 따른다고 인식
- 사용자 신뢰도 향상
로딩 중에도 페이지 구조를 보여줌으로써, 사용자는 "앱이 제대로 작동하고 있다"는 확신을 얻습니다. 빈 화면이나 텍스트만 보일 때는 "멈춘 건 아닐까?", "오류가 난 건 아닐까?" 같은 불안감이 들 수 있지만, Skeleton UI는 "데이터를 불러오고 있으며, 곧 이런 형태의 콘텐츠가 나올 것이다"는 명확한 메시지를 전달합니다.
- 인지 부하 감소
갑작스러운 레이아웃 변화는 사용자의 주의를 산만하게 만들고, 새로운 화면을 이해하는 데 추가적인 인지 자원을 소모하게 합니다. Skeleton UI는 점진적으로 정보를 드러냄으로써, 사용자가 화면을 파악하는 데 드는 노력을 최소화합니다.
- 전문성 인식
주요 글로벌 서비스(Facebook, LinkedIn, YouTube, Airbnb 등)에서 이미 Skeleton UI를 표준으로 사용하고 있습니다. 사용자는 이러한 패턴에 익숙하며, Skeleton UI를 사용하는 앱을 "최신 UX 트렌드를 따르는 전문적인 서비스"로 인식합니다.
- 개발 효율성 향상
21개의 재사용 가능한 Skeleton 컴포넌트 라이브러리를 구축함으로써, 향후 새로운 페이지를 추가할 때:
- 기존 Skeleton을 그대로 재사용하거나
- 기존 Skeleton을 조합하여 새 Skeleton을 빠르게 만들 수 있습니다.
- Base Skeleton (Box, Circle, Text)을 사용하면 어떤 형태의 UI도 Skeleton으로 표현 가능합니다.
- A/B 테스트를 통한 최적 로딩 시간 검증
- 현재는 경험적으로 500ms를 선택했지만, 실제 사용자 데이터를 기반으로 최적값을 찾을 수 있습니다.
- 페이지별로 다른 로딩 시간이 적합한지 테스트 가능합니다.
- 애니메이션 효과 추가
- 현재 Skeleton은 정적이지만, Shimmer 효과(반짝이는 애니메이션)를 추가하면 "로딩 중"임을 더 명확히 전달할 수 있습니다.
- 단, 애니메이션은 성능에 영향을 줄 수 있으므로 신중한 검토 필요합니다.
- Progressive Loading 전략
- 중요한 콘텐츠(Above the Fold)는 Skeleton으로 표시하고, 덜 중요한 영역은 나중에 로드하는 방식으로 체감 성능을 더욱 향상시킬 수 있습니다.
- Skeleton 개수 동적 조정
- 현재는 대부분 5개의 Skeleton을 표시하지만, 화면 크기나 예상 결과 개수에 따라 동적으로 조정할 수 있습니다.
Skeleton UI 시스템 구축 및 전역 적용을 통해, THIP 서비스는 단순히 "로딩을 표시한다"는 기능적 측면을 넘어, 사용자에게 신뢰감과 안정감을 주는 고품질 로딩 경험을 제공하게 되었습니다.
15개 페이지에 걸친 일관된 Skeleton UI 적용, 500ms 최소 로딩 시간을 통한 깜빡임 방지, 도메인별로 체계화된 폴더 구조, 그리고 21개의 재사용 가능한 컴포넌트 라이브러리는 서비스의 UX 품질을 한 단계 끌어올렸을 뿐만 아니라, 향후 개발 효율성과 유지보수성에도 크게 기여할 것입니다.
이는 단순한 "기능 추가"가 아닌, 사용자 중심의 디자인 사고와 체계적인 시스템 설계가 결합된 의미 있는 리팩토링이었습니다.
- 전체 팀 깃허브 https://github.com/THIP-TextHip
- Web 저장소 https://github.com/THIP-TextHip/THIP-Web