Vuex에서 마이그레이션하기

Vuex는 GitLab에서 더 이상 사용되지 않습니다. 기존의 Vuex 스토어가 있는 경우, 마이그레이션을 고려하는 것이 좋습니다.

왜?

우리는 모든 사용자 대면 기능의 기본 선택으로 GraphQL API를 정의했습니다.

우리는 GraphQL이 존재할 때 Apollo Client도 존재할 것이라고 안전하게 가정할 수 있습니다.

우리는 Apollo와 함께 Vuex를 사용하고 싶지 않습니다, 따라서 Vuex 스토어 사용량은 REST API에서 GraphQL로 전환됨에 따라 자연스럽게 감소할 것입니다.

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

어떻게?

이주를 진행하기 전에 선호하는 상태 관리 솔루션을 선택하세요.

Vue가 관리하는 상태 및 Apollo Client로 마이그레이션하기

전체적으로, 우리는 변화가 얼마나 복잡할지를 이해하고자 합니다. 때때로, 글로벌 상태에 저장될 가치가 있는 몇 가지 속성만 존재할 수 있으며, 때로는 이러한 속성을 모두 순수 Vue로 안전하게 추출할 수 있습니다. Vuex 속성은 일반적으로 다음 카테고리 중 하나에 속합니다:

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

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

높은 수준에서, 우리는 각 카테고리를 비-Vuex 코드 패턴과 매핑할 수 있습니다:

  • 정적 속성: Vue API에서 제공/주입.
  • 반응성 가변 속성: Vue 이벤트 및 props, Apollo Client.
  • Getter: 유틸리티 함수, 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가 실제로 상수라는 점입니다! 이는 수정되지 않으며, 우리가 getter 내에서 사용하는 기본값으로만 사용됩니다:

// 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 props 및 이벤트를 선호하여 이러한 속성을 제거하는 것이 더 쉬워집니다.

간단한 읽기/쓰기 값

예제로 돌아가면, selectedSuiteIndex하나의 구성 요소에서만 사용되며 getter 내에서 한 번만 사용됩니다. 또한 이 getter는 본인도 한 번만 사용됩니다! 이 논리를 Vue로 변환하는 것은 꽤 쉬울 것입니다. 왜냐하면 이 속성은 구성 요소 인스턴스의 data 속성이 될 수 있기 때문입니다. getter 대신에 계산된 속성을 사용하거나 구성 요소의 메서드를 통해 올바른 항목을 반환할 수 있습니다. 거기에서 인덱스에도 접근할 수 있습니다. 이것은 VueX 스토어가 많은 추상화를 추가함으로써 애플리케이션을 복잡하게 만드는 완벽한 예입니다. 사실 모든 것이 같은 구성 요소 내에서 살아 있을 수 있습니다.

운 좋게도, 우리의 예에서는 모든 속성이 동일한 구성 요소 내에서 살아 있을 수 있습니다. 그러나 불가능한 경우도 있을 수 있습니다. 이럴 때는 Vue 이벤트와 props를 사용하여 형제 구성 요소 간에 통신할 수 있습니다. 상태를 알고 있어야 하는 부모 구성 요소 내에 문제의 데이터를 저장하고, 자식 구성 요소가 구성 요소에 쓰기를 원할 때 새로운 값으로 이벤트를 $emit하고 부모가 업데이트하도록 할 수 있습니다. 그런 다음 모든 자식에게 props를 전파함으로써 모든 형제 구성 요소 인스턴스가 동일한 데이터를 공유하게 됩니다.

때로는 이벤트와 props가 번거롭게 느껴질 수 있습니다. 특히 매우 깊은 구성 요소 트리에서요. 그러나 이는 주로 불편한 문제이지 구조적 결함이나 수정해야 할 문제가 아닙니다. props를 전달하는 것은, 깊게 중첩되어 있어도, 구성 요소 간 통신을 위한 수용 가능한 패턴입니다.

공유 읽기/쓰기 값

스토어 내에 여러 구성 요소에서 읽고 쓰는 데 사용되는 속성이 있어, 너무 많거나 떨어져서 Vue props 및 이벤트가 나쁜 솔루션처럼 보인다고 가정해 봅시다. 대신, 우리는 Apollo 클라이언트 사이드 리졸버를 사용합니다. 이 섹션은 Apollo Client에 대한 지식을 필요로 합니다. 필요에 따라 Apollo 세부사항을 확인하세요.

먼저 VueApollo를 사용하도록 Vue 앱을 설정해야 합니다. 그런 다음 스토어를 생성할 때 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,
          },
        },
      });
    },
  }
}

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

이제 이 값을 읽고 싶을 때는 로컬 쿼리를 사용할 수 있습니다. 업데이트가 필요할 때는 변이를 호출하고 새 값을 인수로 전달하면 됩니다.

네트워크 관련 값

isLoadingerrorMessage와 같은 값들은 네트워크 요청 상태와 연결되어 있습니다. 이들은 읽기/쓰기 속성이지만, 추가 작업 없이 나중에 Apollo Client의 고유한 기능으로 쉽게 대체될 수 있습니다:

// state.js AKA our store
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
  blobPath, // 정적 - 데이터 세트
  summaryEndpoint, // 정적 - 데이터 세트
  suiteEndpoint, // 정적 - 데이터 세트
  testReports: {},
  selectedSuiteIndex: null, // 가변 -> 데이터 속성
  isLoading: false, // 가변 -> 네트워크와 연결됨
  errorMessage: null, // 가변 -> 네트워크와 연결됨
  pageInfo: { // 정적 - 상수
    page: 1, // 정적 - 상수
    perPage: 20, // 정적 - 상수
  },
});

게터 마이그레이션 방법

게터는 사례별로 검토해야 하지만, 일반적인 지침은 게터 내에서 사용하던 상태 값을 인수로 받는 순수 JavaScript 유틸리티 함수를 작성할 가능성이 높다는 것입니다. 다음 게터를 고려해보세요:

// 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라 불리며, 이는 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|요약을 가져오는 중 오류가 발생했습니다.'),
      });
    })
    .finally(() => {
      dispatch('toggleLoading');
    });
};

우리는 여기서 두 가지 옵션이 있습니다. 이 액션이 한 번만 사용된다면, 우리가 수행하는 모든 코드를 actions.js 파일에서 가져와서 데이터를 가져오는 컴포넌트로 이동하는 데 아무것도 방해할 것이 없습니다. 그러면 data 속성으로 인해 모든 상태 관련 코드를 쉽게 제거할 수 있습니다. 이 경우 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가 이미 결과를 알고 있습니다!

추가적으로, 우리는 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.loading으로 대체할 수 있습니다. 오류는 쿼리의 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- 접두사를 붙여 변경하는 방안을 고려합니다. 새로운 컴포넌트는 모든 기능을 즉시 구현할 수 없을 수 있지만, MRs를 만들면서 점진적으로 추가할 수 있습니다. 이렇게 하면, 우리의 레거시 컴포넌트는 VueX를 스토어로 독점적으로 사용하고 새로운 컴포넌트는 Apollo만 사용합니다. 새로운 컴포넌트가 모든 로직을 재구현한 후, 기능 플래그를 켜고 예상대로 작동하는지 확인할 수 있습니다.