Vuex Store를 사용한 로딩 스피너 관리

부루베릐·2023년 4월 30일
0

TIL

목록 보기
9/20

이번에 내가 담당하는 서비스에서 로딩 스피너 UI의 작동 방식을 개선하였다. 기존에는 Nuxt 컴포넌트나 페이지에서 각각 로딩 스피너를 관리하였다면, 이제는 Vuex store와 Nuxt 레이아웃에서 전역적으로 관리하도록 수정하였다. 이 모든 것은 새롭게 페이지를 만들면서 그 안의 자식 컴포넌트에 필요한 데이터를 어떻게 가져올 것인지 고민하면서부터 시작되었다.

API 호출에 대한 고민

상황

Vue 페이지 컴포넌트 A가 있고, A의 자식 컴포넌트 B, C, D가 있다. 이 때 B, C, D에서 필요로 하는 데이터들을 서버에서 각각 B-API, C-API, D-API API 호출로 받아 와야 한다.

이 때 A가 created될 때 이 3개의 API를 모두 다 받아 와 데이터를 B, C, D에 props로 넘겨주는 것이 좋을까, 아니면 각 컴포넌트가 필요로 하는 API를 각 컴포넌트가 created될 때 받아오는 것이 좋을까?


나의 판단

굳이 부모 컴포넌트에서 데이터를 사용할 것이 아니면 각 컴포넌트 별로 각각 필요한 데이터를 받아오는 것이 좋을 것 같다.

그 이유는 부모 컴포넌트와 자식 컴포넌트 간의 결합도도 높지 않아야 한다 생각하기 때문이다. 결합도가 높을 경우 둘 중 하나만 변경하였을 때 다른 하나도 같이 변경되어야 하기 때문에 유지보수가 쉽지 않다. 따라서 자식 컴포넌트가 필요한 데이터는 각 컴포넌트가 API 요청을 받아 유지하는 것이 좋다.

따라서 인보이스 대시보드의 데이터를 인보이스 페이지에서 사용하는 경우가 없으므로 대시보드 컴포넌트 안에서 데이터를 받아오기로 하였다.


로딩 스피너를 공통으로 관리하자

이렇게 컴포넌트 데이터를 가져오면 로딩 스피너 UI 구현이 살짝 복잡해진다. 페이지에 필요한 데이터를 모두 다 받아올 때까지 로딩 스피너를 UI에서 표시해주어야 하는데, 만약 페이지에서 모든 데이터를 받아온다면 어렵지는 않다. 아래가 바로 그런 코드다.

로딩 스피너 구현 1 - 페이지에서 필요한 모든 데이터를 페이지 컴포넌트에서 요청

// pages/pageA.vue
<template>
  <div>
    <component-b :data="dataB" />
    <component-c :data="dataC" />
    <component-d :data="dataD" />
    <loading-spinner v-if="isLoading"/>
  </div>
</template>

<script>
//... 
export default defineComponent({
  components: { componentB, componentC, componentD, loadingSpinner },
  layout: { layoutA },
  setup() {
    const isLoading = ref(false)
    const dataB = ref({})
    const dataC = ref({})
    const dataD = ref({})

    isLoading.value = true
    axios.get('get/data/B')
      .then((res) => {
        dataB.value = res.data
        return axios.get('get/data/C')
      })
      .then((res) => {
        dataC.value = res.data
        return axios.get('get/data/D')
      })
      .then((res) => {
        dataD.value = res.data
        isLoading.value = false
      })
      .catch((error) => {
        console.log(error)
        isLoading.value = false
      })

    return { isLoading, dataB, dataC, dataD }
  },
})
</script>

일단 Promise.all이나 allSettled를 통한 병렬 처리는 해 주지는 않은 상태다. 로딩 스피너를 페이지에 import한 다음, 로딩 스피너를 컨트롤하는 플래그 isLoading를 만들어서 사용한다. 이 때 모든 데이터를 페이지 컴포넌트에서 받아온 다음, 그 데이터들을 각자가 필요한 컴포넌트에게 props를 통해 넘겨주고 있다. 하지만 우리는 각 컴포넌트에서 필요한 데이터는 각자 컴포넌트에서 받아오기로 했다. 따라서 위의 코드를 수정해 주어야 한다.

로딩 스피너 구현 2 - 각 컴포넌트가 필요한 데이터를 요청

아래처럼 로딩 스피너는 컴포넌트 하나가 아닌 한 페이지를 다 덮어야 한다. 따라서 로딩 스피너는 페이지 컴포넌트에 구현되어야 한다.

그럼 페이지에서 isLoading 플래그를 관리하여야 하는 건데… 어떻게 해야 자신의 하위 컴포넌트에서 데이터 요청이 완료되었다는 것을 알 수 있을까? 바로 이벤트 emit이다. 하위 컴포넌트에서 자신의 데이터 fetching이 끝나면 이벤트를 부모 컴포넌트로 emit할 수 있다. 페이지 컴포넌트는 하위 컴포넌트 모두에게서 이 이벤트를 isLoading 플래그의 값을 변경하면 된다.

자식 컴포넌트에서 부모 페이지 컴포넌트로 이벤트를 emit하는 코드이다.

// components/component-b.vue

export default defineComponent({
  setup(props, context) {
    const dataB = ref({})

    axios.get('get/data/B')
      .then(res => {
        dataB.value = res.data
        context.emit('loaded')
      })
  }
})

부모 컴포넌트에서 이벤트를 받아 isLoading 플래그를 변경하는 코드이다.

// pages/pageA.vue

<template>
  <div>
    <component-b :data="dataB" @loaded="onComponentBLoad" />
    <component-c :data="dataC" @loaded="onComponentCLoad" />
    <component-d :data="dataD" @loaded="onComponentDLoad" />
    <loading-spinner v-if="isLoading" />
  </div>
</template>

<script>
// ... 
export default defineComponent({
  components: { componentB, componentC, componentD, loadingSpinner },
  layout: { layoutA },
  setup() {
    const dataB = ref({})
    const dataC = ref({})
    const dataD = ref({})

    const isComponentBLoading = ref(false)
    const isComponentCLoading = ref(false)
    const isComponentDLoading = ref(false)

    const onComponentBLoad = () => { isComponentBLoading.value = true }
    const onComponentCLoad = () => { isComponentCLoading.value = true }
    const onComponentDLoad = () => { isComponentDLoading.value = true }

    const isLoading 
      = computed(() => isComponentBLoading && isComponentCLoading && isComponentDLoading)

    return {
      onComponentBLoad,
      onComponentCLoad,
      onComponentDLoad,
      isLoading
    }
  }
})
</script>

이렇게 놓고 보니 좀 복잡하다.

일단 각 자식 컴포넌트마다 로딩이 완료되었는지 여부를 판단하는 플래그를 따로 두어야 한다. 지금 컴포넌트가 3개만 되었는데도 이렇게 복잡한데, 자식 컴포넌트의 수가 더 늘어나면 늘어날수록 관리하기 힘들어질 게 뻔하다. 게다가 로딩 플래그의 값을 변경해주기 위해 각 컴포넌트로부터 이벤트를 받아 핸들링을 해야 한다. 리팩토링을 좀 거치면 코드의 복잡도가 줄어들 수는 있지만, 그래도 더 깔끔한 해결 방안이 있을 거 같다.

그러다 보니 하나의 의문이 떠올랐다. 왜 로딩 스피너를 전역적으로 관리해주지 않는 거지?

지금 우리의 로딩 스피너는 각 페이지별로 따로따로 구성되어 있다. 게다가 페이지의 자식 컴포넌트에선 로딩 스피너를 핸들링하기도 어렵다. 하지만 컴포넌트에서 로딩이 완료되었는지 여부를 전역적으로 체크하고 페이지의 상위 컴포넌트인 레이아웃에서 로딩 스피너를 구현한다면 어떨까? UI는 오로지 레이아웃에서만 고민하고, 각 컴포넌트에서 데이터를 로딩한 후 전역 플래그 값을 변경해주면 되므로 관리도 단순해질 것이다.

이런 생각에 Vuex Store를 사용하여 위의 코드를 리팩토링하기로 하였다.

로딩 스피너 구현 3 - Vuex store를 이용한 로딩 스피너 관리

Vuex 스토어 안에서 isLoading 플래그를 관리하기 위한 state, getter, mutation, action을 설정해 주었다.

// store/index.js

export const state = () => ({
  isLoading: false,
})

export const getters = {
  isLoading: state => state.isLoading,
}

export const mutations = {
  setIsLoading(state, payload) {
    state.isLoading = payload
  },
}

export const actions = {
  updateIsLoading({ commit }, payload:) {
    commit(setIsLoading, payload)
  },
}

그리고 로딩 스피너 UI를 각 페이지 컴포넌트가 아닌 레이아웃에 설정해주도록 한다. 우리 서비스의 대부분의 페이지에서 사용하는 레이아웃을 layoutA라고 했을 때, 다음과 같이 작성할 수 있다.

// layouts/layout-a.vue

<template>
  <main>
    <nav-bar />
    <Nuxt />
    <dot-flashing v-show="isLoading" />
  </main>
</template>

<script>
// ... 
export default defineComponent({
  name: 'layoutA',
  components: { navBar, dotFlashing },
  setup() {
    const { getters, dispatch } = useStore()
    const isLoading = computed(() => getters.isLoading)

    return { isLoading }
  }
})
</script>

이제 전역적으로 관리되는 isLoading 상태값이 true일 때 이 레이아웃을 사용하는 페이지의 화면에 로딩 스피너가 나타나게 될 것이다.

// pages/pageA.vue

<template>
  <div>
    <component-b />
    <component-c />
    <component-d />
  </div>
</template>

<script>
// ... 
export default defineComponent({
  components: { componentB, componentC, componentD },
  layout: { layoutA }, // 레이아웃!
  setup() {
		// 다른 작업들...
  }
})
</script>

이제 페이지에서 로딩 스피너를 관리할 필요가 없다. 각 컴포넌트에서 데이터 로딩 전후로 직접 store에서 isLoading 플래그를 변경해주면 된다.

// components/component-b.vue

export default defineComponent({
  setup(props, context) {
    const { dispatch } = useStore()    
    const dataB = ref({})

    dispatch('updateIsLoading', true)
    axios.get('get/data/B')
      .then(res => {
        dataB.value = res.data				
      })
      .catch(error => {
        console.error(error)
      })
      .finally(() => {
        dispatch('updateIsLoading', false)
      }
  }
})

then 뿐만 아니라 catch나 finally를 통해 프로미스 체인에서 에러 핸들링을 가능하도록 개선하였다.

이제 각 컴포넌트에서 자신이 필요한 데이터를 로딩한 후 isLoading 값을 각자 수정해주면 된다. isLoading 값을 수정하면 레이아웃에서 computed로 관리하는 getters의 값이 변경되면서 로딩 스피너 UI가 동작하게 될 것이다!

0개의 댓글