Vue 3 마이그레이션
GitLab v19.1Vue 2에서 Vue 3으로의 마이그레이션은 에픽 &6252에서 추적됩니다. Vue 3.x로의 마이그레이션을 용이하게 하기 위해, 코드베이스에서 다음의 더 이상 사용되지 않는 기능을 사용하지 못하도록 방지하는 ESLint 규칙을 추가했습니다.
Vue 2에서 Vue 3으로의 마이그레이션은 에픽 &6252에서 추적됩니다.
Vue 3.x로의 마이그레이션을 용이하게 하기 위해, 코드베이스에서 다음의 더 이상 사용되지 않는 기능을 사용하지 못하도록 방지하는 ESLint 규칙을 추가했습니다.
GitLab에서 Vue 3 (@vue/compat) 사용 가능#
GitLab 프론트엔드 팀은 GDK와 같은 개발 환경에서 Vue 3 (@vue/compat)을 활성화했습니다. 아직 프로덕션에 사용할 준비는 되지 않았지만, 로컬에서 opt-in하여 클라이언트 코드가 Vue 3과 순방향 호환되는지 확인할 수 있습니다.
어떻게 동작하나요? 빌드 도구(Vite 또는 Webpack)가 VUE_VERSION=3 환경 변수를 감지하면, 모듈 에일리어싱(module aliasing)을 사용하여 Vue 자체를 포함한 특정 의존성을 Vue 3 호환 버전으로 교체합니다.
이 대체 라이브러리 중 일부는 팀에서 유지 관리합니다. 이 라이브러리들은 기존 라이브러리를 감싸는 얇은 래퍼(thin wrapper) 역할을 하여, 컨슈머(consumer) 코드를 변경하지 않고도 Vue 3와 호환되도록 합니다.
GDK에서 Vue 3 (@vue/compat) 설정#
이 가이드는 GitLab Development Kit (GDK)에서 Vite를 빌드 도구로 사용하여 Vue 3을 설정하는 방법을 안내합니다.
사전 요구 사항#
-
GDK가 설치 및 구성되어 있어야 합니다.
-
Vue.js와 Vite에 대한 기본 지식이 있어야 합니다.
-
GDK 환경에서 Vite가 구성되어 있어야 합니다(GDK Vite Settings 참고).
초기 설정#
Vue 버전 간 전환#
Vue 2와 Vue 3 사이를 전환하려면 다음 단계를 따르세요:
원하는 Vue 버전을 설정합니다:
gdk config set vite.vue_version 3 # or 2
GDK를 재구성합니다:
gdk reconfigure
GDK를 재시작합니다:
gdk restart # or `gdk start` if running for the first time
중요: Vue 버전을 전환할 때 문제가 발생하면 yarn clean 또는 gdk kill vite로 캐시를 지울 수 있습니다.
설정 확인#
gdk.yml 파일을 확인하여 Vite 구성을 검증할 수 있습니다:
gdk config get vite
이 명령은 활성화 상태와 Vue 버전을 포함한 현재 Vite 설정을 표시합니다. GDK도 실행 중이어야 합니다.
---
enabled: true
hot_module_reloading: true
https:
enabled: true
port: 3038
vue_version: 3
문제 해결#
일반적인 디버깅#
문제가 발생하면 Vite 로그를 먼저 확인하세요:
gdk tail vite
이 명령은 실시간 Vite 출력 및 오류 메시지를 표시하여 문제를 파악하는 데 도움이 됩니다.
버전 전환 후 빌드 오류#
Vue 버전 전환 후 빌드 오류가 발생하면:
yarn clean으로 Vite 캐시를 지웠는지 확인하세요.
node_modules를 삭제하고 의존성을 재설치해 보세요:
rm -rf node_modules
yarn install
Vite가 시작되지 않는 경우#
Vite가 시작에 실패하면:
-
vite.enabled가true로 설정되어 있는지 확인하세요. -
Node.js 버전이 Vite의 요구 사항을 충족하는지 확인하세요.
-
특정 오류 메시지를 확인하기 위해 GDK 로그를 검토하세요.
추가 리소스#
호환성 변경 사항#
Vue 필터#
이유
필터는 Vue 3 API에서 완전히 제거되었습니다.
대신 사용할 것
컴포넌트의 computed 속성 / 메서드 또는 외부 헬퍼를 사용하세요.
이벤트 허브#
이유
$on, $once, $off 메서드가 Vue 인스턴스에서 제거되었으므로, Vue 3에서는 이벤트 허브를 생성하는 데 사용할 수 없습니다.
사용 시기
이벤트 허브를 사용하지 않는 Vue 앱에 있다면, 꼭 필요한 경우가 아니면 새 이벤트 허브를 추가하지 마세요. 예를 들어, 자식 컴포넌트가 부모의 이벤트에 반응해야 하는 경우, prop을 아래로 전달하는 것이 좋습니다. 그런 다음, 자식 컴포넌트에서 해당 prop의 watch 속성을 사용하여 원하는 사이드 이펙트를 생성하세요.
서로 다른 Vue 앱 간의 크로스 컴포넌트 통신이 필요한 경우, 허브를 도입하는 것이 올바른 결정일 수 있습니다.
대신 사용할 것
새로운 mitt 유사 이벤트 허브를 인스턴스화하는 데 사용할 수 있는 팩토리를 만들었습니다.
이를 통해 기존 이벤트 허브를 새로운 권장 방식으로 마이그레이션하거나 새로운 이벤트 허브를 생성하기가 더 쉬워집니다.
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
팩토리로 생성된 이벤트 허브는 Vue 2 이벤트 허브와 동일한 메서드($on, $once, $off, $emit)를 노출하여 이전 방식과 하위 호환됩니다.
이유
Vue 3에서 { functional: true } 옵션은 제거되었으며 <template functional>은 더 이상 지원되지 않습니다.
대신 사용할 것
함수형 컴포넌트는 일반 함수로 작성해야 합니다:
import { h } from 'vue'
const FunctionalComp = (props, slots) => {
return h('div', `Hello! ${props.name}`)
}
지금 당장 성능 개선이 절대적으로 필요한 경우가 아니면 상태 있는(stateful) 컴포넌트를 함수형 컴포넌트로 교체하는 것은 권장하지 않습니다. Vue 3에서는 함수형 컴포넌트의 성능 이점이 미미합니다.
slot 속성을 사용한 구식 슬롯 문법#
이유
Vue 2.6에서 slot 속성은 이미 v-slot 디렉티브를 위해 deprecated되었습니다. slot 속성 사용은 여전히 허용되며, 단위 테스트를 간소화하기 때문에 때로는 이를 선호하기도 합니다(구식 문법에서는 슬롯이 shallowMount에서 렌더링됩니다). 그러나 Vue 3에서는 더 이상 구식 문법을 사용할 수 없습니다.
대신 사용할 것
v-slot 디렉티브를 사용하는 문법을 사용하세요. shallowMount에서 슬롯 렌더링을 수정하려면, 슬롯이 있는 자식 컴포넌트를 명시적으로 스텁(stub)해야 합니다.
<!-- MyAwesomeComponent.vue -->
<script>
import SomeChildComponent from './some_child_component.vue'
export default {
components: {
SomeChildComponent
}
}
</script>
<template>
<div>
<h1>Hello GitLab!</h1>
<some-child-component>
<template #header>
Header content
</template>
</some-child-component>
</div>
</template>
// MyAwesomeComponent.spec.js
import SomeChildComponent from '~/some_child_component.vue'
shallowMount(MyAwesomeComponent, {
stubs: {
SomeChildComponent
}
})
Props 기본값 함수에서의 this 접근#
이유
Vue 3에서 props 기본값 팩토리 함수는 더 이상 this
(컴포넌트 인스턴스)에 접근할 수 없습니다.
대신 사용할 것
다른 props에서 원하는 값을 결정하는 computed prop을 작성하세요. 이 방법은 Vue 2와 Vue 3 모두에서 동작합니다.
<script>
export default {
props: {
metric: {
type: String,
required: true,
},
title: {
type: String,
required: false,
default: null,
},
},
computed: {
actualTitle() {
return this.title ?? this.metric;
},
},
}
</script>
<template>
<div>{{ actualTitle }}</div>
</template>
Vue 3에서, props 기본값 팩토리에는 raw props가 인수로 전달되며, 인젝션(injection)에도 접근할 수 있습니다.
Vue.observable#
이유
Vue.observable은 이를 생성한 Vue 버전에 종속된 반응형 상태를 생성합니다.
Vue 2/Vue 3 하이브리드 인젝션 시스템에서는 모듈이 복제될 수 있으며,
각 Vue 버전마다 하나씩 복사본이 생성됩니다. 이 모듈들이 Vue.observable()을 사용하면 각 복사본이 자체적인 별도 반응형 객체를 생성하므로, 한쪽의 상태 변경이 다른 쪽에는 보이지 않게 됩니다.
대신 사용할 것
~/lib/utils/observable에서 observable()을 사용하세요:
import { observable } from '~/lib/utils/observable';
// Before
export const state = Vue.observable({ count: 0 });
// After
export const state = observable('unique_key', { count: 0 });
observable(key, defaults) 함수는:
-
key를 키로 하는 전역 레지스트리에 단일 정식(canonical) 상태를 저장합니다. -
내부적으로
Vue.observable()을 통해 Vue 컨텍스트별 반응형 미러를 생성합니다. -
모든 Vue 버전의 미러에 쓰기를 동기화하는 Proxy를 반환합니다.
-
플랫 객체, getter, 메서드를 지원합니다.
key는 고유한 문자열 식별자여야 합니다(예: 'super_sidebar_state'). 이를 통해
두 모듈 복사본이 동일한 기반 상태를 공유할 수 있습니다.
ESLint 규칙(no-restricted-properties)이 이를 강제합니다. 직접 Vue.observable을 사용하면
린트 오류가 발생합니다.
제한 사항
플랫 객체만 지원: state.nested.prop = value 또는 state.array.push(item)과 같은 중첩 변이는 Vue 버전 간에 동기화되지 않습니다. 대신 최상위 속성 교체 방식으로 리팩토링하세요:
// Instead of: state.items.push(newItem)
state.items = [...state.items, newItem];
// Instead of: state.config[key] = value
state.config = { ...state.config, [key]: value };
@vue/compat에서 동작하지 않는 라이브러리 처리#
문제
일부 라이브러리는 Vue.js 2 내부에 의존합니다. 이러한 라이브러리는 @vue/compat에서 동작하지 않을 수 있으므로, 호환성 레이어로서 어댑터나 대체품을 추가했습니다.
목표
-
새 라이브러리를 지원하기 위해 기존 코드를 최소한으로 변경해야 합니다. 대신, 새 버전을 기존 버전과 호환되도록 만드는 파사드(facade) 역할을 하는 새로운 코드를 추가해야 합니다.
-
새 버전과 구 버전 간의 전환은 툴링(webpack / jest) 내부에서 처리되어야 하며, 코드에 노출되어서는 안 됩니다.
-
마이그레이션에 특정한 모든 파사드는 향후 마이그레이션 단계를 단순화하기 위해 동일한 디렉터리에 위치해야 합니다.
페이지 엔트리포인트를 @vue/compat으로 마이그레이션#
@vue/compat 가이드는 compat 모드를 사용하여 GitLab 페이지 엔트리포인트를 Vue 3을 지원하도록 마이그레이션하는 패턴을 다룹니다.
compat 하이브리드 모드에서는 Vue 2와 Vue 3이 동일한 페이지에서 동시에 렌더링될 수 있습니다. 피처 플래그가
어떤 모듈이 어떤 버전으로 로드될지 제어합니다.
일반적인 Vue 3 마이그레이션 정보는 Vue 3 공식 마이그레이션 가이드를 참고하세요.
webpack 인젝션 메커니즘 이해#
동적 임포트의 ?vue3 쿼리 파라미터는 webpack이 Vue 및 관련 라이브러리를
Vue 3 버전으로 해석하도록 트리거합니다.
webpack이 await import('~/my_feature/init_my_app?vue3')를 처리할 때:
-
Webpack이 이 모듈에 대한 별도의 청크(chunk)를 생성합니다.
-
?vue3쿼리가webpack.config.js에 구성된 webpack 에일리어스를 트리거합니다. -
해당 모듈과 그 의존성에서
vue,vue-apollo,vuex,vue-router의 모든 임포트가 Vue 3 버전으로 해석됩니다. -
전체 의존성 트리가 Vue 3 shim으로 "인젝션(infected)"됩니다.
인젝션은 엔트리 포인트만이 아닌 전체 모듈 의존성 체인에 적용됩니다.
피처 플래그로 제어하는 로딩#
페이지는 피처 플래그를 사용하여 어떤 버전을 로드할지 결정합니다. 이를 통해 GDK를 재구성하지 않고도 점진적으로 롤아웃할 수 있습니다.
피처 플래그 액터로는 project 또는 group 액터가 필요한 특정 사용 사례가 없는 한, user를 사용하는 것을 권장합니다. 예를 들어, 내부적으로 많이 사용되지 않는 Compliance Center와 같은 경우, 그룹에 대해 활성화하면 롤아웃 중에 더 많은 데이터를 얻을 수 있습니다.
엔트리포인트 패턴:
// pages/projects/blob/show/index.js
import { initBlobShow } from '~/blob/show';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
if (gon.features?.vue3MigrateRepository) {
(async () => {
try {
const { initBlobShow } = await import('~/blob/show?vue3');
initBlobShow();
return;
} catch (e) {
Sentry.captureException(e);
}
initBlobShow(); // Fallback to Vue 2 on error
})();
} else {
initBlobShow(); // Use Vue 2
}
파일 구조#
엔트리포인트(최소 로더)와 번들 파일(초기화 로직)을 분리하세요:
app/assets/javascripts/
├── [feature]/
│ └── [page]/
│ └── index.js # Bundle: all initialization logic
└── pages/
└── [area]/
└── [page]/
└── index.js # Entrypoint: minimal loader (under 20 lines)
Vue 앱을 별도 init 함수로 추출#
엔트리포인트에서 인라인 Vue 앱 생성을 전용 init 함수로 이동하세요.
// bad
// pages/projects/blob/show/index.js
new Vue({
el: '#js-my-app',
apolloProvider,
render(h) {
return h(MyComponent);
},
});
// good
// blob/init_my_app.js
import Vue from 'vue';
import apolloProvider from '~/repository/graphql';
export default function initMyApp() {
const el = document.getElementById('js-my-app');
if (!el) return null;
return new Vue({ el, apolloProvider, /* ... */ });
}
// pages/projects/blob/show/index.js
import initMyApp from '~/blob/init_my_app.js';
export default function initBlobShow() {
initMyApp()
}
공통 마이그레이션 패턴#
Vue Router props 반응성#
props 함수로 전달된 라우터 props는 Vue 3에서 반응형이 아닙니다. 대신 this.$route에서 읽는 computed 속성을 사용하세요.
// Component - use computed property instead of props
computed: {
currentPath() {
return this.$route?.params.path || '';
}
}
Watch 표현식#
전체 $route 객체가 아닌 문자열 경로를 사용하여 특정 라우트 속성을 감시하세요.
$route에 deep: true를 계속 사용할 수 있지만, 불필요하며 성능 오버헤드를 발생시킵니다.
특정 속성을 감시하는 것이 더 효율적이며 컴포넌트의 의존성을 더 명확하게 표현합니다.
watch: {
'$route.params.path'() {
this.fetchData();
}
}
Vue 2 폴백을 통한 오류 처리#
Vue 3 초기화가 실패하면 자동으로 Vue 2로 폴백(fallback)되도록 하세요.
if (gon.features?.vue3MigrateFeature) {
(async () => {
try {
const { init } = await import('~/feature?vue3');
init();
return;
} catch (e) {
Sentry.captureException(e);
}
init(); // Vue 2 fallback
})();
} else {
init();
}
마이그레이션 체크리스트#
-
모든 초기화 로직이 포함된 번들 파일을 생성합니다.
-
인라인 Vue 앱을 별도 init 함수로 추출합니다.
-
공유 apollo provider, 라우터, 스토어를 사용하도록 임포트를 업데이트합니다.
-
피처 플래그 조건부 로딩이 포함된 최소 엔트리포인트를 생성합니다.
-
Vue 2 폴백을 제공합니다.
-
init 함수에 named exports를 사용합니다.
테스팅#
Vue 3을 사용하는 동안 실패하는 테스트를 구현하거나 수정하는 방법에 대한 자세한 내용은 Vue 3 테스팅 가이드를 참고하세요.
@vue/compat 패치 업데이트#
@vue/compat 패치를 업데이트하는 방법에 대한 정보는 이 문서를 참고하세요. 까다로울 수 있습니다.