InfoGrab DocsInfoGrab Docs

Vuex에서 마이그레이션하기

요약

GitLab에서 Vuex는 더 이상 사용되지 않습니다. 사용자를 대상으로 하는 모든 기능에서 GraphQL API를 우선 선택지로 정해 두었습니다. 이 절에서는 기존 VueX 스토어를 순수 Vue와 Apollo로 옮기는 방법, 또는 VueX 의존도를 낮추는 방법에 관한 지침을 제시합니다.

GitLab에서 Vuex는 더 이상 사용되지 않습니다. 기존 Vuex 스토어가 있다면 마이그레이션을 적극적으로 검토합니다.

마이그레이션 배경#

사용자를 대상으로 하는 모든 기능에서 GraphQL API를 우선 선택지로 정해 두었습니다. GraphQL 이 있는 곳에는 Apollo Client도 함께 있다고 보아도 무방합니다. Apollo와 Vuex를 함께 쓰지 않는다는 방침이므로, REST API에서 GraphQL로 옮겨 가면서 VueX 스토어 수는 자연스럽게 줄어듭니다.

이 절에서는 기존 VueX 스토어를 순수 Vue와 Apollo로 옮기는 방법, 또는 VueX 의존도를 낮추는 방법에 관한 지침을 제시합니다.

마이그레이션 방법#

마이그레이션을 진행하기 전에 사용할 상태 관리 방식을 선택합니다.

Vue 관리 상태와 Apollo Client로 마이그레이션#

전체적으로는 변경 작업이 얼마나 복잡한지부터 파악해야 합니다. 전역 상태에 둘 가치가 있는 속성이 몇 개뿐일 때도 있고, 전부 순수 Vue로 추출해도 문제가 없을 때도 있습니다. VueX 속성은 일반적으로 다음 범주 중 하나에 해당합니다.

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

따라서 첫 단계는 현재 VueX 상태를 읽고 각 속성이 어느 범주에 속하는지 판별하는 것입니다.

큰 틀에서 각 범주는 VueX를 쓰지 않는 코드 패턴에 다음과 같이 대응합니다.

  • 정적 속성: Vue API의 Provide/Inject.
  • 반응형 가변 속성: Vue 이벤트와 props, Apollo Client.
  • Getter: 유틸리티 함수, Apollo update 훅, computed 속성.
  • 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, // Static - Dataset
  summaryEndpoint, // Static - Dataset
  suiteEndpoint, // Static - Dataset
  testReports: {},
  selectedSuiteIndex: null,
  isLoading: false,
  errorMessage: null,
  pageInfo: { // Static - Constant
    page: 1, // Static - Constant
    perPage: 20, // Static - Constant
  },
});

반응형 가변 값 마이그레이션 방법#

이러한 값은 여러 컴포넌트가 사용할 때 특히 유용하므로, 먼저 각 속성의 읽기와 쓰기가 몇 번 일어나는지, 그리고 그 위치가 서로 얼마나 떨어져 있는지 평가합니다. 읽기가 적고 위치가 가까울수록 해당 속성을 네이티브 Vue props와 이벤트로 대체하기 쉽습니다.

단순 읽기/쓰기 값#

앞의 예시로 돌아가면 selectedSuiteIndex는 컴포넌트 한 곳과 getter 한 곳에서만 사용됩니다. 게다가 그 getter 자체도 한 번만 사용됩니다. 이 로직은 컴포넌트 인스턴스의 data 속성으로 만들 수 있으므로 Vue로 옮기기가 매우 쉽습니다. getter는 computed 속성으로 대체하거나, 인덱스에 접근할 수 있으므로 해당 항목을 반환하는 컴포넌트 메서드로 대체할 수 있습니다. 모든 것이 같은 컴포넌트 안에 있어도 되는데 VueX 스토어가 추상화를 잔뜩 더해 애플리케이션을 복잡하게 만드는 전형적인 사례입니다.

이 예시에서는 다행히 모든 속성을 같은 컴포넌트 안에 둘 수 있습니다. 하지만 그렇게 할 수 없는 경우도 있습니다. 그럴 때는 Vue 이벤트와 props로 형제 컴포넌트 사이에서 통신할 수 있습니다. 해당 데이터를 상태를 알아야 하는 부모 컴포넌트에 저장하고, 자식 컴포넌트가 값을 쓰려고 할 때 새 값을 담아 이벤트를 $emit 해 부모가 갱신하도록 합니다. 그런 다음 props를 모든 자식으로 내려보내면 형제 컴포넌트의 모든 인스턴스가 같은 데이터를 공유합니다.

특히 컴포넌트 트리가 깊을 때는 이벤트와 props가 번거롭게 느껴질 수 있습니다. 다만 이는 대부분 불편함의 문제이지 고쳐야 할 아키텍처 결함이 아니라는 점을 알아 두어야 합니다. 깊게 중첩된 경우라도 props를 내려보내는 방식은 컴포넌트 간 통신에서 충분히 받아들일 만한 패턴입니다.

공유 읽기/쓰기 값#

스토어의 어떤 속성을 여러 컴포넌트가 읽고 쓰는데, 그 횟수가 너무 많거나 위치가 너무 떨어져 있어 Vue props와 이벤트가 적절치 않은 상황을 가정합니다. 이럴 때는 Apollo 클라이언트 측 리졸버를 사용합니다. 이 절은 Apollo Client 지식이 필요하므로 필요에 따라 Apollo 관련 내용을 참고합니다.

먼저 Vue 앱이 VueApollo를 사용하도록 설정합니다. 그런 다음 스토어를 만들 때 resolvers와 typedefs(뒤에서 정의합니다)를 Apollo Client에 전달합니다.

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

...
const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient({
    resolvers, // To be written soon
    { typeDefs }, // We are going to create this in a sec
  }),
});

예시에서는 필드 이름을 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 is the argument to our mutation
    updateAppStatus: (_, { appStatus }, { cache }) => {
      cache.writeQuery({
        query: getAppStatus,
        data: {
          app: {
            __typename: 'TestReportApp',
            status: appStatus,
          },
        },
      });
    },
  }
}

조회는 일반 Object와 동일하게 동작하므로 추가 설정 없이 됩니다. app { status }를 조회하는 것은 app.status와 같기 때문입니다. 다만 필드가 가질 최초 값을 정의하는 "기본" writeQuery를 작성하거나, cacheConfig의 typePolicies를 설정해 기본값을 제공해야 합니다.

이제 이 값을 읽을 때는 로컬 쿼리를 사용하고, 갱신할 때는 뮤테이션을 호출하면서 새 값을 인수로 전달하면 됩니다.

네트워크 관련 값#

isLoading과 errorMessage처럼 네트워크 요청 상태에 묶인 값도 있습니다. 읽기/쓰기 속성이지만, 추가 작업 없이 나중에 Apollo Client 자체 기능으로 손쉽게 대체됩니다.

// state.js AKA our store
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
  blobPath, // Static - 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 이며 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 속성으로 대체하기 쉬워집니다. 이때 isLoading과 errorMessage도 한 번만 사용되므로 함께 옮기면 됩니다.

이 함수를 여러 번 재사용하고 있거나 그럴 계획이라면, 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,
            # There are more fields here, but they aren't needed for our example
          }
        }
      }
    }
  }
}

여기서 구조는 원하는 대로 작성할 수 있다는 의미에서 임의적입니다. REST 호출의 구조와 다르므로 project.pipeline.testReportSummary를 생략하고 싶을 수 있습니다. 하지만 쿼리 구조를 GraphQL API에 맞춰 두면 나중에 GraphQL로 전환하기로 결정했을 때 쿼리를 수정할 필요 없이 @client 디렉티브만 제거하면 됩니다. 덤으로 캐싱도 얻게 되는데, 같은 파이프라인의 요약을 다시 가져오려고 하면 Apollo Client가 이미 결과를 가지고 있음을 알기 때문입니다.

또한 testReportSummary 필드에 endpoint 인수를 전달하고 있습니다. 순수 GraphQL 이라면 필요 없겠지만, 리졸버가 나중에 REST 호출을 하려면 그 정보가 필요합니다.

이제 클라이언트 측 리졸버를 작성해야 합니다. 필드에 @client 디렉티브를 붙이면 그 필드는 서버로 전송되지 않고, Apollo Client는 값을 해석할 코드를 직접 정의하기를 기대합니다. Apollo Client에 전달하는 cacheConfig 객체 안에 testReportSummary 용 클라이언트 측 리졸버를 작성할 수 있습니다. 이 리졸버가 Axios 호출을 수행하고 원하는 데이터 구조를 반환하도록 만듭니다. API 데이터에 접근할 때 항상 사용하거나 데이터 구조를 가공하던 getter가 있다면 이곳으로 옮기기에 적합합니다.

// graphql_config.js
export const resolvers = {
  Query: {
    testReportSummary(_, { summaryEndpoint }): {
    return axios.get(summaryEndpoint).then(({ data }) => {
      return data // we could format/massage our data here instead of using a getter
    }
  }
}

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

GraphQL 호출 결과가 testReportSummary라는 data 속성에 저장된다고 가정하면, 이 쿼리를 실행하는 모든 컴포넌트에서 isLoading을 this.$apollo.queries.testReportSummary.loading으로 대체할 수 있습니다. 오류는 Query의 error 훅에서 처리할 수 있습니다.

마이그레이션 전략#

지금까지 데이터 유형을 하나씩 살펴보았으니, VueX 기반 스토어에서 그렇지 않은 스토어로 전환하는 계획을 어떻게 세울지 정리합니다. VueX와 Apollo가 공존하는 상황은 피해야 하므로, 같은 컨텍스트에 두 스토어가 함께 있는 기간이 짧을수록 좋습니다. 겹치는 구간을 줄이려면 Apollo 스토어 추가와 무관한 항목부터 스토어에서 걷어내며 마이그레이션을 시작합니다. 아래 각 항목은 별도의 머지 리퀘스트로 진행할 수 있습니다.

  1. Rails 데이터셋과 클라이언트 측 상수를 포함한 정적 값을 스토어에서 걷어내고 provide/inject와 constants.js 파일을 사용합니다.
  2. 단순 읽기/쓰기 동작을 다음으로 대체합니다.
    • 단일 컴포넌트 안이라면 data 속성과 methods.
    • 인접한 컴포넌트 그룹에서 공유한다면 props와 emits.
  3. 공유 읽기/쓰기 동작을 Apollo Client @client 디렉티브로 대체합니다.
  4. 네트워크 데이터를 Apollo Client로 대체합니다. 가능하면 실제 GraphQL 호출을 사용하고, 그렇지 않으면 클라이언트 측 리졸버로 REST 호출을 수행합니다.

공유 읽기/쓰기 동작이나 네트워크 데이터를 빠르게(예: 한두 개 마일스톤 안에) 대체할 수 없다면, Apollo Client 로만 동작하는 별도의 Vue 컴포넌트를 기능 플래그 뒤에 두고 VueX를 사용하는 현재 컴포넌트 이름에는 legacy- 접두사를 붙이는 방법을 검토합니다. 새 컴포넌트가 처음부터 모든 기능을 구현하지 못할 수 있지만, 머지 리퀘스트를 거치며 기능을 점진적으로 추가할 수 있습니다. 이렇게 하면 레거시 컴포넌트는 VueX만 스토어로 사용하고 새 컴포넌트는 Apollo만 사용합니다. 새 컴포넌트가 모든 로직을 다시 구현한 뒤에는 기능 플래그를 켜고 기대대로 동작하는지 확인하면 됩니다.

Vuex에서 마이그레이션하기

GitLab v19.4
원문 보기

요약

GitLab에서 Vuex는 더 이상 사용되지 않습니다. 사용자를 대상으로 하는 모든 기능에서 GraphQL API를 우선 선택지로 정해 두었습니다. 이 절에서는 기존 VueX 스토어를 순수 Vue와 Apollo로 옮기는 방법, 또는 VueX 의존도를 낮추는 방법에 관한 지침을 제시합니다.

GitLab에서 Vuex는 더 이상 사용되지 않습니다. 기존 Vuex 스토어가 있다면 마이그레이션을 적극적으로 검토합니다.

마이그레이션 배경#

사용자를 대상으로 하는 모든 기능에서 GraphQL API를 우선 선택지로 정해 두었습니다. GraphQL 이 있는 곳에는 Apollo Client도 함께 있다고 보아도 무방합니다. Apollo와 Vuex를 함께 쓰지 않는다는 방침이므로, REST API에서 GraphQL로 옮겨 가면서 VueX 스토어 수는 자연스럽게 줄어듭니다.

이 절에서는 기존 VueX 스토어를 순수 Vue와 Apollo로 옮기는 방법, 또는 VueX 의존도를 낮추는 방법에 관한 지침을 제시합니다.

마이그레이션 방법#

마이그레이션을 진행하기 전에 사용할 상태 관리 방식을 선택합니다.

Vue 관리 상태와 Apollo Client로 마이그레이션#

전체적으로는 변경 작업이 얼마나 복잡한지부터 파악해야 합니다. 전역 상태에 둘 가치가 있는 속성이 몇 개뿐일 때도 있고, 전부 순수 Vue로 추출해도 문제가 없을 때도 있습니다. VueX 속성은 일반적으로 다음 범주 중 하나에 해당합니다.

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

따라서 첫 단계는 현재 VueX 상태를 읽고 각 속성이 어느 범주에 속하는지 판별하는 것입니다.

큰 틀에서 각 범주는 VueX를 쓰지 않는 코드 패턴에 다음과 같이 대응합니다.

  • 정적 속성: Vue API의 Provide/Inject.
  • 반응형 가변 속성: Vue 이벤트와 props, Apollo Client.
  • Getter: 유틸리티 함수, Apollo update 훅, computed 속성.
  • 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, // Static - Dataset
  summaryEndpoint, // Static - Dataset
  suiteEndpoint, // Static - Dataset
  testReports: {},
  selectedSuiteIndex: null,
  isLoading: false,
  errorMessage: null,
  pageInfo: { // Static - Constant
    page: 1, // Static - Constant
    perPage: 20, // Static - Constant
  },
});

반응형 가변 값 마이그레이션 방법#

이러한 값은 여러 컴포넌트가 사용할 때 특히 유용하므로, 먼저 각 속성의 읽기와 쓰기가 몇 번 일어나는지, 그리고 그 위치가 서로 얼마나 떨어져 있는지 평가합니다. 읽기가 적고 위치가 가까울수록 해당 속성을 네이티브 Vue props와 이벤트로 대체하기 쉽습니다.

단순 읽기/쓰기 값#

앞의 예시로 돌아가면 selectedSuiteIndex는 컴포넌트 한 곳과 getter 한 곳에서만 사용됩니다. 게다가 그 getter 자체도 한 번만 사용됩니다. 이 로직은 컴포넌트 인스턴스의 data 속성으로 만들 수 있으므로 Vue로 옮기기가 매우 쉽습니다. getter는 computed 속성으로 대체하거나, 인덱스에 접근할 수 있으므로 해당 항목을 반환하는 컴포넌트 메서드로 대체할 수 있습니다. 모든 것이 같은 컴포넌트 안에 있어도 되는데 VueX 스토어가 추상화를 잔뜩 더해 애플리케이션을 복잡하게 만드는 전형적인 사례입니다.

이 예시에서는 다행히 모든 속성을 같은 컴포넌트 안에 둘 수 있습니다. 하지만 그렇게 할 수 없는 경우도 있습니다. 그럴 때는 Vue 이벤트와 props로 형제 컴포넌트 사이에서 통신할 수 있습니다. 해당 데이터를 상태를 알아야 하는 부모 컴포넌트에 저장하고, 자식 컴포넌트가 값을 쓰려고 할 때 새 값을 담아 이벤트를 $emit 해 부모가 갱신하도록 합니다. 그런 다음 props를 모든 자식으로 내려보내면 형제 컴포넌트의 모든 인스턴스가 같은 데이터를 공유합니다.

특히 컴포넌트 트리가 깊을 때는 이벤트와 props가 번거롭게 느껴질 수 있습니다. 다만 이는 대부분 불편함의 문제이지 고쳐야 할 아키텍처 결함이 아니라는 점을 알아 두어야 합니다. 깊게 중첩된 경우라도 props를 내려보내는 방식은 컴포넌트 간 통신에서 충분히 받아들일 만한 패턴입니다.

공유 읽기/쓰기 값#

스토어의 어떤 속성을 여러 컴포넌트가 읽고 쓰는데, 그 횟수가 너무 많거나 위치가 너무 떨어져 있어 Vue props와 이벤트가 적절치 않은 상황을 가정합니다. 이럴 때는 Apollo 클라이언트 측 리졸버를 사용합니다. 이 절은 Apollo Client 지식이 필요하므로 필요에 따라 Apollo 관련 내용을 참고합니다.

먼저 Vue 앱이 VueApollo를 사용하도록 설정합니다. 그런 다음 스토어를 만들 때 resolvers와 typedefs(뒤에서 정의합니다)를 Apollo Client에 전달합니다.

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

...
const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient({
    resolvers, // To be written soon
    { typeDefs }, // We are going to create this in a sec
  }),
});

예시에서는 필드 이름을 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 is the argument to our mutation
    updateAppStatus: (_, { appStatus }, { cache }) => {
      cache.writeQuery({
        query: getAppStatus,
        data: {
          app: {
            __typename: 'TestReportApp',
            status: appStatus,
          },
        },
      });
    },
  }
}

조회는 일반 Object와 동일하게 동작하므로 추가 설정 없이 됩니다. app { status }를 조회하는 것은 app.status와 같기 때문입니다. 다만 필드가 가질 최초 값을 정의하는 "기본" writeQuery를 작성하거나, cacheConfig의 typePolicies를 설정해 기본값을 제공해야 합니다.

이제 이 값을 읽을 때는 로컬 쿼리를 사용하고, 갱신할 때는 뮤테이션을 호출하면서 새 값을 인수로 전달하면 됩니다.

네트워크 관련 값#

isLoading과 errorMessage처럼 네트워크 요청 상태에 묶인 값도 있습니다. 읽기/쓰기 속성이지만, 추가 작업 없이 나중에 Apollo Client 자체 기능으로 손쉽게 대체됩니다.

// state.js AKA our store
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
  blobPath, // Static - 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 이며 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 속성으로 대체하기 쉬워집니다. 이때 isLoading과 errorMessage도 한 번만 사용되므로 함께 옮기면 됩니다.

이 함수를 여러 번 재사용하고 있거나 그럴 계획이라면, 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,
            # There are more fields here, but they aren't needed for our example
          }
        }
      }
    }
  }
}

여기서 구조는 원하는 대로 작성할 수 있다는 의미에서 임의적입니다. REST 호출의 구조와 다르므로 project.pipeline.testReportSummary를 생략하고 싶을 수 있습니다. 하지만 쿼리 구조를 GraphQL API에 맞춰 두면 나중에 GraphQL로 전환하기로 결정했을 때 쿼리를 수정할 필요 없이 @client 디렉티브만 제거하면 됩니다. 덤으로 캐싱도 얻게 되는데, 같은 파이프라인의 요약을 다시 가져오려고 하면 Apollo Client가 이미 결과를 가지고 있음을 알기 때문입니다.

또한 testReportSummary 필드에 endpoint 인수를 전달하고 있습니다. 순수 GraphQL 이라면 필요 없겠지만, 리졸버가 나중에 REST 호출을 하려면 그 정보가 필요합니다.

이제 클라이언트 측 리졸버를 작성해야 합니다. 필드에 @client 디렉티브를 붙이면 그 필드는 서버로 전송되지 않고, Apollo Client는 값을 해석할 코드를 직접 정의하기를 기대합니다. Apollo Client에 전달하는 cacheConfig 객체 안에 testReportSummary 용 클라이언트 측 리졸버를 작성할 수 있습니다. 이 리졸버가 Axios 호출을 수행하고 원하는 데이터 구조를 반환하도록 만듭니다. API 데이터에 접근할 때 항상 사용하거나 데이터 구조를 가공하던 getter가 있다면 이곳으로 옮기기에 적합합니다.

// graphql_config.js
export const resolvers = {
  Query: {
    testReportSummary(_, { summaryEndpoint }): {
    return axios.get(summaryEndpoint).then(({ data }) => {
      return data // we could format/massage our data here instead of using a getter
    }
  }
}

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

GraphQL 호출 결과가 testReportSummary라는 data 속성에 저장된다고 가정하면, 이 쿼리를 실행하는 모든 컴포넌트에서 isLoading을 this.$apollo.queries.testReportSummary.loading으로 대체할 수 있습니다. 오류는 Query의 error 훅에서 처리할 수 있습니다.

마이그레이션 전략#

지금까지 데이터 유형을 하나씩 살펴보았으니, VueX 기반 스토어에서 그렇지 않은 스토어로 전환하는 계획을 어떻게 세울지 정리합니다. VueX와 Apollo가 공존하는 상황은 피해야 하므로, 같은 컨텍스트에 두 스토어가 함께 있는 기간이 짧을수록 좋습니다. 겹치는 구간을 줄이려면 Apollo 스토어 추가와 무관한 항목부터 스토어에서 걷어내며 마이그레이션을 시작합니다. 아래 각 항목은 별도의 머지 리퀘스트로 진행할 수 있습니다.

  1. Rails 데이터셋과 클라이언트 측 상수를 포함한 정적 값을 스토어에서 걷어내고 provide/inject와 constants.js 파일을 사용합니다.
  2. 단순 읽기/쓰기 동작을 다음으로 대체합니다.
    • 단일 컴포넌트 안이라면 data 속성과 methods.
    • 인접한 컴포넌트 그룹에서 공유한다면 props와 emits.
  3. 공유 읽기/쓰기 동작을 Apollo Client @client 디렉티브로 대체합니다.
  4. 네트워크 데이터를 Apollo Client로 대체합니다. 가능하면 실제 GraphQL 호출을 사용하고, 그렇지 않으면 클라이언트 측 리졸버로 REST 호출을 수행합니다.

공유 읽기/쓰기 동작이나 네트워크 데이터를 빠르게(예: 한두 개 마일스톤 안에) 대체할 수 없다면, Apollo Client 로만 동작하는 별도의 Vue 컴포넌트를 기능 플래그 뒤에 두고 VueX를 사용하는 현재 컴포넌트 이름에는 legacy- 접두사를 붙이는 방법을 검토합니다. 새 컴포넌트가 처음부터 모든 기능을 구현하지 못할 수 있지만, 머지 리퀘스트를 거치며 기능을 점진적으로 추가할 수 있습니다. 이렇게 하면 레거시 컴포넌트는 VueX만 스토어로 사용하고 새 컴포넌트는 Apollo만 사용합니다. 새 컴포넌트가 모든 로직을 다시 구현한 뒤에는 기능 플래그를 켜고 기대대로 동작하는지 확인하면 됩니다.