Vuex에서 마이그레이션하기
GitLab v19.1GitLab에서 Vuex는 더 이상 사용되지 않습니다. GraphQL API를 모든 사용자 대면 기능의 주요 선택지로 정의했습니다. 이 섹션은 기존 Vuex 스토어를 순수 Vue와 Apollo로 변환하거나, Vuex 의존도를 줄이는 방법에 대한 가이드라인과 방법을 제공합니다.
GitLab에서 Vuex는 더 이상 사용되지 않습니다. 기존 Vuex 스토어가 있다면 마이그레이션을 강력히 권장합니다.
왜 마이그레이션해야 하나요?#
GraphQL API를 모든 사용자 대면 기능의 주요 선택지로 정의했습니다. GraphQL이 있는 곳에는 Apollo Client도 함께 있다고 안전하게 가정할 수 있습니다. Vuex와 Apollo를 함께 사용하는 것을 원하지 않으므로, REST API에서 GraphQL로 이동함에 따라 Vuex 스토어 수는 시간이 지남에 따라 자연스럽게 줄어들 것입니다.
이 섹션은 기존 Vuex 스토어를 순수 Vue와 Apollo로 변환하거나, Vuex 의존도를 줄이는 방법에 대한 가이드라인과 방법을 제공합니다.
어떻게 마이그레이션하나요?#
마이그레이션을 진행하기 전에 선호하는 상태 관리 솔루션을 선택하세요.
-
Pinia를 사용할 계획이라면 이 가이드를 따르세요.
-
모든 상태 관리에 Apollo Client를 사용할 계획이라면 아래 가이드를 따르세요.
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/injectVue 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 자체도 한 번만 사용됩니다! 이 로직을 Vue로 변환하기가 매우 쉬울 것입니다. 컴포넌트 인스턴스의 data 속성이 될 수 있기 때문입니다. getter의 경우, computed 속성 또는 인덱스에도 접근할 수 있는 컴포넌트의 메서드를 대신 사용할 수 있습니다. 이는 VueX 스토어가 실제로는 모든 것이 동일한 컴포넌트 내에 있을 수 있는데도 많은 추상화를 추가하여 애플리케이션을 복잡하게 만드는 완벽한 예입니다.
다행히, 우리 예제에서는 모든 속성이 동일한 컴포넌트 내에 있을 수 있습니다. 그러나 불가능한 경우도 있습니다. 이런 경우, Vue 이벤트와 props를 사용하여 형제 컴포넌트 간에 통신할 수 있습니다. 해당 데이터를 상태를 알아야 하는 부모 컴포넌트에 저장하고, 자식 컴포넌트가 컴포넌트에 쓰기를 원할 때 새 값과 함께 이벤트를 $emit하여 부모가 업데이트하도록 할 수 있습니다. 그런 다음 모든 자식에게 props를 내려보내면, 형제 컴포넌트의 모든 인스턴스가 동일한 데이터를 공유하게 됩니다.
때로는 이벤트와 props가 번거롭게 느껴질 수 있으며, 특히 매우 깊은 컴포넌트 트리에서 그렇습니다. 그러나 이것이 주로 불편함의 문제이지 수정해야 할 아키텍처적 결함이나 문제가 아님을 인식하는 것이 매우 중요합니다. 깊이 중첩된 경우에도 props를 전달하는 것은 컴포넌트 간 통신에서 매우 허용 가능한 패턴입니다.
공유 읽기/쓰기 값#
여러 컴포넌트에서 읽기와 쓰기에 사용되는 속성이 스토어에 있고, 그 수가 너무 많거나 너무 멀리 떨어져 있어 Vue props와 이벤트가 좋지 않은 해결책처럼 보인다고 가정해 봅시다. 대신 Apollo 클라이언트 측 리졸버를 사용합니다. 이 섹션은 Apollo Client에 대한 지식이 필요하므로, 필요에 따라 Apollo 세부 사항을 확인하세요.
먼저 Vue 앱이 VueApollo를 사용하도록 설정해야 합니다. 그런 다음 스토어를 생성할 때, Apollo Client에 resolvers와 typedefs(나중에 정의됨)를 전달합니다:
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,
},
},
});
},
}
}
쿼리의 경우, 추가 지시 없이 작동합니다. app { status }를 쿼리하는 것이 app.status와 동일하기 때문에 Object처럼 동작합니다. 그러나 "기본" 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로, 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 속성으로 쉽게 대체할 수 있습니다. 이 경우, isLoading과 errorMessages도 한 번만 사용되므로 함께 이동할 수 있습니다.
이 함수를 여러 번 재사용한다면(또는 그럴 계획이라면), 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 지시문을 제거하면 됩니다. 이것은 또한 무료로 캐싱을 제공합니다. 동일한 파이프라인에 대해 summary를 다시 가져오려고 하면, 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.lodaing으로 대체할 수 있습니다. 오류는 Query의 error 훅 내에서 처리할 수 있습니다.
마이그레이션 전략#
각 데이터 유형을 살펴보았으니, 이제 VueX 기반 스토어에서 그것 없는 스토어로의 전환을 계획하는 방법을 검토해 봅시다. VueX와 Apollo가 공존하는 것을 피하려 하므로, 두 스토어가 동일한 컨텍스트에서 사용 가능한 시간이 짧을수록 좋습니다. 이 중복을 최소화하기 위해, Apollo 스토어 추가를 포함하지 않는 모든 것을 스토어에서 제거하는 것부터 마이그레이션을 시작해야 합니다. 다음 각 포인트는 별도의 머지 리퀘스트가 될 수 있습니다:
-
정적 값(Rails 데이터셋과 클라이언트 측 상수 모두)에서 마이그레이션하고
provide/inject와constants.js파일을 대신 사용합니다. -
단순 읽기/쓰기 작업을 다음 중 하나로 대체합니다:
data 속성과 단일 컴포넌트인 경우 methods.
-
컴포넌트의 지역적 그룹에서 공유되는 경우
props와emits. -
공유 읽기/쓰기 작업을 Apollo Client
@client지시문으로 대체합니다. -
네트워크 데이터를 Apollo Client로 대체합니다. 사용 가능한 경우 실제 GraphQL 호출로 대체하거나, 클라이언트 측 리졸버를 사용하여 REST 호출을 수행합니다.
공유 읽기/쓰기 작업이나 네트워크 데이터를 빠르게 대체하기 불가능한 경우(예: 하나 또는 두 개의 마일스톤 내에), 기능 플래그 뒤에 Apollo Client에서만 기능하는 다른 Vue 컴포넌트를 만드는 것을 고려하고, VueX를 사용하는 현재 컴포넌트의 이름을 legacy- 접두사로 변경하세요. 새 컴포넌트는 처음에 모든 기능을 구현하지 못할 수 있지만, 머지 리퀘스트를 만들면서 점진적으로 추가할 수 있습니다. 이렇게 하면 레거시 컴포넌트는 VueX만을 스토어로 사용하고 새 컴포넌트는 Apollo만을 사용합니다. 새 컴포넌트가 모든 로직을 재구현하면, 기능 플래그를 켜고 예상대로 동작하는지 확인할 수 있습니다.