InfoGrab DocsInfoGrab Docs

Pinia

요약

Pinia는 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 설계 ❌#

Mermaid 다이어그램 (10줄)
소스 코드 보기
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 설계 ✅#

Mermaid 다이어그램 (14줄)
소스 코드 보기
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 --&gt; M[isSubmitting]</code></pre></details></div>

단일 파일 스토어#

상태(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 테스트#

스토어 단위 테스트#

공식 테스트 문서를 따릅니다.

공식 문서는 Pinia를 테스트할 때 setActivePinia(createPinia())를 사용하도록 권장합니다.

GitLab이 권장하는 방식은 액션을 스텁하지 않은 채 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);
  });
});

각 테스트는 다음 세 가지 중 하나만 확인해야 합니다.

  1. 스토어 상태의 변경
  2. 다른 액션 호출
  3. 부수 효과 호출(예: Axios 요청)

같은 Pinia 인스턴스를 두 개 이상의 테스트 케이스에서 사용하지 않습니다. 상태를 실제로 보유하는 것이 Pinia 인스턴스이므로, 항상 새 Pinia 인스턴스를 만듭니다.

스토어를 사용하는 컴포넌트 단위 테스트#

공식 테스트 문서를 따릅니다.

Pinia는 Vue 3 호환 모드를 지원하기 위해 특별한 처리가 필요합니다.

  1. Vue 인스턴스에 PiniaVuePlugin을 등록해야 합니다
  2. Vue Test Utils의 shallowMount/mount에 Pinia 인스턴스를 명시적으로 제공해야 합니다
  3. 컴포넌트를 렌더링하기 전에 스토어를 만들어야 하며, 그렇지 않으면 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 마이그레이션은 단일 단계 마이그레이션과 다단계 마이그레이션의 두 가지 방식으로 진행할 수 있습니다.

스토어가 다음 기준을 충족하면 단일 단계 마이그레이션을 따릅니다.

  1. 스토어에 모듈이 하나만 있습니다
  2. 액션, 게터, 뮤테이션의 합계가 1000줄을 넘지 않습니다

그 밖의 경우에는 다단계 마이그레이션을 사용합니다.

단일 단계 마이그레이션#

공식 Vuex 마이그레이션 가이드를 따릅니다.

  1. 코드모드를 사용해 스토어를 Pinia로 마이그레이션합니다
  2. GitLab 가이드와 모범 사례에 따라 스토어 테스트를 수정합니다
  3. 마이그레이션한 Pinia 스토어를 사용하도록 컴포넌트를 업데이트합니다
    1. mapActions, mapState를 Pinia의 대응 함수로 교체합니다
    2. mapMutations를 Pinia의 mapActions로 교체합니다
    3. mapGetters를 Pinia의 mapState로 교체합니다
  4. GitLab 가이드와 모범 사례에 따라 컴포넌트 테스트를 수정합니다

diff가 리뷰 가능한 크기를 넘기 시작하면 다단계 마이그레이션을 사용합니다.

다단계 마이그레이션#

공식 Vuex 마이그레이션 가이드를 참고합니다.

2부로 구성된 영상 시리즈에서 전체 과정을 확인할 수 있습니다.

  1. 스토어 마이그레이션(1부)
  2. 컴포넌트 마이그레이션(2부)

다음 단계를 따라 마이그레이션 과정을 반복하면서 작업을 더 작은 머지 리퀘스트로 나눕니다.

  1. 마이그레이션할 스토어를 파악합니다. new Vuex.Store()로 스토어를 정의하는 파일에서 시작해 이어 나갑니다. 이 스토어 안에서 사용하는 모든 모듈을 포함합니다.

  2. 마이그레이션 이슈를 만들고 마이그레이션 DRI를 지정한 뒤, 마이그레이션할 스토어 모듈을 모두 나열합니다. 마이그레이션 진행 상황은 해당 이슈에서 추적합니다. 필요하면 마이그레이션을 여러 이슈로 나눕니다.

  3. 마이그레이션하는 스토어 파일에 대한 새 CODEOWNERS(.gitlab/CODEOWNERS) 규칙을 만들고, Vuex 모듈 의존성과 스토어 스펙을 모두 포함합니다.

    스토어 모듈 하나만 마이그레이션한다면 state.js(또는 index.js), actions.js, mutations.js, getters.js와 각각의 스펙 파일만 포함하면 됩니다.

    Vuex 스토어에 적용된 변경 사항을 검토할 담당자를 최소 두 명 지정합니다. Vuex 스토어의 변경 사항은 항상 Pinia로 동기화합니다. Pinia 스토어에 회귀가 생기지 않도록 하는 데 매우 중요합니다.

  4. 기존 스토어를 그대로 새 위치로 복사합니다(예: stores/legacy_store). 파일 구조는 유지합니다. 마이그레이션할 모든 스토어 모듈에 대해 이 작업을 수행합니다. 필요하면 여러 머지 리퀘스트로 나눕니다.

  5. 스토어 정의(defineStore)가 담긴 인덱스 파일(index.js)을 만들고 그 안에 상태를 정의합니다. 상태 정의는 state.js에서 복사합니다. 액션, 뮤테이션, 게터는 아직 임포트하지 않습니다.

  6. 코드모드를 사용해 스토어 파일을 마이그레이션합니다. 마이그레이션한 모듈을 새 스토어 정의(index.js)에서 임포트합니다.

  7. 스토어에 순환 의존성이 있으면 tryStore 플러그인 사용을 고려합니다.

  8. 스토어 스펙을 직접 마이그레이션합니다.

  9. Vuex 스토어를 Pinia 스토어와 동기화합니다.

  10. 새 스토어를 사용하도록 컴포넌트를 리팩토링합니다. 필요한 만큼 여러 머지 리퀘스트로 나눕니다. 컴포넌트와 함께 항상 스펙을 업데이트합니다.

  11. Vuex 스토어를 제거합니다.

  12. CODEOWNERS 규칙을 제거합니다.

  13. 마이그레이션 이슈를 닫습니다.

마이그레이션 분류 예시#

머지 리퀘스트 마이그레이션 분류를 참고 자료로 사용할 수 있습니다.

  1. Diffs 스토어
    1. 스토어를 새 위치로 복사하고 CODEOWNERS 규칙 도입
    2. 자동화된 스토어 마이그레이션
      1. MrNotes 스토어도 생성
    3. 스펙 마이그레이션(actions, getters, mutations)
  2. Notes 스토어
    1. 스토어를 새 위치로 복사
    2. 자동화된 스토어 마이그레이션
    3. 스펙 마이그레이션(actions, getters, mutations)
  3. Batch comments 스토어
    1. 스토어를 새 위치로 복사
    2. 자동화된 스토어 마이그레이션
    3. 스펙 마이그레이션(actions, getters, mutations)
  4. Vuex 스토어를 Pinia 스토어와 동기화
  5. Diffs 스토어 컴포넌트 마이그레이션
    1. Diffs 앱
    2. diff 외 컴포넌트
    3. 파일 브라우저
    4. Diffs 컴포넌트
    5. Diff 파일 컴포넌트
    6. 나머지 diffs 컴포넌트
  6. Batch comments 컴포넌트 마이그레이션
  7. MrNotes 컴포넌트 마이그레이션
  8. Notes 스토어 컴포넌트 마이그레이션
    1. Diffs 컴포넌트
    2. 단순 notes 컴포넌트
    3. 추가 notes 컴포넌트
    4. 나머지 notes 컴포넌트
    5. Notes 앱
  9. 머지 리퀘스트에서 Vuex 제거
    1. CODEOWNERS 규칙도 제거

마이그레이션 후 단계#

스토어를 마이그레이션한 뒤에는 모범 사례에 맞게 리팩토링하는 것을 고려합니다. 큰 스토어는 더 작은 스토어로 나눕니다. tryStore 사용 부분을 리팩토링합니다.

코드모드를 사용한 자동화된 마이그레이션#

ast-grep 코드모드를 사용하면 Vuex에서 Pinia로의 마이그레이션을 단순화할 수 있습니다.

  1. 진행하기 전에 시스템에 ast-grep을 설치합니다.
  2. 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 모듈을 마이그레이션할 때는 아래 안내를 따릅니다.

중첩 모듈이 있는 스토어 마이그레이션#

서로 의존성이 있는 중첩 모듈을 가진 스토어를 점진적으로 마이그레이션하는 일은 간단하지 않습니다. 이런 경우에는 중첩 모듈을 먼저 마이그레이션하는 편이 좋습니다.

  1. 중첩된 Vuex 스토어 모듈에 대응하는 Pinia 스토어를 만듭니다.
  2. 해당되는 경우 루트 모듈 의존성을 위한 플레이스홀더 Pinia 'root' 스토어를 만듭니다.
  3. 마이그레이션한 모듈의 기존 테스트를 복사해 맞게 수정합니다.
  4. 마이그레이션한 모듈은 아직 사용하지 않습니다.
  5. 중첩 모듈을 모두 마이그레이션한 뒤에는 루트 모듈을 마이그레이션하고 플레이스홀더 스토어를 실제 스토어로 교체합니다.
  6. 컴포넌트에서 Vuex 스토어를 Pinia 스토어로 교체합니다.

순환 의존성 방지#

Pinia 스토어에 순환 의존성을 만들지 않는 것이 반드시 필요합니다. 아쉽게도 Vuex 설계는 나중에 리팩토링해야 하는 상호 의존 모듈을 만들 수 있게 허용합니다.

스토어 설계의 순환 의존성 예시는 다음과 같습니다.

Mermaid 다이어그램 (5줄)
소스 코드 보기
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 리팩토링#

마이그레이션을 마친 뒤에는 순환 의존성이 더 이상 없도록 스토어를 재설계하는 것이 매우 중요합니다.

가장 쉬운 해결책은 다른 스토어를 조율하는 최상위 스토어를 만드는 것입니다.

변경 전#
Mermaid 다이어그램 (5줄)
소스 코드 보기
graph TD
    A[Store Alpha] --> Foo(Action Foo)
    A -- calls --> Bar
    B[Store Beta] --> Bar(Action Bar)
    B -- calls --> Foo
변경 후#
Mermaid 다이어그램 (6줄)
소스 코드 보기
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() {
    // ...
  },
  // ...
});

재정의#

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에서 커스텀 게터를 정의할 수 있게 합니다. GitLab은 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
  });
});

Pinia

GitLab v19.4
원문 보기

요약

Pinia는 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 설계 ❌#

Mermaid 다이어그램 (10줄)
소스 코드 보기
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 설계 ✅#

Mermaid 다이어그램 (14줄)
소스 코드 보기
flowchart TD
    A[Items Store]
    A --> B[State]
    A --> C[Actions]
    A --> D[Getters]
    B --> E[items]
    B --> F[isLoading]
H[Form Store]
H --&gt; I[State]
H --&gt; J[Actions]
H --&gt; K[Getters]
I --&gt; L[activeItem]
I --&gt; M[isSubmitting]</code></pre></details></div>

단일 파일 스토어#

상태(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 테스트#

스토어 단위 테스트#

공식 테스트 문서를 따릅니다.

공식 문서는 Pinia를 테스트할 때 setActivePinia(createPinia())를 사용하도록 권장합니다.

GitLab이 권장하는 방식은 액션을 스텁하지 않은 채 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);
  });
});

각 테스트는 다음 세 가지 중 하나만 확인해야 합니다.

  1. 스토어 상태의 변경
  2. 다른 액션 호출
  3. 부수 효과 호출(예: Axios 요청)

같은 Pinia 인스턴스를 두 개 이상의 테스트 케이스에서 사용하지 않습니다. 상태를 실제로 보유하는 것이 Pinia 인스턴스이므로, 항상 새 Pinia 인스턴스를 만듭니다.

스토어를 사용하는 컴포넌트 단위 테스트#

공식 테스트 문서를 따릅니다.

Pinia는 Vue 3 호환 모드를 지원하기 위해 특별한 처리가 필요합니다.

  1. Vue 인스턴스에 PiniaVuePlugin을 등록해야 합니다
  2. Vue Test Utils의 shallowMount/mount에 Pinia 인스턴스를 명시적으로 제공해야 합니다
  3. 컴포넌트를 렌더링하기 전에 스토어를 만들어야 하며, 그렇지 않으면 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 마이그레이션은 단일 단계 마이그레이션과 다단계 마이그레이션의 두 가지 방식으로 진행할 수 있습니다.

스토어가 다음 기준을 충족하면 단일 단계 마이그레이션을 따릅니다.

  1. 스토어에 모듈이 하나만 있습니다
  2. 액션, 게터, 뮤테이션의 합계가 1000줄을 넘지 않습니다

그 밖의 경우에는 다단계 마이그레이션을 사용합니다.

단일 단계 마이그레이션#

공식 Vuex 마이그레이션 가이드를 따릅니다.

  1. 코드모드를 사용해 스토어를 Pinia로 마이그레이션합니다
  2. GitLab 가이드와 모범 사례에 따라 스토어 테스트를 수정합니다
  3. 마이그레이션한 Pinia 스토어를 사용하도록 컴포넌트를 업데이트합니다
    1. mapActions, mapState를 Pinia의 대응 함수로 교체합니다
    2. mapMutations를 Pinia의 mapActions로 교체합니다
    3. mapGetters를 Pinia의 mapState로 교체합니다
  4. GitLab 가이드와 모범 사례에 따라 컴포넌트 테스트를 수정합니다

diff가 리뷰 가능한 크기를 넘기 시작하면 다단계 마이그레이션을 사용합니다.

다단계 마이그레이션#

공식 Vuex 마이그레이션 가이드를 참고합니다.

2부로 구성된 영상 시리즈에서 전체 과정을 확인할 수 있습니다.

  1. 스토어 마이그레이션(1부)
  2. 컴포넌트 마이그레이션(2부)

다음 단계를 따라 마이그레이션 과정을 반복하면서 작업을 더 작은 머지 리퀘스트로 나눕니다.

  1. 마이그레이션할 스토어를 파악합니다. new Vuex.Store()로 스토어를 정의하는 파일에서 시작해 이어 나갑니다. 이 스토어 안에서 사용하는 모든 모듈을 포함합니다.

  2. 마이그레이션 이슈를 만들고 마이그레이션 DRI를 지정한 뒤, 마이그레이션할 스토어 모듈을 모두 나열합니다. 마이그레이션 진행 상황은 해당 이슈에서 추적합니다. 필요하면 마이그레이션을 여러 이슈로 나눕니다.

  3. 마이그레이션하는 스토어 파일에 대한 새 CODEOWNERS(.gitlab/CODEOWNERS) 규칙을 만들고, Vuex 모듈 의존성과 스토어 스펙을 모두 포함합니다.

    스토어 모듈 하나만 마이그레이션한다면 state.js(또는 index.js), actions.js, mutations.js, getters.js와 각각의 스펙 파일만 포함하면 됩니다.

    Vuex 스토어에 적용된 변경 사항을 검토할 담당자를 최소 두 명 지정합니다. Vuex 스토어의 변경 사항은 항상 Pinia로 동기화합니다. Pinia 스토어에 회귀가 생기지 않도록 하는 데 매우 중요합니다.

  4. 기존 스토어를 그대로 새 위치로 복사합니다(예: stores/legacy_store). 파일 구조는 유지합니다. 마이그레이션할 모든 스토어 모듈에 대해 이 작업을 수행합니다. 필요하면 여러 머지 리퀘스트로 나눕니다.

  5. 스토어 정의(defineStore)가 담긴 인덱스 파일(index.js)을 만들고 그 안에 상태를 정의합니다. 상태 정의는 state.js에서 복사합니다. 액션, 뮤테이션, 게터는 아직 임포트하지 않습니다.

  6. 코드모드를 사용해 스토어 파일을 마이그레이션합니다. 마이그레이션한 모듈을 새 스토어 정의(index.js)에서 임포트합니다.

  7. 스토어에 순환 의존성이 있으면 tryStore 플러그인 사용을 고려합니다.

  8. 스토어 스펙을 직접 마이그레이션합니다.

  9. Vuex 스토어를 Pinia 스토어와 동기화합니다.

  10. 새 스토어를 사용하도록 컴포넌트를 리팩토링합니다. 필요한 만큼 여러 머지 리퀘스트로 나눕니다. 컴포넌트와 함께 항상 스펙을 업데이트합니다.

  11. Vuex 스토어를 제거합니다.

  12. CODEOWNERS 규칙을 제거합니다.

  13. 마이그레이션 이슈를 닫습니다.

마이그레이션 분류 예시#

머지 리퀘스트 마이그레이션 분류를 참고 자료로 사용할 수 있습니다.

  1. Diffs 스토어
    1. 스토어를 새 위치로 복사하고 CODEOWNERS 규칙 도입
    2. 자동화된 스토어 마이그레이션
      1. MrNotes 스토어도 생성
    3. 스펙 마이그레이션(actions, getters, mutations)
  2. Notes 스토어
    1. 스토어를 새 위치로 복사
    2. 자동화된 스토어 마이그레이션
    3. 스펙 마이그레이션(actions, getters, mutations)
  3. Batch comments 스토어
    1. 스토어를 새 위치로 복사
    2. 자동화된 스토어 마이그레이션
    3. 스펙 마이그레이션(actions, getters, mutations)
  4. Vuex 스토어를 Pinia 스토어와 동기화
  5. Diffs 스토어 컴포넌트 마이그레이션
    1. Diffs 앱
    2. diff 외 컴포넌트
    3. 파일 브라우저
    4. Diffs 컴포넌트
    5. Diff 파일 컴포넌트
    6. 나머지 diffs 컴포넌트
  6. Batch comments 컴포넌트 마이그레이션
  7. MrNotes 컴포넌트 마이그레이션
  8. Notes 스토어 컴포넌트 마이그레이션
    1. Diffs 컴포넌트
    2. 단순 notes 컴포넌트
    3. 추가 notes 컴포넌트
    4. 나머지 notes 컴포넌트
    5. Notes 앱
  9. 머지 리퀘스트에서 Vuex 제거
    1. CODEOWNERS 규칙도 제거

마이그레이션 후 단계#

스토어를 마이그레이션한 뒤에는 모범 사례에 맞게 리팩토링하는 것을 고려합니다. 큰 스토어는 더 작은 스토어로 나눕니다. tryStore 사용 부분을 리팩토링합니다.

코드모드를 사용한 자동화된 마이그레이션#

ast-grep 코드모드를 사용하면 Vuex에서 Pinia로의 마이그레이션을 단순화할 수 있습니다.

  1. 진행하기 전에 시스템에 ast-grep을 설치합니다.
  2. 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 모듈을 마이그레이션할 때는 아래 안내를 따릅니다.

중첩 모듈이 있는 스토어 마이그레이션#

서로 의존성이 있는 중첩 모듈을 가진 스토어를 점진적으로 마이그레이션하는 일은 간단하지 않습니다. 이런 경우에는 중첩 모듈을 먼저 마이그레이션하는 편이 좋습니다.

  1. 중첩된 Vuex 스토어 모듈에 대응하는 Pinia 스토어를 만듭니다.
  2. 해당되는 경우 루트 모듈 의존성을 위한 플레이스홀더 Pinia 'root' 스토어를 만듭니다.
  3. 마이그레이션한 모듈의 기존 테스트를 복사해 맞게 수정합니다.
  4. 마이그레이션한 모듈은 아직 사용하지 않습니다.
  5. 중첩 모듈을 모두 마이그레이션한 뒤에는 루트 모듈을 마이그레이션하고 플레이스홀더 스토어를 실제 스토어로 교체합니다.
  6. 컴포넌트에서 Vuex 스토어를 Pinia 스토어로 교체합니다.

순환 의존성 방지#

Pinia 스토어에 순환 의존성을 만들지 않는 것이 반드시 필요합니다. 아쉽게도 Vuex 설계는 나중에 리팩토링해야 하는 상호 의존 모듈을 만들 수 있게 허용합니다.

스토어 설계의 순환 의존성 예시는 다음과 같습니다.

Mermaid 다이어그램 (5줄)
소스 코드 보기
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 리팩토링#

마이그레이션을 마친 뒤에는 순환 의존성이 더 이상 없도록 스토어를 재설계하는 것이 매우 중요합니다.

가장 쉬운 해결책은 다른 스토어를 조율하는 최상위 스토어를 만드는 것입니다.

변경 전#
Mermaid 다이어그램 (5줄)
소스 코드 보기
graph TD
    A[Store Alpha] --> Foo(Action Foo)
    A -- calls --> Bar
    B[Store Beta] --> Bar(Action Bar)
    B -- calls --> Foo
변경 후#
Mermaid 다이어그램 (6줄)
소스 코드 보기
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() {
    // ...
  },
  // ...
});

재정의#

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에서 커스텀 게터를 정의할 수 있게 합니다. GitLab은 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
  });
});