로딩 화면을 어떻게 보여줄까?

드뮴·2025년 3월 5일
18

🪴 개발일지

목록 보기
5/6
post-thumbnail

이 포스팅은 데이터를 기다리는 동안 보여줄 스켈레톤 UI를 도입하며 학습한 내용에 대한 글이다.

스켈레톤 UI를 네트워크 상태와 정해둔 지연 시간에 따라 언제 보여줄 지를 결정하여 사용하도록 했고, 스켈레톤 UI를 만드는 작업이 번거로워 생성해주는 제너레이터를 만든 과정을 작성했다. 그리고 실제로 어떻게 변경되었는지 UI 변화에 대한 결과도 넣었다.


로딩 화면을 어떻게 구성하는게 좋을까?

앞선 글에서 Suspense를 사용해 데이터 로딩 관리하기를 할 수 있게 설정했다.

검색하면 많은 종류의 로딩 스피너 컴포넌트 코드를 찾을 수 있다. 그런데 이렇게 처리하게 되는게 최선일까?하는 고민이 생겼다. 지금 이 글을 작성하는 velog에서도 포스팅을 로딩할 때 실제 포스팅 구조와 같은 UI로 로딩 상태를 보여주는 스켈레톤 UI를 사용한다.

스켈레톤 UI를 보면 로딩 스피너보다 더 깔끔해보여서 이를 적용해보고 싶어 학습해보았다.


스켈레톤 UI란?

스켈레톤 UI는 웹이나 모바일 애플리케이션에서 콘텐츠가 로딩되는 동안 사용자에게 표시되는 시각적인 placeholder다. 그런데 데이터가 로딩되고 있다는 것을 단순히 알려주는 로딩 스피너나 메세지와 달리, 스켈레톤 UI는 로딩 중인 콘텐츠의 최종 레이아웃과 구조를 미리 보여준다.

스켈레톤 UI의 특징

  • 실제 콘텐츠와 동일한 레이아웃 구조로, 데이터가 로드된 후에도 화면 구조가 크게 변하지 않는다.
  • 일반적으로 회색 막대, 사각형으로 표현되며 pulse 애니메이션 효과가 적용되어 로딩 중임을 표시한다.
  • 실제 내용이 어떤 모습일지 암시하여 사용자가 기대할 수 있는 콘텐츠의 유형과 배치를 미리 이해할 수 있게 한다.

스켈레톤 UI는 사용자가 빈 화면이나 로딩 스피너를 볼 때보다 로딩 시간을 더 짧게 느끼는 효과가 있다. 또한 실제 콘텐츠와 동일한 레이아웃을 가지므로 콘텐츠가 로드될 때 갑작스러운 레이아웃 변경이 최소화된다는 장점이 있다.

스켈레톤 UI는 항상 좋을까?

스켈레톤 UI를 도입하기 전 고민이 있었다. 항상 좋을까?하는 생각이 들었다.

실제로 무조건 스켈레톤 화면을 보여주는게 사용자 경험에 도움이 될까요? 글을 읽고 고민도 생겼다. 지연 시간이 짧은 경우에는 오히려 스켈레톤 UI는 화면이 깜빡이는 듯한 느낌을 주고 중간 로딩 화면이 스켈레톤 UI임을 인지하지도 못한채로 깜빡거린다는 느낌만 줄 수도 있다.

따라서 이 글을 읽고 특정 지연 시간에 따라 스켈레톤 UI를 보여주고, 설정한 지연 시간 아래로는 스켈레톤 UI를 보여주지 않는 방법도 사용해보기로 했다.


🏋️ 스켈레톤 UI를 효율적으로 만드는 방법

1. 특정 지연 시간을 기준으로 스켈레톤 UI를 보여주기

특정 지연 시간을 기준으로 해당 시간보다 로딩이 빠르다면 스켈레톤 UI를 보여주지 않고, 로딩이 기준 시간보다 느리면 스켈레톤 UI를 보여주는 로직을 작성하기로 했다.

const DeferredComponent = ({ children }: PropsWithChildren<{}>) => {
  const [isDeferred, setIsDeferred] = useState(false);

  useEffect(() => {
    // 200ms 지난 후 children Render
    const timeoutId = setTimeout(() => {
      setIsDeferred(true);
    }, 200);
    return () => clearTimeout(timeoutId);
  }, []);

  if (!isDeferred) {
    return null;
  }

  return <>{children}</>;
};
<Route exact path={ROUTE.CATEGORY_LIST}>
  <Suspense
    fallback={
      <DeferredComponent>
        <HomeSkeleton />
      </DeferredComponent>
    }
  >
    <CategoryList />
  </Suspense>
</Route>

위에서 언급한 카카오페이의 기술 블로그 글인 무조건 스켈레톤 화면을 보여주는게 사용자 경험에 도움이 될까요?에서는 위와 같이 컴포넌트를 작성해서 이를 스켈레톤 컴포넌트에 감싸주게 된다. 그렇다면 200ms 시간을 기준으로 스켈레톤 컴포넌트는 렌더링되기 때문에 200ms보다 로딩이 빠르면 스켈레톤 UI가 보이지 않는 것이다.

위와 같이 사용하는 것도 좋지만 좀 더 개선할 수 없을까?하는 생각이 들었고, 로딩 상태 표시를 위해 네트워크 탭에서 더 느린 네트워크 상태일 때를 선택해서 확인했던 것이 생각나서 더 개선할 방법을 생각을 할 수 있었다.

어떻게 개선할까?

더 효율적인 스켈레톤 UI를 도입하기 위해 지연 시간 자체를 네트워크 별로 다르게 설정하기로 했다. 느린 네트워크에서는 로딩 상태가 길어지기 때문에 특정 시간이 지나고 스켈레톤 UI를 보여준 후 실제 콘텐츠를 로딩하게 될텐데 이렇게 하는 것보다 애초에 느린 환경에서는 스켈레톤 UI를 먼저 보여주는 전략을 사용하기로 했다.

각 네트워크 환경에서 스터디 채널이 표시되는 시간을 정리해보았다.

  • No throttling
    • 54ms 정도만에 데이터를 요청해서 받아온다. 그렇다면 실제 화면에 그려지는 시간은 이것보다 좀 더 걸리지만, 화면을 실제로 보면 데이터를 로딩하는 화면이 뜨는 게 느껴지지도 않은 채로 데이터가 표시된다.
    • 즉, 스켈레톤 UI가 표시되지 않아도 될 정도로 빠르게 데이터가 그려진다.
  • Fast 4G
    • Fast 4G에서는 182ms 정도의 시간이 소요된다.
    • 이때는 로딩 중 화면이 표시되는 건 인지 할 수 있지만 한 번 깜빡이는 속도로 사용자 입장에서 오래 기다리지 않고 바로 데이터가 표시되는 속도였다.
  • Slow 4G
    • Slow 4G에서는 Fast 4G에 비해 3배 이상 차이가 나며, 623ms가 소요된다.
    • 실제로 답답한 느낌은 없으나 로딩 중 화면이 꽤 잘 표시된다.
    • 이 부분부터는 스켈레톤을 도입해도 괜찮겠다는 생각이 들었다.
  • 3G
    • 3G에서는 2.19s 시간이 소요된다.
    • 이는 확연히 로딩 중 상태가 오래 보였고, 스켈레톤 UI를 바로 도입해도 상관없었다.

위 정리를 바탕으로 Slow 4G 환경일 때는 지연 없이 스켈레톤 UI를 도입하고, 빠른 네트워크 환경에서는 200ms 정도를 기준으로 지연을 둔 후 스켈레톤 UI가 보이도록 하도록 설정해야겠다고 결정했다.


네트워크 상황에 따라 스켈레톤 UI를 보여줄지 말지 어떻게 결정할까?

우선 네트워크 상황에 대해 알 수 있어야한다. 자바스크립트에서 사용자의 네트워크가 어떤지 확인하는 방법에 대해서는 해당 글을 참고하였다.

현재 사용자가 slow-2g, 2g, 3g, 4g 중 어떤 네트워크 환경에 있는지 확인하고 그에 맞춰 로직을 작성해주었다.

느린 네트워크 환경에 있다면 로딩 중 상태가 길어지기 때문에 스켈레톤 UI를 지연시키지 않고 바로 보여주도록 했다. 반면 빠른 네트워크 환경이라면? 200ms 정도의 지연 시간 뒤에 스켈레톤 UI를 보여주도록 했다. 빠른 네트워크에서는 사실 상 로딩 화면을 안보여주는게 깜빡임도 덜해서 안 보여주는게 좋을거 같아 지연 시간을 200ms 정도로 설정했다.

1. 사용자의 네트워크 상태를 감지하는 훅 만들기

import { useEffect, useState } from "react"

interface NetworkInformation {
  readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';
  readonly downlink: number;
  readonly rtt: number;
  readonly saveData: boolean;
  readonly downlinkMax?: number;
  readonly type?: 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown';
  onchange?: () => void;
  addEventListener(type: string, listener: EventListenerOrEventListenerObject): void;
  removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void;
}

interface NavigatorWithConnection extends Navigator {
  connection: NetworkInformation;
}

const useNetworkStatus = () => {
  const getInitialConnectionType = () => {
    if ("connection" in navigator) {
      const nav = navigator as NavigatorWithConnection;
      if (nav.connection) {
        return nav.connection.effectiveType;
      }
    }
    return "unknown";
  };

  const [connectionType, setConnectionType] = useState<string>(getInitialConnectionType());

  useEffect(() => {
    if ("connection" in navigator) {
      const nav = navigator as NavigatorWithConnection;

      if (nav.connection) {
        const updateConnectionStatus = () => {
          setConnectionType(nav.connection.effectiveType);
        };

        nav.connection.addEventListener("change", updateConnectionStatus);
        return () => {
          nav.connection.removeEventListener("change", updateConnectionStatus);
        };
      }
    }
  }, []);

  return { connectionType };
}

export default useNetworkStatus;
  • 코드가 생각보다 긴데, 이는 타입 정의 때문이지 실제 로직은 간단한 편이다.
  • 먼저 현재 네트워크가 어떤 타입인지 getInitialConnectionType 함수에서 확인하고 이 값을 connectionType에 넣어준다.
  • useEffect 내에서는 네트워크 상태가 변경되는 것에 대한 이벤트 리스너를 등록해준다.
  • 최종적으로 반환하는 것은 현재 네트워크 타입인데 이를 통해 스켈레톤 UI를 언제 지연시킬지 결정하면 된다.

네트워크 타입을 감지하는 훅이 만들어졌으니 이를 활용해서 지연 시켜주는 컴포넌트를 만들어줘야한다. 카카오페이 기술 블로그에서 만든 DeferredComponent는 위에 있는데 이를 참고해서 네트워크 타입에 따른 처리를 추가해주었다.

import useNetworkStatus from "@/hooks/useNetworkStatus";
import { ReactNode, useEffect, useState } from "react";

interface AdaptiveDeferredProps {
  children: ReactNode;
}

const DELAY_MS = 200;

const isSlowNetwork = (type: string) => {
  return ["slow-2g", "2g", "3g"].includes(type);
}

const DeferredComponent = ({
  children
}: AdaptiveDeferredProps) => {
  const { connectionType } = useNetworkStatus();
  const [isDeferred, setIsDeferred] = useState(false);

  useEffect(() => {
    if (isSlowNetwork(connectionType)) {
      setIsDeferred(true);
      return;
    }

    const timeoutId = setTimeout(() => {
      setIsDeferred(true);
    }, DELAY_MS);

    return () => clearTimeout(timeoutId);
  }, []);

  if (!isDeferred) return null;
  return <>{children}</>;
}

export default DeferredComponent;
  • 현재 느린 네트워크에 있다면 바로 스켈레톤 UI를 보여줄 수 있게 isDeferred = true로 변경해서 UI를 보여준다.
  • 반면 느린 네트워크 상황이 아니라면, 200ms가 지나고 나서 보여줄 수 있게 설정한다.
  • 이 컴포넌트 안에 Suspense 코드를 작성해서 사용해주면 된다.

빠른 네트워크 상황에 있을 때

  • 빠른 네트워크 상황에 있을 때는 로딩 중을 보여주지 않고 빈 화면만을 보여준다.
  • 사용자가 로딩 중임을 인식하는 시간이 짧기 때문에 로딩 중을 굳이 보여줘서 깜빡 거리는 현상을 제거했다.

느린 네트워크 상황에 있을 때

  • 느린 네트워크 상황에 있을 때는 로딩 중을 지연 없이 바로 보여준다.
  • 사용자가 로딩 중을 인식하는 시간이 충분하기 때문에 로딩 중을 그냥 바로 보여주게 했다.

2. 스켈레톤 컴포넌트를 효율적으로 생성하기

스켈레톤 UI를 한 번도 사용해본 적이 없어서 어떻게 만들지에 대한 고민이 있었다. 스켈레톤 UI는 로딩 스피너와 달리 로딩 후 데이터가 받아와지고 보이는 화면의 뼈대 그대로 만들어야하므로 로딩되는 페이지마다 UI가 달랐다.

그래서 만들어줘야하는 스켈레톤 컴포넌트가 생각보다 많았고, 고민되는 지점은 많은 것보다 변경에 대한 고민이었다.

만약 특정 페이지 로딩을 위한 스켈레톤 컴포넌트를 만들었는데, 해당 페이지에서 디자인이 바뀌게 된다면? 이때 기존 컴포넌트 뿐만 아니라 스켈레톤 컴포넌트 코드도 수정해주어야한다. 수정을 2번 하는 것도 번거롭지만 실수로 똑같이 반영하지 못해서 다시 코드를 보게되고 이런 작업은 복잡할 거 같다는 생각이 들었다.

프로젝트에서 스켈레톤 UI를 우선적으로 채널 리스트 페이지에 넣을 생각이다. 현재 채널 리스트 페이지를 보면 다음과 같다.

  • 채널 리스트 컴포넌트를 Suspense로 감싸준다. 그렇다면 채널 리스트를 로딩하는 동안은 <div>로딩 중</div>이 뜨게 된다.

채널 리스트 컴포넌트를 보면 위와 같다.

  • 채널 리스트 컴포넌트 안에서는 채널 카드를 반복적으로 보여주게 된다.
  • 현재 이 페이지는 페이지네이션이 적용되어있지 않다. (추후 적용할 예정)
  • 페이지네이션이 있다고 가정하고 한 페이지 당 6개를 보여준다고 설정할 생각이다.
  • 그렇다면 스켈레톤 UI를 도입하게 된다면 채널 카드 6개를 보여줘야한다.

채널 카드를 스켈레톤 UI 컴포넌트로 만들고 로딩 중일 때 스켈레톤 채널 카드 6개를 보여준다.

그렇다면 로딩 중일 때는 스켈레톤 카드 컴포넌트 6개를 보여주는 컴포넌트를 만들면 된다.

그렇지만 앞서 말했듯 카드 디자인이 변경된다면? 스켈레톤 컴포넌트와 카드 컴포넌트 둘 다를 수정하는 번거로움이 있다.

그래서 시간이 좀 더 걸리더라도 기본적인 스켈레톤 컴포넌트를 만들어주는 함수를 작성했다. 해당 함수 코드는 아래와 같다. (함수가 길어서 일부는 생략했다.)

const DEFAULT_TRANSFORM_TAGS = ["Link", "a", "button", "img"];
const DEFAULT_SKELETON_STYLE = ["animate-pulse", "bg-gray-200", "rounded"]; // 루트에는 포함 안되도록 설정
const DEFAULT_EXCLUDE_CLASSES = ["hover", "transition", "ease", "animate"]; // 모든 곳에서 제거해야할 항목
const DEFAULT_NOT_IN_ROOT = ["bg", "rounded", "border"]; // 루트에서 제거하면 안되는 항목

export class SkeletonTransformer {
  processedNodes;
  jsxElement = null;
  componentName = "";

  constructor(sourcePath, targetPath) {
    this.sourcePath = sourcePath;
    this.targetPath = targetPath;
    this.processedNodes = new WeakMap();
  }

  // 스켈레톤 변환을 위한 메인 메소드
  generate() {
    const parserOptions = { } // parser 옵션 설정

    // 입력된 컴포넌트의 소스 코드를 읽어서 AST로 변환
    this.extractJSXAndComponentName(ast);

    // 스켈레톤 컴포넌트 생성
    const skeletonComponentCode = this.generateSkeletonComponentCode();
    const skeletonAst = parser.parse(skeletonComponentCode, parserOptions);
    this.transformToSkeleton(skeletonAst);

    const { code } = generate.default(skeletonAst, {}, sourceCode); // 변환한 AST를 다시 코드로 변환
    fs.writeFileSync(this.targetPath, code, "utf-8"); // 최종 변환 파일로 저장
  }

  extractJSXAndComponentName(ast) {
    // JSX 요소와 컴포넌트 이름을 추출
  }
  
  generateSkeletonComponentCode() {
    // 추출한 컴포넌트 이름과 JSX 요소를 이용해 컴포넌트 코드 생성
  }

  transformToSkeleton(ast) {
    traverse.default(ast, {
      JSXElement: // JSX 태그 요소 처리,
      JSXExpressionContainer: // {}로 둘러싸인 표현식 처리,
      JSXText: // 일반 텍스트 처리
    })
  }

  getTagName(openingElement) {
    // JSX 요소의 태그 이름 가져오기
  }
  
  replaceTagWithDiv(element) {
    // 특정 JSX 요소 태그(Link, img, button 등)를 div 태그로 교체
  }
  
  updateClassNameAttribute(openingElement, className) {
    // JSX 요소의 className 속성을 업데이트 
  }
  
  getClassNameValue(attributes) {
    // JSX 요소의 속성 목록에서 className 값 추출
  }

  filterClassNames(classNames, excludeClasses, isRootElement = false, keepClasses = []) {
    // 클래스 이름 목록을 필터링해서 스켈레톤에 적합한 클래스만 유지
  }

  combineClassNames(filteredClasses, skeletonStyle, isRootElement = false) {
    // 필터링된 클래스 이름과 스켈레톤 스타일 결합
  }

  createSkeletonElement(className) {
    // className을 가진 스켈레톤 div 요소 생성
  }
}

실제 코드는 이 곳에서 볼 수 있습니다.

커스텀 컴포넌트와 같은 건 그대로 남아있기 때문에 이 부분은 수정해서 사용해야한다. 커스텀 컴포넌트의 스타일을 제대로 모르기도 하고 다양한 케이스가 있어서 이 부분은 직접 수정이 더 빨라 놔두었다.

급하게 만들어서 로직에 부족함이 있을 수도 있다. 일단 프로젝트에서 사용하려는 스켈레톤 부분은 다 적용하는데 무리가 없었다. (tailwind를 사용하기 때문에 tailwind를 사용해야 잘 동작할거 같다.)

스켈레톤 컴포넌트를 만드는 동작은 다음과 같다.

  • 먼저 스켈레톤 컴포넌트로 만들 함수는 sourcePath에 위치를 넣어준다. 반대로 스켈레톤 컴포넌트를 저장할 위치는 targetPath로 저장해준다.
  • 스켈레톤은 말 그대로 뼈대를 만드는 것이기 때문에 루트 요소에는 스켈레톤 박스(회색 배경으로 반짝이는 효과)를 만들어주면 안되어서 루트와 아닌 요소를 구분했다.
  • 기본적으로 스켈레톤 박스를 주는 곳은 텍스트 영역, {데이터} 영역이므로 이 부분을 스켈레톤 박스로 만들도록 구현했고, 이 부분은 텍스트로 "채우기"를 적어줬다.
    • "채우기"라고 적은 이유는 각 요소의 높이, 너비를 제대로 모르기 때문이었다. 이 부분을 임의로 지정하면 UI가 깨지고, 실제 h-[높이], w-[너비]로만 요소가 차지하는 공간이 정해지는게 아닌 margin, padding 값 등 다양하게 지정되므로 채우기 텍스트를 넣어 실제 공간이 얼마인지 보여주도록 했다.
    • 위 채우기 텍스트가 있는 공간을 보고 사용자가 알맞게 조절할 수 있게 틀만 제공하기로 했다. 예를 들어 text 설정에서도 line-height 등 다양한 속성이 있으므로 각 속성을 보며 수정하면 된다.
  • 스켈레톤 제너레이터는 기존 컴포넌트 함수의 props나 Import 문 제거, 타입 선언 제거, 비즈니스 로직 제거를 하도록 구현했고, 기존 컴포넌트에 Skeleton을 붙여 이름을 자동으로 만들어주었다.

해당 스켈레톤 컴포넌트를 만들어주는 클래스에서는 기본적으로 UI만 남기고 기존 데이터가 있는 구역을 회색 박스로 전환하고, hover와 같이 스켈레톤 UI에는 필요없는 디자인은 제거했다.

그러나 실제 내가 사용하는 컴포넌트에서는 잘 전환되었지만 내가 생각하지 못한 경우의 수가 있기 때문에 앞으로 사용해보며 조건을 더 추가해나가야한다. (내가 예상하지 못한 경우의 수가 많을 것이기 때문에 이런 케이스를 찾아나가며 발전시켜야할 거 같다.)

글을 작성한 시점인 기존 로직에서 다시 방법을 수정(2025.03.11)했다.

기존엔 컴포넌트 코드에서 필요 없는 부분을 지워나가도록 했는데 타입, props 제거 import 문 제거 등 고려해야할게 너무 많았다. 특이한 케이스에서는 지워지지 않는 문제가 있어서 더 편한 방법을 떠올리다가 기존 컴포넌트에서 return으로 JSX 코드를 내보내는 부분을 복사해서 새로운 컴포넌트를 만드는 방법으로 변경했다. 이 외에는 스켈레톤을 만드는 부분은 동일하다.

또한 기존에는 컴포넌트 경로를 하드코딩으로 넣어뒀는데 입력 받아서 사용자가 사용할 수 있게 해두었다 package.json에 정의해두고 npm run skeleton을 하면 스켈레톤으로 바꿀 컴포넌트 경로를 입력해주기만 하면 같은 위치에 생성된다. (frontend 폴더의 package.json에 정의해줘서, frontend/src/components/a.tsx 컴포넌트를 이용하면 경로는 src/components/a.tsx와 같이 입력하면 된다.)

만든 스켈레톤 제너레이터로 스켈레톤을 만들면 초기 UI는 이렇다.

이렇게 자동으로 만들어진 부분에서 텍스트를 제거하고 원하는 높이 너비를 맞춰주기만 하면 된다.

스켈레톤 컴포넌트로 바꿔줄 컴포넌트

import { FaUserGroup } from "react-icons/fa6";
import { Link } from "react-router-dom";
import type { Channel } from "@/pages/channels/types/channel";
import useToast from "@/hooks/useToast";

interface ChannelCardProps extends Omit<Channel, "host" | "createdAt"> {
  host: string;
}

const ChannelCard = ({
  category = ["기타"],
  id,
  title,
  host,
  participants,
  maxParticipants,
  inProgress,
  questionListTitle,
}: ChannelCardProps) => {
  const toast = useToast();

  return (
    <li className={`relative h-52 bg-white rounded-custom-m px-5 py-6 transition-all duration-200 ease-in-out hover:-translate-y-1.5 border-custom-s border-gray-200
      ${inProgress === false ? "hover:shadow-16 hover:ring-1 hover:ring-green-200" : ""}`}>
      <Link
        to={`/channel/${id}`}
        className="w-full h-full"
        onClick={() => toast.success("채널에 참가했습니다.")}
      >
        <div className="flex flex-col justify-between">
          <div className="flex-grow flex flex-col items-start">
            <span className="text-semibold-s text-green-600 bg-green-50 border-custom-s border-gray-300 rounded-2xl py-px px-2">
              {category[0]}
            </span>
            <div className="px-0.5">
              <h3 className="text-semibold-m mt-2 mb-0.5">{title}</h3>
              <p className="text-medium-m text-gray-400">
                {questionListTitle ?? "함께 면접 스터디에 참여해보세요!"}
              </p>
              <div className="absolute bottom-5 left-6 text-medium-r flex flex-col">
                <span className="text-gray-600">{host}</span>
                <span className="text-gray-black flex gap-2 items-center">
                  <FaUserGroup className="text-green-400" />
                  참여자 {participants}/{maxParticipants}</span>
              </div>
            </div>
          </div>
        </div>
      </Link>
    </li>
  );
};

export default ChannelCard;

스켈레톤 제너레이터로 생성한 스켈레톤 컴포넌트

const SkeletonChannelCard = () => {
  return <li className="relative h-52 bg-white rounded-custom-m px-5 py-6 duration-200 border-custom-s border-gray-200">
      <div className="w-full h-full">
        <div className="flex flex-col justify-between">
          <div className="flex-grow flex flex-col items-start">
            <div className="animate-pulse bg-gray-200 rounded text-semibold-s text-green-600 py-px px-2">채우기</div>
            <div className="px-0.5">
              <div className="animate-pulse bg-gray-200 rounded text-semibold-m mt-2 mb-0.5">채우기</div>
              <div className="animate-pulse bg-gray-200 rounded text-medium-m text-gray-400">채우기</div>
              <div className="absolute bottom-5 left-6 text-medium-r flex flex-col">
                <div className="animate-pulse bg-gray-200 rounded text-gray-600">채우기</div>
                <div className="animate-pulse bg-gray-200 rounded text-gray-black flex gap-2 items-center">채우기</div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </li>;
};
export default SkeletonChannelCard;

처음에는 기존 카드 컴포넌트를 조건문을 통해 스켈레톤과 아닌 것을 구분해 카드 디자인 변경에도 유용하게 만들 수 있을거라 생각했지만, 둘을 조건문으로 나눠 구현하게 되면 결국 코드가 복잡해지고 나눠서 구현하면 복붙해서 스켈레톤을 만드는 노력과 비슷하다 생각했다.
그래서 결국 자동으로 생성해주는 로직을 만들어두면 좋겠다는 생각에 자동으로 생성하는 쪽을 택했다. 물론 이 로직 작성이 꽤나 시간이 걸렸지만, 앞으로 계속 사용할 수 있고 이후로는 금방 만들 수 있다는 점에서 이 선택이 만족스러웠다.


스켈레톤 UI를 도입하기 전과 후

도입 전

도입 후

스켈레톤 UI를 도입하고 나서 로딩 중 혹은 로딩 스피너를 띄우는 것보다 좀 더 효과적으로 느껴진 것은 로딩될 데이터 형식이 어떤 것인지 미리 보여줌으로써 기다리는 시간이 그리 길게 느껴지지 않았던 것이다.

스켈레톤 UI를 그냥 도입했다면 위에 언급했던 대로 빠른 로딩으로 스켈레톤 UI를 인지하지 못할 정도로 짧게 보여줘서 화면이 깜빡 거리는 것 같은 느낌을 줘서 오히려 사용자 경험이 저하됐을 수도 있었겠다는 생각이 들었다. 또한 스켈레톤 컴포넌트를 만들기 위해 만드려는 컴포넌트를 계속 복붙해서 뼈대 남기는 작업만 했다면 시간도 오래 걸려서 리팩토링에 많은 시간을 사용했을 거 같다.

profile
안녕하세오

8개의 댓글

comment-user-thumbnail
7일 전

tanstack router 사용할때 pendingms 사용하면서 유용하다고 생각했었는데 이런 방법으로 구현이 가능하군요.. 스켈레톤 컴포넌트 생성 코드도 궁금하네요 글 잘 봤습니다!

1개의 답글
comment-user-thumbnail
3일 전

이렇게 구현이 가능한지 처음 알았습니다. 좋은 인사이트 얻고 갑니다!

1개의 답글
comment-user-thumbnail
2일 전

좋은 글 잘 봤습니다.
하지만 사용자의 네트워크가 빠르다고 꼭 응답도 빠를거라는 보장이 되지 못 할거 같다는 생각도 듭니다.
예를 들어, 서버가 문제가 있어서 응답이 늦어진다거나,
아니면 사용자의 네트워크는 좋지만 멀리 해외에 살아서 한국에만 서비스 중인 사이트에 접속한다거나 할 때 네트워크 타입으로 제대로 된 스켈레톤이 나올까? 하는 생각이 들었어요!

그래두 모르는 정보였어서 많은 인사이트를 얻고 갑니다 ! :) 감사합니다

1개의 답글

관련 채용 정보