Pinia
GitLab v19.1Pinia는 Vue 애플리케이션의 클라이언트 측 상태 관리를 위한 도구입니다. 항상 ~/pinia/instance에서 공유 Pinia 인스턴스를 사용하는 것이 좋습니다. 단일 작업에만 집중하는 소규모 스토어를 생성하는 것이 좋습니다.
Pinia는 Vue 애플리케이션의 클라이언트 측 상태 관리를 위한 도구입니다. Pinia 사용 방법은 공식 문서를 참조하세요.
모범 사례#
Pinia 인스턴스#
항상 ~/pinia/instance에서 공유 Pinia 인스턴스를 사용하는 것이 좋습니다.
이렇게 하면 여러 Pinia 인스턴스에 대해 걱정하지 않고 컴포넌트에 더 많은 스토어를 쉽게 추가할 수 있습니다.
import { pinia } from '~/pinia/instance';
new Vue({ pinia, render(h) { return h(MyComponent); } });
소규모 스토어#
단일 작업에만 집중하는 소규모 스토어를 생성하는 것이 좋습니다. 이는 더 큰 스토어 생성을 권장하는 Vuex 방식과 반대됩니다.
Pinia 스토어는 거대한 상태 파사드(Vuex 모듈)가 아닌 응집력 있는 컴포넌트처럼 취급하세요.
Vuex 설계 ❌#
flowchart TD A[Store] A --> B[State] A --> C[Actions] A --> D[Mutations] A --> E[Getters] B --> F[items] B --> G[isLoadingItems] B --> H[itemWithActiveForm] B --> I[isSubmittingForm]
Pinia 설계 ✅#
flowchart TD A[Items Store] A --> B[State] A --> C[Actions] A --> D[Getters] B --> E[items] B --> F[isLoading]
H[Form Store]
H --> I[State]
H --> J[Actions]
H --> K[Getters]
I --> L[activeItem]
I --> M[isSubmitting]
단일 파일 스토어#
상태(state), 액션(action), 게터(getter)를 단일 파일에 배치하세요.
actions.js, state.js, getters.js에서 모든 것을 임포트하는 '배럴(barrel)' 스토어 인덱스 파일을 생성하지 마세요.
스토어 파일이 너무 커지면 해당 스토어를 여러 스토어로 분리하는 것을 고려할 때입니다.
Option 스토어 사용#
Pinia는 두 가지 유형의 스토어 정의를 제공합니다: option과 setup. 새 스토어를 생성할 때는 option 타입을 선호하세요. 이를 통해 일관성을 높이고 Vuex에서의 마이그레이션 경로를 단순화할 수 있습니다.
전역 스토어#
전역 반응형 상태에는 전역 Pinia 스토어를 사용하는 것이 좋습니다.
// bad ❌
import { isNarrowScreenMediaQuery } from '~/lib/utils/css_utils';
new Vue({
data() {
return {
isNarrow: false,
};
},
mounted() {
const query = isNarrowScreenMediaQuery();
this.isNarrow = query.matches;
query.addEventListener('change', (event) => {
this.isNarrow = event.matches;
});
},
render() {
if (this.isNarrow) return null;
//
},
});
// good ✅
import { pinia } from '~/pinia/instance';
import { useViewport } from '~/pinia/global_stores/viewport';
new Vue({
pinia,
...mapState(useViewport, ['isNarrowScreen']),
render() {
if (this.isNarrowScreen) return null;
//
},
});
Hot Module Replacement#
Pinia는 HMR 옵션을 제공합니다. 이를 코드에 수동으로 연결해야 합니다. 이 방법으로 Pinia가 제공하는 경험은 기대에 미치지 못하며 사용을 피해야 합니다.
Pinia 테스트#
스토어 단위 테스트#
공식 문서에서는 setActivePinia(createPinia())를 사용하여 Pinia를 테스트하도록 권장합니다.
저희 권장 사항은 액션이 스텁되지 않은 createTestingPinia를 활용하는 것입니다.
이는 setActivePinia(createPinia())와 동일하게 동작하지만, 기본적으로 모든 액션을 스파이할 수도 있습니다.
스토어를 단위 테스트할 때는 항상 stubActions: false와 함께 createTestingPinia를 사용하세요.
기본적인 테스트는 다음과 같습니다:
import { createTestingPinia } from '@pinia/testing';
import { useMyStore } from '~/my_store.js';
describe('MyStore', () => {
beforeEach(() => {
createTestingPinia({ stubActions: false });
});
it('does something', () => {
useMyStore().someAction();
expect(useMyStore().someState).toBe(true);
});
});
각 테스트는 다음 세 가지 중 하나만 확인해야 합니다:
-
스토어 상태의 변경
-
다른 액션 호출
-
사이드 이펙트 호출 (예: Axios 요청)
동일한 Pinia 인스턴스를 두 개 이상의 테스트 케이스에서 사용하지 마세요. 상태를 실제로 보유하는 것은 Pinia 인스턴스이므로 항상 새로운 Pinia 인스턴스를 생성하세요.
스토어를 사용하는 컴포넌트 단위 테스트#
Pinia는 Vue 3 compat 모드를 지원하기 위해 특별한 처리가 필요합니다:
-
Vue 인스턴스에
PiniaVuePlugin을 등록해야 합니다 -
Vue Test Utils의
shallowMount/mount에 Pinia 인스턴스를 명시적으로 제공해야 합니다 -
컴포넌트를 렌더링하기 전에 스토어를 먼저 생성해야 합니다. 그렇지 않으면 Vue가 Vue 3용 Pinia를 사용하려고 시도합니다
전체 설정은 다음과 같습니다:
import Vue from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { PiniaVuePlugin } from 'pinia';
import { shallowMount } from '@vue/test-utils';
import { useMyStore } from '~/my_store.js';
import MyComponent from '~/my_component.vue';
Vue.use(PiniaVuePlugin);
describe('MyComponent', () => {
let pinia;
let wrapper;
const createComponent = () => {
wrapper = shallowMount(MyComponent, { pinia });
}
beforeEach(() => {
pinia = createTestingPinia();
// store is created before component is rendered
useMyStore();
});
it('does something', () => {
createComponent();
// all actions are stubbed by default
expect(useMyStore().someAction).toHaveBeenCalledWith({ arg: 'foo' });
expect(useMyStore().someAction).toHaveBeenCalledTimes(1);
});
});
대부분의 경우 컴포넌트를 테스트할 때 stubActions: false를 설정할 필요가 없습니다.
대신 스토어 자체를 올바르게 테스트하고, 컴포넌트 테스트에서는 액션이 올바른 인수로 호출되었는지 확인해야 합니다.
초기 상태 설정#
Pinia는 액션이 스텁된 후에는 스텁 해제를 허용하지 않습니다.
즉, stubActions: false를 설정하지 않은 경우 초기 상태를 설정하는 데 액션을 사용할 수 없습니다.
이 경우 상태를 직접 설정하는 것이 허용됩니다:
describe('MyComponent', () => {
let pinia;
let wrapper;
const createComponent = () => {
wrapper = shallowMount(MyComponent, { pinia });
}
beforeEach(() => {
// all the actions are stubbed, we can't use them to change the state anymore
pinia = createTestingPinia();
// store is created before component is rendered
useMyStore();
});
it('does something', () => {
// state is set directly instead of using an action
useMyStore().someState = { value: 1 };
createComponent();
// ...
});
});
Vuex에서 마이그레이션#
GitLab은 현재 Vuex에서 활발하게 마이그레이션 중이며, 에픽 18476에서 기여하고 진행 상황을 추적할 수 있습니다.
마이그레이션 전에 먼저 주요 상태 관리자를 결정하세요. Pinia를 선택했다면 이 가이드를 계속 따르세요.
Pinia로의 마이그레이션은 단일 단계 마이그레이션과 다단계 마이그레이션, 두 가지 방법으로 완료할 수 있습니다.
다음 기준을 충족하는 스토어는 단일 단계 마이그레이션을 따르세요:
-
스토어에 모듈이 하나만 포함된 경우
-
액션, 게터, 뮤테이션의 합계가 1000줄을 초과하지 않는 경우
그 외의 경우에는 다단계 마이그레이션을 선호하세요.
단일 단계 마이그레이션#
-
코드모드를 사용하여 스토어를 Pinia로 마이그레이션하세요
-
마이그레이션된 Pinia 스토어를 사용하도록 컴포넌트를 업데이트하세요
mapActions, mapState를 Pinia 대응 함수로 교체하세요
-
mapMutations를 Pinia의mapActions로 교체하세요 -
mapGetters를 Pinia의mapState로 교체하세요
diff가 리뷰 가능한 크기를 초과하기 시작하면 다단계 마이그레이션을 선호하세요.
다단계 마이그레이션#
두 파트로 구성된 비디오 시리즈에서 단계별 안내를 확인할 수 있습니다:
마이그레이션 프로세스를 반복하고 작업을 더 작은 머지 리퀘스트로 분할하려면 다음 단계를 따르세요:
마이그레이션할 스토어를 식별합니다.
new Vuex.Store()로 스토어를 정의하는 파일부터 시작하세요.
이 스토어 내에서 사용되는 모든 모듈을 포함하세요.
마이그레이션 이슈를 생성하고, 마이그레이션 DRI를 지정하며, 마이그레이션할 모든 스토어 모듈을 나열하세요. 해당 이슈에서 마이그레이션 진행 상황을 추적하세요. 필요한 경우 마이그레이션을 여러 이슈로 분할하세요.
마이그레이션할 스토어 파일에 대한 새 CODEOWNERS(.gitlab/CODEOWNERS) 규칙을 생성하고, 모든 Vuex 모듈 의존성과 스토어 사양을 포함하세요.
단일 스토어 모듈만 마이그레이션하는 경우 state.js(또는 index.js),
actions.js, mutations.js, getters.js 및 해당 사양 파일만 포함하면 됩니다.
Vuex 스토어에 변경된 내용을 검토할 책임이 있는 최소 두 명을 지정하세요. 항상 Vuex 스토어에서 Pinia로 변경 사항을 동기화하세요. 이는 Pinia 스토어로 회귀가 발생하지 않도록 매우 중요합니다.
기존 스토어를 새 위치에 그대로 복사하세요(예: stores/legacy_store라고 부를 수 있습니다). 파일 구조를 유지하세요.
마이그레이션할 모든 스토어 모듈에 대해 이 작업을 수행하세요. 필요한 경우 여러 머지 리퀘스트로 분할하세요.
스토어 정의(defineStore)가 있는 인덱스 파일(index.js)을 생성하고 거기에 상태를 정의하세요.
state.js에서 상태 정의를 복사하세요. 아직 액션, 뮤테이션, 게터를 임포트하지 마세요.
코드 모드를 사용하여 스토어 파일을 마이그레이션하세요.
마이그레이션된 모듈을 새 스토어 정의(index.js)에서 임포트하세요.
스토어에 순환 의존성이 있는 경우 tryStore 플러그인 사용을 고려하세요.
컴포넌트를 리팩토링하여 새 스토어를 사용하세요. 필요한 만큼 많은 머지 리퀘스트로 분할하세요. 항상 컴포넌트와 함께 사양을 업데이트하세요.
Vuex 스토어를 제거하세요.
CODEOWNERS 규칙을 제거하세요.
마이그레이션 이슈를 닫으세요.
마이그레이션 분류 예시#
머지 리퀘스트 마이그레이션 분류를 참고로 사용할 수 있습니다:
- Diffs 스토어
스토어를 새 위치로 복사하고 CODEOWNERS 규칙 도입
MrNotes 스토어도 생성
-
Batch comments 스토어
-
Diffs 스토어 컴포넌트 마이그레이션
-
Notes 스토어 컴포넌트 마이그레이션
CODEOWNERS 규칙도 제거
마이그레이션 후 단계#
스토어 마이그레이션이 완료되면 모범 사례를 따르도록 리팩토링하는 것을 고려하세요. 큰 스토어를 더 작게 분할하세요.
tryStore 사용을 리팩토링하세요.
코드모드를 사용한 자동화된 마이그레이션#
ast-grep 코드모드를 사용하여 Vuex에서 Pinia로의 마이그레이션을 단순화할 수 있습니다.
-
진행 전에 시스템에 ast-grep를 설치하세요.
-
scripts/frontend/codemods/vuex-to-pinia/migrate.sh path/to/your/store를 실행하세요
코드모드는 스토어 폴더에 있는 actions.js, mutations.js, getters.js를 마이그레이션합니다.
코드모드 실행 후 이 파일들을 수동으로 스캔하여 올바르게 마이그레이션되었는지 확인하세요.
Vuex 사양은 자동으로 마이그레이션할 수 없으므로 직접 수동으로 마이그레이션하세요.
Vuex 모듈 호출은 Pinia 규칙을 사용하여 교체됩니다:
| Vuex | Pinia |
|---|---|
| dispatch('anotherModule/action', ...args, { root: true }) | useAnotherModule().action(...args) |
| dispatch('action', ...args, { root: true }) | useRootStore().action(...args) |
| rootGetters['anotherModule/getter'] | useAnotherModule().getter |
| rootGetters.getter | useRootStore().getter |
| rootState.anotherModule.state | useAnotherModule().state |
아직 의존 모듈(위 예시의 useAnotherModule과 useRootStore)을 마이그레이션하지 않은 경우 임시 더미 스토어를 생성할 수 있습니다.
아래 지침을 사용하여 Vuex 모듈을 마이그레이션하세요.
중첩 모듈이 있는 스토어 마이그레이션#
서로 의존성이 있는 중첩 모듈이 있는 스토어를 반복적으로 마이그레이션하는 것은 간단하지 않습니다. 이러한 경우 중첩 모듈을 먼저 마이그레이션하는 것이 좋습니다:
-
중첩된 Vuex 스토어 모듈에 대한 Pinia 스토어 대응 항목을 생성하세요.
-
해당하는 경우 루트 모듈 의존성을 위한 플레이스홀더 Pinia '루트' 스토어를 생성하세요.
-
마이그레이션된 모듈에 대한 기존 테스트를 복사하고 조정하세요.
-
마이그레이션된 모듈을 아직 사용하지 마세요.
-
모든 중첩 모듈이 마이그레이션되면 루트 모듈을 마이그레이션하고 플레이스홀더 스토어를 실제 스토어로 교체할 수 있습니다.
-
컴포넌트에서 Vuex 스토어를 Pinia 스토어로 교체하세요.
순환 의존성 방지#
Pinia 스토어에 순환 의존성을 생성하지 않는 것이 중요합니다. 안타깝게도 Vuex 설계는 나중에 리팩토링해야 하는 상호 의존 모듈을 생성하는 것을 허용합니다.
스토어 설계에서 순환 의존성의 예:
graph TD A[Store Alpha] --> Foo(Action Foo) B[Store Beta] --> Bar(Action Bar) A -- calls --> Bar B -- calls --> Foo
이 문제를 완화하려면 Vuex에서 마이그레이션하는 동안 Pinia용 tryStore 플러그인 사용을 고려하세요:
변경 전#
// store_alpha/actions.js
function callOtherStore() {
// bad ❌, circular dependency created
useBetaStore().bar();
}
// store_beta/actions.js
function callOtherStore() {
// bad ❌, circular dependency created
useAlphaStore().bar();
}
변경 후#
// store_alpha/actions.js
function callOtherStore() {
// OK ✅, circular dependency avoided
this.tryStore('betaStore').bar();
}
// store_beta/actions.js
function callOtherStore() {
// OK ✅, circular dependency avoided
this.tryStore('alphaStore').bar();
}
이렇게 하면 Pinia 인스턴스를 사용하여 이름으로 스토어를 조회하고 순환 의존성 문제를 방지합니다.
스토어 이름은 defineStore('storeName', ...)을 호출할 때 정의됩니다.
tryStore를 사용할 때는 컴포넌트 마운팅 전에 두 스토어를 모두 초기화해야 합니다:
// stores are created in advance
useAlphaStore();
useBetaStore();
new Vue({ pinia, render(h) { return h(MyComponent); } });
tryStore 헬퍼 함수는 마이그레이션 중에만 사용할 수 있습니다. 정식 Pinia 스토어에서는 절대 사용하지 마세요.
tryStore 리팩토링#
마이그레이션이 완료된 후에는 더 이상 순환 의존성이 없도록 스토어를 재설계하는 것이 매우 중요합니다.
이를 해결하는 가장 쉬운 방법은 다른 스토어를 조율하는 최상위 스토어를 생성하는 것입니다.
변경 전#
graph TD A[Store Alpha] --> Foo(Action Foo) A -- calls --> Bar B[Store Beta] --> Bar(Action Bar) B -- calls --> Foo
변경 후#
graph TD C[Store Gamma] A[Store Alpha] --- Bar(Action Bar) B[Store Beta] --- Foo(Action Foo) C -- calls --> Bar C -- calls --> Foo
Vuex와 동기화#
이 syncWithVuex 플러그인은 Vuex에서 Pinia로, 그리고 반대 방향으로 상태를 동기화합니다.
이를 통해 마이그레이션 중에 앱에 두 스토어를 모두 유지하면서 컴포넌트를 반복적으로 마이그레이션할 수 있습니다.
사용 예시:
// Vuex store @ ./store.js
import Vuex from 'vuex';
import createOldStore from './stores/old_store';
export default new Vuex.Store({
modules: {
oldStore: createOldStore(),
},
});
// Pinia store
import { defineStore } from 'pinia';
import oldVuexStore from './store'
export const useMigratedStore = defineStore('migratedStore', {
syncWith: {
store: oldVuexStore,
name: 'oldStore', // use legacy store name if it is defined inside Vuex `modules`
namespaced: true, // set to 'true' if Vuex module is namespaced
},
// the state here gets sync with Vuex, any changes to migratedStore also propagate to the Vuex store
state() {
// ...
},
// ...
});
재정의(Override)#
Vuex 스토어 정의는 여러 Vuex 스토어 인스턴스에서 공유될 수 있습니다.
이 경우 스토어 설정만으로는 Pinia 스토어를 Vuex 스토어와 동기화할 수 없습니다.
syncWith 헬퍼 함수를 사용하여 Pinia 스토어가 실제 Vuex 스토어 인스턴스를 가리키도록 해야 합니다.
// this overrides the existing `syncWith` config
useMigratedStore().syncWith({ store: anotherOldStore });
// `useMigratedStore` state now is synced only with `anotherOldStore`
new Vue({ pinia, render(h) { return h(MyComponent) } });
스토어 테스트 마이그레이션#
testAction#
일부 Vuex 테스트는 testAction 헬퍼를 사용하여 특정 액션 또는 뮤테이션이 호출되었는지 테스트할 수 있습니다.
Jest의 helpers/pinia_helpers에 있는 createTestPiniaAction 헬퍼를 사용하여 이러한 사양을 마이그레이션할 수 있습니다.
변경 전#
describe('SomeStore', () => {
it('runs actions', () => {
return testAction(
store.actionToBeCalled, // action to be called immediately
{ someArg: 1 }, // action call arguments
{ someState: 1 }, // initial store state
[{ type: 'MUTATION_NAME', payload: '123' }], // mutation calls to expect
[{ type: 'actionName' }], // action calls to expect
);
});
});
변경 후#
import { createTestPiniaAction } from 'helpers/pinia_helpers';
describe('SomeStore', () => {
let store;
let testAction;
beforeEach(() => {
store = useMyStore();
testAction = createTestPiniaAction(store);
});
it('runs actions', () => {
return testAction(
store.actionToBeCalled,
{ someArg: 1 },
{ someState: 1 },
[{ type: store.MUTATION_NAME, payload: '123' }], // explicit reference to migrated mutation
[{ type: store.actionName }], // explicit reference to migrated action
);
});
});
정식 Pinia 테스트에서는 testAction 사용을 피하세요. 이는 마이그레이션 중에만 사용해야 합니다.
항상 각 액션 호출을 명시적으로 테스트하는 것을 선호하세요.
커스텀 게터#
Pinia는 Vue 3에서 커스텀 게터를 정의할 수 있습니다. Vue 2를 사용하고 있으므로 이것은 불가능합니다.
이를 해결하려면 helpers/pinia_helpers의 createCustomGetters 헬퍼를 사용할 수 있습니다.
변경 전#
describe('SomeStore', () => {
it('runs actions', () => {
const dispatch = jest.fn();
const getters = { someGetter: 1 };
someAction({ dispatch, getters });
expect(dispatch).toHaveBeenCalledWith('anotherAction', 1);
});
});
변경 후#
import { createCustomGetters } from 'helpers/pinia_helpers';
describe('SomeStore', () => {
let store;
let getters;
beforeEach(() => {
getters = {};
createTestingPinia({
stubActions: false,
plugins: [
createCustomGetters(() => ({
myStore: getters, // each store used in tests should be also declared here
})),
],
});
store = useMyStore();
});
it('runs actions', () => {
getters.someGetter = 1;
store.someAction();
expect(store.anotherAction).toHaveBeenCalledWith(1);
});
});
정식 Pinia 테스트에서는 게터를 모킹하지 마세요. 이는 마이그레이션용으로만 사용해야 합니다. 대신 게터가 올바른 값을 반환할 수 있도록 유효한 상태를 제공하세요.
컴포넌트 테스트 마이그레이션#
Pinia는 기본적으로 액션에서 프로미스를 반환하지 않습니다.
따라서 createTestingPinia를 사용할 때 특별한 주의가 필요합니다.
모든 액션을 스텁하기 때문에 액션이 프로미스를 반환한다는 것을 보장하지 않습니다.
컴포넌트 코드에서 액션이 프로미스를 반환할 것으로 예상하는 경우 그에 맞게 스텁하세요.
describe('MyComponent', () => {
let pinia;
beforeEach(() => {
pinia = createTestingPinia();
useMyStore().someAsyncAction.mockResolvedValue(); // this now returns a promise
});
});