Home 우아한? 비동기 로딩 처리
Post
Cancel

우아한? 비동기 로딩 처리

개요

HelloMaple 백오피스 개발하는 중 데이터를 불러올 때 테이블에 로딩 처리를 하는 페이지와 그렇지 않은 페이지가 있어서 로딩을 제거할지 아니면 테이블에 모두 적용할지 고민에 빠졌다. 같이 고민하면 좋을 것 같아서 팀 메신저를 통해 아래 내용을 물어봤다.

흠 HelloMaple 백오피스를 개발하면서 고민이 되는 사항이 있어서 여기 남겨봅니다.

백오피스에서 vuetify를 사용하는데요!
v-data-tableloading props가 있어 비동기 통신이 끝나기 전 까지 로딩화면을 보여주고 있어요. 하지만,
v-tableloading props가 없어 로딩 관련 처리를 하지 않고 있습니다.

라이브 기준으로 카테고리를 조회하는데 약 461ms 정도 소요되네요. 이 상황에서!

  • 1안. 백오피스는 대유저 서비스가 아니니까 별도 로딩 처리를 하지 않아도 괜찮다!
  • 2안. 무슨소리냐! 백오피스도 로딩처리를 해야한다.

2안으로 진행한다고 했을 때 n-data-table, n-table 래핑 컴포넌트를 만들어서 컴포넌트 내부에서 로딩 처리해 재 사용성을 높이는 방법을 사용하면 좋을 것 같습니다. 또 로딩 UI 지속 시간에 대해서 생각할 때 추가 고민이 발생하네요. (아래)

  • 2-A안. 서버 통신이 너무 짧을 경우 로딩 UI는 사용자에게 혼란스러운 느낌을 줄 수 있기 때문에 최소한 300ms 정도 로딩 UI를 보여준다.
  • 2-B안. 로딩 UI를 바로 보여주지 않고 200ms 정도 빈 화면을 보여주고 그 이후 아직도 서버로 부터 데이터를 받아오지 못했다면 로딩 UI를 보여준다.

2-B안은 카카오 기술 블로그에서 로딩 처리 방법을 보고 가져온 내용입니다.

이에 팀원 대다수가 2-A안이 좋을 것 같다는 의견이 있어 개발 방향을 2-A안으로 잡았다.

구현하기

useDelayedLoading Composable 구현하기

Vue 3의 Composable 을 사용해 최소 300ms 만큼 지연시간을 갖는 로딩 상태 값을 구현했다. 아래 코드를 보자!

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
31
32
33
34
35
36
37
38
import type { Ref } from 'vue';
import dayjs from 'dayjs';
import { DEFAULT_PENDING_TIME } from 'assets/ts/common/constants';
 
export function useDelayedLoading(loading: Ref<boolean>) {
  const startTime: Ref<null | number> = ref(null);
  const delayedLoading = ref(false);
 
  watch(
    loading,
    (newLoading: boolean) => {
      if (newLoading) {
        delayedLoading.value = true;
        startTime.value = dayjs().valueOf();
      } else {
        if (!startTime.value) {
          return;
        }
 
        const endTime = dayjs().valueOf();
        const fetchingTime = endTime - startTime.value;
        if (fetchingTime > DEFAULT_PENDING_TIME) {
          clear();
        } else {
          setTimeout(clear, DEFAULT_PENDING_TIME - fetchingTime);
        }
      }
    },
    { immediate: true }
  );
 
  function clear() {
    startTime.value = null;
    delayedLoading.value = false;
  }
 
  return delayedLoading;
}

코드를 설명하면,

  1. 외부에서 loading 이라는 상태(ref)값을 전달 받아서 watch에서 구독한다.
  2. loading 상태 값이 true가 된 시점에 startTime을 기록한다.
  3. loading 상태 값이 false가 된 시점에 endTime을 기록한다.
  4. endTimestartTime 차이를 구한다.
    • 300ms 보다 크면 delayedLoadingfalse로 변경하고 끝난다.
    • 300ms 보다 작으면 그 차이만큼 delay를 준 후 delayedLoadingfalse로 변경하고 끝난다.

n-data-table, n-table에 구현하기

n-data-table, n-table 원리는 거의 비슷해 n-table 코드만 부분적으로 살펴보자.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// props는 ref 형식이 아니라 watch를 이용해 ref 형식으로 변경한다.
const isLoading: Ref<boolean> = ref(false);
watch(
  () => props.loading,
  (newValue) => {
    isLoading.value = newValue || false;
  },
  { immediate: true }
);
 
// Composable을 사용해 delayedLoading 값을 가져온다.
const delayedLoading = useDelayedLoading(isLoading);
 
// props로 전달 받은 header 정보를 이용해 skeleton 배열을 만든다.
const skeletons = computed(() => {
  return (
    props.headers &&
    props.headers.map((n, index) => {
      return { key: index, width: n.width };
    })
  );
});

// template 부분 코드
<template v-if="delayedLoading">
  <tbody>
    <tr
      :key="index"
      class="v-data-table__tr"
      v-for="index in ITEM_PER_PAGE"
    >
      <td
        :key="key"
        class="v-data-table__td"
        :style="{ width }"
        v-for="{ key, width } in skeletons"
      >
        <div class="d-flex">
          <v-skeleton-loader
            class="skeleton_table"
            :class="!width && 'flex-grow-1'"
            type="list-item"
          />
        </div>
      </td>
    </tr>
  </tbody>
</template>
<template v-else>
  <slot name="body"></slot>
</template>

사용하기

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
31
32
33
34
35
36
37
38
39
40
41
42
// useAsyncData 사용 부분 코드
const {
  data: list,
  refresh,
  pending, // pending은 useAsnycData에서 내려주는 비동기 로딩 ref 값이다.
  error
} = await useAsyncData(
  () => {
    return $api.category.getExposureCategoryList({
      categoryTypeId: selectedTab.value,
      countryGroupId: COUNTRY_GROUP_ID
    });
  },
  {
    watch: [selectedTab],
    default(): TransformedExposureCategoryList {
      return { result: [] };
    },
    lazy: true
  }
);
 
// template 부분 코드 (pending 전달)
<n-table
  class="table_category"
  :loading="pending"
  :headers="// prettier-ignore
  [
    { title: '정렬', width: '65px', align: 'center' },
    { title: '카테고리 번호', width: '120px', align: 'center' },
    { title: '이름', width: '200px', },
    { title: '노출상태', width: '200px', align: 'center' },
    { title: '즉시노출', width: '100px', align: 'center' },
    { title: '노출시간설정', align: 'center' },
    { title: '삭제', width: '100px', align: 'center' }
  ]"
>
  <template #body>
    <!-- 중략...->
  </template>
</n-table>

결과물

Mock 데이터를 활용해 테스트를 해봤다. 아래 영상을 보자

서버 통신이 300ms 보다 긴 경우 (서버 통신 시간: 1,500ms)

loading gif, 서버 통신이 300ms 보다 긴 경우 실제 통신 시간이 300ms 보다 길기 때문에 1,500ms 동안 Skeleton UI를 노출한다.

서버 통신이 300ms 보다 짧은 경우 (서버 통신 시간: 100ms)

loading gif, 서버 통신이 300ms 보다 짧은 경우 실제 통신 시간이 100ms 라고 하더라도 300ms 동안 Skeleton UI를 노출한다.