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 또는 우리의 리포지터리
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 또는 우리의 리포지터리
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 이벤트 및 프롭스를 사용하여 형제 컴포넌트 간에 통신할 수 있습니다. 상태를 알아야 할 부모 컴포넌트에 해당 데이터를 저장하고, 자식 컴포넌트가 값에 쓰기를 원할 때 새 값으로 이벤트를 $emit하여 부모가 업데이트하도록 할 수 있습니다. 그럼으로써 모든 자식 컴포넌트에 프롭스를 전파함으로써 형제 컴포넌트의 모든 인스턴스가 동일한 데이터를 공유할 수 있습니다.

인경우가 많이 일어나진 않지만, 때로는 일부적으로 매우 깊게 자리한 컴포넌트에서도 프롭스 및 이벤트가 불편할 수 있습니다. 그러나 이것은 주로 불편 문제이며, 아키텍처적인 결함이나 해결해야 할 문제가 아님을 꽤 중요하게 인식하는 것이 매우 중요합니다. 깊게 중첩된 프롭을 전달하는 것은 컴포넌트간 통신에 대한 매우 수용할 수 있는 패턴입니다.

공유 읽기/쓰기 값

여러 컴포넌트에서 읽기 및 쓰기에 사용되는 리포지터리의 속성이 너무 많거나 너무 멀리 떨어져 있어 Vue 프롭과 이벤트가 좋지 않은 해결책처럼 보인다고 가정해 봅시다. 대신에 Apollo 클라이언트 사이드 리졸버를 사용합니다. 이 섹션은 Apollo Client에 대한 지식이 필요하므로 필요할 때 Apollo 세부 정보를 확인하십시오.

먼저 우리는 VueApollo를 사용하기위한 Vue 앱을 설정해야 합니다. 그리고 리포지터리를 생성할 때, Apollo Client에 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,
          },
        },
      });
    },
  }
}

쿼리하기 위해서는 추가적인 지시없이 작동하므로, 이것은 Object와 마찬가지로 행동하기 때문에 app { status }에 대해 쿼리하는 것은 app.status에 대해 쿼리하는 것과 동일합니다. 그러나 “기본” writeQuery를 작성하여 최초 값(필드가 가질 가장 처음의 값)을 정의하거나 우리 cacheConfig에 대한 typePolicies를 설정하여 기본 값을 제공할 수 있습니다.

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

네트워크 관련 값

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

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

게터 이주 방법

게터는 경우에 따라 검토되어야 하지만, 전반적인 가이드라인은 우리가 이전에 게터 내부에서 사용했던 상태 값들을 인수로 취하고 원하는 값을 반환하는 순수 자바스크립트 유틸 함수를 작성할 수 있다는 것입니다. 다음과 같은 게터를 고려해보세요:

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

여기서 우리가 하는 일은 두 상태 값을 참조하는 것뿐이며, 이 두 값은 함수의 인수로 변환될 수 있습니다:

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

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

API 데이터 이주 방법

마지막 속성으로는 testReports라는 것이 있으며, 이는 axios를 사용하여 API에서 가져옵니다. 우리는 순수 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 쿼리(@client 디렉티브 포함)를 사용하여 데이터를 수신하는 방식에 대한 구조를 정의하고, 클라이언트 측 리졸버를 사용하여 해당 쿼리를 해석하는 방법을 결정할 수 있습니다. 저희의 예에서는 쿼리를 다음과 같이 작성할 수 있습니다:

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 디렉티브를 간단히 제거할 수 있습니다. 또한 동일한 파이프라인에 대해 요약을 다시 가져와야 할 때 Apollo Client는 결과가 이미 있는지 알고 있기 때문에 이것은 무료 캐싱을 제공합니다!

또한, testReportSummary 필드에 endpoint 인수를 전달하고 있습니다. 이것은 순수 GraphQL에서는 필요하지 않지만, 리졸버는 나중에 REST 호출을 수행할 때 이 정보가 필요할 것입니다.

이제 클라이언트 측 리졸버를 작성해야 합니다. 해당하는 필드에 @client 디렉티브를 붙이면, 이것은 서버로 보내지 않습니다. 대신에 Apollo Client는 값을 해결하기 위한 코드를 정의하기를 기대합니다. 우리는 Apollo Client에 전달하는 cacheConfig 객체 내부에서 testReportSummary를 클라이언트 측 리졸버로 작성할 수 있습니다. 이 리졸버에서 Axios 호출을 수행하고 원하는 데이터 구조를 반환합니다. 그러면 이것은 항상 API 데이터에 액세스할 때 사용하는 것이라면 게터를 전송하는 것도 완벽합니다:

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

우리가 testReportSummary @client 필드 호출할 때마다, 이 리졸버는 실행되어 작업의 결과를 반환합니다. 즉, 이것은 사실상 VueX 액션이 하는 일과 같습니다.

이제 우리가 만든 GraphQL 호출이 testReportSummary라는 데이터 속성에 저장된다고 가정하면, 이 쿼리를 발생시키는 컴포넌트 내에서 isLoading 대신 this.$apollo.queries.testReportSummary.lodaing를 사용하여 대체할 수 있습니다. 오류는 쿼리의 error 훅 내부에서 처리 될 수 있습니다.

이주 전략

이제 각 종류의 데이터를 살펴봤으므로 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 컴포넌트를 기능플래그 뒤에 만들어 검토하는 것이 좋습니다. Apollo로만 작동하는 새 컴포넌트에서는 즉시 모든 기능을 구현할 수 없을 수 있지만, MR을 진행할 수록 그 기능을 점진적으로 추가할 수 있습니다. 이렇게 하면 우리의 레거시 컴포넌트는 오직 VueX 리포지터리로만 사용하고, 새 컴포넌트는 오직 Apollo를 사용합니다. 새로운 컴포넌트가 모든 로직을 재구현하면 피처 플래그를 켜고 예상대로 작동하는지 확인할 수 있습니다.

FAQ

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

이는 드물게 발생하는 일이며 다음 질문을 제안해야 합니다: “정말로 전역 리포지터리가 필요한가요?” (대답은 아마 그렇지 않을 것입니다!) 대답이 예일 경우, 앞서 설명한 Apollo를 사용한 공유 읽기/쓰기 기술을 사용할 수 있습니다. Apollo Client를 클라이언트 전용 리포지터리로 사용하는 것은 완벽히 허용됩니다.

Pinia를 사용할 예정인가요?

짧은 대답은: 우리는 모르지만, 그 가능성은 낮습니다. 이는 여전히 두 개의 전역 리포지터리 라이브러리를 갖는 것을 의미하는데, 이는 VueX와 Apollo Client가 함께 존재하는 것과 동일한 단점을 갖습니다. Pinia를 사용하든 사용하지 않든, 전역 리포지터리의 크기를 줄이는 것은 긍정적입니다!

Apollo 클라이언트는 클라이언트 지시어를 위해 매우 말이 많습니다. VueX와 섞어서 사용할 수 있나요?

섞어서 사용하는 것은 권장되지 않습니다. 이에는 많은 이유가 있지만, 코드베이스가 유기적으로 성장하는 방식과 어떤 것을 사용할 수 있는 지에 따라 생기는 상황을 생각해보세요. 실제로 네트워크 상태와 클라이언트 측 상태를 분리하는 데 매우 능숙하더라도, 다른 개발자들이 같은 헌신을 나누지 않을 수도 있거나 어떤 것을 어느 리포지터리에 보관해야 하는 지를 이해하지 못할 수도 있습니다. 시간이 흐르면 VueX 리포지터리와 Apollo Client 간에 근본적으로 통신해야 하는 경우가 거의 피할 수 없는데, 그 결과는 문제밖에 없을 것입니다.