Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
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
Archives
Today
Total
관리 메뉴

코딩핑

응답 지연 시간에 따른 Skeleton UI 최적화하기 (TanStack-Query, Suspense) + 업데이트 Suspense 제거 본문

프로젝트/keynut

응답 지연 시간에 따른 Skeleton UI 최적화하기 (TanStack-Query, Suspense) + 업데이트 Suspense 제거

코딩핑 2024. 9. 27. 15:13


개요

처음 프로젝트에 스켈레톤 UI를 적용할 때는 무조건 사용자 경험에 더 좋을거라고 생각하며 데이터를 패칭하는 곳에 다 추가를 해주었다.
최적화를 고민하며 계속 확인하던 중 api응답시간이 빠를 때 깜박이듯이 스켈레톤UI가 뜨는 것이 사용자 경험을 더 떨어뜨린다는 생각이 들었다. 이를 고민하던 중 카카오 기술 블로그에 이에 대해 정리해놓은 걸 발견했다!

https://tech.kakaopay.com/post/skeleton-ui-idea/

 

무조건 스켈레톤 화면을 보여주는게 사용자 경험에 도움이 될까요? | 카카오페이 기술 블로그

카카오페이에서 프론트엔드 개발을 하며 스켈레톤 UI와 사용자 경험 향상에 대해 고민한 내용을 공유합니다.

tech.kakaopay.com


요약하자면 세계적인 UX 리서치 그룹 닐슨 노먼의 지침에서도 약 1초 이상 걸리는 작업에만 progress indicator를 사용하라고 명시되어있었고, 카카오페이의 공지사항은 api 응답이 평균적으로 60ms 전후의 지연시간을 보이고 있어 100ms 내의 응답 지연에는 Skeleton을 보여주지 않는 방향으로 구현했다고 적혀있었다.

위 내용을 바탕으로 응답 속도가 빠를 때는 스켈레톤UI를 보여주지 않는 것이 사용자 경험을 올려줄 것이라는 판단하에 이 기능을 넣어주기로 했다!

처음에는 setTimeout만을 사용하여 구현하려 하였으나 코드의 일관성과 유지 보수성을 높이기 위해 Suspense와 함께 사용하기로 결정하였다.



++ Suspense?


Suspense는 공식문서에 '<Suspense> 는 자식 요소가 로드되기 전까지 화면에 대체 UI를 보여줍니다.'라고 적혀있다.
Suspense의 동작 원리를 간단히 설명하자면 다음과 같다.
<Suspense fallback={<Skeleton />}>
       <Children/>

<Suspense/>
데이터를 패칭하여 보여주는 컴포넌트를 Suspense로 감싸주고 fallback에 Skeleton 컴포넌트를 넣어준다.
children 컴포넌트의 상태가 pending 또는 error 이면 promise가 상위로 throw가 되고 데이터가 준비된 시점에는 response가 return된다. Suspense는 이러한 상태를 받아 데이터 패칭이 완료되지 않은 시점이라면 children component의 렌더링을 중지하고 
fallback UI를 보여준다. 그리고 데이터패칭이 끝나면 children component을 보여주는 식이다. 내가 비동기 통신을 isFetching 등으로 완료 여부 체크할 필요가 없는 것이다!

내가 shop페이지에서 Suspense를 추가해야하는 부분은 인기 상품을 렌더링하는 <RenderPopularProducts /> 와 전체 상품을 보여주는 <RenderProducts /> 였다.

 

 

 

전체 상품 렌더링에 Suspense 추가하기

 

 

200ms의 지연시간을 고려하지 않고 단순히 데이터패칭 시 Skeletons 컴포넌트를 보여주는 코드는 다음과 같다.

  <Suspense
        fallback={
            <Skeletons />
        }
      >
        <RenderProducts
          isMaxtb={isMaxtb}
          params={params}
          includeBooked={includeBooked}
          categoriesState={categoriesState}
          pricesState={pricesState}
          handleCategoryChange={handleCategoryChange}
          handlePriceChange={handlePriceChange}
        />
</Suspense>


기존의 <RenderProducts /> 를 Suspense로 묶어주고 fallback에 <Skeletons />를 넣어주면 된다.
react의 Suspense를 사용하기 위해서는 RenderProducts 내에서 데이터를 가져올때 최신버전 Tanstack Query에서는 suspense 옵션을 사용하는게 아니라 useSuspenseQuery 또는 useSuspenseInfinteQuery를 사용해야한다. 나는 기존에 useInfinteQuery를 사용하고 있었기 때문에 useSuspenseInfinteQuery로 변경을 해주었다. 


이제 200ms의 지연 시간 내에는 스켈레톤 UI를 보여주지 않기 위한 컴포넌트를 추가해준다.
이 코드는 카카오 기술블로그에서 설명해준 코드와 동일하다
Skeletons를 이 컴포넌트로 묶어 아직 데이터가 패칭중일 때 바로 스켈레톤UI를 보여주지 않고 setTimeout으로 체크해 200m가 지났을 경우에만 Skeletons을 보여주도록 children을 return해준다.

const DefferedComponent = ({ children }) => {
  const [isDeferred, setIsDeferred] = useState(false);

  useEffect(() => {
    const timeoutId = setTimeout(() => {
      setIsDeferred(true);
    }, 200);
    return () => clearTimeout(timeoutId);
  }, []);

  if (!isDeferred) {
    return null;
  }

  return <>{children}</>;
};

 

 

 
문제

 

200ms내에는 스켈레톤UI가 뜨지 않고 잘 동작하는 것을 확인하고 <RenderPopulerProducts />에도 동일한 방식으로 코드를 추가해주었다. 근데 Suspense가 두개가 되니 내가 예상한대로 동작하지 않기 시작했다. 둘다 176ms 정도로 정보가 불러와지고 둘의 차이가 미세함에도 불구하고 하나에는 스켈레톤이 뜨고 하나에는 안뜨는 현상이 나타났다. 둘 중의 하나가 제대로 동작하지 않나 확인하러 각자 확인해봤지만 하나씩만 있을 때는 잘 동작했다. 이유가 무엇일지 계속 붙잡고 고민했다

 

 

 

추론

 

정확하지 않은 내 생각일 뿐이지만 이유라고 생각하는 부분에 대해 적어보겠다.
setTimeout은 어떻게 동작하나 생각해보면
seTimeout이 있으면 브라우저API가 시간을 체크하다가 시간이 다 됐을 때 setTimeout의 콜백함수가 태스크큐로 옮겨진다. 이후 이벤트 루프가 콜스택이 비었는지 확인하다가 비었을 때 콜스택으로 콜백함수가 옮겨지고 실행되는 것이다. 
지금과 같은 두개의 setTimeout이 있는 상황은 어떨까?
두개의 콜백함수 모두 시간이 다 되면 태스크큐로 옮겨질 것이고 콜스택이 비면 순서대로 옮겨질 것이다. 이때 먼저 콜스택으로 간 setTimeout의 콜백함수가 실행되는 동안 나머지 한개의 데이터패칭이 끝나면 어떨까? 아마 하나는 콜백함수가 실행됐기 때문에 스켈레톤UI가 보여질것이고 다른 하나는 데이터패칭이 끝나 DefferedComponent가 언마운트 돼 스켈레톤UI를 보여주지 않고 children 컴포넌트를 보여주게 될 것이다.
확실하지는 않지만 시간을 조금 넉넉하게 잡아 setTimeout에 500ms 정도를 줬을 때는 둘이 똑같이 동작하는 것을 보고 이러한 문제일 것이라고 조금 더 확신이 들었다.

 

 

결론

 

그래서 위에 문제를 해결하여 둘이 똑같이 동작하도록 해결을 했냐 하면 못했다..위에서 말한 것처럼 500ms로 설정하면 똑같이 동작하기는 하겠지만 우리 사이트의 응답 지연시간을 네트워크에 따라 확인해본 결과 3G에서만 500ms를 넘어가게 걸렸다. 즉, 500ms로 설정하면 느린 네트워크에서도 스켈레톤을 안보여주게 되어 스켈레톤이 아예 없는 것과 같은 사용자 경험을 제공할 것이다. 딱 200ms로 설정하고 똑같이 동작하게 하고 싶은데 현재로서는 해결책이 보이지 않아 일단 여기서 보류하고 조금 더 고민해봐야할 것 같다. 해결 한다면 그 내용을 블로그에 추가하겠다!  

해결을 하지는 못했지만 이 문제에 대해 고민하면서 Suspense에 동작과 setTimeout의 동작에 대해 더 깊이 고민해보는 시간을 가질 수 있었다:)

 


+ 10 / 1 업데이트 (문제 해결)

 

구현하고 나서 계속 이해가 안가는 부분이 있었다. 데이터를 가져올 때 깜박임 현상이였는데, 데이터 로드 후 깜박임이 아니었다.
개발자 도구의 네트워크 탭을 확인해봤을 때 자바스크립트 로딩 전에 미리 데이터가 가져와져있고 자바스크립트 로드돤 후 데이터 패칭이 일어나면서 데이터가 두번 렌더링이 발생하는 문제였다.


원인을 확인해보다가 Suspense가 깜박임 현상을 유발하는 것을 확인했다. 그래서 Suspense를 없애고 setTimeout으로만 구현하였더니 이러한 문제가 해결됐다. 정확히 어떻게 이런 일이 일어나는지는 모르겠지만 사용자 경험에 Suspense를 없애는게 좋을 것 같아서 결국은 Suspense를 제거 해주었다. 

Suspense를 제거해주었더니 위에서 내가 추론만 하고 해결하지 못했던 두 컴포넌트가 다르게 동작하는 문제까지 해결됐다..!
이제 200ms 안에서는 두 컴포넌트 모두 스켈레톤 없이 거의 동시에 렌더링이 되고 200ms를 넘을 시 스켈레톤 UI가 뜨고 렌더링이 됐다.

아직 Suspense가 이러한 문제를 유발한 정확한 이유는 파악하지 못했다..이후 이 현상에 대해 정확한 원인을 파악하면 블로그를 업데이트하겠다!


결과 화면


- 응답 지연시간이 200ms보다 작을 때

 

- 응답 지연시간이 200ms보다 클 때

 

 


++ 추가 문제


Suspense 제거 후 확인을 하던 중 데이터가 로드 되고 나서의 깜박임 현상을 발견했다. 이는 useInfinteQuery에서 다음 페이지를 확인하는 getNextPageParam의 조건 때문에 발생하는 문제였다. 

  const useProducts = queryString => {
    return useSuspenseInfiniteQuery({
      queryKey: ['products', !!queryString.length ? queryString : 'all'],
      queryFn: ({ pageParam }) => getProducts(queryString, pageParam),
      initialPageParam: 0,
      getNextPageParam: (lastPage, allPages) => {
        if (lastPage.length < 48) return undefined; // 이 부분을 === 0에서 < 48로 변경했다
        const lastProduct = lastPage[lastPage.length - 1];
        return { lastId: lastProduct._id, lastCreatedAt: lastProduct.createdAt };
      },
      staleTime: 60 * 1000,
    });
  };


조건은 0일 때가 아니라 가져오고 있는 limit개수인 48보다 작을 시 다음 페이지가 없는 것이니 이를 가져오지 않게 수정을 해주었다.
깜박임 현상도 사라졌고 react developer tools를 확인한 결과 커밋 횟수도 14 -> 9로 줄어든 것을 볼 수 있었다.

 




계속해서 들여다보니 수정할게 보인다 좀 더 나은 방향으로 수정할 수 있게 계속해서 고민해야겠다..화이팅!-!