본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 15. 22:32

거짓 없는 로딩 스켈레톤(Skeleton): 정직한 인지 성능(Perceived Performance)을 위한 5가지 패턴

요약

사용자 경험을 저해하는 레이아웃 이동(CLS)을 최소화하기 위해 실제 콘텐츠와 픽셀 단위로 일치하는 '정직한 스켈레톤' 패턴을 제안합니다. 스피너 대신 정확한 크기와 개수를 반영한 플레이스홀더를 사용하여 인지 성능을 높이는 방법을 다룹니다.

핵심 포인트

  • 스켈레톤은 최종 콘텐츠의 높이, 너비, 줄 수와 정확히 일치해야 함
  • aspect-ratio를 활용해 이미지 공간을 미리 예약하여 CLS를 0으로 방지
  • 재사용 가능한 범용 컴포넌트보다 레이아웃별 전용 스켈레톤 제작 권장
  • 데이터 개수와 스켈레톤 개수를 일치시켜 사용자 기대치 충족
  • 콘텐츠 형태의 스켈레톤은 스피너(Spinner)의 0.18 CLS 대비 레이아웃 이동(Layout Shift)을 0으로 줄입니다.

  • 플레이스홀더(Placeholder)의 크기를 최종 DOM과 정확히 일치시키지 않으면 사용자에게 거짓말을 하는 것입니다.

  • 300ms 미만의 짧은 시간이나 형태를 알 수 없는 콘텐츠의 경우에는 스피너가 스켈레톤보다 낫습니다.

  • 스트리밍 SSR(Streaming SSR)은 실제로 도착하고 있는 데이터에 대해 스켈레톤이 정직하게 보여줄 수 있게 합니다.

저는 제 스토어프런트(Storefront)의 로딩 상태를 다시 구축했고, 제품 그리드에서의 누적 레이아웃 이동(Cumulative Layout Shift, CLS)이 0.18에서 0.00으로 떨어지는 것을 확인했습니다. 해결책은 더 빠른 코드가 아니었습니다. 최종 레이아웃과 픽셀 단위로 일치하는 정직한 플레이스홀더였습니다. 실제로 효과가 있었던 방법은 다음과 같습니다.

최종 레이아웃과 일치하지 않는 스켈레톤은 거짓말을 한다

대부분의 스켈레톤 스크린(Skeleton screens)은 장식용입니다. 실제 콘텐츠는 두 줄의 텍스트와 이미지가 있는데 스켈레톤은 회색 막대 세 개를 보여주고, 데이터가 도착하면 페이지가 완전히 다른 모양으로 툭 튀어 오르듯 변합니다. 그 튀어 오르는 현상이 레이아웃 이동(Layout shift)이며, 이는 로딩 상태가 할 수 있는 가장 짜증 나는 일입니다. 사용자는 스켈레톤을 보고 정신적 모델(Mental model)을 구축하지만, 그 모델은 틀리게 됩니다.

제가 현재 따르는 규칙은 다음과 같습니다: 스켈레톤 플레이스홀더는 교체될 콘텐츠와 정확히 동일한 박스를 차지해야 합니다. 동일한 높이, 동일한 너비, 동일한 줄 수, 동일한 이미지 종횡비(Aspect ratio)를 가져야 합니다. 만약 제품 카드가 1:1 이미지와 두 줄의 텍스트를 포함하여 높이가 320픽셀이라면, 스켈레톤도 1:1 회색 블록과 두 줄의 회색 선을 가진 320픽셀 높이여야 합니다. 더 많아서도, 더 적어서도 안 됩니다.

이는 제가 범용적인 스켈레톤 컴포넌트를 만드는 것을 그만두었음을 의미합니다. "막대 세 개"를 렌더링하는 재사용 가능한 컴포넌트는 그 무엇과도 일치하지 않기 때문에 함정입니다. 대신 저는 레이아웃별로 스켈레톤을 만듭니다. 제품 카드에는 ProductCardSkeleton이 있고, 기사 헤더에는 ArticleHeaderSkeleton이 있습니다. 각각은 데이터를 회색 모양으로 교체한 실제 컴포넌트이며, 동일한 CSS 그리드(CSS grid)와 동일한 고정 크기를 공유합니다.

저를 설득했던 측정 결과는 다음과 같습니다. 이전에는 제 제품 그리드(product grid)의 Lighthouse CLS(Cumulative Layout Shift) 점수가 0.18이었는데, 이는 주로 공간이 예약되지 않은 상태에서 이미지가 로드되거나 텍스트가 재배치(reflow)되면서 발생했습니다. 스켈레톤(skeleton) 박스를 최종 박스와 일치시키고 aspect-ratio를 사용하여 이미지 크기를 예약한 후에는, 세 개의 테스트 페이지에서 연속으로 CLS 0.00을 기록했습니다. 데이터가 도착해도 그리드는 움직이지 않습니다. 그저 채워질 뿐입니다.

정직함의 원칙은 단순히 크기(dimensions)에만 국한되지 않습니다. 만약 스켈레톤 카드 4개를 보여주었는데 응답으로 6개의 아이템이 돌아온다면, 당신은 사용자에게 4개를 기대하도록 가르친 셈입니다. 가능하다면 이전 페이지의 캐시된 길이(cached previous page length)나 알려진 페이지 크기(known page size)와 같이 실제 개수와 동일한 소스에서 스켈레톤 개수를 가져오세요. 4개를 약속하고 6개를 전달하는 스켈레톤은 작은 거짓말이며, UI에서의 작은 거짓말은 불신으로 쌓이게 됩니다.

박스 맞추기: aspect-ratio와 예약된 공간이 스피너(Spinner)보다 낫다

레이아웃 이동(layout shift)을 제로로 만드는 기계적인 방법은 아무것도 로드되기 전에 공간을 예약하는 것입니다. 이미지는 가장 큰 원인 제공자입니다. 너비(width)와 높이(height)가 없는 <img> 태그는 높이가 0으로 축소되었다가, 파일이 디코딩(decode)될 때 전체 높이로 급격히 커집니다. 브라우저는 알지 못하는 공간을 예약할 수 없습니다.

두 줄의 코드가 이 문제의 대부분을 해결합니다. 이미지 컨테이너에 aspect-ratio를 설정하고 이미지에 width: 100%를 설정하세요:

.card-image {
  aspect-ratio: 1 / 1;
...

이제 박스는 첫 번째 페인트(first paint) 시점부터 형태를 유지합니다. 스켈레톤은 동일한 비율의 회색 블록으로 해당 박스를 채우고, 실제 이미지가 디코딩될 때 단 1픽셀도 움직이지 않고 그 자리에 끼워 맞춰집니다. 저 또한 텍스트 블록에 대해 고정된 줄 수(line counts)와 min-height를 사용하여, 두 줄짜리 제목 플레이스홀더(placeholder)가 세 줄짜리 제목으로 변하지 않도록 동일하게 처리합니다.

쉬머(shimmer) 애니메이션은 사람들이 실수하는 부분입니다. 회색 위를 훑고 지나가는 대각선 그라데이션(diagonal gradient)은 괜찮지만, 반드시 동작 선호도(motion preferences)를 존중해야 합니다. 저는 모든 쉬머 효과를 미디어 쿼리(media query) 뒤에 배치합니다:

@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: none; }
...

그렇게 하지 않는다면, 운영체제에 움직임을 줄여달라고 명시적으로 요청한 사용자들에게 움직이는 그라데이션을 강제로 밀어붙이는 셈이 됩니다. 이는 접근성(Accessibility) 실패이며, 단 두 줄의 코드로 수정할 수 있는 문제입니다.

또한 아무도 이야기하지 않는 쉬머(Shimmer) 효과의 성능 비용도 존재합니다. 수백 개의 스켈레톤 요소에 대해 background-position을 애니메이션화하면 리페인트(Repaint)를 강제하게 됩니다. 저사양 Android 기기에서 제가 예전에 사용했던 쉬머 효과는 로딩 상태의 프레임을 초당 22프레임(FPS)까지 떨어뜨렸습니다. 하나의 키프레임(Keyframe)을 구동하는 단일 CSS 변수로 전환하고, background-position 대신 가상 요소(Pseudo-element)의 opacity를 애니메이션화함으로써 이를 다시 60프레임으로 복구했습니다. 로딩 상태가 페이지에서 가장 느린 부분이 되어서는 안 됩니다.

저는 스켈레톤의 크기를 레이아웃 토큰(Layout tokens)과 동일한 곳에 저장합니다. 카드의 높이, 이미지 비율, 간격(Gap) 모두 스켈레톤과 실제 카드가 공통으로 읽는 CSS 사용자 정의 속성(CSS custom properties)으로 존재합니다. 제가 카드 높이를 변경하면 두 요소 모두 함께 업데이트됩니다. 하나의 소스(Source)를 공유하기 때문에 서로 어긋날 수 없습니다. 스토어프런트 레이아웃을 조기에 예약하는 것에 대한 더 깊은 맥락을 알고 싶다면, Shopify Section Rendering API를 참조하세요.

스피너(Spinner)가 실제로 스켈레톤보다 나은 경우

스켈레톤이 항상 더 나은 것은 아닙니다. 저는 스피너가 구식이라고 말하는 기사를 단 하나 읽고 모든 것에 스켈레톤을 강요하는 팀들을 보곤 합니다. 그것은 잘못된 판단입니다. 스피너가 세 가지 구체적인 상황에서 승리합니다.

첫째, 300ms 미만의 로딩입니다. 데이터가 180ms 만에 돌아온다면, 스켈레톤은 0.1초 동안 깜빡였다가 사라지며 그 깜빡임 자체가 글리치(Glitch, 오류)처럼 보입니다. 대략 300ms 미만일 때는, 눈이 인지하기도 전에 사라져 버리는 스켈레톤을 보여주는 것보다 아무것도 보여주지 않는 것이 더 낫습니다. 저는 빠른 응답 시 플레이스홀더(Placeholder)가 아예 나타나지 않도록 로딩 상태를 200ms 동안 지연시킵니다. 콘텐츠가 그냥 바로 나타나게 되는데, 이것이 가능한 최선의 결과입니다.

둘째, 형태를 알 수 없는 콘텐츠(unknown-shape content)입니다. 스켈레톤(Skeleton)은 레이아웃을 미리 알고 있을 때만 작동합니다. 응답이 리스트(list)일 수도, 단일 카드(single card)일 수도, 에러(error)나 빈 상태(empty state)일 수도 있다면, 그에 맞는 플레이스홀더(placeholder)를 그릴 수 없습니다. 추측해서 그리는 것은 거의 확실하게 거짓을 그리는 것을 의미합니다. 중앙에 위치한 스피너(spinner)는 형태에 대해 어떠한 주장도 하지 않으므로 사용자를 오도할 수 없습니다. 검색 결과가 0개일지 50개일지 알 수 없는 경우에는 스피너를 사용한 다음, 개수를 파악한 후에 실제 레이아웃을 렌더링(render)합니다.

셋째, 전체 페이지 전환(full-page transitions) 및 "저장 중" 또는 "결제 처리 중"과 같은 액션(actions)입니다. 방금 클릭한 버튼의 스켈레톤을 기대하는 사람은 아무도 없습니다. 그들은 시스템이 자신의 요청을 들었다는 피드백을 기대합니다. 버튼 위에 표시되는 작은 인라인 스피너(inline spinner)가 정직한 신호입니다. 그 자리에 스켈레톤을 사용하는 것은 터무니없는 일입니다.

제가 사용하는 의사결정 트리(decision tree)는 간단합니다. 최종 레이아웃을 정확히 알고 있는가? 아니라면, 스피너(spinner). 로딩이 300ms 미만일 가능성이 높은가? 그렇다면, 지연시킨 후 아무것도 보여주지 않거나 스피너를 보여줍니다. 300ms 이상 걸리는 알려진 콘텐츠 형태인가? 스켈레톤(Skeleton). 레이아웃 변화가 없는 개별적인 액션인가? 인라인 스피너(inline spinner).

실수는 이 선택을 유행(fashion)의 문제로 취급하는 것입니다. 이것은 정보(information)에 관한 문제입니다. 로딩 상태는 "무엇이 오고 있으며 대략 어느 정도의 크기인지"를 전달합니다. 스켈레톤은 이를 정확하게 답변합니다. 스피너는 "무언가 오고 있지만 형태는 알 수 없다"라고 답변합니다. 당신이 실제로 알고 있는 것에 대해 진실을 말하는 것을 선택하세요. 형태를 진정으로 모를 때, 아는 척하는 것이 바로 거짓말입니다.

스트리밍 SSR은 스켈레톤의 타이밍을 정직하게 만든다

가장 정직한 스켈레톤은 마지막에 한꺼번에 교체되는 것이 아니라, 실제 데이터가 스트리밍(streaming)됨에 따라 섹션별로 사라지는 것입니다. 스트리밍 서버 사이드 렌더링(Streaming SSR)이 이를 가능하게 합니다. 서버는 즉시 페이지 쉘(page shell)을 보내고, 연결을 열어둔 상태로 유지하며, 각 느린 섹션의 데이터가 해결(resolve)될 때마다 해당 섹션을 플러시(flush)합니다.

실제로 제 페이지의 빠른 부분(헤더, 네비게이션, 정적 제품 상세 정보)은 실제 콘텐츠와 함께 즉시 렌더링됩니다. 느린 부분(별도의 서비스에 요청을 보내는 개인화된 추천 섹션)은 데이터가 도착할 때까지 스켈레톤(Skeleton)을 보여준 뒤, 스트리밍(streaming)을 통해 해당 영역만 교체합니다. 이 스켈레톤이 '정직한' 이유는 이미 렌더링이 완료된 페이지 전체가 아니라, 실제로 아직 로딩 중인 정확한 부분만을 가려주기 때문입니다.

Suspense 경계(boundary)를 사용하는 React Server Components는 이를 깔끔하게 처리합니다. 느린 컴포넌트를 <Suspense fallback={<Skeleton />}>로 감싸면, 프레임워크는 먼저 폴백(fallback)을 스트리밍하고 그 다음 실제 마크업(markup)을 보냅니다. 대부분의 사람들이 놓치는 핵심 디테일은 다음과 같습니다. 폴백 스켈레톤은 반드시 해결(resolved)된 컴포넌트의 크기(dimensions)와 일치해야 합니다. 그렇지 않으면 스트리밍 과정에서 레이아웃 시프트(layout shift)가 발생하여, 지금까지 해온 모든 CLS(Cumulative Layout Shift) 최적화 노력이 수포로 돌아가게 됩니다. 정직함과 레이아웃 시프트 제로(zero shift)는 결국 같은 목표를 지향합니다.

저는 제품 페이지에서 그 차이를 측정해 보았습니다. 스트리밍을 사용하지 않았을 때는 페이지 전체가 추천 서비스의 응답을 기다려야 했기에, 첫 콘텐츠풀 페인트(First Contentful Paint, FCP)까지 1.4초가 걸렸습니다. 반면 스트리밍을 사용했을 때는 셸(shell)과 주요 제품 정보가 즉시 렌더링되었고, 추천 섹션만 나머지 1.1초 동안 스켈레톤을 보여주었기 때문에 FCP가 0.3초로 단축되었습니다. 사용자는 느린 부분이 플레이스홀더(placeholder) 뒤에서 로딩되는 동안 제품 정보를 읽을 수 있습니다.

이 지점이 바로 스켈레톤이 그 명성을 얻는 구간입니다. 서버가 이미 렌더링을 마친 섹션을 가리는 스켈레톤은 '연극(theater)'에 불과합니다. 하지만 느린 업스트림 호출(upstream call)을 기다리고 있는 단 하나의 섹션을 가려주는 스켈레톤은 '정확한 상태 보고'입니다. 스트리밍을 사용하면 정확히 올바른 위치에 그 경계선을 그을 수 있습니다.

한 가지 주의할 점은 스트리밍이 에러 핸들링(error handling)의 복잡성을 더한다는 것입니다. 만약 추천 서비스에서 타임아웃(timeout)이 발생하면, 스켈레톤은 에러 상태나 빈 상태(empty state)로 전환되어야 하며, 결코 영원히 로딩 상태로 돌아가서는 안 됩니다. 저는 멈춰버린 스트림이 스켈레톤을 영원히 반짝거리게 두지 않도록, 엄격한 타임아웃과 폴백 빈 상태를 설정했습니다. 탈출구가 없는 스켈레톤은 결코 오지 않을 콘텐츠를 약속하는, 가장 최악의 거짓말입니다.

요약 (Bottom Line)

정직한 로딩 상태(Loading states)는 하나의 아이디어로 귀결됩니다. 도착할 콘텐츠와 모순되는 플레이스홀더(Placeholder)를 절대 보여주지 마십시오. 레이아웃 이동(Layout shift)이 전혀 발생하지 않도록 박스 크기를 정확히 일치시키십시오. aspect-ratio를 사용하여 이미지 공간을 확보하십시오. prefers-reduced-motion 설정 뒤에 쉬머(Shimmer) 효과를 배치하십시오. 형태를 알 수 없거나 로딩이 300ms 미만인 경우에는 스피너(Spinner)를 사용하고, 레이아웃을 미리 알고 있는 경우에만 스켈레톤(Skeleton)을 사용하십시오. SSR(Server-Side Rendering)을 스트리밍(Stream)하여 스켈레톤이 실제로 아직 로딩 중인 부분만을 덮고 그 외의 부분은 덮지 않도록 하십시오.

저는 이 모든 것을 Shopify 스토어프런트(Storefront)에 적용했으며, CLS(Cumulative Layout Shift)가 0.18에서 0.00으로 떨어진 것이 가장 눈에 띄는 단일 성과였습니다. 하지만 이 패턴들은 프레임워크에 구애받지 않습니다(Framework-agnostic). 크기가 예약된 차원(Dimensions)과 스트리밍되는 섹션(Streamed sections)을 갖춘 스택이라면 무엇이든 동일하게 구현할 수 있습니다.

사이트 전체에서 이러한 UI 규칙을 일관되게 유지하기 위해 제가 사용하는 시스템이 궁금하다면, Claude Blueprint를 통해 제가 레이아웃 토큰(Layout tokens)과 컴포넌트 계약(Component contracts)을 어떻게 문서화하여 스켈레톤과 실제 콘텐츠가 결코 어긋나지 않게 하는지 확인해 보십시오. 컴포넌트 하나로 시작하여, 적용 전후의 CLS를 측정하고, 그 수치로 증명하십시오.

이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면 귀하에게 추가 비용 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0