Vuex에서의 이주

왜?

우리는 GraphQL API를 모든 사용자 지향 기능에 대한 주요 API로 정의했기 때문에 GraphQL이 등장할 때마다 Apollo Client도 함께 등장할 것으로 가정할 수 있습니다. 우리는 Vuex를 Apollo와 함께 사용하고 싶지 않습니다, 그래서 REST API에서 GraphQL로 이동함에 따라 VueX 스토어 수는 자연스럽게 줄어들 것입니다.

이 섹션에서는 기존의 VueX 스토어를 순수 Vue 및 Apollo로 이주하거나 VueX에 덜 의존하는 방법에 대한 지침과 방법을 제공합니다.

어떻게?

개요

전체적으로, 우리는 변경이 얼마나 복잡한지 이해하고 싶습니다. 때로는 전역 상태에 저장할 가치가 있는 속성이 매우 적을 수 있으며 때로는 모두 순수한 Vue로 추출할 수 있는 경우도 있습니다. VueX 속성은 일반적으로 다음 범주 중 하나에 속합니다.

  • 정적 속성
  • 반응적 가변 속성
  • 게터
  • API 데이터

따라서 첫 번째 단계는 현재 VueX 상태를 읽고 각 속성의 범주를 결정하는 것입니다.

전반적으로, 각 범주를 동등한 비-VueX 코드 패턴과 매핑할 수 있습니다.

  • 정적 속성: Vue API에서 제공/주입.
  • 반응적 가변 속성: Vue 이벤트 및 프롭스, Apollo Client.
  • 게터: 유틸리티 함수, Apollo update 훅, 계산된 속성.
  • API 데이터: Apollo Client.

예제를 통해 진행해보겠습니다. 각 섹션에서 이 상태를 참조하고 점진적으로 전이하는 방법을 설명합니다.

// state.js 또는 store
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
  blobPath,
  summaryEndpoint,
  suiteEndpoint,
  testReports: {},
  selectedSuiteIndex: null,
  isLoading: false,
  errorMessage: null,
  limit : 10,
  pageInfo: {
    page: 1,
    perPage: 20,
  },
});

정적 값의 이주 방법

가장 쉽게 이주할 값은 정적 값입니다. 다음과 같습니다.

  • 클라이언트 측 상수: 정적 값이 클라이언트 측 상수인 경우, 해당 정적 값을 다른 상태 속성이나 메서드에서 쉽게 액세스할 수 있도록 저장소에 추가되었을 수 있습니다. 그러나 보통은 constants.js 파일에 해당 값을 추가하고 필요할 때 가져오는 것이 더 좋은 방법입니다.
  • Rails 주입 데이터셋: 이는 Vue 앱에 제공해야 할 값입니다. 이 값들은 정적이므로 Vuex 스토어에 추가할 필요가 없으며 오히려 provide/inject Vue API를 통해 쉽게 수행할 수 있습니다. 이것은 VueX의 오버헤드 없이 동등하게 가능하지만 이 작업은 컴포넌트를 마운트하는 최상위 JS 파일 내에서 주입되어야 합니다.

이 예제를 살펴보면 두 개의 속성에 이름이 Endpoint로 끝나는 것을 이미 볼 수 있습니다. 이는 아마도 이들이 Rails 데이터셋에서 가져온 것이라는 것을 의미합니다. 이것을 확인하기 위해 코드베이스에서 이러한 속성을 검색하여 어디에서 정의되었는지 확인합니다. 또한 blobPath도 정적 속성이고, 여기서 조금 덜 명확한 것은 pageInfo가 실제로 상수라는 것입니다! 이것은 결코 수정되지 않으며 getter 내에서만 사용되는 기본값으로 사용되는 상수입니다.

// state.js 또는 store
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
  limit
  blobPath, // 정적 - 데이터셋
  summaryEndpoint, // 정적 - 데이터셋
  suiteEndpoint, // 정적 - 데이터셋
  testReports: {},
  selectedSuiteIndex: null,
  isLoading: false,
  errorMessage: null,
  pageInfo: { // 정적 - 상수
    page: 1, // 정적 - 상수
    perPage: 20, // 정적 - 상수
  },
});

반응적 가변 값의 이주 방법

이러한 값은 다양한 컴포넌트에서 사용할 때 특히 유용하므로 각 속성이 얼마나 자주 읽히고 쓰이는지, 그리고 이들 사이의 거리가 어떻게 되는지를 먼저 평가할 수 있습니다. 읽기가 적고 가깝게 위치한 경우 이러한 속성을 네이티브 Vue 프롭스와 이벤트를 통해 제거하는 것이 쉬워집니다.

간단한 읽기/쓰기 값

예제로 돌아가면, selectedSuiteIndex하나의 컴포넌트에서만 사용되며 게터 내에서 한 번 사용됩니다. 게다가, 이 게터도 한 번만 사용됩니다! 이 로직을 Vue로 변환하는 것은 매우 쉽습니다. 이것은 컴포넌트 인스턴스의 data 속성이 될 수 있습니다. 게터의 경우에는 계산된 속성이나 해당 인덱스를 반환하는 컴포넌트의 메서드를 사용할 수 있습니다. 여기서 모든 것이 같은 컴포넌트 내에서 처리될 수 있기 때문에 VueX 스토어가 실제로 많은 추상화를 추가하여 모든 것이 동일한 컴포넌트 내에 존재할 때도 복잡성을 증가시킨다는 것입니다.

다행히도, 이 예제에서 모든 속성이 동일한 컴포넌트 내에 있을 수 있습니다. 그러나 이를 위한 가능성이 없는 경우도 있습니다. 이럴 때에는 Vue 이벤트와 프롭스를 사용하여 형제 컴포넌트 간에 통신할 수 있습니다. 상태에 대해 알아야 하는 부모 컴포넌트에 데이터를 저장하고, 자식 컴포넌트가 컴포넌트에 새 값으로 이벤트를 보내어 부모가 업데이트할 수 있게 합니다. 그런 다음, 모든 하위 컴포넌트에 props를 계단식으로 전달함으로써 형제 컴포넌트의 모든 인스턴스가 동일한 데이터를 공유하게 됩니다.

때로는 이벤트와 프롭스가 매우 귀찮게 느껴질 수 있습니다. 그러나 이것은 대부분의 경우 편의 문제이며, 아키텍처적인 결함이나 해결해야 할 문제가 아님을 알아두는 것이 매우 중요합니다. 매우 깊게 중첩된 경우에도 프롭스를 전달하는 것은 컴포넌트 간 통신에 대한 매우 허용 가능한 패턴입니다.

공유 읽기/쓰기 값

여러 컴포넌트에서 읽기와 쓰기에 사용되는 스토어 속성이 있을 때, Vue props 및 이벤트가 나쁜 해결책으로 보일 수 있을 정도로 횟수가 많거나 멀리 떨어져 있을 수 있습니다. 대신, 우리는 Apollo 클라이언트 측 리졸버를 사용합니다. 이 섹션은 Apollo Client의 지식이 필요하므로 필요에 따라 Apollo 상세 정보를 확인하십시오.

먼저 Vue 앱이 VueApollo를 사용하도록 설정해야 합니다. 그런 다음, 스토어를 생성할 때 Apollo 클라이언트에 resolverstypedefs (나중에 정의 됨)를 전달합니다.

import { resolvers } from "./graphql/settings.js"
import typeDefs from './graphql/typedefs.graphql';

...
const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient({
    resolvers, // 곧 작성 예정
    { typeDefs }, // 이것은 잠시 후에 생성할 예정입니다
  }),
});

당사의 예제에서 우리 필드를 app.status라고 하고, @client 지시문을 사용하는 쿼리와 뮤테이션을 정의해야 합니다. 이제 바로 만들어 봅시다.

// get_app_status.query.graphql
query getAppStatus {
  app @client {
    status
  }
}
// update_app_status.mutation.graphql
mutation updateAppStatus($appStatus: String) {
  updateAppStatus(appStatus: $appStatus) @client
}

스키마에 존재하지 않는 필드의 경우에는 typeDefs를 설정해야 합니다. 예를 들어:

// typedefs.graphql

type TestReportApp {
  status: String!
}

extend type Query {
  app: TestReportApp
}

이제 우리는 리졸버를 작성하여 뮤테이션을 통해 필드를 업데이트할 수 있습니다.

// settings.js
export const resolvers = {
  Mutation: {
    // 우리 뮤테이션의 인수인 appStatus
    updateAppStatus: (_, { appStatus }, { cache }) => {
      cache.writeQuery({
        query: getAppStatus,
        data: {
          app: {
            __typename: 'TestReportApp',
            status: appStatus,
          },
        },
      });
    },
  }
}

쿼리 작성 시 추가 지시가 없이도 작동하는 이유는 app { status }에 대한 쿼리가 app.status와 동일하게 작동하기 때문입니다. 그러나 “기본” writeQuery를 작성하거나 cacheConfigtypePolicies를 설정하여 이 기본 값을 제공해야 합니다.

이제 이 값에서 읽을 때는 로컬 쿼리를 사용할 수 있습니다. 업데이트가 필요할 때는 뮤테이션을 호출하고 새 값으로 전달할 수 있습니다.

네트워크 관련 값

isLoadingerrorMessage와 같이 네트워크 요청 상태에 연결된 값이 있습니다. 이러한 값들은 읽기/쓰기 속성이지만, 우리가 추가 작업을 하지 않고도 Apollo 클라이언트의 능력으로 쉽게 대체될 것입니다.

// state.js 또는 우리의 스토어
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
  blobPath, // 정적 - 데이터셋
  summaryEndpoint, // 정적 - 데이터셋
  suiteEndpoint, // 정적 - 데이터셋
  testReports: {},
  selectedSuiteIndex: null, // 변경 가능 -> 데이터 속성
  isLoading: false, // 변경 가능 -> 네트워크에 연결
  errorMessage: null, // 변경 가능 -> 네트워크에 연결
  pageInfo: { // 정적 - 상수
    page: 1, // 정적 - 상수
    perPage: 20, // 정적 - 상수
  },
});

Getters 마이그레이션 방법

Getters는 경우에 따라 검토해야 하지만, 일반적인 지침은 우리가 이전에 getter 내부에서 사용했던 state 값을 인수로 받는 순수한 JavaScript 유틸 함수를 작성할 수 있다는 것입니다. 다음과 같은 getter를 고려해 보십시오:

// getters.js
export const getSelectedSuite = (state) =>
  state.testReports?.test_suites?.[state.selectedSuiteIndex] || {};

여기서 우리가 하는 것은 두 개의 state 값을 참조한 것뿐이며, 이 둘은 함수의 인수가 될 수 있습니다.

// new_utils.js
export const getSelectedSuite = (testReports, selectedSuiteIndex) =>
  testReports?.test_suites?.[selectedSuiteIndex] || {};

이 새로운 유틸은 이전과 같이 가져와서 사용할 수 있지만, 직접적으로 컴포넌트 내부에서 사용됩니다. 또한, 대부분의 getter 사양들은 로직이 보존되어 있기 때문에 대부분을 유틸로 쉽게 옮길 수 있습니다.

API 데이터 마이그레이션 방법

마지막 속성은 testReports라고 하며, API로부터 axios 호출을 통해 가져옵니다. 우리는 순수 REST 애플리케이션에 있다고 가정하고 현재는 GraphQL 데이터를 사용할 수 없다고 가정합니다.

// actions.js
export const fetchSummary = ({ state, commit, dispatch }) => {
  dispatch('toggleLoading');

  return axios
    .get(state.summaryEndpoint)
    .then(({ data }) => {
      commit(types.SET_SUMMARY, data);
    })
    .catch(() => {
      createAlert({
        message: s__('TestReports|There was an error fetching the summary.'),
      });
    })
    .finally(() => {
      dispatch('toggleLoading');
    });
};

여기에는 두 가지 옵션이 있습니다. 이 액션이 한 번만 사용된다면, actions.js 파일의 모든 코드를 actions.js 파일에서 데이터 속성을 사용하는 해당 컴포넌트로 이동하는 데 아무런 장애요인이 없습니다. 그런 다음 isLoadingerrorMessages는 그것과 함께 살 것입니다.

여러 번 이 기능을 재사용하는 경우 (또는 재사용할 계획이 있다면), Apollo Client를 이용하여 최상으로 네트워크 호출과 캐싱을 수행하는 데 제한이 없습니다. 이 섹션에서는 Apollo Client 지식 및 설정 방법을 가정하고 있지만, GraphQL 설명서를 읽어보세요.

로컬 GraphQL 쿼리(@client 지시문 사용)를 사용하여 데이터를 수신하는 방식을 구성하고, 그런 다음 클라이언트 측 리졸버를 사용하여 해당 쿼리를 해결하는 방법을 Apollo Client에 알려야 합니다. 브라우저 네트워크 탭에서 REST 호출을 확인하고 사용 사례에 적합한 구조를 결정할 수 있습니다. 우리의 예에서는 쿼리를 다음과 같이 작성할 수 있습니다:

query getTestReportSummary($fullPath: ID!, $iid: ID!, endpoint: String!) {
  project(fullPath: $fullPath){
    id,
    pipeline(iid: $iid){
      id,
      testReportSummary(endpoint: $endpoint) @client {
        testSuites{
          nodes{
            name
            totalTime,
            # 여기에는 더 많은 필드가 있지만 예제에는 필요하지 않습니다
          }
        }
      }
    }
  }
}

이 구조는 우리가 원하는 대로 작성할 수 있는 임의의 구조이며, REST 호출 구조와 일치하지 않아도 됩니다. 그러나 GraphQL API를 따르도록 쿼리 구조를 만들면 우리가 나중에 GraphQL로 전환하려고 결정하더라도 쿼리를 수정할 필요가 없으며 @client 지시문만 제거하면 될 것입니다. 이로 인해 우리는 이미 결과를 가지고 있는지 Apollo Client가 알기 때문에 이것은 또한 무료 캐싱을 제공합니다! 또한, testReportSummaryendpoint 인수를 전달하고 있습니다. 순수한 GraphQL에서는 이것이 필요하지 않을 수 있지만, 우리의 리졸버가 나중에 REST 호출을 수행하기 위해 그 정보가 필요합니다.

이제 클라이언트 측 리졸버를 작성해야합니다. 필드를 @client 지시문과 함께 표시하면 서버로 전송되지 않으며, Apollo Client는 대신에 값을 해결하기 위한 코드를 정의하도록 기대합니다. 클라이언트 측 리졸버는 Axios 호출을 수행하고 원하는 데이터 구조를 반환하는 작업의 결과를 반환합니다. 또한, 항상 API 데이터를 액세스할 때 사용되거나 데이터 구조를 조작하는 것이었다면 이것이 완벽한 위치라는 것입니다.

// graphql_config.js
export const resolvers = {
  Query: {
    testReportSummary(_, { summaryEndpoint }): {
    return axios.get(summaryEndpoint).then(({ data }) => {
      return data // 여기에서 데이터를 형식화/조작할 수 있습니다, getter를 사용하는 대신
    }
  }
}

testReportSummary @client 필드에 호출을 할 때마다, 이 리졸버가 실행되어 작업의 결과를 반환하게 되며, 이는 실제로 VueX 액션과 동일한 작업을 수행하게 됩니다.

만약 우리의 GraphQL 호출이 testReportSummary 데이터 속성에 저장된다고 가정한다면, 이 쿼리를 화재시키는 모든 컴포넌트에서 isLoadingthis.$apollo.queries.testReportSummary.loading로 바꿀 수 있습니다. 오류를 Queryerror 후크에서 다룰 수 있습니다.

마이그레이션 전략

이제 우리는 각 데이터 유형을 살펴본 후에 VueX 기반 스토어에서 그렇지 않은 스토어로의 전환을 계획하는 방법을 검토해봅시다. VueX와 Apollo가 공존하는 것을 피하려고 하기 때문에, 두 스토어가 동시에 사용 가능한 시간을 최소화하는 것이 좋습니다. 이 중첩을 최소화하기 위해, 우리의 마이그레이션을 시작할 때, Apollo 스토어를 추가하는 것과 관련이 없는 것들은 스토어에서 제거하는 것으로 해야 합니다. 다음은 각각이 별도의 MR이 될 수 있는 포인트입니다:

  1. 정적 값에서의 마이그레이션, Rails 데이터 집합과 클라이언트 측 상수를 제거하고 대신에 provide/injectconstants.js 파일을 사용합니다.
  2. 단순한 읽기/쓰기 작업을 다음 중 하나로 대체합니다:
    • 단일 컴포넌트에서라면 data 속성 및 methods를 사용합니다.
    • 로컬화된 그룹의 컴포넌트들에서 공유된 경우 propsemits를 사용합니다.
  3. 공유된 읽기/쓰기 작업을 Apollo Client @client 지시문으로 대체합니다.
  4. 네트워크 데이터를 Apollo Client로 대체합니다. 가능한 경우 실제 GraphQL 호출이나 REST 호출을 하기 위해 클라이언트 측 리졸버를 사용합니다.

공유된 읽기/쓰기 작업이나 네트워크 데이터를 빠르게 대체할 수 없는 경우(예: 한 두 개의 마일스톤에서), Apollo Client와 전용 기능 플래그 뒤에 있는 다른 Vue 컴포넌트를 만들어 보세요. 그리고 VueX를 사용하는 현재 컴포넌트의 접두사로 legacy-를 붙입니다. 새로운 컴포넌트가 바로 모든 기능을 구현할 수 없을 수도 있지만, 우리는 MR을 만들면서 점진적으로 추가할 수 있습니다. 이렇게 하면 우리의 기존 컴포넌트는 VueX만 사용하고, 새로운 컴포넌트는 오직 Apollo를 사용하게 됩니다. 새 컴포넌트가 모든 로직을 다시 구현한 후에 우리는 기능 플래그를 켜고, 그것이 기대한 대로 작동하는지 확인할 수 있습니다.

FAQ

네트워크 호출 없이 전역 스토어가 필요한 경우 어떻게 해야 하나요?

이것은 드물게 발생하는 일이며 다음 질문을 제안해야 합니다: “그렇다면 정말 전역 스토어가 필요한가요?” (그 대답은 아마도 아닐 것입니다!) 대답이 예이면, 위에서 설명한 reactive mutable values를 마이그레이션하는 방법을 사용할 수 있습니다. 클라이언트 측 전용 스토어를 위해 Apollo Client를 사용하는 것은 완벽히 허용됩니다.

우리는 Pinia를 사용할 건가요?

짧은 답변은: 우리는 모릅니다만, 그 가능성은 낮습니다. 여전히 두 가지의 전역 스토어 라이브러리를 유지하는 것인데, 이것은 VueX와 Apollo Client가 공존하는 것과 동일한 단점을 가지게 됩니다. Pinia를 사용하든 말든, 우리의 전역 스토어 크기를 줄이는 것은 긍정적인 일입니다!

Apollo Client는 클라이언트 지시문에 대해 정말 매우 상세합니다. VueX와 혼합해서 사용할 수 있나요?

혼합해서 사용하는 것은 권장되지 않습니다. 그 이유는 많지만, 사용 가능한 것들로 코드베이스가 유기적으로 성장한다고 상상해보세요. 네트워크 상태와 클라이언트 측 상태를 효과적으로 분리하는 것에 정말 뛰어난 실력을 가졌다 하더라도, 다른 개발자들이 같은 헌신을 공유하지 않을 수 있거나 간단히 어느 상점에 어떤 것을 두어야 하는지 이해하지 못할 수 있습니다. 시간이 지남에 따라, VueX 스토어와 Apollo Client 간에 통신할 필요가 있게 되는데, 이는 문제만 야기할 수밖에 없습니다.