Vue.js와 Element UI를 기반으로 한 관리자 대시보드에서 탭 기반 네비게이션(TagsView)을 구현할 때, 사용자가 방문한 페이지를 시각적으로 유지하고 컴포넌트 상태를 효율적으로 캐싱하는 것은 중요한 요구사항이다. 이 기능은 주로 keep-alive와 Vue Router의 조합을 통해 실현되며, Vuex를 활용해 상태를 중앙에서 관리한다.
visitedViews 와 cachedViews: 두 가지 핵심 상태
탭 시스템은 다음 두 가지 배열을 중심으로 동작한다:
- visitedViews: 사용자가 실제로 방문한 라우트 정보를 저장하며, UI 상에서 표시되는 탭 목록에 해당한다.
- cachedViews:
keep-alive에 의해 캐싱된 컴포넌트 이름 목록이다. 라우트 설정에서meta.noCache: true로 지정하면 이 배열에 포함되지 않는다.
keep-alive는 내부적으로 include 속성을 통해 컴포넌트 이름 기준으로 캐싱 여부를 판단하므로, 라우트 정의 시 name 속성과 실제 뷰 컴포넌트의 name 옵션이 일치해야 한다. 불일치 시 캐싱이 동작하지 않거나 예기치 않은 재귀 참조가 발생할 수 있다.
동적 경로 캐싱 문제 및 해결 방안
예를 들어 /article/1, /article/2처럼 동일한 컴포넌트를 공유하는 동적 세그먼트 라우트는 모두 같은 컴포넌트 이름을 가지므로, keep-alive는 이를 하나의 인스턴스로 취급하게 된다. 이 경우 다음과 같은 접근이 필요하다:
- include 제거 후 전체 캐싱 적용:
<keep-alive>에include속성을 제거하면 모든 라우트 컴포넌트가 캐싱된다. 단점은 특정 탭만 제거하는 것이 어렵고, 최대 캐시 수(max) 외에는 제어가 불가능하다는 점이다. - 브라우저 스토리지 활용:
localStorage나sessionStorage를 사용하여 데이터 상태를 수동으로 저장하고 복원하는 방식. 라우트 진입 시 이전 상태를 불러오는 로직을 개발자가 직접 구현해야 한다.
ScrollPane 컴포넌트: 가로 스크롤 가능한 컨테이너
여러 탭이 생성되어 화면 너비를 초과할 경우, 마우스 휠 이벤트로 가로 스크롤이 가능하도록 해야 한다. 아래는 el-scrollbar를 기반으로 한 스크롤 패널 구성 예시이다.
<template>
<el-scrollbar
ref="scrollContainer"
:vertical="false"
class="scroll-container"
@wheel.native.prevent="handleWheel"
>
<slot />
</el-scrollbar>
</template>
<script>
export default {
name: 'HorizontalScroller',
computed: {
scrollElement() {
return this.$refs.scrollContainer.$refs.wrap;
}
},
methods: {
handleWheel(event) {
const delta = event.wheelDelta || -event.deltaY * 40;
const container = this.scrollElement;
container.scrollLeft += delta / 4;
},
scrollToActive(targetTag) {
const container = this.$refs.scrollContainer.$el;
const containerWidth = container.offsetWidth;
const wrapper = this.scrollElement;
const tags = this.$parent.$refs.tabRefs;
if (!tags.length) return;
const first = tags[0];
const last = tags[tags.length - 1];
if (targetTag === first) {
wrapper.scrollLeft = 0;
} else if (targetTag === last) {
wrapper.scrollLeft = wrapper.scrollWidth - containerWidth;
} else {
const index = tags.findIndex(t => t === targetTag);
const prev = tags[index - 1];
const next = tags[index + 1];
const spacing = 4;
if (next) {
const rightEdge = next.$el.offsetLeft + next.$el.offsetWidth + spacing;
if (rightEdge > wrapper.scrollLeft + containerWidth) {
wrapper.scrollLeft = rightEdge - containerWidth;
}
}
if (prev) {
const leftEdge = prev.$el.offsetLeft - spacing;
if (leftEdge < wrapper.scrollLeft) {
wrapper.scrollLeft = leftEdge;
}
}
}
}
}
};
</script>
다국어 지원을 위한 타이틀 변환 유틸리티
메뉴나 탭에 표시되는 제목은 다국어(i18n) 처리가 필요하다. 다음 유틸 함수는 라우트 메타의 타이틀 키를 기반으로 번역된 문자열을 반환한다.
// @/utils/i18n.js
export function translateRouteTitle(key) {
if (!key) return '';
const hasTranslation = this.$te(`route.${key}`);
return hasTranslation ? this.$t(`route.${key}`) : key;
}
Vuex를 통한 탭 상태 관리
탭 목록과 캐싱 상태는 중앙 집중식으로 관리되어야 하며, Vuex 모듈을 통해 구현한다. 주요 액션은 탭 추가, 제거, 갱신 등을 포함한다.
// store/modules/tabManager.js
const state = {
visitedTabs: [],
cachedComponents: []
};
const mutations = {
APPEND_TAB(state, route) {
if (!state.visitedTabs.some(tab => tab.path === route.path)) {
state.visitedTabs.push({
...route,
title: route.meta.title || '무제목'
});
}
},
CACHE_COMPONENT(state, route) {
if (!route.meta.noCache && !state.cachedComponents.includes(route.name)) {
state.cachedComponents.push(route.name);
}
},
REMOVE_TAB(state, target) {
const index = state.visitedTabs.findIndex(tab => tab.path === target.path);
if (index > -1) {
state.visitedTabs.splice(index, 1);
}
},
UNCACHE_COMPONENT(state, route) {
const i = state.cachedComponents.indexOf(route.name);
if (i > -1) {
state.cachedComponents.splice(i, 1);
}
},
CLEAR_OTHERS(state, current) {
state.visitedTabs = state.visitedTabs.filter(
tab => tab.meta.preserve || tab.path === current.path
);
state.cachedComponents = state.cachedComponents.includes(current.name)
? [current.name]
: [];
},
CLEAR_ALL(state) {
const preserved = state.visitedTabs.filter(tab => tab.meta.preserve);
state.visitedTabs = preserved;
state.cachedComponents = [];
}
};
const actions = {
addTab({ commit }, route) {
commit('APPEND_TAB', route);
commit('CACHE_COMPONENT', route);
},
removeTab({ commit, state }, route) {
return new Promise(resolve => {
commit('REMOVE_TAB', route);
commit('UNCACHE_COMPONENT', route);
resolve({ tabs: [...state.visitedTabs] });
});
},
refreshTab({ dispatch }, route) {
return dispatch('removeTab', route).then(() => {
this.$nextTick(() => {
this.$router.replace(`/redirect${route.fullPath}`);
});
});
}
};
export default { namespaced: true, state, mutations, actions };
TagsView 컴포넌트 구현
실제 탭 리스트는 router-link를 사용해 라우트 이동을 처리하며, 마우스 우클릭 시 컨텍스트 메뉴를 표시한다. 고정 탭(affix/preserve)은 닫기 기능에서 제외된다.
<template>
<div class="tab-container">
<horizontal-scroller ref="scroller" class="tab-wrapper">
<router-link
v-for="tab in visitedTabs"
:key="tab.path"
ref="tabRefs"
:to="tab.fullPath"
class="tab-item"
:class="{ active: isActive(tab) }"
@click.middle.native="!isPreserved(tab) && closeTab(tab)"
@contextmenu.prevent.native="openContextMenu(tab, $event)"
>
{{ translateRouteTitle(tab.title) }}
<span
v-if="!isPreserved(tab)"
class="close-btn"
@click.stop="closeTab(tab)"
/>
</router-link>
</horizontal-scroller>
<ul v-show="menuVisible" class="context-menu" :style="{ left, top }">
<li @click="refreshTab(selected)">새로고침</li>
<li v-if="!isPreserved(selected)" @click="closeTab(selected)">닫기</li>
<li @click="closeOthers()">기타 닫기</li>
<li @click="closeAll()">모두 닫기</li>
</ul>
</div>
</template>
<script>
import HorizontalScroller from './HorizontalScroller';
import { translateRouteTitle } from '@/utils/i18n';
export default {
components: { HorizontalScroller },
data: () => ({
menuVisible: false,
selected: {},
left: 0,
top: 0
}),
computed: {
visitedTabs() {
return this.$store.state.tabManager.visitedTabs;
}
},
watch: {
$route() {
this.addTab();
this.scrollToActive();
}
},
mounted() {
this.initPinnedTabs();
this.addTab();
},
methods: {
translateRouteTitle,
isActive(tab) {
return tab.path === this.$route.path;
},
isPreserved(tab) {
return !!tab.meta.preserve;
},
initPinnedTabs() {
// 초기에 항상 보여야 할 탭들을 등록
},
addTab() {
const { name } = this.$route;
if (name) {
this.$store.dispatch('tabManager/addTab', this.$route);
}
},
scrollToActive() {
this.$nextTick(() => {
const tabs = this.$refs.tabRefs;
const activeTab = tabs.find(tab => tab.to.path === this.$route.path);
if (activeTab) {
this.$refs.scroller.scrollToActive(activeTab);
}
});
},
openContextMenu(tab, e) {
const minWidth = 100;
const containerRect = this.$el.getBoundingClientRect();
const maxX = containerRect.width - minWidth;
const clickX = e.clientX - containerRect.left;
this.left = Math.min(clickX, maxX) + 'px';
this.top = e.clientY + 'px';
this.menuVisible = true;
this.selected = tab;
},
closeMenu() {
this.menuVisible = false;
},
closeTab(tab) {
if (this.isActive(tab)) {
// 다음 활성 탭으로 이동
}
this.$store.dispatch('tabManager/removeTab', tab);
},
closeOthers() {
this.$store.dispatch('tabManager/closeOthers', this.selected);
},
closeAll() {
this.$store.dispatch('tabManager/clearAll');
},
refreshTab(tab) {
this.$store.dispatch('tabManager/refreshTab', tab);
}
},
created() {
document.body.addEventListener('click', () => {
this.menuVisible = false;
});
}
};
</script>
AppMain.vue에서 keep-alive 적용
최종적으로 라우터 뷰를 캐싱하기 위해, 레이아웃 컴포넌트 내부에서 keep-alive를 사용하고 include에 캐싱 목록을 바인딩해야 한다.
<keep-alive :include="cachedComponents">
<router-view :key="$route.fullPath" />
</keep-alive>
이때 :key로 $route.fullPath를 사용하면 쿼리 파라미터 변경 시에도 리렌더링이 발생하므로, 필요에 따라 조정할 수 있다.