rendering

스켈레톤, 중복 코드 없이 만들기

실제 컴포넌트의 CSS 클래스를 재사용해 스켈레톤을 만드는 패턴. 디자인이 바뀌어도 한 번만 고치면 자동 동기화된다.

페이스북·유튜브를 켜면 콘텐츠가 뜨기 전에 회색 박스들이 잠깐 깜빡입니다. 텍스트가 들어갈 자리, 이미지가 들어갈 자리를 미리 회색으로 그려두고, 데이터가 도착하면 진짜 콘텐츠로 바꿔치기하는 UI입니다. 이걸 스켈레톤(skeleton) 이라고 부릅니다.

스켈레톤을 만들 때 가장 흔히 빠지는 함정이 하나 있습니다. 진짜 컴포넌트와 스켈레톤이 같은 디자인 값을 두 군데에 똑같이 적게 되는 중복 문제입니다.

스켈레톤의 역할: 화면 튐 방지

스켈레톤이 없으면 데이터가 도착하는 순간 카드들이 갑자기 튀어나오면서 페이지가 아래로 밀립니다. 이렇게 "화면이 튀는 정도"를 측정하는 지표가 CLS (Cumulative Layout Shift) 인데, 구글이 검색 순위에 반영하는 웹 성능 지표라 가급적 0에 가깝게 유지해야 합니다.

스켈레톤은 데이터가 도착하기 전에 미리 자리를 차지해 두는 역할을 합니다. 진짜 카드와 똑같은 크기·간격의 회색 박스를 깔아두면, 데이터가 와서 진짜 카드가 그려질 때 자리가 정확히 들어맞아 화면이 튀지 않습니다.

스켈레톤 구현 시의 함정: 디자인 값 중복

스켈레톤이 제 역할을 하려면 진짜 카드와 모양·크기·간격이 정확히 일치해야 합니다. 그래서 보통은 이렇게 합니다. 진짜 카드 컴포넌트의 scss(디자인 값을 적어두는 스타일 파일)와 스켈레톤 전용 scss를 따로따로 만들고 같은 값을 양쪽에 똑같이 적는 방식입니다.

진짜 카드 컴포넌트의 scss입니다.

/* ContestCard.module.scss */
.card {
  padding: 12px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
}

카드 안쪽 여백과 3등분 격자 배치를 정의하는 코드입니다.

스켈레톤 컴포넌트의 별도 scss입니다.

/* CardSkeleton.module.scss */
.skeletonCard {
  padding: 12px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  
  background: #eee;
  border-radius: 4px;
}

회색 배경은 스켈레톤 전용이라 진짜 카드에 없지만, padding·grid-template-columns·gap은 같은 값이 두 파일에 똑같이 적혀 있습니다. 디자이너가 padding을 16px로 늘리면 두 파일을 같이 고쳐야 하고, 한쪽을 빼먹으면 모양이 어긋납니다.

원칙: 복사 대신 재사용

스켈레톤 전용 CSS를 새로 만들지 말고, 진짜 컴포넌트가 쓰는 CSS 클래스(스타일 묶음에 붙인 이름)를 그대로 가져다 씁니다.

스켈레톤이 새로 추가하는 건 단 하나, .bone 이라는 클래스입니다. 회색 배경과 깜빡이는 애니메이션만 담당합니다.

.bone {
  background: #eee;
  border-radius: 4px;
  animation: pulse 1.5s ease-in-out infinite;
}

pulse는 1.5초마다 투명도를 1 → 0.4 → 1로 바꿔서 회색 박스가 천천히 깜빡이게 만듭니다. 사용자에게 "지금 뭔가 로딩 중이다"라는 신호를 줍니다. 크기·간격·폰트는 여기서 다루지 않습니다. 그건 진짜 컴포넌트의 클래스가 이미 알고 있기 때문입니다.

패턴: 진짜 카드 클래스에 bone 결합

콘테스트 카드 한 장이 이렇게 생겼다고 해보겠습니다. 썸네일(이미지) 한 장, 제목 한 줄, 설명 두 줄입니다.

진짜 카드의 마크업입니다. 카드라는 박스 안에 썸네일·제목·설명을 차례로 배치한 구조입니다.

<div className={cardStyles.card}>
  <Image className={cardStyles.thumbnail} src={contest.thumbnail} alt="" />
  <h2 className={cardStyles.title}>{contest.title}</h2>
  <p className={cardStyles.description}>{contest.description}</p>
</div>

이 카드의 스켈레톤은 거의 똑같은 마크업으로 만듭니다. 차이는 두 가지입니다.

  • 콘텐츠를 담던 태그(Image, h2, p)를 전부 <div>로 바꾸고 안쪽 콘텐츠를 비웁니다.
  • 각 자리에 bone 클래스를 한 개씩 더 합칩니다.

클래스 위치(cardStyles.thumbnail, cardStyles.title, cardStyles.description)는 진짜 카드와 동일합니다.

<div className={cardStyles.card}>
  <div className={classnames(cardStyles.thumbnail, styles.bone)} />
  <div className={classnames(cardStyles.title, styles.bone)}>&nbsp;</div>
  <div className={classnames(cardStyles.description, styles.bone)}>&nbsp;</div>
</div>

classnames(...)는 클래스 이름 두 개 이상을 한 자리에 합쳐주는 도구입니다. 위 코드에서는 진짜 카드가 쓰는 클래스와 bone을 한 자리에 결합시키는 역할을 합니다.

cardStyles.card, cardStyles.thumbnail, cardStyles.title, cardStyles.description: 카드 컨테이너의 padding·border, 썸네일의 크기·위치, 제목과 설명의 폰트·줄높이·여백, 이 모든 것이 진짜 카드가 쓰는 그 클래스 그대로입니다. 스켈레톤이 새로 추가한 건 회색 + 깜빡임을 담당하는 styles.bone 하나뿐입니다.

자리에 따라 처리가 살짝 갈리는데, 두 가지뿐입니다.

텍스트 아닌 자리: 두 클래스 결합

썸네일처럼 텍스트가 아닌 자리는 진짜 클래스 + bone을 합치는 것 외에 추가 작업이 없습니다. 진짜 cardStyles.thumbnail이 이미 너비·높이·border-radius를 정의하고 있기 때문에, bone이 그 영역을 회색으로 칠하고 깜빡이게 만들면 됩니다.

텍스트 자리: &nbsp; 한 개 추가

제목·설명처럼 텍스트가 들어갈 자리는 한 가지 추가 처리가 필요합니다. <div> 안에 &nbsp; 를 한 개 넣습니다. &nbsp;는 HTML에서 "공백 문자 한 개"를 뜻합니다.

<div>만 두면 line-height가 설정돼 있어도 높이가 0으로 계산됩니다. CSS의 line-height는 안에 글자(인라인 콘텐츠)가 있을 때만 줄 높이를 만들기 때문입니다. &nbsp;가 그 글자 역할을 해줍니다. 보이진 않지만 줄 높이를 정확히 만들어 줍니다.

결과적으로 진짜 텍스트가 차지할 줄 높이와 픽셀 단위까지 정확히 같은 회색 막대가 그려집니다. 진짜 제목이 두 줄짜리 line-height를 잡는다면, 스켈레톤 제목도 두 줄짜리 회색 막대가 됩니다.

정리

스켈레톤 한 장의 구성은 이렇게 압축됩니다.

진짜 컴포넌트와 같은 마크업 + 같은 클래스 + bone 한 개 추가. 텍스트 자리만 안에 &nbsp;.

스켈레톤 전용으로 새로 만드는 CSS는 .bone 하나뿐입니다. 나머지는 전부 진짜 컴포넌트가 이미 갖고 있는 걸 빌려 씁니다.

진짜 컴포넌트와 스켈레톤이 같은 CSS 출처를 쓰니까 중복이 사라집니다.

  • 디자이너가 카드 padding을 바꾸면 진짜 카드와 스켈레톤이 자동으로 같이 바뀝니다.
  • 모바일·데스크톱 반응형도 진짜 클래스의 미디어 쿼리가 처리하니까 스켈레톤 쪽에 별도로 적을 필요가 없습니다.