임팩트: 상태관리 아키텍처 전환 — 번들 사이즈, DX, 마이그레이션 전략 문서화


SCRUM-14457 | 커밋 수: 45개 | 기간: 2025.07


문제상황

Vuex 레거시 코드로 인해 타입 안전성이 부족했습니다. 상태 관리 코드의 복잡성이 계속 증가하고 있었으며, Vue 3 Composition API와의 통합이 어려운 상황이었습니다.

주요 개선사항

전체 Vuex 모듈을 Pinia로 마이그레이션하여 100% 전환을 달성했습니다. benefit, mollys, product, promotion, wine, review 등 전 도메인의 마이그레이션을 완료했으며, store 파일들의 dispatch를 Pinia 방식으로 변경했습니다.

정량적 성과

Vuex 모듈 15개 이상을 Pinia 스토어로 전환했습니다. shims-vuex.d.ts를 삭제하고 타입 안전성을 100% 확보했습니다.

마이그레이션 규모
항목비교값 (개/%)
Vuex 모듈 수1515 개/%
커밋 수4545 개/%
전환율(%)100100 개/%

비즈니스 임팩트

IDE 자동완성이 개선되어 개발 생산성이 향상되었습니다. 런타임 에러를 사전에 방지할 수 있게 되었으며, Vue 3 생태계에 완전히 통합되었습니다.

🎯

타입 안전성

100% 확보, 런타임 에러 사전 방지

⚡

DX 향상

IDE 자동완성, dispatch 문자열 제거

🗑️

레거시 제거

shims-vuex.d.ts 삭제


기술적 구현 상세

Vuex (AS-IS)

장점

  • 안정적 생태계
  • 기존 코드베이스 호환

고려할 점

  • 문자열 기반 dispatch
  • 타입 추론 불가
  • mutations 보일러플레이트
  • IDE 자동완성 미지원

Pinia (TO-BE)

장점

  • 완전한 타입 안전성
  • IDE 자동완성
  • mutations 불필요
  • Composition API 통합

고려할 점

  • 마이그레이션 비용 (45커밋)

AS-IS

기존 Vuex 코드는 타입 추론이 어렵고, dispatch 문자열 기반으로 동작했습니다.

// 기존 Vuex 구조 (더미 예시)
// store/modules/benefit.ts
export default {
  namespaced: true,
  state: () => ({
    benefits: [],
    isLoading: false,
  }),
  mutations: {
    SET_BENEFITS(state, benefits) {
      state.benefits = benefits;
    },
    SET_LOADING(state, isLoading) {
      state.isLoading = isLoading;
    },
  },
  actions: {
    async fetchBenefits({ commit }) {
      commit('SET_LOADING', true);
      const response = await api.getBenefits();
      commit('SET_BENEFITS', response.data);
      commit('SET_LOADING', false);
    },
  },
};

// 컴포넌트에서 사용
this.$store.dispatch('benefit/fetchBenefits');

문제점:

문자열 기반 dispatch로 타입 안전성이 부족했습니다. IDE 자동완성이 지원되지 않았으며, 런타임에서만 에러를 발견할 수 있었습니다.

TO-BE

Pinia로 마이그레이션하여 완전한 타입 안전성을 확보했습니다.

// stores/modules/benefit.ts
import { defineStore } from 'pinia';

export const useBenefitStore = defineStore('benefit', {
  state: () => ({
    benefits: [] as Benefit[],
    isLoading: false,
  }),

  actions: {
    async fetchBenefits() {
      this.isLoading = true;
      try {
        const response = await benefitApi.getBenefits();
        this.benefits = response;
      } finally {
        this.isLoading = false;
      }
    },
  },
});

핵심 구현

1. Pinia 스토어 정의

// stores/modules/benefit.ts
import { defineStore } from 'pinia';
import { benefitApi } from '@/stores/api/benefit';
import type { Benefit, BenefitState } from '@/types/benefit';

export const useBenefitStore = defineStore('benefit', {
  state: (): BenefitState => ({
    benefits: [],
    isLoading: false,
    error: null,
  }),

  getters: {
    activeBenefits: (state) =>
      state.benefits.filter(b => b.isActive),

    totalBenefitAmount: (state) =>
      state.benefits.reduce((sum, b) => sum + b.amount, 0),
  },

  actions: {
    async fetchBenefits() {
      this.isLoading = true;
      this.error = null;
      try {
        this.benefits = await benefitApi.getBenefits();
      } catch (e) {
        this.error = e as Error;
      } finally {
        this.isLoading = false;
      }
    },

    clearBenefits() {
      this.benefits = [];
    },
  },
});

2. common.js에서 Pinia 방식으로 마이그레이션

// public/js/common.js
// 기존 Vuex 방식
// window.emart.store.dispatch('benefit/fetchBenefits');

// Pinia 방식으로 변경
const getBenefitStore = () => {
  const pinia = window.emart.pinia;
  return useBenefitStore(pinia);
};

const fetchBenefits = async () => {
  const benefitStore = getBenefitStore();
  await benefitStore.fetchBenefits();
};

3. 컴포넌트에서 사용

<!-- components/benefit/BenefitList.vue -->
<script setup lang="ts">
import { useBenefitStore } from '@/stores/modules/benefit';
import { storeToRefs } from 'pinia';

const benefitStore = useBenefitStore();
const { benefits, isLoading, activeBenefits } = storeToRefs(benefitStore);

onMounted(() => {
  benefitStore.fetchBenefits();
});
</script>

<template>
  <div v-if="isLoading">로딩 중...</div>
  <ul v-else>
    <li v-for="benefit in activeBenefits" :key="benefit.id">
      {{ benefit.name }}
    </li>
  </ul>
</template>

4. shims-vuex.d.ts 삭제 및 pinia.d.ts 정의

// src/pinia.d.ts
import 'pinia';
import type { Router } from 'vue-router';

declare module 'pinia' {
  export interface PiniaCustomProperties {
    router: Router;
  }
}

// 전역 타입 확장
declare global {
  interface Window {
    emart: {
      pinia: Pinia;
      // ...
    };
  }
}

5. App.vue에서 inject 방식 변경

<!-- App.vue -->
<script setup lang="ts">
import { inject } from 'vue';
import type { VueCookies } from 'vue-cookies';

// 기존: this.$cookies
// 변경: inject를 통한 접근
const cookies = inject<VueCookies>('$cookies');

const getMallType = () => {
  return cookies?.get('mallType') ?? 'emart';
};
</script>


회고

잘한 점

전체 모듈 100% 마이그레이션을 완료했습니다. shims-vuex.d.ts 삭제로 레거시 코드를 완전히 제거했으며, IDE 자동완성 및 타입 체크가 활성화되었습니다.

개선할 점

마이그레이션 중 일부 런타임 에러가 발생했습니다. 테스트 커버리지가 부족했던 부분이 원인이었으며, common.js의 Pinia 접근 방식도 개선이 필요합니다.

배운 점

Vuex에서 Pinia로의 마이그레이션 전략을 수립하고 실행하는 경험을 얻었습니다. Pinia의 타입 안전성 장점을 실감했으며, 레거시 JavaScript 파일에서 Pinia에 접근하는 방법을 학습했습니다.