Vuex에서의 이주

왜?

GraphQL API를 모든 사용자를 대상으로 한 주요 API로 정의했으므로 GraphQL이 존재할 때마다 Apollo Client도 함께 있을 것이라고 가정할 수 있습니다. 우리는 Apollo과 함께 Vuex를 사용하고 싶지 않습니다 그래서 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 AKA our 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가 실제로 상수임을 확인해야 합니다! 이것은 결코 수정되지 않고 게터 내에서만 사용되며 기본값으로 사용되는 상수입니다.

// state.js AKA our 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로 번역하는 것은 매우 쉽습니다. Vue에서 이것은 컴포넌트 인스턴스의 data 속성이 될 수 있습니다. 게터의 경우, 계산된 속성을 사용하거나, 적절한 항목을 반환하는 컴포넌트의 메서드를 사용할 수 있습니다. 왜냐하면 여기에서 모든 것이 같은 컴포넌트 내에 있기 때문에 인덱스에 액세스할 수 있을 것입니다. 여기서 VueX 리포지터리가 애플리케이션을 복잡하게 만들고 실제로 모든 것이 같은 컴포넌트 내에 존재할 때 모든 것이 아주 많은 추상화를 추가합니다.

다행히도, 우리의 예제에서 모든 속성은 같은 컴포넌트 내에 존재할 수 있습니다. 그러나 그렇지 않은 경우도 있습니다. 이런 경우에는 Vue 이벤트 및 프롭을 사용하여 형제 컴포넌트 간에 통신할 수 있습니다. 상태를 알아야 하는 부모 컴포넌트에 해당 데이터를 저장하고, 자식 컴포넌트가 값을 작성하려면 $emit 이벤트를 호출하여 부모를 업데이트하게 만들 수 있습니다. 그런 다음, 모든 형제 컴포넌트에 프롭을 연쇄적으로 전달하여 형제 컴포넌트의모든 인스턴스가 동일한 데이터를 공유할 수 있습니다.

때로는 이벤트와 프롭이 불편하게 느껴질 수 있습니다, 특히 매우 깊게 중첩된 컴포넌트 트리에서는요. 그러나 이것은 주로 불편한 문제이며, 건축적 결함이나 고칠 문제가 아님을 알고 있어야 합니다. 매우 깊게 중첩된 컴포넌트에 걸쳐 프롭을 전달하는 것은 컴포넌트 간 통신에 대한 매우 수용할 수 있는 패턴입니다.

공유 읽기/쓰기 값

여러 컴포넌트에서 사용되는 속성이며 읽고 쓰는 횟수가 많거나 멀리 떨어져 있다면 Vue 프롭과 이벤트가 나쁜 솔루션으로 보일 수 있습니다. 대신, 우리는 Apollo 클라이언트 측 리졸버를 사용합니다. 이 섹션은 Apollo Client에 대한 지식이 필요하므로 필요할 때 apollo 세부 정보를 확인하십시오.

먼저 Vue 앱에서 VueApollo를 사용하도록 설정해야 합니다. 그런 다음, 리포지터리를 만들 때 resolverstypedefs (나중에 정의됨)를 Apollo 클라이언트에 전달합니다.

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,
          },
        },
      });
    },
  }
}

쿼리 작성은 추가 지시 사항 없이도 작동합니다. 왜냐하면 Object와 같이 작동하기 때문입니다. app { status }를 쿼리하는 것은 app.status를 쿼리하는 것과 동등하기 때문입니다. 그러나 “기본” writeQuery를 작성하거나 cacheConfig에 대한 typePolicies를 설정하여 기본값을 제공할 수 있습니다.

이제 이 값에서 읽고 싶을 때 지역 쿼리를 사용할 수 있습니다. 이를 업데이트해야 하는 경우 뮤테이션을 호출하고 새 값이 인수로 전달될 수 있습니다.

네트워크 관련 값

네트워크 요청 상태에 종속된 isLoadingerrorMessage와 같은 값들이 있습니다. 이들은 읽기/쓰기 속성입니다. 그러나 추가적인 작업을 하지 않고 Apollo Client의 기능으로 나중에 쉽게 교체될 것입니다.

// state.js, 저희의 스토어
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
  blobPath, // Staic - Dataset
  summaryEndpoint, // Static - Dataset
  suiteEndpoint, // Static - Dataset
  testReports: {},
  selectedSuiteIndex: null, // Mutable -> data property
  isLoading: false, // Mutable -> tied to network
  errorMessage: null, // Mutable -> tied to network
  pageInfo: { // Static - Constant
    page: 1, // Static - Constant
    perPage: 20, // Static - Constant
  },
});

Getter 이주 방법

Getter는 상황별로 검토해야 하지만, 일반적으로 사용된 상태 값을 getter 내부에서 사용하던 순수한 JavaScript 유틸 함수를 작성하는 것이 가능합니다. 아래와 같은 getter를 고려해보세요.

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

여기서 하는 일은 두 가지 상태 값을 참조하는 것뿐이며, 이는 두 상태 값을 함수의 인수로 전달할 수 있습니다.

//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 파일에서 모든 이 코드를 컴포넌트로 옮기는 것을 막는 것이 없습니다. 그럼으로써 data 속성을 선호하여 상태와 관련된 모든 코드를 쉽게 제거할 수 있습니다. 이 경우, isLoadingerrorMessages가 모두 한 번만 사용되기 때문에 함께 존재합니다.

이 기능을 여러 번 재사용하고 있다면 (또는 재사용할 계획이 있다면), Apollo Client를 사용하여 최선을 다해 네트워크 호출과 캐싱을 수행할 수 있습니다. 이 섹션에서는 Apollo Client 지식을 전제로 하고 설정하는 방법을 알고 있다고 가정하지만, GraphQL 문서를 자유롭게 참조하세요.

로컬 GraphQL 쿼리를 사용하여 데이터를 받을 모습을 구성하고, 클라이언트 측 리졸버를 사용하여 해당 쿼리를 어떻게 해결할지 알려줄 수 있습니다. 또한 이곳은 항상 API 데이터에 액세스할 때나 데이터 구조를 조정할 때 사용되므로, 여기에서 getter를 이전하는 것도 적합합니다.

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 호출 구조가 아니기 때문에 project.pipeline.testReportSummary를 건너뛰는 것이 유혹적일 수 있습니다. 그러나 GraphQL API와 호환되는 쿼리 구조를 만들면 나중에 GraphQL로 전환하게 되어도 쿼리를 수정할 필요가 없으며, 간단히 @client 지시어를 제거할 수 있습니다. 또한 우리에게는 이미 결과가 있음을 알고 있기 때문에 이것은 무료 캐싱도 제공합니다!

또한, endpoint 인수를 필드 testReportSummary에 전달하고 있습니다. 이것은 순수 GraphQL에서는 필요하지 않은 것인데, 우리의 리졸버는 나중에 REST 호출을 하기 위해 그 정보를 필요로 할 것입니다.

이제 클라이언트 측 리졸버를 작성해야 합니다. 필드에 @client 지시어를 붙이면 서버로 전송되지 않으며 Apollo Client는 우리가 값을 해결하기 위해 사용자 지정 코드를 정의하도록 기대할 것입니다. 이 해결자는 VueX 액션이 수행한 작업과 본질적으로 동일한 작업을 수행하게 됩니다.

단순히 testReportSummary @client 필드에 대한 호출을 하게 될 때마다, 이 리졸버가 실행되고 작업의 결과를 리턴하게 되며, 이는 결국 VueX 액션이 했던 것과 동일한 역할을 하게 됩니다.

만약 우리가 GraphQL 호출이 testReportSummary 데이터 속성에 저장되어 있다고 가정한다면, 이 쿼리를 발생시키는 모든 컴포넌트에서 isLoadingthis.$apollo.queries.testReportSummary.lodaing으로 대체할 수 있습니다. 에러는 쿼리의 error 훅 내부에서 처리될 수 있습니다.

마이그레이션 전략

이제 각 데이터 유형을 살펴보았으니, VueX 기반 스토어에서 그렇지 않은 스토어로의 전환 계획에 대해 검토해 봅시다. VueX와 Apollo가 동시에 존재하는 시간을 최소화하기 위해 Apollo 스토어를 추가하는 것과 관련이 없는 것을 스토어에서 제거하는 것으로 우리의 마이그레이션을 시작해야 합니다. 이 중첩을 최소화하기 위해 각각의 다음 단계는 각자의 MR이 될 수 있습니다:

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

만약 공유 읽기/쓰기 작업이나 네트워크 데이터를 빠르게 대체할 수 없는 경우(예: 한 두 단계에서), Apollo Client와 독점적으로 기능 하는 특성 플래그 뒤에 다른 Vue 컴포넌트를 만들어 현재 VueX를 사용하는 컴포넌트에 legacy- 접두사가 붙은 이름으로 변경하는 것을 고려해 보세요. 새로운 컴포넌트는 처음에 모든 기능을 구현할 수 없을 수 있지만, 우리는 MR을 만들면서 점진적으로 추가할 수 있습니다. 이렇게 하면, 우리의 레거시 컴포넌트는 VueX를 전용 스토어로 사용하고, 새로운 컴포넌트는 단순히 Apollo를 사용합니다. 새로운 컴포넌트가 모든 로직을 다시 구현한 후에 특성 플래그를 켜고 예상대로 작동하는지 확인할 수 있습니다.

FAQ

네트워크 호출 없이 전역 스토어가 필요한 경우는?

이러한 경우는 드물며 다음 질문을 제안해야 합니다: “그렇다면 정말 전역 스토어가 필요한가요?” (답은 아마도 아닙니다!) 답이 예이면, 앞서 설명한 반응적 가변 값들을 마이그레이션하는 방법을 사용할 수 있습니다. 클라이언트 측 전용 스토어에 Apollo Client를 사용하는 것은 완전히 허용됩니다.

우리는 Pinia를 사용할 예정인가요?

간단한 답은: 우리는 모릅니다. 하지만 그것은 그다지 가능성이 낮습니다. 여전히 두 개의 전역 스토어 라이브러리를 유지하는 것은 VueX와 Apollo Client가 공존하는 것과 동일한 단점을 가지기 때문에 크기를 줄이는 것은 긍정적입니다!

Apollo 클라이언트는 클라이언트 지시문이 정말로 장황합니다. VueX와 혼용할 수 있을까요?

혼용하는 것은 권장되지 않습니다. 그 이유는 많지만, 사용 가능한 것으로 코드베이스가 유기적으로 성장한다고 생각해 보세요. 네트워크 상태와 클라이언트 측 상태를 정확하게 구분하는 데 정말 뛰어난 분들이 있어도, 다른 개발자들은 같은 헌신을 나누지 않을 수도 있거나 어떤 것을 어떤 스토어에 저장해야 하는지 이해하지 못할 수도 있습니다. 시간이 지남에 따라, 거의 필연적으로 VueX 스토어와 Apollo Client 간에 통신이 필요해지며, 이는 결과적으로 문제만 초래할 수 있습니다.